This commit is contained in:
Yangxt
2026-09-21 16:46:55 +08:00
parent 01842c59c6
commit ad2b0a7d3e
58 changed files with 6906 additions and 3 deletions
+139
View File
@@ -0,0 +1,139 @@
package app
import (
"context"
"fmt"
"mysql-tunnel/internal/config"
"mysql-tunnel/internal/logger"
"mysql-tunnel/internal/mysqlserver"
"mysql-tunnel/internal/navicat"
"mysql-tunnel/internal/tunnel"
)
type TestReport struct {
TunnelOK bool
RemoteOK bool
QueryOK bool
Lines []string
}
func (r TestReport) OK() bool { return r.TunnelOK && r.RemoteOK && r.QueryOK }
func (r TestReport) Text() string {
s := ""
for i, line := range r.Lines {
if i > 0 {
s += "\r\n"
}
s += line
}
return s
}
func NewTunnelClient(cfg *config.Config) *navicat.Client {
httpClient := tunnel.NewHTTPClient(tunnel.Options{
Timeout: cfg.RequestTimeout,
TLSInsecureSkipVerify: cfg.TLSInsecureSkipVerify,
})
return navicat.NewClient(httpClient, navicat.ClientOptions{
URL: cfg.TunnelURL,
EncodeBase64: cfg.EncodeBase64,
Retries: 1,
Target: navicat.Target{
Host: cfg.RemoteHost,
Port: cfg.RemotePort,
User: cfg.RemoteUsername,
Password: cfg.RemotePassword,
Database: cfg.RemoteDatabase,
Charset: cfg.RemoteCharset,
},
})
}
func Serve(ctx context.Context, cfg *config.Config, log *logger.Logger) error {
navi := NewTunnelClient(cfg)
srv := mysqlserver.New(mysqlserver.Options{
Host: cfg.ListenHost,
Port: cfg.ListenPort,
Username: cfg.LocalUsername,
Password: cfg.LocalPassword,
DefaultDB: cfg.RemoteDatabase,
MaxConnections: cfg.MaxConnections,
Timeout: cfg.RequestTimeout,
LogSQL: cfg.LogSQL,
}, navi, log)
if log != nil {
log.Info("server starting", fmt.Sprintf("%s:%d", cfg.ListenHost, cfg.ListenPort))
}
return srv.ListenAndServe(ctx)
}
func Test(ctx context.Context, cfg *config.Config, log *logger.Logger) TestReport {
var r TestReport
navi := NewTunnelClient(cfg)
r.Lines = append(r.Lines, "Tunnel URL: "+config.RedactedTunnelURL(cfg.TunnelURL))
conn, err := navi.Connect(ctx)
switch {
case err == nil:
r.TunnelOK = true
r.RemoteOK = true
r.Lines = append(r.Lines, "Tunnel: OK", "Remote MySQL: OK")
if conn != nil && conn.Header.UncertainVersion {
r.Lines = append(r.Lines, fmt.Sprintf(" (script version %d is not 202/206; continuing)", conn.Header.Version))
}
if log != nil && conn != nil {
log.Info("tunnel connect ok", conn.Conn.ServerInfo)
}
case isTunnelLayer(err):
r.Lines = append(r.Lines, "Tunnel: FAIL", " "+logger.RedactError(err), "Remote MySQL: FAIL", "Query SELECT 1: FAIL")
if log != nil {
log.Error("tunnel connect failed", logger.RedactError(err))
}
return r
default:
r.TunnelOK = true
r.Lines = append(r.Lines, "Tunnel: OK", "Remote MySQL: FAIL", " "+logger.RedactError(err), "Query SELECT 1: FAIL")
if log != nil {
log.Error("remote mysql connect failed", logger.RedactError(err))
}
return r
}
q, err := navi.Query(ctx, cfg.RemoteDatabase, "SELECT 1")
if err != nil {
r.Lines = append(r.Lines, "Query SELECT 1: FAIL", " "+logger.RedactError(err))
return r
}
res, ok := navicat.LastResultSet(q)
if !ok || res.IsError() || len(res.Rows) != 1 || len(res.Rows[0]) != 1 || res.Rows[0][0].Text() != "1" {
msg := "unexpected result"
if res.IsError() {
msg = fmt.Sprintf("mysql errno %d: %s", res.Errno, res.Message)
}
r.Lines = append(r.Lines, "Query SELECT 1: FAIL", " "+msg)
return r
}
r.QueryOK = true
r.Lines = append(r.Lines, "Query SELECT 1: OK")
if log != nil {
log.Info("query completed rows=1")
}
return r
}
func isTunnelLayer(err error) bool {
ne, ok := err.(*navicat.Error)
if !ok {
return true
}
if ne.Kind == "http" {
return true
}
switch ne.Errno {
case navicat.ErrnoUnsupportedPHP, navicat.ErrnoInvalidParams, navicat.ErrnoMySQLMissing, navicat.ErrnoInvalidToken:
return true
}
return false
}
+296
View File
@@ -0,0 +1,296 @@
package config
import (
"errors"
"fmt"
"net"
"net/url"
"os"
"runtime"
"strings"
"time"
)
const (
DefaultListenHost = "127.0.0.1"
DefaultListenPort = 13306
DefaultTimeoutSec = 30
DefaultMaxConnections = 5
DefaultLogLevel = "info"
DefaultLogFile = "logs/tunnel.log"
DefaultCharset = "utf8mb4"
)
// File holds the on-disk YAML shape. Nested pointers distinguish "unset" from zero values.
type File struct {
Listen ListenFile `yaml:"listen"`
LocalAuth LocalAuthFile `yaml:"local_auth"`
Tunnel TunnelFile `yaml:"tunnel"`
Remote RemoteFile `yaml:"remote"`
Options OptionsFile `yaml:"options"`
}
type ListenFile struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
}
type LocalAuthFile struct {
Username string `yaml:"username"`
Password string `yaml:"password"`
}
type TunnelFile struct {
URL string `yaml:"url"`
EncodeBase64 bool `yaml:"encode_base64"`
TLSInsecureSkipVerify bool `yaml:"tls_insecure_skip_verify"`
}
type RemoteFile struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
Username string `yaml:"username"`
Password string `yaml:"password"`
Database string `yaml:"database"`
Charset string `yaml:"charset"`
}
type OptionsFile struct {
RequestTimeout int `yaml:"request_timeout"`
MaxConnections int `yaml:"max_connections"`
LogLevel string `yaml:"log_level"`
LogSQL bool `yaml:"log_sql"`
LogFile string `yaml:"log_file"`
}
// Config is the validated runtime configuration.
type Config struct {
Path string
ListenHost string
ListenPort int
LocalUsername string
LocalPassword string
TunnelURL string
EncodeBase64 bool
TLSInsecureSkipVerify bool
TLSInsecureSkipVerifyWarned bool
RemoteHost string
RemotePort int
RemoteUsername string
RemotePassword string
RemoteDatabase string
RemoteCharset string
RequestTimeout time.Duration
MaxConnections int
LogLevel string
LogSQL bool
LogFile string
Warnings []string
}
type Overrides struct {
ListenHost string
ListenPort int
}
func Defaults() Config {
return Config{
ListenHost: DefaultListenHost,
ListenPort: DefaultListenPort,
RequestTimeout: time.Duration(DefaultTimeoutSec) * time.Second,
MaxConnections: DefaultMaxConnections,
LogLevel: DefaultLogLevel,
LogFile: DefaultLogFile,
RemotePort: 3306,
RemoteCharset: DefaultCharset,
}
}
func Parse(path string, ov Overrides) (*Config, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config %s: %w", path, err)
}
var file File
if err := unmarshalYAML(raw, &file); err != nil {
return nil, fmt.Errorf("parse config %s: %w", path, err)
}
cfg := Defaults()
cfg.Path = path
applyFile(&cfg, file)
applyOverrides(&cfg, ov)
return &cfg, nil
}
func Load(path string, ov Overrides) (*Config, error) {
cfg, err := Parse(path, ov)
if err != nil {
return nil, err
}
if err := cfg.Validate(); err != nil {
return nil, err
}
cfg.warnFilePermissions()
return cfg, nil
}
func applyFile(cfg *Config, file File) {
if file.Listen.Host != "" {
cfg.ListenHost = file.Listen.Host
}
if file.Listen.Port != 0 {
cfg.ListenPort = file.Listen.Port
}
cfg.LocalUsername = file.LocalAuth.Username
cfg.LocalPassword = file.LocalAuth.Password
cfg.TunnelURL = strings.TrimSpace(file.Tunnel.URL)
cfg.EncodeBase64 = file.Tunnel.EncodeBase64
cfg.TLSInsecureSkipVerify = file.Tunnel.TLSInsecureSkipVerify
cfg.RemoteHost = file.Remote.Host
if file.Remote.Port != 0 {
cfg.RemotePort = file.Remote.Port
}
cfg.RemoteUsername = file.Remote.Username
cfg.RemotePassword = file.Remote.Password
cfg.RemoteDatabase = file.Remote.Database
if file.Remote.Charset != "" {
cfg.RemoteCharset = file.Remote.Charset
}
if file.Options.RequestTimeout != 0 {
cfg.RequestTimeout = time.Duration(file.Options.RequestTimeout) * time.Second
}
if file.Options.MaxConnections != 0 {
cfg.MaxConnections = file.Options.MaxConnections
}
if file.Options.LogLevel != "" {
cfg.LogLevel = strings.ToLower(strings.TrimSpace(file.Options.LogLevel))
}
cfg.LogSQL = file.Options.LogSQL
if file.Options.LogFile != "" {
cfg.LogFile = file.Options.LogFile
}
}
func applyOverrides(cfg *Config, ov Overrides) {
if ov.ListenHost != "" {
cfg.ListenHost = ov.ListenHost
}
if ov.ListenPort != 0 {
cfg.ListenPort = ov.ListenPort
}
}
func (c *Config) Validate() error {
var errs []string
if c.ListenHost == "" {
errs = append(errs, "listen.host is required")
} else if ip := net.ParseIP(c.ListenHost); ip != nil && ip.IsUnspecified() {
errs = append(errs, "listen.host must not be 0.0.0.0/::; bind loopback only unless you fully understand the risk")
} else if !isLoopbackHost(c.ListenHost) {
c.Warnings = append(c.Warnings, fmt.Sprintf("listen.host %q is not loopback; the local MySQL port will be reachable beyond this machine", c.ListenHost))
}
if c.ListenPort < 1 || c.ListenPort > 65535 {
errs = append(errs, "listen.port must be 1-65535")
}
if strings.TrimSpace(c.LocalUsername) == "" {
errs = append(errs, "local_auth.username is required")
}
if c.TunnelURL == "" {
errs = append(errs, "tunnel.url is required")
} else {
u, err := url.Parse(c.TunnelURL)
if err != nil {
errs = append(errs, "tunnel.url is not a valid URL")
} else {
if u.Scheme != "http" && u.Scheme != "https" {
errs = append(errs, "tunnel.url scheme must be http or https")
}
if u.Host == "" {
errs = append(errs, "tunnel.url must include a host")
}
if u.Query().Get("token") == "" {
c.Warnings = append(c.Warnings, "tunnel.url has no token query parameter; ntunnel_mysql.php will reject requests with errno 204")
}
}
}
if strings.TrimSpace(c.RemoteHost) == "" {
errs = append(errs, "remote.host is required")
}
if c.RemotePort < 1 || c.RemotePort > 65535 {
errs = append(errs, "remote.port must be 1-65535")
}
if strings.TrimSpace(c.RemoteUsername) == "" {
errs = append(errs, "remote.username is required")
}
if c.RequestTimeout <= 0 {
errs = append(errs, "options.request_timeout must be > 0")
}
if c.MaxConnections < 1 {
errs = append(errs, "options.max_connections must be >= 1")
}
switch c.LogLevel {
case "debug", "info", "warn", "error":
default:
errs = append(errs, "options.log_level must be debug, info, warn, or error")
}
if c.TLSInsecureSkipVerify {
c.TLSInsecureSkipVerifyWarned = true
c.Warnings = append(c.Warnings, "SECURITY: tls_insecure_skip_verify is true; TLS certificate verification is disabled")
}
if len(errs) == 0 {
return nil
}
return errors.New(strings.Join(errs, "; "))
}
func (c *Config) warnFilePermissions() {
info, err := os.Stat(c.Path)
if err != nil {
return
}
if runtime.GOOS != "windows" {
if info.Mode().Perm()&0o004 != 0 {
c.Warnings = append(c.Warnings, fmt.Sprintf("%s is world-readable; it contains a tunnel token and database password", c.Path))
}
} else {
c.Warnings = append(c.Warnings, fmt.Sprintf("%s contains secrets (token, passwords); restrict NTFS permissions to your account", c.Path))
}
}
func isLoopbackHost(host string) bool {
h := strings.TrimSpace(host)
if strings.EqualFold(h, "localhost") {
return true
}
ip := net.ParseIP(h)
return ip != nil && ip.IsLoopback()
}
// RedactedTunnelURL strips userinfo and token/password query values for logs.
func RedactedTunnelURL(raw string) string {
u, err := url.Parse(raw)
if err != nil {
return "(invalid-url)"
}
u.User = nil
q := u.Query()
for _, key := range []string{"token", "password", "pass", "access_token", "accessToken"} {
if q.Has(key) {
q.Set(key, "***")
}
}
u.RawQuery = q.Encode()
u.Fragment = ""
return u.String()
}
+154
View File
@@ -0,0 +1,154 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func writeTempConfig(t *testing.T, body string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
return path
}
func TestLoadValidConfig(t *testing.T) {
path := writeTempConfig(t, `
listen:
host: 127.0.0.1
port: 13306
local_auth:
username: root
password: "local-secret"
tunnel:
url: "https://example.invalid/ntunnel?token=abc"
remote:
host: mysql
port: 3306
username: remote-root
password: "remote-secret"
database: app
charset: utf8mb4
options:
request_timeout: 15
max_connections: 3
log_level: debug
log_sql: false
`)
cfg, err := Load(path, Overrides{})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.ListenHost != "127.0.0.1" || cfg.ListenPort != 13306 {
t.Fatalf("listen = %s:%d", cfg.ListenHost, cfg.ListenPort)
}
if cfg.RemoteHost != "mysql" || cfg.RemoteUsername != "remote-root" {
t.Fatalf("remote mismatch")
}
if cfg.RequestTimeout.Seconds() != 15 {
t.Fatalf("timeout = %s", cfg.RequestTimeout)
}
}
func TestRejectUnspecifiedListen(t *testing.T) {
path := writeTempConfig(t, `
listen:
host: 0.0.0.0
port: 13306
local_auth:
username: root
password: x
tunnel:
url: "https://example.invalid/?token=t"
remote:
host: mysql
username: root
password: x
`)
_, err := Load(path, Overrides{})
if err == nil || !strings.Contains(err.Error(), "0.0.0.0") {
t.Fatalf("expected unspecified listen error, got %v", err)
}
}
func TestMissingTunnelURL(t *testing.T) {
path := writeTempConfig(t, `
local_auth:
username: root
password: x
remote:
host: mysql
username: root
password: x
`)
_, err := Load(path, Overrides{})
if err == nil || !strings.Contains(err.Error(), "tunnel.url") {
t.Fatalf("expected tunnel.url error, got %v", err)
}
}
func TestCLIOverrides(t *testing.T) {
path := writeTempConfig(t, `
listen:
host: 127.0.0.1
port: 13306
local_auth:
username: root
password: x
tunnel:
url: "https://example.invalid/?token=t"
remote:
host: mysql
username: root
password: x
`)
cfg, err := Load(path, Overrides{ListenHost: "127.0.0.1", ListenPort: 23306})
if err != nil {
t.Fatal(err)
}
if cfg.ListenPort != 23306 {
t.Fatalf("port override failed: %d", cfg.ListenPort)
}
}
func TestRedactedTunnelURL(t *testing.T) {
got := RedactedTunnelURL("https://bth-ntunnel.example/?token=super-secret&x=1")
if strings.Contains(got, "super-secret") {
t.Fatalf("token leaked: %s", got)
}
if !strings.Contains(got, "token=%2A%2A%2A") && !strings.Contains(got, "token=***") {
t.Fatalf("token not redacted: %s", got)
}
}
func TestWarnWhenTokenMissing(t *testing.T) {
path := writeTempConfig(t, `
local_auth:
username: root
password: x
tunnel:
url: "https://example.invalid/ntunnel"
remote:
host: mysql
username: root
password: x
`)
cfg, err := Load(path, Overrides{})
if err != nil {
t.Fatal(err)
}
found := false
for _, w := range cfg.Warnings {
if strings.Contains(w, "token") {
found = true
}
}
if !found {
t.Fatalf("expected token warning, got %#v", cfg.Warnings)
}
}
+62
View File
@@ -0,0 +1,62 @@
package config
import (
"fmt"
"os"
"path/filepath"
)
// ResolveConfigPath finds config.yaml: --config, then next to the exe, then cwd.
func ResolveConfigPath(flagPath string) (string, error) {
var cands []string
if flagPath != "" {
cands = append(cands, flagPath)
}
if exe, err := os.Executable(); err == nil {
dir := filepath.Dir(exe)
cands = append(cands, filepath.Join(dir, "config.yaml"))
cands = append(cands, filepath.Join(dir, "..", "config.yaml"))
}
cands = append(cands, "config.yaml")
seen := map[string]bool{}
for _, p := range cands {
abs, err := filepath.Abs(p)
if err != nil {
continue
}
abs = filepath.Clean(abs)
if seen[abs] {
continue
}
seen[abs] = true
st, err := os.Stat(abs)
if err == nil && !st.IsDir() {
return abs, nil
}
}
if flagPath == "" {
flagPath = "config.yaml"
}
abs, err := filepath.Abs(flagPath)
if err != nil {
return flagPath, fmt.Errorf("config not found: %s", flagPath)
}
return abs, fmt.Errorf("config not found: %s", abs)
}
// ResolvePaths makes LogFile absolute relative to the config file directory.
func (c *Config) ResolvePaths() {
if c == nil {
return
}
if p, err := filepath.Abs(c.Path); err == nil {
c.Path = p
}
if c.LogFile == "" {
c.LogFile = DefaultLogFile
}
if !filepath.IsAbs(c.LogFile) {
c.LogFile = filepath.Join(filepath.Dir(c.Path), c.LogFile)
}
c.LogFile = filepath.Clean(c.LogFile)
}
+50
View File
@@ -0,0 +1,50 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestResolvePathsMakesLogAbsolute(t *testing.T) {
path := writeTempConfig(t, `
local_auth:
username: root
password: x
tunnel:
url: "https://example.invalid/?token=t"
remote:
host: mysql
username: root
password: x
options:
log_file: logs/tunnel.log
`)
cfg, err := Load(path, Overrides{})
if err != nil {
t.Fatal(err)
}
cfg.ResolvePaths()
want := filepath.Join(filepath.Dir(path), "logs", "tunnel.log")
if cfg.LogFile != want {
t.Fatalf("LogFile=%s want %s", cfg.LogFile, want)
}
}
func TestResolveConfigPathPrefersExisting(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(p, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
got, err := ResolveConfigPath(p)
if err != nil {
t.Fatal(err)
}
if got != p && got != filepath.Clean(p) {
// Abs may change separators on Windows
if filepath.Clean(got) != filepath.Clean(p) {
t.Fatalf("got %s", got)
}
}
}
+99
View File
@@ -0,0 +1,99 @@
package config
import (
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
func (c *Config) ToFile() File {
if c == nil {
return File{}
}
return File{
Listen: ListenFile{Host: c.ListenHost, Port: c.ListenPort},
LocalAuth: LocalAuthFile{Username: c.LocalUsername, Password: c.LocalPassword},
Tunnel: TunnelFile{
URL: c.TunnelURL,
EncodeBase64: c.EncodeBase64,
TLSInsecureSkipVerify: c.TLSInsecureSkipVerify,
},
Remote: RemoteFile{
Host: c.RemoteHost,
Port: c.RemotePort,
Username: c.RemoteUsername,
Password: c.RemotePassword,
Database: c.RemoteDatabase,
Charset: c.RemoteCharset,
},
Options: OptionsFile{
RequestTimeout: int(c.RequestTimeout.Seconds()),
MaxConnections: c.MaxConnections,
LogLevel: c.LogLevel,
LogSQL: c.LogSQL,
LogFile: relativeLogFile(c.Path, c.LogFile),
},
}
}
func relativeLogFile(cfgPath, logFile string) string {
if logFile == "" {
return DefaultLogFile
}
if !filepath.IsAbs(logFile) {
return filepath.ToSlash(filepath.Clean(logFile))
}
dir := filepath.Dir(cfgPath)
rel, err := filepath.Rel(dir, logFile)
if err != nil {
return logFile
}
return filepath.ToSlash(rel)
}
func Save(path string, c *Config) error {
if path == "" {
return fmt.Errorf("config path is empty")
}
if dir := filepath.Dir(path); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o750); err != nil {
return err
}
}
raw, err := yaml.Marshal(c.ToFile())
if err != nil {
return err
}
out := append([]byte("# Written by mysql-tunnel. Contains secrets; do not commit.\n"), raw...)
return os.WriteFile(path, out, 0o600)
}
func DefaultSavePath() string {
if exe, err := os.Executable(); err == nil {
return filepath.Join(filepath.Dir(exe), "config.yaml")
}
return "config.yaml"
}
// LoadOrEmpty returns a parsed config, or defaults if the file is missing or invalid.
func LoadOrEmpty(path string) *Config {
if path == "" {
path = DefaultSavePath()
}
if _, err := os.Stat(path); err != nil {
c := Defaults()
c.Path = path
return &c
}
cfg, err := Parse(path, Overrides{})
if err != nil {
c := Defaults()
c.Path = path
c.Warnings = append(c.Warnings, err.Error())
return &c
}
cfg.ResolvePaths()
return cfg
}
+71
View File
@@ -0,0 +1,71 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestSaveRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
in := Defaults()
in.Path = path
in.LocalUsername = "root"
in.LocalPassword = "local-secret"
in.TunnelURL = "https://example.invalid/ntunnel?token=abc"
in.RemoteHost = "mysql"
in.RemoteUsername = "remote-root"
in.RemotePassword = "remote-secret"
in.RemoteDatabase = "app"
in.LogFile = filepath.Join(dir, "logs", "tunnel.log")
in.EncodeBase64 = true
in.LogSQL = true
if err := Save(path, &in); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), "token=abc") {
t.Fatalf("saved yaml missing url: %s", raw)
}
if !strings.Contains(string(raw), "logs/tunnel.log") && !strings.Contains(string(raw), `log_file: logs/tunnel.log`) {
if !strings.Contains(string(raw), "log_file:") {
t.Fatalf("log_file missing: %s", raw)
}
}
cfg, err := Load(path, Overrides{})
if err != nil {
t.Fatal(err)
}
if cfg.LocalPassword != "local-secret" || cfg.RemotePassword != "remote-secret" {
t.Fatalf("passwords not round-tripped")
}
if cfg.TunnelURL != in.TunnelURL {
t.Fatalf("url=%s", cfg.TunnelURL)
}
if !cfg.EncodeBase64 || !cfg.LogSQL {
t.Fatalf("bools not round-tripped")
}
}
func TestLoadOrEmptyMissing(t *testing.T) {
path := filepath.Join(t.TempDir(), "nope.yaml")
cfg := LoadOrEmpty(path)
if cfg.ListenPort != DefaultListenPort {
t.Fatalf("port=%d", cfg.ListenPort)
}
if cfg.Path != path {
t.Fatalf("path=%s", cfg.Path)
}
}
func TestRelativeLogFileKeepsRelative(t *testing.T) {
got := relativeLogFile(filepath.Join(t.TempDir(), "c.yaml"), "logs/tunnel.log")
if got != "logs/tunnel.log" {
t.Fatalf("got %s", got)
}
}
+7
View File
@@ -0,0 +1,7 @@
package config
import "gopkg.in/yaml.v3"
func unmarshalYAML(raw []byte, v any) error {
return yaml.Unmarshal(raw, v)
}
+13
View File
@@ -0,0 +1,13 @@
//go:build !windows
package gui
import (
"fmt"
"os"
)
func Run(string) int {
fmt.Fprintln(os.Stderr, "GUI is only available on Windows; use --console to listen")
return 1
}
+692
View File
@@ -0,0 +1,692 @@
//go:build windows
package gui
import (
"bytes"
"context"
_ "embed"
"fmt"
"image/png"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"unsafe"
"github.com/lxn/walk"
. "github.com/lxn/walk/declarative"
"github.com/lxn/win"
"golang.org/x/sys/windows"
"mysql-tunnel/internal/app"
"mysql-tunnel/internal/config"
"mysql-tunnel/internal/logger"
)
//go:embed logo.png
var logoPNG []byte
func init() {
runtime.LockOSThread()
}
func Run(configFlag string) int {
hideConsoleIfStandalone()
path := strings.TrimSpace(configFlag)
if path == "" {
path = "config.yaml"
}
if resolved, err := config.ResolveConfigPath(path); err == nil {
path = resolved
} else if path == "config.yaml" {
path = config.DefaultSavePath()
} else if abs, err := filepath.Abs(path); err == nil {
path = abs
}
if err := runWindow(config.LoadOrEmpty(path)); err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
return 0
}
type ui struct {
mw *walk.MainWindow
ni *walk.NotifyIcon
listenHost *walk.LineEdit
listenPort *walk.NumberEdit
localUser *walk.LineEdit
localPass *walk.LineEdit
tunnelURL *walk.LineEdit
encodeB64 *walk.CheckBox
tlsSkip *walk.CheckBox
remoteHost *walk.LineEdit
remotePort *walk.NumberEdit
remoteUser *walk.LineEdit
remotePass *walk.LineEdit
remoteDB *walk.LineEdit
charset *walk.LineEdit
timeout *walk.NumberEdit
maxConn *walk.NumberEdit
logLevel *walk.ComboBox
logSQL *walk.CheckBox
logFile *walk.LineEdit
status *walk.TextEdit
saveBtn *walk.PushButton
testBtn *walk.PushButton
startBtn *walk.PushButton
stopBtn *walk.PushButton
pathLabel *walk.Label
hint *walk.StatusBarItem
logoView *walk.ImageView
appIcon *walk.Icon
appBmp *walk.Bitmap
cfgPath string
mu sync.Mutex
running bool
busy bool
cancel context.CancelFunc
log *logger.Logger
exiting bool
hidOnce bool
}
func runWindow(cfg *config.Config) error {
u := &ui{cfgPath: cfg.Path}
if err := u.build(cfg); err != nil {
return err
}
defer u.dispose()
u.mw.Run()
return nil
}
func (u *ui) dispose() {
u.stopServer()
if u.ni != nil {
_ = u.ni.Dispose()
}
}
func (u *ui) build(cfg *config.Config) error {
logFile := relativeLogFile(cfg)
levels := []string{"debug", "info", "warn", "error"}
levelIdx := 1
for i, s := range levels {
if s == cfg.LogLevel {
levelIdx = i
break
}
}
if err := (MainWindow{
AssignTo: &u.mw,
Title: "MySQL Tunnel",
MinSize: Size{Width: 780, Height: 560},
Size: Size{Width: 840, Height: 620},
Layout: VBox{Margins: Margins{Left: 12, Top: 10, Right: 12, Bottom: 8}, Spacing: 8},
StatusBarItems: []StatusBarItem{
{AssignTo: &u.hint, Text: "就绪", Width: 800},
},
Children: []Widget{
Composite{
Layout: HBox{Spacing: 10, MarginsZero: true},
Children: []Widget{
ImageView{
AssignTo: &u.logoView,
MinSize: Size{Width: 40, Height: 40},
MaxSize: Size{Width: 40, Height: 40},
Mode: ImageViewModeZoom,
},
Composite{
Layout: VBox{MarginsZero: true, Spacing: 2},
Children: []Widget{
Label{Text: "MySQL Tunnel", Font: Font{Family: "Segoe UI", PointSize: 13, Bold: true}},
Label{AssignTo: &u.pathLabel, Text: cfg.Path},
},
},
HSpacer{},
},
},
Composite{
Layout: HBox{Spacing: 10, MarginsZero: true},
Children: []Widget{
Composite{
StretchFactor: 1,
MinSize: Size{Width: 380},
Layout: VBox{MarginsZero: true, Spacing: 8},
Children: []Widget{
GroupBox{
Title: "本机监听(影刀连这里)",
Layout: Grid{Columns: 2, Spacing: 6},
Children: []Widget{
fieldLabel("地址"),
LineEdit{AssignTo: &u.listenHost, Text: cfg.ListenHost},
fieldLabel("端口"),
NumberEdit{AssignTo: &u.listenPort, Value: float64(cfg.ListenPort), MinValue: 1, MaxValue: 65535, Decimals: 0, MinSize: Size{Width: 90}},
fieldLabel("本机用户"),
LineEdit{AssignTo: &u.localUser, Text: cfg.LocalUsername},
fieldLabel("本机密码"),
LineEdit{AssignTo: &u.localPass, Text: cfg.LocalPassword, PasswordMode: true},
},
},
GroupBox{
Title: "HTTP 隧道",
StretchFactor: 1,
Layout: Grid{Columns: 2, Spacing: 6},
Children: []Widget{
fieldLabel("URL"),
LineEdit{AssignTo: &u.tunnelURL, Text: cfg.TunnelURL},
CheckBox{AssignTo: &u.encodeB64, Text: "SQL Base64(WAF)", Checked: cfg.EncodeBase64, ColumnSpan: 2},
CheckBox{AssignTo: &u.tlsSkip, Text: "跳过 TLS 证书校验(仅排障)", Checked: cfg.TLSInsecureSkipVerify, ColumnSpan: 2},
},
},
},
},
Composite{
StretchFactor: 1,
MinSize: Size{Width: 380},
Layout: VBox{MarginsZero: true, Spacing: 8},
Children: []Widget{
GroupBox{
Title: "远程 MySQL(PHP 主机访问)",
Layout: Grid{Columns: 2, Spacing: 6},
Children: []Widget{
fieldLabel("主机"),
LineEdit{AssignTo: &u.remoteHost, Text: cfg.RemoteHost},
fieldLabel("端口"),
NumberEdit{AssignTo: &u.remotePort, Value: float64(nonzero(cfg.RemotePort, 3306)), MinValue: 1, MaxValue: 65535, Decimals: 0, MinSize: Size{Width: 90}},
fieldLabel("用户名"),
LineEdit{AssignTo: &u.remoteUser, Text: cfg.RemoteUsername},
fieldLabel("密码"),
LineEdit{AssignTo: &u.remotePass, Text: cfg.RemotePassword, PasswordMode: true},
fieldLabel("默认库"),
LineEdit{AssignTo: &u.remoteDB, Text: cfg.RemoteDatabase},
fieldLabel("字符集"),
LineEdit{AssignTo: &u.charset, Text: orDefault(cfg.RemoteCharset, "utf8mb4")},
},
},
GroupBox{
Title: "选项",
StretchFactor: 1,
Layout: Grid{Columns: 2, Spacing: 6},
Children: []Widget{
fieldLabel("超时(秒)"),
NumberEdit{AssignTo: &u.timeout, Value: float64(timeoutSec(cfg)), MinValue: 1, MaxValue: 600, Decimals: 0, MinSize: Size{Width: 90}},
fieldLabel("最大连接"),
NumberEdit{AssignTo: &u.maxConn, Value: float64(nonzero(cfg.MaxConnections, 5)), MinValue: 1, MaxValue: 256, Decimals: 0, MinSize: Size{Width: 90}},
fieldLabel("日志级别"),
ComboBox{AssignTo: &u.logLevel, Model: levels, CurrentIndex: levelIdx, Editable: false},
fieldLabel("日志文件"),
LineEdit{AssignTo: &u.logFile, Text: logFile},
CheckBox{AssignTo: &u.logSQL, Text: "记录 SQL(仍会脱敏)", Checked: cfg.LogSQL, ColumnSpan: 2},
},
},
},
},
},
},
Composite{
Layout: HBox{Spacing: 8},
Children: []Widget{
PushButton{AssignTo: &u.saveBtn, Text: "保存", MinSize: Size{Width: 88}},
PushButton{AssignTo: &u.testBtn, Text: "测试连接", MinSize: Size{Width: 88}},
PushButton{AssignTo: &u.startBtn, Text: "启动", MinSize: Size{Width: 88}},
PushButton{AssignTo: &u.stopBtn, Text: "停止", Enabled: false, MinSize: Size{Width: 88}},
HSpacer{},
},
},
GroupBox{
Title: "运行日志",
StretchFactor: 1,
MinSize: Size{Height: 140},
Layout: VBox{MarginsZero: true},
Children: []Widget{
TextEdit{AssignTo: &u.status, ReadOnly: true, VScroll: true, MaxLength: 64 * 1024},
},
},
},
}).Create(); err != nil {
return err
}
if u.saveBtn != nil {
u.saveBtn.Clicked().Attach(u.onSave)
}
if u.testBtn != nil {
u.testBtn.Clicked().Attach(u.onTest)
}
if u.startBtn != nil {
u.startBtn.Clicked().Attach(u.onStart)
}
if u.stopBtn != nil {
u.stopBtn.Clicked().Attach(u.onStop)
u.stopBtn.SetEnabled(false)
}
u.appendStatus("填写后点「保存」,再「测试连接」。通过后点「启动」,影刀连 本机地址:端口。")
u.appendStatus("最小化或关闭窗口会藏到系统托盘(监听可继续)。托盘左键打开,右键退出。")
for _, w := range cfg.Warnings {
u.appendStatus("注意: " + w)
}
if err := u.applyBranding(); err != nil {
return err
}
ni, err := walk.NewNotifyIcon(u.mw)
if err != nil {
return err
}
u.ni = ni
if u.appIcon != nil {
_ = ni.SetIcon(u.appIcon)
_ = u.mw.SetIcon(u.appIcon)
}
_ = ni.SetToolTip("MySQL Tunnel")
_ = ni.SetVisible(true)
ni.MouseDown().Attach(func(x, y int, button walk.MouseButton) {
if button == walk.LeftButton {
u.showWindow()
}
})
showAct := walk.NewAction()
_ = showAct.SetText("打开窗口")
showAct.Triggered().Attach(u.showWindow)
exitAct := walk.NewAction()
_ = exitAct.SetText("退出")
exitAct.Triggered().Attach(u.onExit)
if menu := ni.ContextMenu(); menu != nil {
_ = menu.Actions().Add(showAct)
_ = menu.Actions().Add(exitAct)
}
// Minimize → tray (not taskbar). Close also → tray unless exiting.
u.mw.SizeChanged().Attach(func() {
hwnd := win.HWND(u.mw.Handle())
if win.IsIconic(hwnd) {
u.hideToTray()
}
})
u.mw.Closing().Attach(func(canceled *bool, reason walk.CloseReason) {
u.mu.Lock()
exiting := u.exiting
u.mu.Unlock()
if exiting {
return
}
*canceled = true
u.hideToTray()
})
return nil
}
func (u *ui) hideToTray() {
if u.mw == nil {
return
}
u.mw.Hide()
hwnd := win.HWND(u.mw.Handle())
win.ShowWindow(hwnd, win.SW_HIDE)
if u.ni != nil && !u.hidOnce {
u.hidOnce = true
_ = u.ni.ShowInfo("MySQL Tunnel", "已放到系统托盘。左键打开窗口,右键退出。")
}
}
func (u *ui) showWindow() {
if u.mw == nil {
return
}
u.mw.Show()
hwnd := win.HWND(u.mw.Handle())
win.ShowWindow(hwnd, win.SW_RESTORE)
win.SetForegroundWindow(hwnd)
}
func (u *ui) onExit() {
u.mu.Lock()
u.exiting = true
u.mu.Unlock()
u.stopServer()
if u.ni != nil {
_ = u.ni.SetVisible(false)
}
u.mw.Close()
}
func (u *ui) collect() (*config.Config, error) {
c := config.Defaults()
c.Path = u.cfgPath
c.ListenHost = strings.TrimSpace(lineText(u.listenHost))
c.ListenPort = numValue(u.listenPort, config.DefaultListenPort)
c.LocalUsername = strings.TrimSpace(lineText(u.localUser))
c.LocalPassword = lineText(u.localPass)
c.TunnelURL = strings.TrimSpace(lineText(u.tunnelURL))
c.EncodeBase64 = boxChecked(u.encodeB64)
c.TLSInsecureSkipVerify = boxChecked(u.tlsSkip)
c.RemoteHost = strings.TrimSpace(lineText(u.remoteHost))
c.RemotePort = numValue(u.remotePort, 3306)
c.RemoteUsername = strings.TrimSpace(lineText(u.remoteUser))
c.RemotePassword = lineText(u.remotePass)
c.RemoteDatabase = strings.TrimSpace(lineText(u.remoteDB))
c.RemoteCharset = strings.TrimSpace(lineText(u.charset))
c.RequestTimeout = time.Duration(numValue(u.timeout, config.DefaultTimeoutSec)) * time.Second
c.MaxConnections = numValue(u.maxConn, config.DefaultMaxConnections)
if u.logLevel != nil {
c.LogLevel = strings.ToLower(strings.TrimSpace(u.logLevel.Text()))
}
if c.LogLevel == "" {
c.LogLevel = config.DefaultLogLevel
}
c.LogSQL = boxChecked(u.logSQL)
c.LogFile = strings.TrimSpace(lineText(u.logFile))
if c.LogFile == "" {
c.LogFile = config.DefaultLogFile
}
if err := c.Validate(); err != nil {
return &c, err
}
c.ResolvePaths()
return &c, nil
}
func (u *ui) onSave() {
c, err := u.collect()
if err != nil {
u.appendStatus("保存失败: " + err.Error())
walk.MsgBox(u.mw, "MySQL Tunnel", err.Error(), walk.MsgBoxIconError)
return
}
if err := config.Save(u.cfgPath, c); err != nil {
u.appendStatus("保存失败: " + err.Error())
walk.MsgBox(u.mw, "MySQL Tunnel", err.Error(), walk.MsgBoxIconError)
return
}
u.appendStatus("已保存 " + u.cfgPath)
for _, w := range c.Warnings {
u.appendStatus("注意: " + w)
}
}
func (u *ui) onTest() {
c, err := u.collect()
if err != nil {
u.appendStatus("无法测试: " + err.Error())
walk.MsgBox(u.mw, "MySQL Tunnel", "无法测试:\n"+err.Error(), walk.MsgBoxIconError)
return
}
u.setBusy(true)
u.setHint("正在测试隧道…")
u.appendStatus("正在测试 " + config.RedactedTunnelURL(c.TunnelURL) + " …")
go func() {
defer func() {
if rec := recover(); rec != nil {
u.mw.Synchronize(func() {
msg := fmt.Sprintf("测试异常: %v", rec)
u.appendStatus(msg)
u.setHint("测试异常")
walk.MsgBox(u.mw, "测试结果", msg, walk.MsgBoxIconError)
u.setBusy(false)
})
}
}()
log, _ := logger.New(c.LogFile, c.LogLevel, c.LogSQL)
r := app.Test(context.Background(), c, log)
if log != nil {
_ = log.Close()
}
u.mw.Synchronize(func() {
u.appendStatus(strings.ReplaceAll(r.Text(), "\n", "\r\n"))
icon := walk.MsgBoxIconError
hint := "测试未通过"
if r.OK() {
icon = walk.MsgBoxIconInformation
hint = "测试通过"
u.appendStatus("测试通过,可以启动监听。")
} else {
u.appendStatus("测试未通过。")
}
u.setHint(hint)
walk.MsgBox(u.mw, "测试结果", strings.ReplaceAll(r.Text(), "\r\n", "\n"), icon)
u.setBusy(false)
})
}()
}
func (u *ui) onStart() {
u.mu.Lock()
if u.running {
u.mu.Unlock()
return
}
u.mu.Unlock()
c, err := u.collect()
if err != nil {
u.appendStatus("无法启动: " + err.Error())
walk.MsgBox(u.mw, "MySQL Tunnel", err.Error(), walk.MsgBoxIconError)
return
}
log, err := logger.New(c.LogFile, c.LogLevel, c.LogSQL)
if err != nil {
u.appendStatus("日志: " + err.Error())
return
}
ctx, cancel := context.WithCancel(context.Background())
u.mu.Lock()
u.cancel = cancel
u.log = log
u.running = true
u.mu.Unlock()
u.refreshButtons()
u.appendStatus(fmt.Sprintf("正在监听 %s:%d 影刀用户=%s", c.ListenHost, c.ListenPort, c.LocalUsername))
u.appendStatus("隧道: " + config.RedactedTunnelURL(c.TunnelURL))
if u.ni != nil {
_ = u.ni.SetToolTip(fmt.Sprintf("MySQL Tunnel · %s:%d", c.ListenHost, c.ListenPort))
}
go func() {
err := app.Serve(ctx, c, log)
u.mw.Synchronize(func() {
u.mu.Lock()
u.running = false
u.cancel = nil
if u.log != nil {
_ = u.log.Close()
u.log = nil
}
u.mu.Unlock()
if err != nil && ctx.Err() == nil {
u.appendStatus("监听结束: " + logger.RedactError(err))
} else {
u.appendStatus("监听已停止")
}
if u.ni != nil {
_ = u.ni.SetToolTip("MySQL Tunnel")
}
u.refreshButtons()
})
}()
}
func (u *ui) onStop() {
u.stopServer()
}
func (u *ui) stopServer() {
u.mu.Lock()
cancel := u.cancel
u.mu.Unlock()
if cancel != nil {
cancel()
}
}
func (u *ui) setBusy(b bool) {
u.mu.Lock()
u.busy = b
u.mu.Unlock()
u.refreshButtons()
}
func (u *ui) refreshButtons() {
u.mu.Lock()
running, busy := u.running, u.busy
u.mu.Unlock()
if u.saveBtn != nil {
u.saveBtn.SetEnabled(!busy)
}
if u.testBtn != nil {
u.testBtn.SetEnabled(!busy)
if busy {
_ = u.testBtn.SetText("测试中…")
} else {
_ = u.testBtn.SetText("测试连接")
}
}
if u.startBtn != nil {
u.startBtn.SetEnabled(!running && !busy)
}
if u.stopBtn != nil {
u.stopBtn.SetEnabled(running)
}
}
func (u *ui) setHint(s string) {
if u.hint == nil {
return
}
_ = u.hint.SetText(s)
}
func (u *ui) appendStatus(line string) {
line = strings.ReplaceAll(strings.TrimRight(line, "\r\n"), "\n", "\r\n")
if u.status != nil {
prev := u.status.Text()
if prev != "" {
prev += "\r\n"
}
text := prev + time.Now().Format("15:04:05") + " " + line
_ = u.status.SetText(text)
end := len([]rune(text))
u.status.SetTextSelection(end, end)
}
first := line
if i := strings.Index(first, "\r\n"); i >= 0 {
first = first[:i]
}
if len([]rune(first)) > 80 {
first = string([]rune(first)[:80]) + "…"
}
u.setHint(first)
}
func relativeLogFile(cfg *config.Config) string {
if cfg == nil || cfg.LogFile == "" {
return config.DefaultLogFile
}
if !filepath.IsAbs(cfg.LogFile) {
return filepath.ToSlash(cfg.LogFile)
}
rel, err := filepath.Rel(filepath.Dir(cfg.Path), cfg.LogFile)
if err != nil {
return cfg.LogFile
}
return filepath.ToSlash(rel)
}
func lineText(e *walk.LineEdit) string {
if e == nil {
return ""
}
return e.Text()
}
func numValue(e *walk.NumberEdit, def int) int {
if e == nil {
return def
}
v := int(e.Value())
if v == 0 {
return def
}
return v
}
func boxChecked(b *walk.CheckBox) bool {
return b != nil && b.Checked()
}
func nonzero(v, def int) int {
if v == 0 {
return def
}
return v
}
func orDefault(s, def string) string {
if strings.TrimSpace(s) == "" {
return def
}
return s
}
func fieldLabel(text string) Label {
return Label{Text: text, Alignment: AlignHFarVCenter, MinSize: Size{Width: 72}}
}
func (u *ui) applyBranding() error {
if len(logoPNG) == 0 {
return nil
}
img, err := png.Decode(bytes.NewReader(logoPNG))
if err != nil {
return nil
}
if ico, err := walk.NewIconFromImage(img); err == nil {
u.appIcon = ico
if u.mw != nil {
_ = u.mw.SetIcon(ico)
}
}
if bmp, err := walk.NewBitmapFromImage(img); err == nil {
u.appBmp = bmp
if u.logoView != nil {
_ = u.logoView.SetImage(bmp)
}
}
return nil
}
func timeoutSec(cfg *config.Config) int {
if cfg == nil || cfg.RequestTimeout <= 0 {
return config.DefaultTimeoutSec
}
return int(cfg.RequestTimeout.Seconds())
}
func hideConsoleIfStandalone() {
var pids [16]uint32
n, _, _ := procGetConsoleProcessList.Call(uintptr(unsafe.Pointer(&pids[0])), uintptr(len(pids)))
if n == 0 || n > 1 {
return
}
hwnd, _, _ := procGetConsoleWindow.Call()
if hwnd == 0 {
return
}
procShowWindow.Call(hwnd, 0)
}
var (
user32 = windows.NewLazySystemDLL("user32.dll")
kernel32 = windows.NewLazySystemDLL("kernel32.dll")
procShowWindow = user32.NewProc("ShowWindow")
procGetConsoleWindow = kernel32.NewProc("GetConsoleWindow")
procGetConsoleProcessList = kernel32.NewProc("GetConsoleProcessList")
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

+262
View File
@@ -0,0 +1,262 @@
package logger
import (
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
)
const (
RetainDays = 7
dateLayout = "2006-01-02"
)
type Level int
const (
LevelDebug Level = iota
LevelInfo
LevelWarn
LevelError
)
func ParseLevel(s string) Level {
switch strings.ToLower(strings.TrimSpace(s)) {
case "debug":
return LevelDebug
case "warn", "warning":
return LevelWarn
case "error":
return LevelError
default:
return LevelInfo
}
}
func (l Level) String() string {
switch l {
case LevelDebug:
return "DEBUG"
case LevelWarn:
return "WARN"
case LevelError:
return "ERROR"
default:
return "INFO"
}
}
type Logger struct {
mu sync.Mutex
level Level
out io.Writer
file *os.File
basePath string // e.g. .../logs/tunnel.log → files tunnel-YYYY-MM-DD.log
day string // current file date YYYY-MM-DD
logSQL bool
}
func New(path string, level string, logSQL bool) (*Logger, error) {
l := &Logger{
level: ParseLevel(level),
logSQL: logSQL,
}
if strings.TrimSpace(path) == "" {
l.out = os.Stdout
return l, nil
}
l.basePath = path
if err := l.rotateLocked(time.Now()); err != nil {
return nil, err
}
return l, nil
}
func (l *Logger) Close() error {
if l == nil {
return nil
}
l.mu.Lock()
defer l.mu.Unlock()
if l.file == nil {
return nil
}
err := l.file.Close()
l.file = nil
l.out = os.Stdout
return err
}
func (l *Logger) LogSQLEnabled() bool {
return l.logSQL
}
func (l *Logger) Debug(msg string, args ...any) { l.log(LevelDebug, msg, args...) }
func (l *Logger) Info(msg string, args ...any) { l.log(LevelInfo, msg, args...) }
func (l *Logger) Warn(msg string, args ...any) { l.log(LevelWarn, msg, args...) }
func (l *Logger) Error(msg string, args ...any) { l.log(LevelError, msg, args...) }
func (l *Logger) log(level Level, msg string, args ...any) {
if l == nil {
return
}
if level < l.level {
return
}
now := time.Now()
line := fmt.Sprintf("%s %s %s", now.Format("2006-01-02 15:04:05"), level.String(), format(msg, args...))
l.mu.Lock()
defer l.mu.Unlock()
if l.basePath != "" {
_ = l.rotateLocked(now)
}
fmt.Fprintln(l.out, line)
}
// rotateLocked opens today's file when the calendar day changes. Caller holds mu.
func (l *Logger) rotateLocked(now time.Time) error {
day := now.Format(dateLayout)
if l.file != nil && l.day == day {
return nil
}
path := DatedPath(l.basePath, now)
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return fmt.Errorf("create log dir: %w", err)
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o640)
if err != nil {
return fmt.Errorf("open log file: %w", err)
}
if l.file != nil {
_ = l.file.Close()
}
l.file = f
l.day = day
l.out = io.MultiWriter(os.Stdout, f)
_ = purgeOld(l.basePath, now, RetainDays)
return nil
}
// DatedPath turns logs/tunnel.log into logs/tunnel-2006-01-02.log.
func DatedPath(base string, day time.Time) string {
dir := filepath.Dir(base)
ext := filepath.Ext(base)
stem := strings.TrimSuffix(filepath.Base(base), ext)
if stem == "" || stem == "." {
stem = "tunnel"
}
if ext == "" {
ext = ".log"
}
return filepath.Join(dir, stem+"-"+day.Format(dateLayout)+ext)
}
func purgeOld(base string, now time.Time, retainDays int) error {
if retainDays < 1 {
retainDays = RetainDays
}
dir := filepath.Dir(base)
ext := filepath.Ext(base)
stem := strings.TrimSuffix(filepath.Base(base), ext)
if stem == "" || stem == "." {
stem = "tunnel"
}
if ext == "" {
ext = ".log"
}
re := regexp.MustCompile("^" + regexp.QuoteMeta(stem) + `-(\d{4}-\d{2}-\d{2})` + regexp.QuoteMeta(ext) + `$`)
// Keep retainDays calendar days including today (e.g. 7 → today back through today-6).
oldest := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).
AddDate(0, 0, -(retainDays - 1))
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
for _, e := range entries {
if e.IsDir() {
continue
}
m := re.FindStringSubmatch(e.Name())
if m == nil {
continue
}
d, err := time.ParseInLocation(dateLayout, m[1], now.Location())
if err != nil {
continue
}
if d.Before(oldest) {
_ = os.Remove(filepath.Join(dir, e.Name()))
}
}
return nil
}
func format(msg string, args ...any) string {
if len(args) == 0 {
return msg
}
return msg + " " + strings.TrimSpace(fmt.Sprint(args...))
}
// RedactURL removes userinfo and known secret query keys.
func RedactURL(raw string) string {
u, err := url.Parse(raw)
if err != nil || raw == "" {
return "(redacted-url)"
}
u.User = nil
q := u.Query()
changed := false
for key := range q {
lk := strings.ToLower(key)
if lk == "token" || lk == "password" || lk == "pass" || strings.Contains(lk, "secret") || strings.Contains(lk, "access") {
q.Set(key, "***")
changed = true
}
}
if changed {
u.RawQuery = q.Encode()
}
u.Fragment = ""
return u.String()
}
var secretKV = regexp.MustCompile(`(?i)(token|password|passwd|authorization)\s*[=:]\s*[^\s&;]+`)
func RedactError(err error) string {
if err == nil {
return ""
}
return RedactText(err.Error())
}
func RedactText(s string) string {
out := secretKV.ReplaceAllString(s, "${1}=***")
if u := extractURL(out); u != "" {
out = strings.ReplaceAll(out, u, RedactURL(u))
}
return out
}
func extractURL(s string) string {
lower := strings.ToLower(s)
start := strings.Index(lower, "https://")
if start < 0 {
start = strings.Index(lower, "http://")
}
if start < 0 {
return ""
}
end := start
for end < len(s) && !strings.ContainsRune(" \t\r\n<>\"'", rune(s[end])) {
end++
}
return s[start:end]
}
+87
View File
@@ -0,0 +1,87 @@
package logger
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestRedactURLToken(t *testing.T) {
raw := "http://ntunnel.example.com/?token=secret-token-value"
got := RedactURL(raw)
if strings.Contains(got, "secret-token-value") {
t.Fatalf("token leaked: %s", got)
}
}
func TestRedactTextPasswordAndToken(t *testing.T) {
in := `login failed password=hunter2 token=abc123 url=https://x.example/?token=zzz`
got := RedactText(in)
for _, secret := range []string{"hunter2", "abc123", "zzz"} {
if strings.Contains(got, secret) {
t.Fatalf("secret %q leaked in %q", secret, got)
}
}
}
func TestParseLevel(t *testing.T) {
if ParseLevel("DEBUG") != LevelDebug {
t.Fatal("debug")
}
if ParseLevel("nope") != LevelInfo {
t.Fatal("default")
}
}
func TestDatedPath(t *testing.T) {
day := time.Date(2026, 9, 21, 15, 0, 0, 0, time.Local)
got := DatedPath(filepath.Join("logs", "tunnel.log"), day)
want := filepath.Join("logs", "tunnel-2026-09-21.log")
if got != want {
t.Fatalf("got %s want %s", got, want)
}
}
func TestDailyRotateAndPurge(t *testing.T) {
dir := t.TempDir()
base := filepath.Join(dir, "tunnel.log")
now := time.Date(2026, 9, 21, 10, 0, 0, 0, time.Local)
keepDay := now.AddDate(0, 0, -3)
dropDay := now.AddDate(0, 0, -8)
keepPath := DatedPath(base, keepDay)
dropPath := DatedPath(base, dropDay)
if err := os.WriteFile(keepPath, []byte("keep\n"), 0o640); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(dropPath, []byte("drop\n"), 0o640); err != nil {
t.Fatal(err)
}
l, err := New(base, "info", false)
if err != nil {
t.Fatal(err)
}
defer l.Close()
l.mu.Lock()
if err := l.rotateLocked(now); err != nil {
l.mu.Unlock()
t.Fatal(err)
}
l.mu.Unlock()
l.Info("hello")
today := DatedPath(base, now)
if _, err := os.Stat(today); err != nil {
t.Fatalf("today log missing: %v", err)
}
if _, err := os.Stat(keepPath); err != nil {
t.Fatalf("recent log should remain: %v", err)
}
if _, err := os.Stat(dropPath); !os.IsNotExist(err) {
t.Fatalf("old log should be purged, err=%v", err)
}
}
+44
View File
@@ -0,0 +1,44 @@
package mysqlserver
import (
"crypto/sha1"
"crypto/subtle"
)
func nativePasswordToken(password string, scramble []byte) []byte {
if password == "" {
return []byte{}
}
if len(scramble) > 20 {
scramble = scramble[:20]
}
hash1 := sha1.Sum([]byte(password))
hash2 := sha1.Sum(hash1[:])
h := sha1.New()
h.Write(scramble)
h.Write(hash2[:])
hash3 := h.Sum(nil)
out := make([]byte, 20)
for i := 0; i < 20; i++ {
out[i] = hash1[i] ^ hash3[i]
}
return out
}
func verifyNativePassword(password string, scramble, auth []byte) bool {
if password == "" {
return len(auth) == 0
}
want := nativePasswordToken(password, scramble)
if len(auth) != len(want) {
return false
}
return subtle.ConstantTimeCompare(auth, want) == 1
}
func usernamesEqual(a, b string) bool {
if len(a) != len(b) {
return false
}
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
+105
View File
@@ -0,0 +1,105 @@
package mysqlserver
import (
"fmt"
"io"
"net"
"time"
)
type packetConn struct {
c net.Conn
seq uint8
max int
readTO time.Duration
writeTO time.Duration
}
func newPacketConn(c net.Conn, timeout time.Duration) *packetConn {
return &packetConn{c: c, max: MaxPacketSize, readTO: timeout, writeTO: timeout}
}
func (p *packetConn) resetSeq() { p.seq = 0 }
func (p *packetConn) readPacket() ([]byte, error) {
var payload []byte
for {
if p.readTO > 0 {
_ = p.c.SetReadDeadline(time.Now().Add(p.readTO))
}
var hdr [4]byte
if _, err := io.ReadFull(p.c, hdr[:]); err != nil {
return nil, err
}
n := int(readU24(hdr[:3]))
seq := hdr[3]
if seq != p.seq {
return nil, fmt.Errorf("sequence mismatch: got %d want %d", seq, p.seq)
}
p.seq++
if n == 0 {
return payload, nil
}
if n > p.max {
return nil, fmt.Errorf("packet too large: %d", n)
}
chunk := make([]byte, n)
if _, err := io.ReadFull(p.c, chunk); err != nil {
return nil, err
}
payload = append(payload, chunk...)
if n < 0xffffff {
return payload, nil
}
}
}
func (p *packetConn) writePacket(payload []byte) error {
off := 0
for {
n := len(payload) - off
if n > 0xffffff {
n = 0xffffff
}
var hdr [4]byte
hdr[0] = byte(n)
hdr[1] = byte(n >> 8)
hdr[2] = byte(n >> 16)
hdr[3] = p.seq
p.seq++
if p.writeTO > 0 {
_ = p.c.SetWriteDeadline(time.Now().Add(p.writeTO))
}
if _, err := p.c.Write(hdr[:]); err != nil {
return err
}
if n > 0 {
if _, err := p.c.Write(payload[off : off+n]); err != nil {
return err
}
}
off += n
if n < 0xffffff {
return nil
}
}
}
func (p *packetConn) writeOK(affected, lastInsertID uint64, status, warnings uint16) error {
return p.writePacket(encodeOK(affected, lastInsertID, status, warnings))
}
func (p *packetConn) writeERR(errno uint16, msg string) error {
return p.writePacket(encodeERR(errno, msg))
}
func (p *packetConn) remote() string {
if p.c.RemoteAddr() == nil {
return ""
}
return p.c.RemoteAddr().String()
}
func (p *packetConn) close() error {
return p.c.Close()
}
+3
View File
@@ -0,0 +1,3 @@
// Package mysqlserver implements a loopback MySQL protocol server.
// Yingdao connects here; COM_QUERY is forwarded through the Navicat HTTP tunnel.
package mysqlserver
+57
View File
@@ -0,0 +1,57 @@
package mysqlserver
import (
"database/sql"
"fmt"
"net"
"testing"
_ "github.com/go-sql-driver/mysql"
)
func TestGoSQLDriverCompat(t *testing.T) {
addr, _ := startServer(t, stubBackend{}, "root", "123456")
host, port, err := net.SplitHostPort(addr)
if err != nil {
t.Fatal(err)
}
dsn := fmt.Sprintf("root:123456@tcp(%s:%s)/app?timeout=5s&readTimeout=5s&writeTimeout=5s&charset=utf8mb4&parseTime=false", host, port)
db, err := sql.Open("mysql", dsn)
if err != nil {
t.Fatal(err)
}
defer db.Close()
db.SetMaxOpenConns(1)
var v string
if err := db.QueryRow("SELECT 1").Scan(&v); err != nil {
t.Fatal(err)
}
if v != "1" {
t.Fatalf("got %q", v)
}
var n sql.NullString
if err := db.QueryRow("SELECT NULL AS n").Scan(&n); err != nil {
t.Fatal(err)
}
if n.Valid {
t.Fatal("expected NULL")
}
var big string
if err := db.QueryRow("SELECT bigint_val").Scan(&big); err != nil {
t.Fatal(err)
}
if big != "18446744073709551615" {
t.Fatalf("bigint %q", big)
}
var zh string
if err := db.QueryRow("SELECT zh_val").Scan(&zh); err != nil {
t.Fatal(err)
}
if zh != "你好世界" {
t.Fatalf("zh %q", zh)
}
}
+138
View File
@@ -0,0 +1,138 @@
package mysqlserver
import "crypto/rand"
func buildHandshake(connID uint32, scramble []byte) []byte {
if len(scramble) < 20 {
s := make([]byte, 20)
copy(s, scramble)
scramble = s
}
b := []byte{ProtocolVersion}
b = appendNUL(b, ServerVersion)
b = appendU32(b, connID)
b = append(b, scramble[:8]...)
b = append(b, 0x00)
b = appendU16(b, uint16(serverCapabilities))
b = append(b, charsetUTF8MB4)
b = appendU16(b, serverStatusAutocommit)
b = appendU16(b, uint16(serverCapabilities>>16))
b = append(b, 21) // auth plugin data len: 20 bytes + NUL
b = append(b, make([]byte, 10)...)
b = append(b, scramble[8:20]...)
b = append(b, 0x00)
b = appendNUL(b, AuthPluginNative)
return b
}
func randomScramble() ([]byte, error) {
s := make([]byte, 20)
if _, err := rand.Read(s); err != nil {
return nil, err
}
for i, c := range s {
if c == 0 {
s[i] = 1
}
}
return s, nil
}
type handshakeResponse struct {
Capabilities uint32
MaxPacket uint32
Charset byte
Username string
Auth []byte
Database string
Plugin string
SSL bool
}
func parseHandshakeResponse(payload []byte) (handshakeResponse, error) {
var hr handshakeResponse
if len(payload) < 32 {
return hr, errPacket("handshake response too short")
}
hr.Capabilities = uint32(payload[0]) | uint32(payload[1])<<8 | uint32(payload[2])<<16 | uint32(payload[3])<<24
hr.MaxPacket = uint32(payload[4]) | uint32(payload[5])<<8 | uint32(payload[6])<<16 | uint32(payload[7])<<24
hr.Charset = payload[8]
hr.SSL = hr.Capabilities&clientSSL != 0
if hr.SSL && len(payload) <= 32 {
return hr, nil
}
if hr.Capabilities&clientProtocol41 == 0 {
return hr, errPacket("CLIENT_PROTOCOL_41 required")
}
off := 32
user, off, err := readNUL(payload, off)
if err != nil {
return hr, err
}
hr.Username = user
switch {
case hr.Capabilities&clientPluginAuthLenencClientData != 0:
n, nOff, err := readLenencInt(payload, off)
if err != nil {
return hr, err
}
off = nOff
end := off + int(n)
if end > len(payload) {
return hr, errPacket("auth-response truncated")
}
hr.Auth = append([]byte(nil), payload[off:end]...)
off = end
case hr.Capabilities&clientSecureConnection != 0:
if off >= len(payload) {
return hr, errPacket("auth-response truncated")
}
n := int(payload[off])
off++
end := off + n
if end > len(payload) {
return hr, errPacket("auth-response truncated")
}
hr.Auth = append([]byte(nil), payload[off:end]...)
off = end
default:
s, nOff, err := readNUL(payload, off)
if err != nil {
return hr, err
}
hr.Auth = []byte(s)
off = nOff
}
if hr.Capabilities&clientConnectWithDB != 0 && off < len(payload) {
db, nOff, err := readNUL(payload, off)
if err != nil {
return hr, err
}
hr.Database = db
off = nOff
}
if hr.Capabilities&clientPluginAuth != 0 && off < len(payload) {
plugin, _, err := readNUL(payload, off)
if err != nil {
return hr, err
}
hr.Plugin = plugin
}
return hr, nil
}
func encodeAuthSwitch(plugin string, scramble []byte) []byte {
b := []byte{markerEOF} // 0xFE AuthSwitchRequest
b = appendNUL(b, plugin)
b = append(b, scramble...)
b = append(b, 0x00)
return b
}
type protoError struct{ s string }
func (e protoError) Error() string { return e.s }
func errPacket(s string) error { return protoError{s} }
+263
View File
@@ -0,0 +1,263 @@
package mysqlserver
import (
"encoding/binary"
"fmt"
"io"
)
const (
ProtocolVersion = 10
ServerVersion = "8.0.0-mysql-tunnel"
AuthPluginNative = "mysql_native_password"
AuthPluginSHA2 = "caching_sha2_password"
MaxPacketSize = 16 << 20
)
const (
ComQuit byte = 0x01
ComInitDB byte = 0x02
ComQuery byte = 0x03
ComFieldList byte = 0x04
ComPing byte = 0x0e
ComStmtPrepare byte = 0x16
ComStmtExecute byte = 0x17
ComStmtClose byte = 0x19
ComResetConnection byte = 0x1f
)
const (
clientLongPassword uint32 = 1
clientFoundRows uint32 = 1 << 1
clientLongFlag uint32 = 1 << 2
clientConnectWithDB uint32 = 1 << 3
clientProtocol41 uint32 = 1 << 9
clientSSL uint32 = 1 << 11
clientTransactions uint32 = 1 << 13
clientSecureConnection uint32 = 1 << 15
clientMultiStatements uint32 = 1 << 16
clientMultiResults uint32 = 1 << 17
clientPSMultiResults uint32 = 1 << 18
clientPluginAuth uint32 = 1 << 19
clientConnectAttrs uint32 = 1 << 20
clientPluginAuthLenencClientData uint32 = 1 << 21
clientSessionTrack uint32 = 1 << 23
clientDeprecateEOF uint32 = 1 << 24
)
const (
serverStatusAutocommit uint16 = 0x0002
)
const (
charsetUTF8MB4 uint8 = 45
charsetBinary uint8 = 63
)
const (
typeDecimal byte = 0
typeTiny byte = 1
typeShort byte = 2
typeLong byte = 3
typeFloat byte = 4
typeDouble byte = 5
typeNull byte = 6
typeTimestamp byte = 7
typeLongLong byte = 8
typeInt24 byte = 9
typeDate byte = 10
typeTime byte = 11
typeDatetime byte = 12
typeYear byte = 13
typeJSON byte = 245
typeNewDecimal byte = 246
typeTinyBlob byte = 249
typeMediumBlob byte = 250
typeLongBlob byte = 251
typeBlob byte = 252
typeVarString byte = 253
typeString byte = 254
)
const (
flagNotNull uint16 = 1
flagPriKey uint16 = 2
flagUniqueKey uint16 = 4
flagMultipleKey uint16 = 8
flagBlob uint16 = 16
flagUnsigned uint16 = 32
flagZerofill uint16 = 64
flagBinary uint16 = 128
flagEnum uint16 = 256
flagAutoIncrement uint16 = 512
flagTimestamp uint16 = 1024
flagSet uint16 = 2048
)
const (
errAccessDenied uint16 = 1045
errUnknownCommand uint16 = 1047
errNoDB uint16 = 1046
errParseError uint16 = 1064
errUnknown uint16 = 1105
errNotSupported uint16 = 1235
)
const (
markerOK byte = 0x00
markerEOF byte = 0xfe
markerERR byte = 0xff
markerNull byte = 0xfb
)
var serverCapabilities = clientLongPassword | clientFoundRows | clientLongFlag |
clientConnectWithDB | clientProtocol41 | clientTransactions | clientSecureConnection |
clientMultiResults | clientPluginAuth | clientPluginAuthLenencClientData |
clientConnectAttrs | clientDeprecateEOF | clientSessionTrack
func appendU24(dst []byte, n uint32) []byte {
return append(dst, byte(n), byte(n>>8), byte(n>>16))
}
func appendU32(dst []byte, n uint32) []byte {
b := make([]byte, 4)
binary.LittleEndian.PutUint32(b, n)
return append(dst, b...)
}
func appendU16(dst []byte, n uint16) []byte {
return append(dst, byte(n), byte(n>>8))
}
func appendNUL(dst []byte, s string) []byte {
dst = append(dst, s...)
return append(dst, 0)
}
func appendLenencInt(dst []byte, n uint64) []byte {
switch {
case n < 251:
return append(dst, byte(n))
case n < 1<<16:
return append(dst, 0xfc, byte(n), byte(n>>8))
case n < 1<<24:
return append(dst, 0xfd, byte(n), byte(n>>8), byte(n>>16))
default:
b := make([]byte, 8)
binary.LittleEndian.PutUint64(b, n)
return append(append(dst, 0xfe), b...)
}
}
func appendLenencString(dst []byte, s []byte) []byte {
dst = appendLenencInt(dst, uint64(len(s)))
return append(dst, s...)
}
func readU24(b []byte) uint32 {
return uint32(b[0]) | uint32(b[1])<<8 | uint32(b[2])<<16
}
func readLenencInt(b []byte, off int) (uint64, int, error) {
if off >= len(b) {
return 0, off, io.ErrUnexpectedEOF
}
switch b[off] {
case 0xfb:
return 0, off + 1, fmt.Errorf("NULL lenenc")
case 0xfc:
if off+3 > len(b) {
return 0, off, io.ErrUnexpectedEOF
}
return uint64(b[off+1]) | uint64(b[off+2])<<8, off + 3, nil
case 0xfd:
if off+4 > len(b) {
return 0, off, io.ErrUnexpectedEOF
}
return uint64(b[off+1]) | uint64(b[off+2])<<8 | uint64(b[off+3])<<16, off + 4, nil
case 0xfe:
if off+9 > len(b) {
return 0, off, io.ErrUnexpectedEOF
}
return binary.LittleEndian.Uint64(b[off+1 : off+9]), off + 9, nil
default:
return uint64(b[off]), off + 1, nil
}
}
func readLenencString(b []byte, off int) ([]byte, int, error) {
n, off, err := readLenencInt(b, off)
if err != nil {
return nil, off, err
}
end := off + int(n)
if end > len(b) {
return nil, off, io.ErrUnexpectedEOF
}
return b[off:end], end, nil
}
func readNUL(b []byte, off int) (string, int, error) {
for i := off; i < len(b); i++ {
if b[i] == 0 {
return string(b[off:i]), i + 1, nil
}
}
return string(b[off:]), len(b), nil
}
func sqlState(errno uint16) string {
switch errno {
case errAccessDenied:
return "28000"
case errParseError:
return "42000"
case errNoDB:
return "3D000"
case errUnknownCommand, errNotSupported:
return "HY000"
default:
return "HY000"
}
}
func encodeOK(affected, lastInsertID uint64, status, warnings uint16) []byte {
b := []byte{markerOK}
b = appendLenencInt(b, affected)
b = appendLenencInt(b, lastInsertID)
b = appendU16(b, status)
b = appendU16(b, warnings)
return b
}
func encodeERR(errno uint16, msg string) []byte {
b := []byte{markerERR}
b = appendU16(b, errno)
b = append(b, '#')
state := sqlState(errno)
if len(state) != 5 {
state = "HY000"
}
b = append(b, state...)
b = append(b, msg...)
return b
}
func encodeEOF(warnings, status uint16) []byte {
b := []byte{markerEOF}
b = appendU16(b, warnings)
b = appendU16(b, status)
return b
}
func isNumericType(t byte) bool {
switch t {
case typeDecimal, typeTiny, typeShort, typeLong, typeFloat, typeDouble,
typeNull, typeLongLong, typeInt24, typeYear, typeNewDecimal, typeBit:
return true
}
return false
}
const typeBit byte = 16
+122
View File
@@ -0,0 +1,122 @@
package mysqlserver
import (
"bytes"
"testing"
"mysql-tunnel/internal/navicat"
)
func TestLenencInt(t *testing.T) {
cases := []uint64{0, 1, 250, 251, 0xffff, 0x10000, 1 << 32}
for _, n := range cases {
enc := appendLenencInt(nil, n)
got, _, err := readLenencInt(enc, 0)
if err != nil || got != n {
t.Fatalf("n=%d got=%d err=%v", n, got, err)
}
}
}
func TestOKERRPackets(t *testing.T) {
ok := encodeOK(3, 9, serverStatusAutocommit, 0)
if ok[0] != markerOK {
t.Fatal("ok header")
}
n, off, err := readLenencInt(ok, 1)
if err != nil || n != 3 {
t.Fatal(n, err)
}
id, _, err := readLenencInt(ok, off)
if err != nil || id != 9 {
t.Fatal(id, err)
}
er := encodeERR(1064, "syntax")
if er[0] != markerERR || er[1] != 0x28 || er[2] != 0x04 {
t.Fatalf("%x", er)
}
if !bytes.Contains(er, []byte("syntax")) {
t.Fatal(string(er))
}
}
func TestNativePasswordRoundtrip(t *testing.T) {
scramble := bytes.Repeat([]byte{3}, 20)
tok := nativePasswordToken("123456", scramble)
if !verifyNativePassword("123456", scramble, tok) {
t.Fatal("should match")
}
if verifyNativePassword("wrong", scramble, tok) {
t.Fatal("should fail")
}
if !verifyNativePassword("", scramble, nil) {
t.Fatal("empty")
}
}
func TestHandshakeBuildParseResponse(t *testing.T) {
scramble := bytes.Repeat([]byte{7}, 20)
hs := buildHandshake(42, scramble)
if hs[0] != ProtocolVersion {
t.Fatal("proto")
}
if !bytes.Contains(hs, []byte(ServerVersion)) {
t.Fatal("version")
}
if !bytes.Contains(hs, []byte(AuthPluginNative)) {
t.Fatal("plugin")
}
token := nativePasswordToken("p", scramble)
resp := buildClientAuth("root", token, "app", clientProtocol41|clientSecureConnection|clientPluginAuth|clientConnectWithDB|clientPluginAuthLenencClientData)
hr, err := parseHandshakeResponse(resp)
if err != nil {
t.Fatal(err)
}
if hr.Username != "root" || hr.Database != "app" || !bytes.Equal(hr.Auth, token) {
t.Fatalf("%+v", hr)
}
}
func TestTextRowNULLAndBigint(t *testing.T) {
row := encodeTextRow([]navicat.Cell{
{Null: true},
{Bytes: []byte("18446744073709551615")},
{Bytes: []byte("1.50")},
{Bytes: []byte("你好")},
})
if row[0] != markerNull {
t.Fatal("null marker")
}
off := 1
b, off, err := readLenencString(row, off)
if err != nil || string(b) != "18446744073709551615" {
t.Fatal(string(b), err)
}
b, off, err = readLenencString(row, off)
if string(b) != "1.50" {
t.Fatal(string(b))
}
b, _, err = readLenencString(row, off)
if string(b) != "你好" {
t.Fatal(string(b))
}
}
func buildClientAuth(user string, token []byte, db string, caps uint32) []byte {
b := appendU32(nil, caps)
b = appendU32(b, MaxPacketSize)
b = append(b, charsetUTF8MB4)
b = append(b, make([]byte, 23)...)
b = appendNUL(b, user)
b = appendLenencInt(b, uint64(len(token)))
b = append(b, token...)
if caps&clientConnectWithDB != 0 {
b = appendNUL(b, db)
}
if caps&clientPluginAuth != 0 {
b = appendNUL(b, AuthPluginNative)
}
return b
}
+95
View File
@@ -0,0 +1,95 @@
package mysqlserver
import (
"mysql-tunnel/internal/navicat"
)
func encodeColumnCount(n int) []byte {
return appendLenencInt(nil, uint64(n))
}
func encodeColumn(f navicat.Field) []byte {
typ := byte(f.Type)
charset := uint16(charsetUTF8MB4)
if isNumericType(typ) || f.Flags&uint32(flagBinary) != 0 && isBlobType(typ) {
charset = uint16(charsetBinary)
}
if isNumericType(typ) {
charset = uint16(charsetBinary)
}
length := f.Length
if length == 0 {
length = uint32(len(f.Name))
if length == 0 {
length = 64
}
}
flags := uint16(f.Flags)
decimals := byte(0)
if typ == typeFloat || typ == typeDouble || typ == typeNewDecimal || typ == typeDecimal {
decimals = 31
}
b := appendLenencString(nil, []byte("def"))
b = appendLenencString(b, nil) // schema
b = appendLenencString(b, []byte(f.Table))
b = appendLenencString(b, []byte(f.Table))
b = appendLenencString(b, []byte(f.Name))
b = appendLenencString(b, []byte(f.Name))
b = appendLenencInt(b, 0x0c)
b = appendU16(b, charset)
b = appendU32(b, length)
b = append(b, typ)
b = appendU16(b, flags)
b = append(b, decimals, 0x00, 0x00)
return b
}
func encodeTextRow(row []navicat.Cell) []byte {
var b []byte
for _, c := range row {
if c.Null {
b = append(b, markerNull)
continue
}
b = appendLenencString(b, c.Bytes)
}
return b
}
func isBlobType(t byte) bool {
switch t {
case typeTinyBlob, typeMediumBlob, typeLongBlob, typeBlob:
return true
}
return false
}
func (p *packetConn) writeResultSet(res navicat.Result, caps uint32, status uint16) error {
if err := p.writePacket(encodeColumnCount(len(res.Fields))); err != nil {
return err
}
for _, f := range res.Fields {
if err := p.writePacket(encodeColumn(f)); err != nil {
return err
}
}
if caps&clientDeprecateEOF != 0 {
if err := p.writeOK(0, 0, status, 0); err != nil {
return err
}
} else {
if err := p.writePacket(encodeEOF(0, status)); err != nil {
return err
}
}
for _, row := range res.Rows {
if err := p.writePacket(encodeTextRow(row)); err != nil {
return err
}
}
if caps&clientDeprecateEOF != 0 {
return p.writeOK(uint64(res.AffectedRows), uint64(res.InsertID), status, 0)
}
return p.writePacket(encodeEOF(0, status))
}
+194
View File
@@ -0,0 +1,194 @@
package mysqlserver
import (
"context"
"fmt"
"net"
"strconv"
"sync"
"sync/atomic"
"time"
"mysql-tunnel/internal/logger"
)
type Options struct {
Host string
Port int
Username string
Password string
DefaultDB string
MaxConnections int
Timeout time.Duration
LogSQL bool
}
type Server struct {
opt Options
backend Backend
log *logger.Logger
ln net.Listener
connID atomic.Uint32
sem chan struct{}
wg sync.WaitGroup
}
func New(opt Options, backend Backend, log *logger.Logger) *Server {
if opt.Host == "" {
opt.Host = "127.0.0.1"
}
if opt.Port == 0 {
opt.Port = 13306
}
if opt.MaxConnections < 1 {
opt.MaxConnections = 5
}
if opt.Timeout <= 0 {
opt.Timeout = 30 * time.Second
}
return &Server{
opt: opt,
backend: backend,
log: log,
sem: make(chan struct{}, opt.MaxConnections),
}
}
func (s *Server) Addr() string {
if s.ln != nil {
return s.ln.Addr().String()
}
return net.JoinHostPort(s.opt.Host, strconv.Itoa(s.opt.Port))
}
func (s *Server) ListenAndServe(ctx context.Context) error {
ln, err := net.Listen("tcp", net.JoinHostPort(s.opt.Host, strconv.Itoa(s.opt.Port)))
if err != nil {
return err
}
return s.Serve(ctx, ln)
}
func (s *Server) Serve(ctx context.Context, ln net.Listener) error {
s.ln = ln
if s.log != nil {
s.log.Info("server started", ln.Addr().String())
}
go func() {
<-ctx.Done()
_ = ln.Close()
}()
for {
c, err := ln.Accept()
if err != nil {
select {
case <-ctx.Done():
s.wg.Wait()
return nil
default:
return err
}
}
select {
case s.sem <- struct{}{}:
default:
_ = c.Close()
if s.log != nil {
s.log.Warn("max connections reached, rejected", c.RemoteAddr().String())
}
continue
}
s.wg.Add(1)
go func(c net.Conn) {
defer s.wg.Done()
defer func() { <-s.sem }()
s.handle(ctx, c)
}(c)
}
}
func (s *Server) handle(ctx context.Context, c net.Conn) {
pc := newPacketConn(c, s.opt.Timeout)
remote := pc.remote()
if s.log != nil {
s.log.Info("client connected", remote)
}
defer func() {
_ = pc.close()
if s.log != nil {
s.log.Info("client disconnected", remote)
}
}()
scramble, err := randomScramble()
if err != nil {
return
}
id := s.connID.Add(1)
if err := pc.writePacket(buildHandshake(id, scramble)); err != nil {
return
}
payload, err := pc.readPacket()
if err != nil {
return
}
hr, err := parseHandshakeResponse(payload)
if err != nil {
_ = pc.writeERR(errUnknown, "bad handshake")
return
}
if hr.SSL {
_ = pc.writeERR(errNotSupported, "SSL is not supported on the local tunnel")
return
}
auth := hr.Auth
plugin := hr.Plugin
if plugin == "" {
plugin = AuthPluginNative
}
if plugin != AuthPluginNative {
if err := pc.writePacket(encodeAuthSwitch(AuthPluginNative, scramble)); err != nil {
return
}
auth, err = pc.readPacket()
if err != nil {
return
}
if len(auth) > 0 && auth[len(auth)-1] == 0 && len(auth) != 20 {
auth = auth[:len(auth)-1]
}
}
db := hr.Database
if db == "" {
db = s.opt.DefaultDB
}
if len(auth) == 21 && auth[20] == 0 {
auth = auth[:20]
}
if !usernamesEqual(hr.Username, s.opt.Username) || !verifyNativePassword(s.opt.Password, scramble, auth) {
_ = pc.writeERR(errAccessDenied, fmt.Sprintf("Access denied for user '%s'@'localhost'", hr.Username))
return
}
if err := pc.writeOK(0, 0, serverStatusAutocommit, 0); err != nil {
return
}
sess := &session{
pc: pc,
backend: s.backend,
log: s.log,
logSQL: s.opt.LogSQL,
timeout: func() (context.Context, context.CancelFunc) {
return context.WithTimeout(ctx, s.opt.Timeout)
},
user: hr.Username,
db: db,
defaultDB: s.opt.DefaultDB,
caps: hr.Capabilities,
status: serverStatusAutocommit,
scramble: scramble,
}
sess.serve(ctx)
}
+423
View File
@@ -0,0 +1,423 @@
package mysqlserver
import (
"context"
"fmt"
"net"
"net/http/httptest"
"strings"
"testing"
"time"
"mysql-tunnel/internal/navicat"
"mysql-tunnel/internal/tunnel"
)
type stubBackend struct{}
func (stubBackend) Query(_ context.Context, _ string, sqls ...string) (*navicat.QueryResponse, error) {
var results []navicat.Result
for _, sql := range sqls {
s := strings.TrimSpace(sql)
u := strings.ToUpper(s)
switch {
case strings.HasPrefix(u, "SET NAMES"), strings.HasPrefix(u, "USE "):
results = append(results, navicat.Result{})
case u == "SELECT 1" || strings.HasPrefix(u, "SELECT 1 "):
results = append(results, navicat.Result{
NumFields: 1, NumRows: 1,
Fields: []navicat.Field{{Name: "1", Type: 8, Length: 20}},
Rows: [][]navicat.Cell{{{Bytes: []byte("1")}}},
})
case strings.Contains(u, "SELECT NULL"):
results = append(results, navicat.Result{
NumFields: 1, NumRows: 1,
Fields: []navicat.Field{{Name: "n", Type: 6}},
Rows: [][]navicat.Cell{{{Null: true}}},
})
case strings.Contains(strings.ToLower(s), "bigint_val"):
results = append(results, navicat.Result{
NumFields: 1, NumRows: 1,
Fields: []navicat.Field{{Name: "bigint_val", Type: 8, Flags: 32, Length: 20}},
Rows: [][]navicat.Cell{{{Bytes: []byte("18446744073709551615")}}},
})
case strings.Contains(strings.ToLower(s), "dec_val"):
results = append(results, navicat.Result{
NumFields: 1, NumRows: 1,
Fields: []navicat.Field{{Name: "dec_val", Type: 246, Length: 32}},
Rows: [][]navicat.Cell{{{Bytes: []byte("1234567890.123456789")}}},
})
case strings.Contains(s, "你好") || strings.Contains(strings.ToLower(s), "zh_val"):
results = append(results, navicat.Result{
NumFields: 1, NumRows: 1,
Fields: []navicat.Field{{Name: "zh_val", Type: 253, Length: 12}},
Rows: [][]navicat.Cell{{{Bytes: []byte("你好世界")}}},
})
case strings.HasPrefix(u, "UPDATE"), strings.HasPrefix(u, "INSERT"):
results = append(results, navicat.Result{AffectedRows: 2, InsertID: 7})
case strings.Contains(u, "SYNTAX"):
results = append(results, navicat.Result{Errno: 1064, Message: "You have an error in your SQL syntax"})
default:
results = append(results, navicat.Result{Errno: 1064, Message: "stub: unhandled SQL"})
}
}
return &navicat.QueryResponse{Results: results}, nil
}
func startServer(t *testing.T, backend Backend, user, pass string) (addr string, cancel context.CancelFunc) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
s := New(Options{
Host: "127.0.0.1", Port: 1, Username: user, Password: pass,
DefaultDB: "app", MaxConnections: 5, Timeout: 5 * time.Second,
}, backend, nil)
errCh := make(chan error, 1)
go func() { errCh <- s.Serve(ctx, ln) }()
t.Cleanup(func() {
cancel()
_ = ln.Close()
})
return ln.Addr().String(), cancel
}
type testClient struct {
pc *packetConn
caps uint32
}
func dialTest(t *testing.T, addr, user, pass, db string) *testClient {
t.Helper()
c, err := net.DialTimeout("tcp", addr, 3*time.Second)
if err != nil {
t.Fatal(err)
}
pc := newPacketConn(c, 5*time.Second)
hs, err := pc.readPacket()
if err != nil {
t.Fatal(err)
}
scramble, err := scrambleFromHandshake(hs)
if err != nil {
t.Fatal(err)
}
caps := clientProtocol41 | clientSecureConnection | clientPluginAuth |
clientConnectWithDB | clientPluginAuthLenencClientData | clientDeprecateEOF | clientMultiResults
token := nativePasswordToken(pass, scramble)
if err := pc.writePacket(buildClientAuth(user, token, db, caps)); err != nil {
t.Fatal(err)
}
ok, err := pc.readPacket()
if err != nil {
t.Fatal(err)
}
if len(ok) == 0 || ok[0] != markerOK {
t.Fatalf("auth failed: %x %q", ok, ok)
}
return &testClient{pc: pc, caps: caps}
}
func scrambleFromHandshake(payload []byte) ([]byte, error) {
if len(payload) < 20 || payload[0] != ProtocolVersion {
return nil, fmt.Errorf("bad handshake")
}
off := 1
_, off, err := readNUL(payload, off)
if err != nil {
return nil, err
}
off += 4 // conn id
part1 := payload[off : off+8]
off += 8 + 1 + 2 + 1 + 2 + 2 + 1 + 10
if off+13 > len(payload) {
return nil, fmt.Errorf("short scramble")
}
part2 := payload[off : off+12]
s := append(append([]byte{}, part1...), part2...)
return s, nil
}
func (c *testClient) query(t *testing.T, sql string) (cols []string, rows [][]*string, affected, lastID uint64, errno int, errMsg string) {
t.Helper()
c.pc.resetSeq()
payload := append([]byte{ComQuery}, []byte(sql)...)
if err := c.pc.writePacket(payload); err != nil {
t.Fatal(err)
}
pkt, err := c.pc.readPacket()
if err != nil {
t.Fatal(err)
}
if len(pkt) == 0 {
t.Fatal("empty")
}
if pkt[0] == markerERR {
errno = int(pkt[1]) | int(pkt[2])<<8
if len(pkt) > 9 {
errMsg = string(pkt[9:])
}
return
}
if pkt[0] == markerOK {
n, off, _ := readLenencInt(pkt, 1)
id, _, _ := readLenencInt(pkt, off)
return nil, nil, n, id, 0, ""
}
nCols, _, err := readLenencInt(pkt, 0)
if err != nil {
t.Fatal(err)
}
for i := uint64(0); i < nCols; i++ {
colPkt, err := c.pc.readPacket()
if err != nil {
t.Fatal(err)
}
name := columnName(colPkt)
cols = append(cols, name)
}
// metadata end OK/EOF
if _, err := c.pc.readPacket(); err != nil {
t.Fatal(err)
}
for {
rowPkt, err := c.pc.readPacket()
if err != nil {
t.Fatal(err)
}
if len(rowPkt) == 0 {
break
}
if rowPkt[0] == markerERR {
errno = int(rowPkt[1]) | int(rowPkt[2])<<8
return
}
if rowPkt[0] == markerEOF && len(rowPkt) < 9 {
break
}
if rowPkt[0] == markerOK && len(rowPkt) >= 7 {
break
}
off := 0
row := make([]*string, nCols)
for i := uint64(0); i < nCols; i++ {
if off >= len(rowPkt) {
t.Fatal("short row")
}
if rowPkt[off] == markerNull {
row[i] = nil
off++
continue
}
b, nOff, err := readLenencString(rowPkt, off)
if err != nil {
t.Fatal(err)
}
s := string(b)
row[i] = &s
off = nOff
}
rows = append(rows, row)
}
return
}
func (c *testClient) ping(t *testing.T) {
t.Helper()
c.pc.resetSeq()
if err := c.pc.writePacket([]byte{ComPing}); err != nil {
t.Fatal(err)
}
pkt, err := c.pc.readPacket()
if err != nil {
t.Fatal(err)
}
if pkt[0] != markerOK {
t.Fatalf("ping %x", pkt)
}
}
func (c *testClient) quit() {
c.pc.resetSeq()
_ = c.pc.writePacket([]byte{ComQuit})
_ = c.pc.close()
}
func columnName(pkt []byte) string {
off := 0
skip := func() {
_, n, err := readLenencString(pkt, off)
if err != nil {
return
}
off = n
}
skip() // catalog
skip() // schema
skip() // table
skip() // org table
b, _, err := readLenencString(pkt, off)
if err != nil {
return ""
}
return string(b)
}
func TestServerAuthPingQueryQuit(t *testing.T) {
addr, _ := startServer(t, stubBackend{}, "root", "123456")
cl := dialTest(t, addr, "root", "123456", "app")
cl.ping(t)
_, rows, _, _, errno, msg := cl.query(t, "SELECT 1")
if errno != 0 {
t.Fatalf("errno %d %s", errno, msg)
}
if len(rows) != 1 || rows[0][0] == nil || *rows[0][0] != "1" {
t.Fatalf("rows=%v", rows)
}
cl.quit()
}
func TestServerBadPassword(t *testing.T) {
addr, _ := startServer(t, stubBackend{}, "root", "123456")
c, err := net.DialTimeout("tcp", addr, 3*time.Second)
if err != nil {
t.Fatal(err)
}
defer c.Close()
pc := newPacketConn(c, 5*time.Second)
hs, err := pc.readPacket()
if err != nil {
t.Fatal(err)
}
scramble, err := scrambleFromHandshake(hs)
if err != nil {
t.Fatal(err)
}
caps := clientProtocol41 | clientSecureConnection | clientPluginAuth | clientPluginAuthLenencClientData
tok := nativePasswordToken("wrong", scramble)
if err := pc.writePacket(buildClientAuth("root", tok, "", caps)); err != nil {
t.Fatal(err)
}
pkt, err := pc.readPacket()
if err != nil {
t.Fatal(err)
}
if pkt[0] != markerERR {
t.Fatalf("want ERR got %x", pkt)
}
errno := int(pkt[1]) | int(pkt[2])<<8
if errno != int(errAccessDenied) {
t.Fatalf("errno %d", errno)
}
}
func TestServerResultTypes(t *testing.T) {
addr, _ := startServer(t, stubBackend{}, "root", "p")
cl := dialTest(t, addr, "root", "p", "app")
defer cl.quit()
_, rows, _, _, errno, _ := cl.query(t, "SELECT NULL AS n")
if errno != 0 || rows[0][0] != nil {
t.Fatalf("null errno=%d row=%v", errno, rows)
}
_, rows, _, _, errno, _ = cl.query(t, "SELECT bigint_val")
if errno != 0 || *rows[0][0] != "18446744073709551615" {
t.Fatalf("bigint %v errno=%d", rows, errno)
}
_, rows, _, _, errno, _ = cl.query(t, "SELECT dec_val")
if errno != 0 || *rows[0][0] != "1234567890.123456789" {
t.Fatalf("dec %v", rows)
}
_, rows, _, _, errno, _ = cl.query(t, "SELECT zh_val")
if errno != 0 || *rows[0][0] != "你好世界" {
t.Fatalf("zh %v", rows)
}
_, _, affected, lastID, errno, msg := cl.query(t, "UPDATE t SET x=1")
if errno != 0 {
t.Fatal(msg)
}
if affected != 2 || lastID != 7 {
t.Fatalf("affected=%d id=%d", affected, lastID)
}
_, _, _, _, errno, msg = cl.query(t, "SELECT SYNTAX")
if errno != 1064 || !strings.Contains(msg, "syntax") {
t.Fatalf("errno=%d msg=%s", errno, msg)
}
}
func TestServerAuthSwitch(t *testing.T) {
addr, _ := startServer(t, stubBackend{}, "root", "p")
c, err := net.DialTimeout("tcp", addr, 3*time.Second)
if err != nil {
t.Fatal(err)
}
defer c.Close()
pc := newPacketConn(c, 5*time.Second)
hs, err := pc.readPacket()
if err != nil {
t.Fatal(err)
}
scramble, err := scrambleFromHandshake(hs)
if err != nil {
t.Fatal(err)
}
caps := clientProtocol41 | clientSecureConnection | clientPluginAuth | clientPluginAuthLenencClientData
b := appendU32(nil, caps)
b = appendU32(b, MaxPacketSize)
b = append(b, charsetUTF8MB4)
b = append(b, make([]byte, 23)...)
b = appendNUL(b, "root")
b = appendLenencInt(b, 0) // empty, will switch
b = appendNUL(b, AuthPluginSHA2)
if err := pc.writePacket(b); err != nil {
t.Fatal(err)
}
sw, err := pc.readPacket()
if err != nil {
t.Fatal(err)
}
if sw[0] != markerEOF {
t.Fatalf("want auth switch %x", sw)
}
tok := nativePasswordToken("p", scramble)
if err := pc.writePacket(tok); err != nil {
t.Fatal(err)
}
ok, err := pc.readPacket()
if err != nil {
t.Fatal(err)
}
if ok[0] != markerOK {
t.Fatalf("auth after switch %x", ok)
}
}
func TestPhase4NavicatStack(t *testing.T) {
mock := httptest.NewServer(navicat.NewMockHandler(navicat.WithToken("t")))
t.Cleanup(mock.Close)
httpClient := tunnel.NewHTTPClient(tunnel.Options{Timeout: 5 * time.Second})
n := navicat.NewClient(httpClient, navicat.ClientOptions{
URL: mock.URL + "?token=t",
Target: navicat.Target{
Host: "mysql", Port: 3306, User: "remote", Password: "rp",
Database: "app", Charset: "utf8mb4",
},
})
addr, _ := startServer(t, n, "root", "local")
cl := dialTest(t, addr, "root", "local", "app")
defer cl.quit()
_, rows, _, _, errno, msg := cl.query(t, "SELECT 1")
if errno != 0 {
t.Fatal(msg)
}
if *rows[0][0] != "1" {
t.Fatalf("%v", rows)
}
}
+168
View File
@@ -0,0 +1,168 @@
package mysqlserver
import (
"context"
"fmt"
"regexp"
"strings"
"mysql-tunnel/internal/logger"
"mysql-tunnel/internal/navicat"
)
var useDBRe = regexp.MustCompile(`(?is)^\s*USE\s+(?:` + "`" + `([^` + "`" + `]+)` + "`" + `|([A-Za-z0-9_]+))\s*;?\s*$`)
type Backend interface {
Query(ctx context.Context, database string, sqls ...string) (*navicat.QueryResponse, error)
}
type session struct {
pc *packetConn
backend Backend
log *logger.Logger
logSQL bool
timeout ctxTimeout
user string
db string
defaultDB string
caps uint32
status uint16
scramble []byte
}
type ctxTimeout func() (context.Context, context.CancelFunc)
func (s *session) serve(ctx context.Context) {
defer s.pc.close()
for {
select {
case <-ctx.Done():
return
default:
}
s.pc.resetSeq()
payload, err := s.pc.readPacket()
if err != nil {
return
}
if len(payload) == 0 {
_ = s.pc.writeERR(errUnknownCommand, "empty command")
continue
}
if err := s.dispatch(ctx, payload[0], payload[1:]); err != nil {
return
}
}
}
func (s *session) dispatch(parent context.Context, cmd byte, body []byte) error {
switch cmd {
case ComQuit:
return fmt.Errorf("quit")
case ComPing:
return s.pc.writeOK(0, 0, s.status, 0)
case ComInitDB:
db := strings.TrimSpace(string(body))
return s.changeDB(parent, db)
case ComQuery:
return s.query(parent, string(body))
case ComResetConnection:
s.db = s.defaultDB
return s.pc.writeOK(0, 0, s.status, 0)
case ComFieldList, ComStmtPrepare, ComStmtExecute, ComStmtClose:
return s.pc.writeERR(errNotSupported, "command not implemented yet")
default:
return s.pc.writeERR(errUnknownCommand, "unknown command")
}
}
func (s *session) changeDB(parent context.Context, db string) error {
if db == "" {
return s.pc.writeERR(errNoDB, "no database selected")
}
s.db = db
return s.pc.writeOK(0, 0, s.status, 0)
}
func (s *session) query(parent context.Context, sql string) error {
sql = strings.TrimRight(sql, "\x00")
s.logQuery(sql)
pendingDB := ""
if m := useDBRe.FindStringSubmatch(sql); m != nil {
pendingDB = m[1]
if pendingDB == "" {
pendingDB = m[2]
}
}
ctx, cancel := s.timeout()
defer cancel()
if ctx == nil {
ctx = parent
}
resp, err := s.backend.Query(ctx, s.db, sql)
if err != nil {
return s.writeBackendErr(err)
}
for _, r := range resp.Results {
if r.IsError() {
return s.pc.writeERR(uint16(r.Errno), r.Message)
}
}
if pendingDB != "" {
s.db = pendingDB
}
res, ok := navicat.LastResultSet(resp)
if !ok {
return s.pc.writeOK(0, 0, s.status, 0)
}
if res.IsError() {
return s.pc.writeERR(uint16(res.Errno), res.Message)
}
if res.IsResultSet() {
if s.log != nil {
s.log.Info("query completed", fmt.Sprintf("rows=%d", len(res.Rows)))
}
return s.pc.writeResultSet(res, s.caps, s.status)
}
if s.log != nil {
s.log.Info("query completed", fmt.Sprintf("affected=%d", res.AffectedRows))
}
return s.pc.writeOK(uint64(res.AffectedRows), uint64(res.InsertID), s.status, 0)
}
func (s *session) writeBackendErr(err error) error {
msg := logger.RedactError(err)
if ne, ok := err.(*navicat.Error); ok {
errno := uint16(ne.Errno)
if errno == 0 {
errno = errUnknown
}
if ne.Kind == "http" || ne.Kind == "tunnel" {
errno = errUnknown
}
return s.pc.writeERR(errno, msg)
}
return s.pc.writeERR(errUnknown, msg)
}
func (s *session) logQuery(sql string) {
if s.log == nil {
return
}
if !s.logSQL {
s.log.Info("query")
return
}
s.log.Info("query", trimSQL(sql))
}
func trimSQL(sql string) string {
s := logger.RedactText(sql)
s = strings.ReplaceAll(s, "\n", " ")
if len(s) > 240 {
return s[:240] + "..."
}
return s
}
+76
View File
@@ -0,0 +1,76 @@
package navicat
import (
"encoding/binary"
"fmt"
)
func putU32(n uint32) []byte {
b := make([]byte, 4)
binary.BigEndian.PutUint32(b, n)
return b
}
func putU16(n uint16) []byte {
b := make([]byte, 2)
binary.BigEndian.PutUint16(b, n)
return b
}
// AppendBlock encodes GetBlock() from ntunnel_mysql.php.
// NULL is not represented here; use AppendNull for row cells.
func AppendBlock(dst []byte, val []byte) []byte {
if val == nil {
val = []byte{}
}
n := len(val)
if n < 254 {
dst = append(dst, byte(n))
return append(dst, val...)
}
dst = append(dst, markLong)
dst = append(dst, putU32(uint32(n))...)
return append(dst, val...)
}
func AppendNull(dst []byte) []byte {
return append(dst, markNull)
}
func AppendHeader(dst []byte, version uint16, errno uint32) []byte {
dst = append(dst, putU32(Magic)...)
dst = append(dst, putU16(version)...)
dst = append(dst, putU32(errno)...)
dst = append(dst, 0, 0, 0, 0, 0, 0)
return dst
}
func AppendResultSetHeader(dst []byte, errno, affected, insertID, numFields, numRows uint32) []byte {
dst = append(dst, putU32(errno)...)
dst = append(dst, putU32(affected)...)
dst = append(dst, putU32(insertID)...)
dst = append(dst, putU32(numFields)...)
dst = append(dst, putU32(numRows)...)
dst = append(dst, make([]byte, 12)...)
return dst
}
func AppendField(dst []byte, name, table string, typ, flags, length uint32) []byte {
dst = AppendBlock(dst, []byte(name))
dst = AppendBlock(dst, []byte(table))
dst = append(dst, putU32(typ)...)
dst = append(dst, putU32(flags)...)
dst = append(dst, putU32(length)...)
return dst
}
func knownVersion(v uint16) bool {
return v == Version202 || v == Version206
}
func fmtErrno(errno uint32, msg string) string {
if msg == "" {
return fmt.Sprintf("tunnel errno %d", errno)
}
return fmt.Sprintf("tunnel errno %d: %s", errno, msg)
}
+177
View File
@@ -0,0 +1,177 @@
package navicat
import (
"context"
"fmt"
"net/http"
"regexp"
"strings"
"mysql-tunnel/internal/logger"
"mysql-tunnel/internal/tunnel"
)
var charsetIdent = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*$`)
// Target is the remote MySQL as seen by ntunnel_mysql.php.
type Target struct {
Host string
Port int
User string
Password string
Database string
Charset string
}
// Client talks Navicat HTTP Tunnel over the generic HTTP client.
type Client struct {
http *tunnel.HTTPClient
url string
target Target
encodeBase64 bool
retries int
}
type ClientOptions struct {
URL string
Target Target
EncodeBase64 bool
// Retries is extra attempts on transport failure only (not SQL errors). 0 means a single try.
Retries int
}
func NewClient(httpClient *tunnel.HTTPClient, opt ClientOptions) *Client {
retries := opt.Retries
if retries < 0 {
retries = 0
}
return &Client{
http: httpClient,
url: opt.URL,
target: opt.Target,
encodeBase64: opt.EncodeBase64,
retries: retries,
}
}
func (c *Client) Connect(ctx context.Context) (*ConnectResponse, error) {
body, status, err := c.post(ctx, Request{
Action: ActionConnect,
Host: c.target.Host,
Port: c.target.Port,
Login: c.target.User,
Password: c.target.Password,
Database: c.target.Database,
EncodeBase64: c.encodeBase64,
})
if err != nil {
return nil, err
}
resp, err := ParseConnect(body)
if err != nil {
return nil, err
}
if !resp.Header.OK() {
return resp, headerError(resp.Header, status)
}
return resp, nil
}
func (c *Client) Query(ctx context.Context, database string, sqls ...string) (*QueryResponse, error) {
db := database
if db == "" {
db = c.target.Database
}
queries := make([]string, 0, len(sqls)+1)
if s := charsetSQL(c.target.Charset); s != "" {
queries = append(queries, s)
}
queries = append(queries, nonEmpty(sqls)...)
if len(queries) == 0 {
return nil, fmt.Errorf("no SQL to execute")
}
body, status, err := c.post(ctx, Request{
Action: ActionQuery,
Host: c.target.Host,
Port: c.target.Port,
Login: c.target.User,
Password: c.target.Password,
Database: db,
Queries: queries,
EncodeBase64: c.encodeBase64,
})
if err != nil {
return nil, err
}
resp, err := ParseQuery(body)
if err != nil {
return nil, err
}
if !resp.Header.OK() {
return resp, headerError(resp.Header, status)
}
return resp, nil
}
func (c *Client) post(ctx context.Context, req Request) (body []byte, status int, err error) {
encoded := req.Encode()
attempts := 1 + c.retries
var last error
for i := 0; i < attempts; i++ {
if err := ctx.Err(); err != nil {
return nil, 0, err
}
httpResp, err := c.http.Do(ctx, http.MethodPost, c.url, "application/x-www-form-urlencoded", strings.NewReader(encoded))
if err != nil {
last = err
continue
}
status = httpResp.StatusCode
body = httpResp.Body
if status == http.StatusForbidden {
return body, status, &Error{Kind: "http", HTTPStatus: status, Errno: ErrnoInvalidToken, Message: "invalid or missing token"}
}
if status < 200 || status >= 300 {
return body, status, &Error{Kind: "http", HTTPStatus: status, Message: "unexpected status"}
}
if looksHTML(body) {
return body, status, &Error{Kind: "http", HTTPStatus: status, Message: "tunnel returned HTML, not Navicat binary"}
}
return body, status, nil
}
return nil, 0, last
}
func charsetSQL(cs string) string {
cs = strings.TrimSpace(cs)
if cs == "" {
return ""
}
if strings.EqualFold(cs, "utf8") || strings.EqualFold(cs, "utf8mb3") {
return ""
}
if !charsetIdent.MatchString(cs) {
return ""
}
return "SET NAMES " + cs
}
// LastResultSet returns the last result that has columns (skipping SET NAMES OK packets).
func LastResultSet(resp *QueryResponse) (Result, bool) {
if resp == nil {
return Result{}, false
}
for i := len(resp.Results) - 1; i >= 0; i-- {
if resp.Results[i].IsResultSet() {
return resp.Results[i], true
}
}
if len(resp.Results) == 0 {
return Result{}, false
}
return resp.Results[len(resp.Results)-1], true
}
func RedactedURL(raw string) string {
return logger.RedactURL(raw)
}
+196
View File
@@ -0,0 +1,196 @@
package navicat
import (
"context"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"mysql-tunnel/internal/tunnel"
)
func testClient(t *testing.T, opts ...MockOption) (*Client, *httptest.Server) {
t.Helper()
srv := httptest.NewServer(NewMockHandler(opts...))
t.Cleanup(srv.Close)
httpClient := tunnel.NewHTTPClient(tunnel.Options{Timeout: 5 * time.Second})
c := NewClient(httpClient, ClientOptions{
URL: srv.URL + "?token=secret",
Target: Target{
Host: "mysql",
Port: 3306,
User: "root",
Password: "remote-pass",
Database: "app",
Charset: "utf8mb4",
},
})
return c, srv
}
func TestClientConnectAndSelect1(t *testing.T) {
var cap CapturedRequest
c, _ := testClient(t, WithToken("secret"), WithCapture(&cap))
ctx := context.Background()
conn, err := c.Connect(ctx)
if err != nil {
t.Fatal(err)
}
if conn.Conn.ServerInfo != "8.0.36-mock" {
t.Fatalf("%+v", conn.Conn)
}
if cap.Action != ActionConnect || cap.Host != "mysql" || cap.Login != "root" {
t.Fatalf("connect capture %+v", cap)
}
if cap.Port != "3306" {
t.Fatalf("port %s", cap.Port)
}
if !cap.HasPassword {
t.Fatal("password field missing")
}
q, err := c.Query(ctx, "", "SELECT 1")
if err != nil {
t.Fatal(err)
}
res, ok := LastResultSet(q)
if !ok || res.Rows[0][0].Text() != "1" {
t.Fatalf("select 1: %+v", q)
}
if cap.Action != ActionQuery {
t.Fatalf("query capture %+v", cap)
}
joined := strings.Join(cap.Queries, " | ")
if !strings.Contains(joined, "SET NAMES utf8mb4") {
t.Fatalf("expected SET NAMES in %q", joined)
}
if !strings.Contains(joined, "SELECT 1") {
t.Fatalf("expected SELECT 1 in %q", joined)
}
}
func TestClientTypesOverHTTP(t *testing.T) {
c, _ := testClient(t, WithToken("secret"))
ctx := context.Background()
q, err := c.Query(ctx, "app", "SELECT NULL AS n")
if err != nil {
t.Fatal(err)
}
res, _ := LastResultSet(q)
if !res.Rows[0][0].Null {
t.Fatal("NULL")
}
q, err = c.Query(ctx, "", "SELECT bigint_val")
if err != nil {
t.Fatal(err)
}
res, _ = LastResultSet(q)
got := res.Rows[0][0].Text()
if got != "18446744073709551615" {
t.Fatalf("bigint %q", got)
}
if _, err := strconv.ParseInt(got, 10, 64); err == nil {
t.Fatal("test value should overflow int64")
}
q, err = c.Query(ctx, "", "SELECT dec_val")
if err != nil {
t.Fatal(err)
}
res, _ = LastResultSet(q)
if res.Fields[0].Type != 246 || res.Rows[0][0].Text() != "1234567890.123456789" {
t.Fatalf("decimal %+v", res)
}
q, err = c.Query(ctx, "", "SELECT zh_val")
if err != nil {
t.Fatal(err)
}
res, _ = LastResultSet(q)
if res.Rows[0][0].Text() != "你好世界" {
t.Fatalf("zh %q", res.Rows[0][0].Text())
}
}
func TestClientSQLError(t *testing.T) {
c, _ := testClient(t, WithToken("secret"))
q, err := c.Query(context.Background(), "", "SELECT SYNTAX")
if err != nil {
t.Fatal(err)
}
res, ok := LastResultSet(q)
if !ok || !res.IsError() || res.Errno != 1064 {
t.Fatalf("%+v", q)
}
}
func TestClientDML(t *testing.T) {
c, _ := testClient(t, WithToken("secret"))
q, err := c.Query(context.Background(), "", "UPDATE product SET price=1")
if err != nil {
t.Fatal(err)
}
res := q.Results[len(q.Results)-1]
if res.IsResultSet() || res.AffectedRows != 1 || res.InsertID != 42 {
t.Fatalf("%+v", res)
}
}
func TestClientBadTokenPOST(t *testing.T) {
srv := httptest.NewServer(NewMockHandler(WithToken("right")))
t.Cleanup(srv.Close)
httpClient := tunnel.NewHTTPClient(tunnel.Options{Timeout: 5 * time.Second})
c := NewClient(httpClient, ClientOptions{
URL: srv.URL + "?token=wrong",
Target: Target{Host: "mysql", Port: 3306, User: "root"},
})
_, err := c.Connect(context.Background())
if err == nil {
t.Fatal("expected token error")
}
ne, ok := err.(*Error)
if !ok || ne.Errno != ErrnoInvalidToken {
t.Fatalf("%v", err)
}
}
func TestClientConnectDenied(t *testing.T) {
c, _ := testClient(t, WithToken("secret"), WithConnectError(1045, "Access denied for user 'root'@'localhost'"))
_, err := c.Connect(context.Background())
if err == nil {
t.Fatal("expected deny")
}
if !strings.Contains(err.Error(), "1045") {
t.Fatal(err)
}
if strings.Contains(err.Error(), "remote-pass") {
t.Fatal("password leaked")
}
}
func TestEncodeBase64Query(t *testing.T) {
var cap CapturedRequest
srv := httptest.NewServer(NewMockHandler(WithToken("secret"), WithCapture(&cap)))
t.Cleanup(srv.Close)
httpClient := tunnel.NewHTTPClient(tunnel.Options{Timeout: 5 * time.Second})
c := NewClient(httpClient, ClientOptions{
URL: srv.URL + "?token=secret",
Target: Target{Host: "mysql", Port: 3306, User: "root", Charset: "utf8"},
EncodeBase64: true,
})
_, err := c.Query(context.Background(), "", "SELECT 1")
if err != nil {
t.Fatal(err)
}
if cap.EncodeBase64 != "1" {
t.Fatalf("%+v", cap)
}
if strings.Contains(strings.Join(cap.Queries, ""), "SELECT 1") {
t.Fatalf("wire should be base64, got %v", cap.Queries)
}
}
+26
View File
@@ -0,0 +1,26 @@
// Package navicat implements the Navicat HTTP Tunnel protocol used by
// ntunnel_mysql.php (script version 206 in this repository).
//
// Every request is stateless: the PHP script opens a new MySQL connection,
// runs actn=C or actn=Q, and disconnects. See docs/PROTOCOL.md.
package navicat
const (
Magic uint32 = 1111
Version202 uint16 = 202
Version206 uint16 = 206
HeaderSize = 16
ActionConnect = "C"
ActionQuery = "Q"
ErrnoUnsupportedPHP uint32 = 201
ErrnoInvalidParams uint32 = 202
ErrnoMySQLMissing uint32 = 203
ErrnoInvalidToken uint32 = 204
sepMore byte = 0x01
sepLast byte = 0x00
markLong byte = 0xFE
markNull byte = 0xFF
)
+45
View File
@@ -0,0 +1,45 @@
package navicat
import "fmt"
// Error is a tunnel-layer or remote MySQL error. It never includes passwords or tokens.
type Error struct {
Kind string // http, tunnel, mysql
HTTPStatus int
Errno uint32
Message string
}
func (e *Error) Error() string {
if e == nil {
return ""
}
msg := e.Message
if msg == "" {
msg = "unknown error"
}
switch e.Kind {
case "http":
if e.HTTPStatus != 0 {
return fmt.Sprintf("tunnel http %d: %s", e.HTTPStatus, msg)
}
return "tunnel http: " + msg
case "mysql":
return fmt.Sprintf("mysql errno %d: %s", e.Errno, msg)
default:
return fmtErrno(e.Errno, msg)
}
}
func headerError(h Header, httpStatus int) *Error {
kind := "tunnel"
switch h.Errno {
case ErrnoUnsupportedPHP, ErrnoInvalidParams, ErrnoMySQLMissing, ErrnoInvalidToken:
kind = "tunnel"
default:
if h.Errno > 0 {
kind = "mysql"
}
}
return &Error{Kind: kind, HTTPStatus: httpStatus, Errno: h.Errno, Message: h.Message}
}
+228
View File
@@ -0,0 +1,228 @@
package navicat
import (
"encoding/base64"
"net/http"
"strings"
)
// MockOption configures the in-process ntunnel_mysql.php stand-in.
type MockOption func(*mockState)
type mockState struct {
token string
connectFail *Error
queryFn func(sql string) Result
capture *CapturedRequest
}
// CapturedRequest is the last POST seen by the mock.
type CapturedRequest struct {
Action string
Host string
Port string
Login string
Database string
Queries []string
EncodeBase64 string
HasPassword bool
}
func WithToken(token string) MockOption {
return func(m *mockState) { m.token = token }
}
func WithConnectError(errno uint32, msg string) MockOption {
return func(m *mockState) {
m.connectFail = &Error{Kind: "mysql", Errno: errno, Message: msg}
}
}
func WithQueryHandler(fn func(sql string) Result) MockOption {
return func(m *mockState) { m.queryFn = fn }
}
func WithCapture(dst *CapturedRequest) MockOption {
return func(m *mockState) { m.capture = dst }
}
// NewMockHandler implements a subset of ntunnel_mysql.php for tests.
func NewMockHandler(opts ...MockOption) http.Handler {
st := &mockState{queryFn: defaultQuery}
for _, o := range opts {
o(st)
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
if st.token != "" && r.URL.Query().Get("token") != st.token {
if r.PostForm.Get("actn") != "" {
writeBytes(w, encodeHeaderError(ErrnoInvalidToken, "invalid access token"))
return
}
http.Error(w, "403 Forbidden: invalid or missing token", http.StatusForbidden)
return
}
if r.Method != http.MethodPost {
writeBytes(w, encodeHeaderError(ErrnoInvalidParams, "invalid parameters"))
return
}
actn := r.PostForm.Get("actn")
host := r.PostForm.Get("host")
login := r.PostForm.Get("login")
if actn == "" || host == "" || !r.PostForm.Has("port") || login == "" {
writeBytes(w, encodeHeaderError(ErrnoInvalidParams, "invalid parameters"))
return
}
if st.capture != nil {
*st.capture = CapturedRequest{
Action: actn,
Host: host,
Port: r.PostForm.Get("port"),
Login: login,
Database: r.PostForm.Get("db"),
Queries: append([]string(nil), r.PostForm["q[]"]...),
EncodeBase64: r.PostForm.Get("encodeBase64"),
HasPassword: r.PostForm.Has("password"),
}
}
if st.connectFail != nil {
writeBytes(w, encodeHeaderError(st.connectFail.Errno, st.connectFail.Message))
return
}
switch actn {
case ActionConnect:
writeBytes(w, encodeConnectOK("127.0.0.1 via TCP/IP", "10", "8.0.36-mock"))
case ActionQuery:
queries := append([]string(nil), r.PostForm["q[]"]...)
if r.PostForm.Get("encodeBase64") == "1" {
decoded := make([]string, 0, len(queries))
for _, q := range queries {
b, err := base64.StdEncoding.DecodeString(q)
if err != nil {
writeBytes(w, encodeQueryResults([]Result{{Errno: 1064, Message: "bad base64 query"}}))
return
}
decoded = append(decoded, string(b))
}
queries = decoded
}
var results []Result
for _, q := range queries {
if q == "" {
continue
}
results = append(results, st.queryFn(q))
}
writeBytes(w, encodeQueryResults(results))
default:
writeBytes(w, AppendHeader(nil, Version206, 0))
}
})
}
func defaultQuery(sql string) Result {
s := strings.TrimSpace(sql)
u := strings.ToUpper(s)
switch {
case strings.HasPrefix(u, "SET NAMES"):
return Result{}
case u == "SELECT 1" || strings.HasPrefix(u, "SELECT 1 "):
return selectLiteral("1", "1", 8, 20)
case strings.Contains(u, "SELECT NULL"):
return Result{
NumFields: 1,
NumRows: 1,
Fields: []Field{{Name: "n", Type: 6, Length: 0}},
Rows: [][]Cell{{{Null: true}}},
}
case strings.Contains(strings.ToLower(s), "bigint_val"):
r := selectLiteral("bigint_val", "18446744073709551615", 8, 20)
r.Fields[0].Flags = 32 // UNSIGNED
return r
case strings.Contains(strings.ToLower(s), "dec_val"):
return selectLiteral("dec_val", "1234567890.123456789", 246, 32)
case strings.Contains(s, "你好") || strings.Contains(strings.ToLower(s), "zh_val"):
return selectLiteral("zh_val", "你好世界", 253, 12)
case strings.HasPrefix(u, "INSERT") || strings.HasPrefix(u, "UPDATE") || strings.HasPrefix(u, "DELETE") || strings.HasPrefix(u, "REPLACE"):
return Result{AffectedRows: 1, InsertID: 42, Info: "Rows matched: 1 Changed: 1 Warnings: 0"}
case strings.Contains(u, "SYNTAX"):
return Result{Errno: 1064, Message: "You have an error in your SQL syntax"}
default:
return Result{Errno: 1064, Message: "mock: unhandled SQL"}
}
}
func selectLiteral(name, value string, typ, length uint32) Result {
return Result{
NumFields: 1,
NumRows: 1,
Fields: []Field{{Name: name, Type: typ, Length: length}},
Rows: [][]Cell{{{Bytes: []byte(value)}}},
}
}
func encodeHeaderError(errno uint32, msg string) []byte {
b := AppendHeader(nil, Version206, errno)
return AppendBlock(b, []byte(msg))
}
func encodeConnectOK(host, proto, server string) []byte {
b := AppendHeader(nil, Version206, 0)
b = AppendBlock(b, []byte(host))
b = AppendBlock(b, []byte(proto))
b = AppendBlock(b, []byte(server))
return b
}
func encodeQueryResults(results []Result) []byte {
b := AppendHeader(nil, Version206, 0)
if len(results) == 0 {
return b
}
for i, res := range results {
b = appendQueryResult(b, res)
if i < len(results)-1 {
b = append(b, sepMore)
} else {
b = append(b, sepLast)
}
}
return b
}
func appendQueryResult(b []byte, res Result) []byte {
nfields := res.NumFields
nrows := res.NumRows
if nfields == 0 {
nfields = uint32(len(res.Fields))
}
if nrows == 0 && len(res.Rows) > 0 {
nrows = uint32(len(res.Rows))
}
b = AppendResultSetHeader(b, res.Errno, res.AffectedRows, res.InsertID, nfields, nrows)
if res.Errno > 0 {
return AppendBlock(b, []byte(res.Message))
}
if nfields == 0 {
return AppendBlock(b, []byte(res.Info))
}
for _, f := range res.Fields {
b = AppendField(b, f.Name, f.Table, f.Type, f.Flags, f.Length)
}
for _, row := range res.Rows {
for _, cell := range row {
if cell.Null {
b = AppendNull(b)
} else {
b = AppendBlock(b, cell.Bytes)
}
}
}
return b
}
func writeBytes(w http.ResponseWriter, body []byte) {
w.Header().Set("Content-Type", "text/plain; charset=x-user-defined")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
}
+116
View File
@@ -0,0 +1,116 @@
package navicat
import (
"encoding/binary"
"fmt"
"io"
)
type reader struct {
buf []byte
off int
}
func newReader(b []byte) *reader {
return &reader{buf: b}
}
func (r *reader) remaining() int {
return len(r.buf) - r.off
}
func (r *reader) peek() (byte, error) {
if r.off >= len(r.buf) {
return 0, io.EOF
}
return r.buf[r.off], nil
}
func (r *reader) readByte() (byte, error) {
if r.off >= len(r.buf) {
return 0, io.ErrUnexpectedEOF
}
b := r.buf[r.off]
r.off++
return b, nil
}
func (r *reader) readN(n int) ([]byte, error) {
if n < 0 {
return nil, fmt.Errorf("negative length")
}
if r.off+n > len(r.buf) {
return nil, io.ErrUnexpectedEOF
}
s := r.buf[r.off : r.off+n]
r.off += n
return s, nil
}
func (r *reader) u16() (uint16, error) {
b, err := r.readN(2)
if err != nil {
return 0, err
}
return binary.BigEndian.Uint16(b), nil
}
func (r *reader) u32() (uint32, error) {
b, err := r.readN(4)
if err != nil {
return 0, err
}
return binary.BigEndian.Uint32(b), nil
}
// block reads GetBlock. nullable allows 0xFF as SQL NULL (EchoData only).
func (r *reader) block(nullable bool) (val []byte, isNull bool, err error) {
if r.off >= len(r.buf) {
return nil, false, io.ErrUnexpectedEOF
}
first := r.buf[r.off]
if nullable && first == markNull {
r.off++
return nil, true, nil
}
r.off++
var n int
if first < 254 {
n = int(first)
} else if first == markLong {
ln, err := r.u32()
if err != nil {
return nil, false, err
}
if ln > 64<<20 {
return nil, false, fmt.Errorf("block length %d exceeds 64MiB limit", ln)
}
n = int(ln)
} else {
return nil, false, fmt.Errorf("invalid block marker 0x%02x", first)
}
b, err := r.readN(n)
if err != nil {
return nil, false, err
}
return b, false, nil
}
func (r *reader) blockBytes() ([]byte, error) {
b, isNull, err := r.block(false)
if err != nil {
return nil, err
}
if isNull {
return nil, fmt.Errorf("unexpected NULL block")
}
return b, nil
}
func (r *reader) blockString() (string, error) {
b, err := r.blockBytes()
if err != nil {
return "", err
}
return string(b), nil
}
+205
View File
@@ -0,0 +1,205 @@
package navicat
import (
"bytes"
"strings"
"testing"
)
func TestBlockRoundTripShortAndLong(t *testing.T) {
cases := [][]byte{
{},
[]byte("a"),
bytes.Repeat([]byte("x"), 253),
bytes.Repeat([]byte("y"), 254),
bytes.Repeat([]byte("z"), 1024),
[]byte("你好世界"),
}
for _, in := range cases {
enc := AppendBlock(nil, in)
r := newReader(enc)
got, isNull, err := r.block(false)
if err != nil {
t.Fatalf("len=%d: %v", len(in), err)
}
if isNull {
t.Fatal("null")
}
if !bytes.Equal(got, in) {
t.Fatalf("mismatch len=%d", len(in))
}
}
}
func TestNullCell(t *testing.T) {
enc := AppendNull(nil)
r := newReader(enc)
_, isNull, err := r.block(true)
if err != nil || !isNull {
t.Fatalf("err=%v null=%v", err, isNull)
}
}
func TestHeader202And206(t *testing.T) {
for _, ver := range []uint16{Version202, Version206} {
body := AppendHeader(nil, ver, 0)
h, _, err := ParseHeader(body)
if err != nil {
t.Fatal(err)
}
if h.Version != ver || !h.OK() {
t.Fatalf("%+v", h)
}
}
}
func TestHeaderError204(t *testing.T) {
body := encodeHeaderError(ErrnoInvalidToken, "invalid access token")
h, _, err := ParseHeader(body)
if err != nil {
t.Fatal(err)
}
if h.Errno != ErrnoInvalidToken || h.Message != "invalid access token" {
t.Fatalf("%+v", h)
}
}
func TestConnectParse(t *testing.T) {
body := encodeConnectOK("127.0.0.1 via TCP/IP", "10", "8.0.36")
resp, err := ParseConnect(body)
if err != nil {
t.Fatal(err)
}
if resp.Conn.ProtoInfo != "10" || resp.Conn.ServerInfo != "8.0.36" {
t.Fatalf("%+v", resp.Conn)
}
}
func TestQuerySelect1(t *testing.T) {
body := encodeQueryResults([]Result{selectLiteral("1", "1", 8, 20)})
resp, err := ParseQuery(body)
if err != nil {
t.Fatal(err)
}
if len(resp.Results) != 1 {
t.Fatalf("results=%d", len(resp.Results))
}
r := resp.Results[0]
if !r.IsResultSet() || r.NumRows != 1 {
t.Fatalf("%+v", r)
}
if r.Rows[0][0].Null || r.Rows[0][0].Text() != "1" {
t.Fatalf("cell=%+v", r.Rows[0][0])
}
}
func TestNullDecimalBigintUTF8(t *testing.T) {
body := encodeQueryResults([]Result{
{
NumFields: 4,
NumRows: 1,
Fields: []Field{
{Name: "n", Type: 6},
{Name: "b", Type: 8, Length: 20},
{Name: "d", Type: 246, Length: 32},
{Name: "z", Type: 253, Length: 12},
},
Rows: [][]Cell{{
{Null: true},
{Bytes: []byte("18446744073709551615")},
{Bytes: []byte("1234567890.123456789")},
{Bytes: []byte("你好世界")},
}},
},
})
resp, err := ParseQuery(body)
if err != nil {
t.Fatal(err)
}
row := resp.Results[0].Rows[0]
if !row[0].Null {
t.Fatal("expected NULL")
}
if row[1].Text() != "18446744073709551615" {
t.Fatalf("bigint parsed badly: %q", row[1].Text())
}
if row[2].Text() != "1234567890.123456789" {
t.Fatalf("decimal parsed badly: %q", row[2].Text())
}
if row[3].Text() != "你好世界" {
t.Fatalf("utf8: %q", row[3].Text())
}
}
func TestSQLError(t *testing.T) {
body := encodeQueryResults([]Result{{Errno: 1064, Message: "You have an error in your SQL syntax"}})
resp, err := ParseQuery(body)
if err != nil {
t.Fatal(err)
}
if !resp.Results[0].IsError() || !strings.Contains(resp.Results[0].Message, "syntax") {
t.Fatalf("%+v", resp.Results[0])
}
}
func TestDMLOk(t *testing.T) {
body := encodeQueryResults([]Result{{AffectedRows: 3, InsertID: 9, Info: "Rows matched: 3"}})
resp, err := ParseQuery(body)
if err != nil {
t.Fatal(err)
}
r := resp.Results[0]
if r.IsResultSet() || r.AffectedRows != 3 || r.InsertID != 9 {
t.Fatalf("%+v", r)
}
}
func TestHTMLRejected(t *testing.T) {
_, err := ParseConnect([]byte("<html>nope</html>"))
if err == nil {
t.Fatal("expected error")
}
}
func TestRequestOmitsEmptyQueriesAndEncodesArray(t *testing.T) {
enc := Request{
Action: ActionQuery,
Host: "mysql",
Port: 3306,
Login: "root",
Queries: []string{"SELECT 1", "", "SELECT 2"},
}.Encode()
if strings.Contains(enc, "password=secret") {
t.Fatal("unexpected")
}
if !strings.Contains(enc, "q%5B%5D=SELECT+1") && !strings.Contains(enc, "q%5B%5D=SELECT%201") {
t.Fatalf("q[] missing: %s", enc)
}
if strings.Count(enc, "q%5B%5D=") != 2 {
t.Fatalf("empty query not skipped: %s", enc)
}
}
func TestRequestBase64(t *testing.T) {
enc := Request{
Action: ActionQuery,
Host: "mysql",
Port: 3306,
Login: "root",
Queries: []string{"SELECT 1"},
EncodeBase64: true,
}.Encode()
if !strings.Contains(enc, "encodeBase64=1") {
t.Fatal(enc)
}
if strings.Contains(enc, "SELECT+1") {
t.Fatalf("sql should be base64: %s", enc)
}
}
func TestErrorDoesNotEmbedPassword(t *testing.T) {
e := &Error{Kind: "mysql", Errno: 1045, Message: "Access denied for user 'root'@'localhost'"}
if strings.Contains(e.Error(), "password") && strings.Contains(e.Error(), "=") {
t.Fatal(e.Error())
}
}
+63
View File
@@ -0,0 +1,63 @@
package navicat
import (
"encoding/base64"
"net/url"
"strconv"
"strings"
)
// Request is one Navicat HTTP Tunnel POST body.
type Request struct {
Action string
Host string
Port int
Login string
Password string
Database string
Queries []string
EncodeBase64 bool
}
// Form builds application/x-www-form-urlencoded fields matching $_POST in ntunnel_mysql.php.
func (r Request) Form() url.Values {
v := url.Values{}
v.Set("actn", r.Action)
v.Set("host", r.Host)
if r.Port > 0 {
v.Set("port", strconv.Itoa(r.Port))
} else {
v.Set("port", "")
}
v.Set("login", r.Login)
v.Set("password", r.Password)
v.Set("db", r.Database)
queries := nonEmpty(r.Queries)
if r.EncodeBase64 {
v.Set("encodeBase64", "1")
for _, q := range queries {
v.Add("q[]", base64.StdEncoding.EncodeToString([]byte(q)))
}
} else {
for _, q := range queries {
v.Add("q[]", q)
}
}
return v
}
func (r Request) Encode() string {
return r.Form().Encode()
}
func nonEmpty(in []string) []string {
out := make([]string, 0, len(in))
for _, s := range in {
if strings.TrimSpace(s) == "" {
continue
}
out = append(out, s)
}
return out
}
+266
View File
@@ -0,0 +1,266 @@
package navicat
import (
"fmt"
"strings"
)
// Header is EchoHeader from ntunnel_mysql.php (16 bytes).
type Header struct {
Magic uint32
Version uint16
Errno uint32
Message string
UncertainVersion bool
}
func (h Header) OK() bool { return h.Errno == 0 }
// ConnInfo is actn=C payload after a successful header.
type ConnInfo struct {
HostInfo string
ProtoInfo string
ServerInfo string
}
// Field is one EchoFieldsHeader column. Type/flags/length are mysqli numeric values.
type Field struct {
Name string
Table string
Type uint32
Flags uint32
Length uint32
}
// Cell is one row field. Bytes are kept as the PHP string; never parsed as int64/float64.
type Cell struct {
Null bool
Bytes []byte
}
func (c Cell) Text() string {
if c.Null {
return ""
}
return string(c.Bytes)
}
// Result is one query in an actn=Q response.
type Result struct {
Errno uint32
Message string
AffectedRows uint32
InsertID uint32 // protocol is 32-bit; do not invent high bits
NumFields uint32
NumRows uint32
Info string
Fields []Field
Rows [][]Cell
}
func (r Result) IsError() bool { return r.Errno > 0 }
func (r Result) IsResultSet() bool {
return r.Errno == 0 && r.NumFields > 0
}
// ConnectResponse is a parsed actn=C reply.
type ConnectResponse struct {
Header Header
Conn ConnInfo
}
// QueryResponse is a parsed actn=Q reply.
type QueryResponse struct {
Header Header
Results []Result
}
func ParseHeader(body []byte) (Header, *reader, error) {
var h Header
if len(body) < HeaderSize {
if looksHTML(body) {
return h, nil, &Error{Kind: "http", Message: "tunnel returned HTML, not Navicat binary"}
}
return h, nil, fmt.Errorf("response too short (%d bytes)", len(body))
}
r := newReader(body)
magic, err := r.u32()
if err != nil {
return h, nil, err
}
if magic != Magic {
if looksHTML(body) {
return h, nil, &Error{Kind: "http", Message: "tunnel returned HTML, not Navicat binary"}
}
return h, nil, fmt.Errorf("invalid Navicat magic %d (want %d)", magic, Magic)
}
ver, err := r.u16()
if err != nil {
return h, nil, err
}
errno, err := r.u32()
if err != nil {
return h, nil, err
}
if _, err := r.readN(6); err != nil {
return h, nil, err
}
h.Magic = magic
h.Version = ver
h.Errno = errno
h.UncertainVersion = !knownVersion(ver)
if errno != 0 {
msg, err := r.blockString()
if err != nil {
return h, nil, fmt.Errorf("read error message: %w", err)
}
h.Message = msg
}
return h, r, nil
}
func ParseConnect(body []byte) (*ConnectResponse, error) {
h, r, err := ParseHeader(body)
if err != nil {
return nil, err
}
out := &ConnectResponse{Header: h}
if !h.OK() {
return out, nil
}
host, err := r.blockString()
if err != nil {
return nil, fmt.Errorf("conn host_info: %w", err)
}
proto, err := r.blockString()
if err != nil {
return nil, fmt.Errorf("conn proto_info: %w", err)
}
server, err := r.blockString()
if err != nil {
return nil, fmt.Errorf("conn server_info: %w", err)
}
out.Conn = ConnInfo{HostInfo: host, ProtoInfo: proto, ServerInfo: server}
return out, nil
}
func ParseQuery(body []byte) (*QueryResponse, error) {
h, r, err := ParseHeader(body)
if err != nil {
return nil, err
}
out := &QueryResponse{Header: h}
if !h.OK() {
return out, nil
}
for {
if r.remaining() == 0 {
break
}
res, err := parseOneResult(r)
if err != nil {
return nil, err
}
out.Results = append(out.Results, res)
if r.remaining() == 0 {
break
}
sep, err := r.readByte()
if err != nil {
return nil, err
}
if sep == sepLast {
break
}
if sep != sepMore {
return nil, fmt.Errorf("invalid query separator 0x%02x", sep)
}
}
return out, nil
}
func parseOneResult(r *reader) (Result, error) {
var res Result
var err error
if res.Errno, err = r.u32(); err != nil {
return res, err
}
if res.AffectedRows, err = r.u32(); err != nil {
return res, err
}
if res.InsertID, err = r.u32(); err != nil {
return res, err
}
if res.NumFields, err = r.u32(); err != nil {
return res, err
}
if res.NumRows, err = r.u32(); err != nil {
return res, err
}
if _, err = r.readN(12); err != nil {
return res, err
}
if res.Errno > 0 {
res.Message, err = r.blockString()
return res, err
}
if res.NumFields == 0 {
res.Info, err = r.blockString()
return res, err
}
res.Fields = make([]Field, 0, res.NumFields)
for i := uint32(0); i < res.NumFields; i++ {
f, err := parseField(r)
if err != nil {
return res, fmt.Errorf("field %d: %w", i, err)
}
res.Fields = append(res.Fields, f)
}
res.Rows = make([][]Cell, 0, res.NumRows)
for i := uint32(0); i < res.NumRows; i++ {
row := make([]Cell, res.NumFields)
for j := uint32(0); j < res.NumFields; j++ {
val, isNull, err := r.block(true)
if err != nil {
return res, fmt.Errorf("row %d col %d: %w", i, j, err)
}
row[j] = Cell{Null: isNull, Bytes: val}
}
res.Rows = append(res.Rows, row)
}
return res, nil
}
func parseField(r *reader) (Field, error) {
var f Field
var err error
if f.Name, err = r.blockString(); err != nil {
return f, err
}
if f.Table, err = r.blockString(); err != nil {
return f, err
}
if f.Type, err = r.u32(); err != nil {
return f, err
}
if f.Flags, err = r.u32(); err != nil {
return f, err
}
if f.Length, err = r.u32(); err != nil {
return f, err
}
return f, nil
}
func looksHTML(body []byte) bool {
s := strings.TrimSpace(string(body))
if len(s) < 5 {
return false
}
n := 64
if len(s) < n {
n = len(s)
}
ls := strings.ToLower(s[:n])
return strings.HasPrefix(ls, "<!doctype") || strings.HasPrefix(ls, "<html")
}
+131
View File
@@ -0,0 +1,131 @@
package tunnel
import (
"context"
"crypto/tls"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
"mysql-tunnel/internal/logger"
)
// HTTPClient is a generic HTTP client for the tunnel endpoint.
// Navicat request/response encoding belongs in package navicat (Phase 2).
type HTTPClient struct {
base *http.Client
insecure bool
userAgent string
}
type Options struct {
Timeout time.Duration
TLSInsecureSkipVerify bool
}
func NewHTTPClient(opt Options) *HTTPClient {
if opt.Timeout <= 0 {
opt.Timeout = 30 * time.Second
}
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 8,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
}
if opt.TLSInsecureSkipVerify {
transport.TLSClientConfig.InsecureSkipVerify = true
}
return &HTTPClient{
base: &http.Client{
Timeout: opt.Timeout,
Transport: transport,
},
insecure: opt.TLSInsecureSkipVerify,
userAgent: "mysql-tunnel/0.1",
}
}
type Response struct {
StatusCode int
ContentType string
Body []byte
}
func (c *HTTPClient) Do(ctx context.Context, method, rawURL string, contentType string, body io.Reader) (*Response, error) {
req, err := http.NewRequestWithContext(ctx, method, rawURL, body)
if err != nil {
return nil, fmt.Errorf("build request: %w", redactErr(err))
}
req.Header.Set("User-Agent", c.userAgent)
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
resp, err := c.base.Do(req)
if err != nil {
return nil, fmt.Errorf("http %s %s: %w", method, logger.RedactURL(rawURL), redactErr(err))
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20))
if err != nil {
return nil, fmt.Errorf("read response from %s: %w", logger.RedactURL(rawURL), err)
}
return &Response{
StatusCode: resp.StatusCode,
ContentType: resp.Header.Get("Content-Type"),
Body: data,
}, nil
}
func (c *HTTPClient) InsecureTLS() bool {
return c.insecure
}
func redactErr(err error) error {
if err == nil {
return nil
}
return fmt.Errorf("%s", logger.RedactError(err))
}
func LooksLikeNavicatHeader(body []byte) bool {
if len(body) < 16 {
return false
}
magic := uint32(body[0])<<24 | uint32(body[1])<<16 | uint32(body[2])<<8 | uint32(body[3])
return magic == 1111
}
func HeaderErrno(body []byte) (uint32, bool) {
if len(body) < 10 {
return 0, false
}
errno := uint32(body[6])<<24 | uint32(body[7])<<16 | uint32(body[8])<<8 | uint32(body[9])
return errno, true
}
func IsProbablyHTML(body []byte) bool {
s := strings.TrimSpace(string(body))
if len(s) < 5 {
return false
}
ls := strings.ToLower(s[:min(64, len(s))])
return strings.HasPrefix(ls, "<!doctype") || strings.HasPrefix(ls, "<html")
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
+88
View File
@@ -0,0 +1,88 @@
package tunnel
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestLooksLikeNavicatHeader(t *testing.T) {
body := make([]byte, 16)
body[2] = 0x04
body[3] = 0x57 // 1111
body[5] = 206
if !LooksLikeNavicatHeader(body) {
t.Fatal("expected magic 1111")
}
body[3] = 0x00
if LooksLikeNavicatHeader(body) {
t.Fatal("false positive")
}
}
func TestHeaderErrno(t *testing.T) {
body := make([]byte, 16)
body[9] = 202
errno, ok := HeaderErrno(body)
if !ok || errno != 202 {
t.Fatalf("errno=%d ok=%v", errno, ok)
}
}
func TestHTTPClientPOST(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method %s", r.Method)
}
if r.Header.Get("Content-Type") != "application/x-www-form-urlencoded" {
t.Errorf("ct %s", r.Header.Get("Content-Type"))
}
b, _ := io.ReadAll(r.Body)
if strings.Contains(string(b), "actn=C") == false {
t.Errorf("body %s", b)
}
w.Header().Set("Content-Type", "text/plain; charset=x-user-defined")
hdr := make([]byte, 16)
hdr[2] = 0x04
hdr[3] = 0x57
hdr[5] = 206
hdr[9] = 202
_, _ = w.Write(hdr)
_, _ = w.Write([]byte{0x12})
_, _ = w.Write([]byte("invalid parameters"))
}))
defer srv.Close()
c := NewHTTPClient(Options{Timeout: 5 * time.Second})
resp, err := c.Do(context.Background(), http.MethodPost, srv.URL, "application/x-www-form-urlencoded", strings.NewReader("actn=C&host=mysql&port=3306&login=root"))
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 200 {
t.Fatalf("status %d", resp.StatusCode)
}
if !LooksLikeNavicatHeader(resp.Body) {
t.Fatal("header")
}
errno, _ := HeaderErrno(resp.Body)
if errno != 202 {
t.Fatalf("errno %d", errno)
}
}
func TestTimeout(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(200 * time.Millisecond)
w.WriteHeader(200)
}))
defer srv.Close()
c := NewHTTPClient(Options{Timeout: 50 * time.Millisecond})
_, err := c.Do(context.Background(), http.MethodGet, srv.URL, "", nil)
if err == nil {
t.Fatal("expected timeout")
}
}
+7
View File
@@ -0,0 +1,7 @@
package winsvc
const (
Name = "mysql-tunnel"
DisplayName = "MySQL Tunnel"
Description = "Local MySQL proxy that translates MySQL protocol to Navicat HTTP Tunnel for Yingdao RPA."
)
+40
View File
@@ -0,0 +1,40 @@
//go:build !windows
package winsvc
import (
"context"
"fmt"
"os"
"path/filepath"
)
func IsService() bool { return false }
func Run(func(ctx context.Context) error) error {
return fmt.Errorf("Windows service is only supported on Windows")
}
func Install(string, string) error { return fmt.Errorf("Windows service is only supported on Windows") }
func Uninstall() error { return fmt.Errorf("Windows service is only supported on Windows") }
func Start() error { return fmt.Errorf("Windows service is only supported on Windows") }
func Stop() error { return fmt.Errorf("Windows service is only supported on Windows") }
func ExePath() (string, error) {
p, err := os.Executable()
if err != nil {
return "", err
}
return filepath.EvalSymlinks(p)
}
func CopyFile(src, dst string) error {
in, err := os.ReadFile(src)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(dst), 0o750); err != nil {
return err
}
return os.WriteFile(dst, in, 0o600)
}
+212
View File
@@ -0,0 +1,212 @@
//go:build windows
package winsvc
import (
"context"
"fmt"
"os"
"path/filepath"
"time"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/svc"
"golang.org/x/sys/windows/svc/mgr"
)
func IsService() bool {
ok, err := svc.IsWindowsService()
return err == nil && ok
}
func Run(run func(ctx context.Context) error) error {
return svc.Run(Name, &handler{run: run})
}
type handler struct {
run func(ctx context.Context) error
}
func (h *handler) Execute(_ []string, r <-chan svc.ChangeRequest, changes chan<- svc.Status) (bool, uint32) {
changes <- svc.Status{State: svc.StartPending}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errCh := make(chan error, 1)
go func() {
errCh <- h.run(ctx)
}()
select {
case err := <-errCh:
if err != nil {
return true, 1
}
return false, 0
case <-time.After(800 * time.Millisecond):
changes <- svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown}
}
for {
select {
case err := <-errCh:
if err != nil {
return true, 1
}
return false, 0
case c := <-r:
switch c.Cmd {
case svc.Interrogate:
changes <- c.CurrentStatus
case svc.Stop, svc.Shutdown:
changes <- svc.Status{State: svc.StopPending}
cancel()
select {
case <-errCh:
case <-time.After(8 * time.Second):
}
return false, 0
default:
changes <- c.CurrentStatus
}
}
}
}
func Install(exePath, configPath string) error {
m, err := mgr.Connect()
if err != nil {
return wrapAccess(err, "connect to service manager")
}
defer m.Disconnect()
if s, err := m.OpenService(Name); err == nil {
s.Close()
return fmt.Errorf("service %s already exists; run --uninstall first", Name)
}
s, err := m.CreateService(Name, exePath, mgr.Config{
DisplayName: DisplayName,
Description: Description,
StartType: mgr.StartAutomatic,
ErrorControl: mgr.ErrorNormal,
}, "--config", configPath)
if err != nil {
return wrapAccess(err, "create service")
}
defer s.Close()
return nil
}
func Uninstall() error {
m, err := mgr.Connect()
if err != nil {
return wrapAccess(err, "connect to service manager")
}
defer m.Disconnect()
s, err := m.OpenService(Name)
if err != nil {
return fmt.Errorf("service %s not installed", Name)
}
defer s.Close()
st, err := s.Query()
if err == nil && st.State != svc.Stopped {
_ = control(s, svc.Stop, svc.Stopped)
}
if err := s.Delete(); err != nil {
return wrapAccess(err, "delete service")
}
return nil
}
func Start() error {
m, err := mgr.Connect()
if err != nil {
return wrapAccess(err, "connect to service manager")
}
defer m.Disconnect()
s, err := m.OpenService(Name)
if err != nil {
return fmt.Errorf("service %s not installed", Name)
}
defer s.Close()
if err := s.Start(); err != nil {
if err == windows.ERROR_SERVICE_ALREADY_RUNNING {
return nil
}
return wrapAccess(err, "start service")
}
return nil
}
func Stop() error {
m, err := mgr.Connect()
if err != nil {
return wrapAccess(err, "connect to service manager")
}
defer m.Disconnect()
s, err := m.OpenService(Name)
if err != nil {
return fmt.Errorf("service %s not installed", Name)
}
defer s.Close()
st, err := s.Query()
if err != nil {
return err
}
if st.State == svc.Stopped {
return nil
}
return control(s, svc.Stop, svc.Stopped)
}
func control(s *mgr.Service, c svc.Cmd, to svc.State) error {
status, err := s.Control(c)
if err != nil {
return err
}
deadline := time.Now().Add(10 * time.Second)
for status.State != to {
if time.Now().After(deadline) {
return fmt.Errorf("timeout waiting for service state %v", to)
}
time.Sleep(300 * time.Millisecond)
status, err = s.Query()
if err != nil {
return err
}
}
return nil
}
func wrapAccess(err error, op string) error {
if err == nil {
return nil
}
if err == windows.ERROR_ACCESS_DENIED {
return fmt.Errorf("%s: access denied; run as Administrator", op)
}
return fmt.Errorf("%s: %w", op, err)
}
func ExePath() (string, error) {
p, err := os.Executable()
if err != nil {
return "", err
}
if r, err := filepath.EvalSymlinks(p); err == nil {
return r, nil
}
return p, nil
}
func CopyFile(src, dst string) error {
in, err := os.ReadFile(src)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(dst), 0o750); err != nil {
return err
}
return os.WriteFile(dst, in, 0o600)
}