app-init
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package config
|
||||
|
||||
import "gopkg.in/yaml.v3"
|
||||
|
||||
func unmarshalYAML(raw []byte, v any) error {
|
||||
return yaml.Unmarshal(raw, v)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 |
@@ -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]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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} }
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user