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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user