app-init
This commit is contained in:
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user