This commit is contained in:
Yangxt
2026-09-21 16:46:55 +08:00
parent 01842c59c6
commit ad2b0a7d3e
58 changed files with 6906 additions and 3 deletions
+296
View File
@@ -0,0 +1,296 @@
package config
import (
"errors"
"fmt"
"net"
"net/url"
"os"
"runtime"
"strings"
"time"
)
const (
DefaultListenHost = "127.0.0.1"
DefaultListenPort = 13306
DefaultTimeoutSec = 30
DefaultMaxConnections = 5
DefaultLogLevel = "info"
DefaultLogFile = "logs/tunnel.log"
DefaultCharset = "utf8mb4"
)
// File holds the on-disk YAML shape. Nested pointers distinguish "unset" from zero values.
type File struct {
Listen ListenFile `yaml:"listen"`
LocalAuth LocalAuthFile `yaml:"local_auth"`
Tunnel TunnelFile `yaml:"tunnel"`
Remote RemoteFile `yaml:"remote"`
Options OptionsFile `yaml:"options"`
}
type ListenFile struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
}
type LocalAuthFile struct {
Username string `yaml:"username"`
Password string `yaml:"password"`
}
type TunnelFile struct {
URL string `yaml:"url"`
EncodeBase64 bool `yaml:"encode_base64"`
TLSInsecureSkipVerify bool `yaml:"tls_insecure_skip_verify"`
}
type RemoteFile struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
Username string `yaml:"username"`
Password string `yaml:"password"`
Database string `yaml:"database"`
Charset string `yaml:"charset"`
}
type OptionsFile struct {
RequestTimeout int `yaml:"request_timeout"`
MaxConnections int `yaml:"max_connections"`
LogLevel string `yaml:"log_level"`
LogSQL bool `yaml:"log_sql"`
LogFile string `yaml:"log_file"`
}
// Config is the validated runtime configuration.
type Config struct {
Path string
ListenHost string
ListenPort int
LocalUsername string
LocalPassword string
TunnelURL string
EncodeBase64 bool
TLSInsecureSkipVerify bool
TLSInsecureSkipVerifyWarned bool
RemoteHost string
RemotePort int
RemoteUsername string
RemotePassword string
RemoteDatabase string
RemoteCharset string
RequestTimeout time.Duration
MaxConnections int
LogLevel string
LogSQL bool
LogFile string
Warnings []string
}
type Overrides struct {
ListenHost string
ListenPort int
}
func Defaults() Config {
return Config{
ListenHost: DefaultListenHost,
ListenPort: DefaultListenPort,
RequestTimeout: time.Duration(DefaultTimeoutSec) * time.Second,
MaxConnections: DefaultMaxConnections,
LogLevel: DefaultLogLevel,
LogFile: DefaultLogFile,
RemotePort: 3306,
RemoteCharset: DefaultCharset,
}
}
func Parse(path string, ov Overrides) (*Config, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config %s: %w", path, err)
}
var file File
if err := unmarshalYAML(raw, &file); err != nil {
return nil, fmt.Errorf("parse config %s: %w", path, err)
}
cfg := Defaults()
cfg.Path = path
applyFile(&cfg, file)
applyOverrides(&cfg, ov)
return &cfg, nil
}
func Load(path string, ov Overrides) (*Config, error) {
cfg, err := Parse(path, ov)
if err != nil {
return nil, err
}
if err := cfg.Validate(); err != nil {
return nil, err
}
cfg.warnFilePermissions()
return cfg, nil
}
func applyFile(cfg *Config, file File) {
if file.Listen.Host != "" {
cfg.ListenHost = file.Listen.Host
}
if file.Listen.Port != 0 {
cfg.ListenPort = file.Listen.Port
}
cfg.LocalUsername = file.LocalAuth.Username
cfg.LocalPassword = file.LocalAuth.Password
cfg.TunnelURL = strings.TrimSpace(file.Tunnel.URL)
cfg.EncodeBase64 = file.Tunnel.EncodeBase64
cfg.TLSInsecureSkipVerify = file.Tunnel.TLSInsecureSkipVerify
cfg.RemoteHost = file.Remote.Host
if file.Remote.Port != 0 {
cfg.RemotePort = file.Remote.Port
}
cfg.RemoteUsername = file.Remote.Username
cfg.RemotePassword = file.Remote.Password
cfg.RemoteDatabase = file.Remote.Database
if file.Remote.Charset != "" {
cfg.RemoteCharset = file.Remote.Charset
}
if file.Options.RequestTimeout != 0 {
cfg.RequestTimeout = time.Duration(file.Options.RequestTimeout) * time.Second
}
if file.Options.MaxConnections != 0 {
cfg.MaxConnections = file.Options.MaxConnections
}
if file.Options.LogLevel != "" {
cfg.LogLevel = strings.ToLower(strings.TrimSpace(file.Options.LogLevel))
}
cfg.LogSQL = file.Options.LogSQL
if file.Options.LogFile != "" {
cfg.LogFile = file.Options.LogFile
}
}
func applyOverrides(cfg *Config, ov Overrides) {
if ov.ListenHost != "" {
cfg.ListenHost = ov.ListenHost
}
if ov.ListenPort != 0 {
cfg.ListenPort = ov.ListenPort
}
}
func (c *Config) Validate() error {
var errs []string
if c.ListenHost == "" {
errs = append(errs, "listen.host is required")
} else if ip := net.ParseIP(c.ListenHost); ip != nil && ip.IsUnspecified() {
errs = append(errs, "listen.host must not be 0.0.0.0/::; bind loopback only unless you fully understand the risk")
} else if !isLoopbackHost(c.ListenHost) {
c.Warnings = append(c.Warnings, fmt.Sprintf("listen.host %q is not loopback; the local MySQL port will be reachable beyond this machine", c.ListenHost))
}
if c.ListenPort < 1 || c.ListenPort > 65535 {
errs = append(errs, "listen.port must be 1-65535")
}
if strings.TrimSpace(c.LocalUsername) == "" {
errs = append(errs, "local_auth.username is required")
}
if c.TunnelURL == "" {
errs = append(errs, "tunnel.url is required")
} else {
u, err := url.Parse(c.TunnelURL)
if err != nil {
errs = append(errs, "tunnel.url is not a valid URL")
} else {
if u.Scheme != "http" && u.Scheme != "https" {
errs = append(errs, "tunnel.url scheme must be http or https")
}
if u.Host == "" {
errs = append(errs, "tunnel.url must include a host")
}
if u.Query().Get("token") == "" {
c.Warnings = append(c.Warnings, "tunnel.url has no token query parameter; ntunnel_mysql.php will reject requests with errno 204")
}
}
}
if strings.TrimSpace(c.RemoteHost) == "" {
errs = append(errs, "remote.host is required")
}
if c.RemotePort < 1 || c.RemotePort > 65535 {
errs = append(errs, "remote.port must be 1-65535")
}
if strings.TrimSpace(c.RemoteUsername) == "" {
errs = append(errs, "remote.username is required")
}
if c.RequestTimeout <= 0 {
errs = append(errs, "options.request_timeout must be > 0")
}
if c.MaxConnections < 1 {
errs = append(errs, "options.max_connections must be >= 1")
}
switch c.LogLevel {
case "debug", "info", "warn", "error":
default:
errs = append(errs, "options.log_level must be debug, info, warn, or error")
}
if c.TLSInsecureSkipVerify {
c.TLSInsecureSkipVerifyWarned = true
c.Warnings = append(c.Warnings, "SECURITY: tls_insecure_skip_verify is true; TLS certificate verification is disabled")
}
if len(errs) == 0 {
return nil
}
return errors.New(strings.Join(errs, "; "))
}
func (c *Config) warnFilePermissions() {
info, err := os.Stat(c.Path)
if err != nil {
return
}
if runtime.GOOS != "windows" {
if info.Mode().Perm()&0o004 != 0 {
c.Warnings = append(c.Warnings, fmt.Sprintf("%s is world-readable; it contains a tunnel token and database password", c.Path))
}
} else {
c.Warnings = append(c.Warnings, fmt.Sprintf("%s contains secrets (token, passwords); restrict NTFS permissions to your account", c.Path))
}
}
func isLoopbackHost(host string) bool {
h := strings.TrimSpace(host)
if strings.EqualFold(h, "localhost") {
return true
}
ip := net.ParseIP(h)
return ip != nil && ip.IsLoopback()
}
// RedactedTunnelURL strips userinfo and token/password query values for logs.
func RedactedTunnelURL(raw string) string {
u, err := url.Parse(raw)
if err != nil {
return "(invalid-url)"
}
u.User = nil
q := u.Query()
for _, key := range []string{"token", "password", "pass", "access_token", "accessToken"} {
if q.Has(key) {
q.Set(key, "***")
}
}
u.RawQuery = q.Encode()
u.Fragment = ""
return u.String()
}
+154
View File
@@ -0,0 +1,154 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func writeTempConfig(t *testing.T, body string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
return path
}
func TestLoadValidConfig(t *testing.T) {
path := writeTempConfig(t, `
listen:
host: 127.0.0.1
port: 13306
local_auth:
username: root
password: "local-secret"
tunnel:
url: "https://example.invalid/ntunnel?token=abc"
remote:
host: mysql
port: 3306
username: remote-root
password: "remote-secret"
database: app
charset: utf8mb4
options:
request_timeout: 15
max_connections: 3
log_level: debug
log_sql: false
`)
cfg, err := Load(path, Overrides{})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.ListenHost != "127.0.0.1" || cfg.ListenPort != 13306 {
t.Fatalf("listen = %s:%d", cfg.ListenHost, cfg.ListenPort)
}
if cfg.RemoteHost != "mysql" || cfg.RemoteUsername != "remote-root" {
t.Fatalf("remote mismatch")
}
if cfg.RequestTimeout.Seconds() != 15 {
t.Fatalf("timeout = %s", cfg.RequestTimeout)
}
}
func TestRejectUnspecifiedListen(t *testing.T) {
path := writeTempConfig(t, `
listen:
host: 0.0.0.0
port: 13306
local_auth:
username: root
password: x
tunnel:
url: "https://example.invalid/?token=t"
remote:
host: mysql
username: root
password: x
`)
_, err := Load(path, Overrides{})
if err == nil || !strings.Contains(err.Error(), "0.0.0.0") {
t.Fatalf("expected unspecified listen error, got %v", err)
}
}
func TestMissingTunnelURL(t *testing.T) {
path := writeTempConfig(t, `
local_auth:
username: root
password: x
remote:
host: mysql
username: root
password: x
`)
_, err := Load(path, Overrides{})
if err == nil || !strings.Contains(err.Error(), "tunnel.url") {
t.Fatalf("expected tunnel.url error, got %v", err)
}
}
func TestCLIOverrides(t *testing.T) {
path := writeTempConfig(t, `
listen:
host: 127.0.0.1
port: 13306
local_auth:
username: root
password: x
tunnel:
url: "https://example.invalid/?token=t"
remote:
host: mysql
username: root
password: x
`)
cfg, err := Load(path, Overrides{ListenHost: "127.0.0.1", ListenPort: 23306})
if err != nil {
t.Fatal(err)
}
if cfg.ListenPort != 23306 {
t.Fatalf("port override failed: %d", cfg.ListenPort)
}
}
func TestRedactedTunnelURL(t *testing.T) {
got := RedactedTunnelURL("https://bth-ntunnel.example/?token=super-secret&x=1")
if strings.Contains(got, "super-secret") {
t.Fatalf("token leaked: %s", got)
}
if !strings.Contains(got, "token=%2A%2A%2A") && !strings.Contains(got, "token=***") {
t.Fatalf("token not redacted: %s", got)
}
}
func TestWarnWhenTokenMissing(t *testing.T) {
path := writeTempConfig(t, `
local_auth:
username: root
password: x
tunnel:
url: "https://example.invalid/ntunnel"
remote:
host: mysql
username: root
password: x
`)
cfg, err := Load(path, Overrides{})
if err != nil {
t.Fatal(err)
}
found := false
for _, w := range cfg.Warnings {
if strings.Contains(w, "token") {
found = true
}
}
if !found {
t.Fatalf("expected token warning, got %#v", cfg.Warnings)
}
}
+62
View File
@@ -0,0 +1,62 @@
package config
import (
"fmt"
"os"
"path/filepath"
)
// ResolveConfigPath finds config.yaml: --config, then next to the exe, then cwd.
func ResolveConfigPath(flagPath string) (string, error) {
var cands []string
if flagPath != "" {
cands = append(cands, flagPath)
}
if exe, err := os.Executable(); err == nil {
dir := filepath.Dir(exe)
cands = append(cands, filepath.Join(dir, "config.yaml"))
cands = append(cands, filepath.Join(dir, "..", "config.yaml"))
}
cands = append(cands, "config.yaml")
seen := map[string]bool{}
for _, p := range cands {
abs, err := filepath.Abs(p)
if err != nil {
continue
}
abs = filepath.Clean(abs)
if seen[abs] {
continue
}
seen[abs] = true
st, err := os.Stat(abs)
if err == nil && !st.IsDir() {
return abs, nil
}
}
if flagPath == "" {
flagPath = "config.yaml"
}
abs, err := filepath.Abs(flagPath)
if err != nil {
return flagPath, fmt.Errorf("config not found: %s", flagPath)
}
return abs, fmt.Errorf("config not found: %s", abs)
}
// ResolvePaths makes LogFile absolute relative to the config file directory.
func (c *Config) ResolvePaths() {
if c == nil {
return
}
if p, err := filepath.Abs(c.Path); err == nil {
c.Path = p
}
if c.LogFile == "" {
c.LogFile = DefaultLogFile
}
if !filepath.IsAbs(c.LogFile) {
c.LogFile = filepath.Join(filepath.Dir(c.Path), c.LogFile)
}
c.LogFile = filepath.Clean(c.LogFile)
}
+50
View File
@@ -0,0 +1,50 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestResolvePathsMakesLogAbsolute(t *testing.T) {
path := writeTempConfig(t, `
local_auth:
username: root
password: x
tunnel:
url: "https://example.invalid/?token=t"
remote:
host: mysql
username: root
password: x
options:
log_file: logs/tunnel.log
`)
cfg, err := Load(path, Overrides{})
if err != nil {
t.Fatal(err)
}
cfg.ResolvePaths()
want := filepath.Join(filepath.Dir(path), "logs", "tunnel.log")
if cfg.LogFile != want {
t.Fatalf("LogFile=%s want %s", cfg.LogFile, want)
}
}
func TestResolveConfigPathPrefersExisting(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(p, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
got, err := ResolveConfigPath(p)
if err != nil {
t.Fatal(err)
}
if got != p && got != filepath.Clean(p) {
// Abs may change separators on Windows
if filepath.Clean(got) != filepath.Clean(p) {
t.Fatalf("got %s", got)
}
}
}
+99
View File
@@ -0,0 +1,99 @@
package config
import (
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
func (c *Config) ToFile() File {
if c == nil {
return File{}
}
return File{
Listen: ListenFile{Host: c.ListenHost, Port: c.ListenPort},
LocalAuth: LocalAuthFile{Username: c.LocalUsername, Password: c.LocalPassword},
Tunnel: TunnelFile{
URL: c.TunnelURL,
EncodeBase64: c.EncodeBase64,
TLSInsecureSkipVerify: c.TLSInsecureSkipVerify,
},
Remote: RemoteFile{
Host: c.RemoteHost,
Port: c.RemotePort,
Username: c.RemoteUsername,
Password: c.RemotePassword,
Database: c.RemoteDatabase,
Charset: c.RemoteCharset,
},
Options: OptionsFile{
RequestTimeout: int(c.RequestTimeout.Seconds()),
MaxConnections: c.MaxConnections,
LogLevel: c.LogLevel,
LogSQL: c.LogSQL,
LogFile: relativeLogFile(c.Path, c.LogFile),
},
}
}
func relativeLogFile(cfgPath, logFile string) string {
if logFile == "" {
return DefaultLogFile
}
if !filepath.IsAbs(logFile) {
return filepath.ToSlash(filepath.Clean(logFile))
}
dir := filepath.Dir(cfgPath)
rel, err := filepath.Rel(dir, logFile)
if err != nil {
return logFile
}
return filepath.ToSlash(rel)
}
func Save(path string, c *Config) error {
if path == "" {
return fmt.Errorf("config path is empty")
}
if dir := filepath.Dir(path); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o750); err != nil {
return err
}
}
raw, err := yaml.Marshal(c.ToFile())
if err != nil {
return err
}
out := append([]byte("# Written by mysql-tunnel. Contains secrets; do not commit.\n"), raw...)
return os.WriteFile(path, out, 0o600)
}
func DefaultSavePath() string {
if exe, err := os.Executable(); err == nil {
return filepath.Join(filepath.Dir(exe), "config.yaml")
}
return "config.yaml"
}
// LoadOrEmpty returns a parsed config, or defaults if the file is missing or invalid.
func LoadOrEmpty(path string) *Config {
if path == "" {
path = DefaultSavePath()
}
if _, err := os.Stat(path); err != nil {
c := Defaults()
c.Path = path
return &c
}
cfg, err := Parse(path, Overrides{})
if err != nil {
c := Defaults()
c.Path = path
c.Warnings = append(c.Warnings, err.Error())
return &c
}
cfg.ResolvePaths()
return cfg
}
+71
View File
@@ -0,0 +1,71 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestSaveRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
in := Defaults()
in.Path = path
in.LocalUsername = "root"
in.LocalPassword = "local-secret"
in.TunnelURL = "https://example.invalid/ntunnel?token=abc"
in.RemoteHost = "mysql"
in.RemoteUsername = "remote-root"
in.RemotePassword = "remote-secret"
in.RemoteDatabase = "app"
in.LogFile = filepath.Join(dir, "logs", "tunnel.log")
in.EncodeBase64 = true
in.LogSQL = true
if err := Save(path, &in); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), "token=abc") {
t.Fatalf("saved yaml missing url: %s", raw)
}
if !strings.Contains(string(raw), "logs/tunnel.log") && !strings.Contains(string(raw), `log_file: logs/tunnel.log`) {
if !strings.Contains(string(raw), "log_file:") {
t.Fatalf("log_file missing: %s", raw)
}
}
cfg, err := Load(path, Overrides{})
if err != nil {
t.Fatal(err)
}
if cfg.LocalPassword != "local-secret" || cfg.RemotePassword != "remote-secret" {
t.Fatalf("passwords not round-tripped")
}
if cfg.TunnelURL != in.TunnelURL {
t.Fatalf("url=%s", cfg.TunnelURL)
}
if !cfg.EncodeBase64 || !cfg.LogSQL {
t.Fatalf("bools not round-tripped")
}
}
func TestLoadOrEmptyMissing(t *testing.T) {
path := filepath.Join(t.TempDir(), "nope.yaml")
cfg := LoadOrEmpty(path)
if cfg.ListenPort != DefaultListenPort {
t.Fatalf("port=%d", cfg.ListenPort)
}
if cfg.Path != path {
t.Fatalf("path=%s", cfg.Path)
}
}
func TestRelativeLogFileKeepsRelative(t *testing.T) {
got := relativeLogFile(filepath.Join(t.TempDir(), "c.yaml"), "logs/tunnel.log")
if got != "logs/tunnel.log" {
t.Fatalf("got %s", got)
}
}
+7
View File
@@ -0,0 +1,7 @@
package config
import "gopkg.in/yaml.v3"
func unmarshalYAML(raw []byte, v any) error {
return yaml.Unmarshal(raw, v)
}