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() }