Files
mysql-tunnel/internal/config/config.go
T
2026-09-21 16:46:55 +08:00

297 lines
7.6 KiB
Go

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