diff --git a/config.go b/config.go index 9d0ae5d..59f69eb 100644 --- a/config.go +++ b/config.go @@ -1,9 +1,12 @@ package main import ( + "encoding/json" "flag" "fmt" "os" + "path/filepath" + "runtime" "strconv" "strings" "time" @@ -23,6 +26,160 @@ func (s *stringSlice) Set(value string) error { return nil } +// configSource records where a resolved setting came from, for --show-config. +type configSource string + +const ( + sourceDefault configSource = "default" + sourceFile configSource = "file" + sourceEnv configSource = "environment" + sourceCLI configSource = "cli" +) + +// fileSettings mirrors the on-disk JSON config file schema. Fields are +// pointers (or, for slices, nil-vs-non-empty) so that "absent from the file" +// can be distinguished from an explicit zero value. +type fileSettings struct { + Endpoint *string `json:"endpoint"` + ID *string `json:"id"` + Secret *string `json:"secret"` + ProvisioningKey *string `json:"provisioningKey"` + Name *string `json:"name"` + + DNS *string `json:"dns"` + LogLevel *string `json:"logLevel"` + UpdownScript *string `json:"updownScript"` + InterfaceName *string `json:"interface"` + MTU *int `json:"mtu"` + Port *int `json:"port"` + + UseNativeInterface *bool `json:"native"` + UseNativeMainInterface *bool `json:"nativeMain"` + NativeMainInterfaceName *string `json:"interfaceMain"` + NoCloud *bool `json:"noCloud"` + PreferEndpoint *string `json:"preferEndpoint"` + + PingInterval *string `json:"pingInterval"` + PingTimeout *string `json:"pingTimeout"` + UDPProxyIdleTimeout *string `json:"udpProxyIdleTimeout"` + + DisableClients *bool `json:"disableClients"` + DisableSSH *bool `json:"disableSsh"` + EnforceHealthcheckCert *bool `json:"enforceHcCert"` + HealthFile *string `json:"healthFile"` + BlueprintFile *string `json:"blueprintFile"` + ProvisioningBlueprintFile *string `json:"provisioningBlueprintFile"` + + DockerSocket *string `json:"dockerSocket"` + DockerEnforceNetworkValidation *bool `json:"dockerEnforceNetworkValidation"` + + AuthDaemonKey *string `json:"adPreSharedKey"` + AuthDaemonPrincipalsFile *string `json:"adPrincipalsFile"` + AuthDaemonCACertPath *string `json:"adCaCertPath"` + AuthDaemonGenerateRandomPassword *bool `json:"adGenerateRandomPassword"` + + TLSClientCert *string `json:"tlsClientCertFile"` + TLSClientKey *string `json:"tlsClientKey"` + TLSClientCAs []string `json:"tlsClientCa"` + TLSPrivateKey *string `json:"tlsClientCert"` // legacy PKCS12 path; matches the key already written by the credential-save path + + MetricsEnabled *bool `json:"metrics"` + OTLPEnabled *bool `json:"otlp"` + AdminAddr *string `json:"metricsAdminAddr"` + Region *string `json:"region"` + MetricsAsyncBytes *bool `json:"metricsAsyncBytes"` + PprofEnabled *bool `json:"pprof"` +} + +// resolveConfigFilePath determines the settings/credentials file path using +// the same precedence as every other setting: CLI > env > OS default. +// It has to run before flag.Parse (which needs the file-resolved defaults), +// so it scans os.Args directly instead of using the flag package. +func resolveConfigFilePath(args []string) string { + for i, a := range args { + if a == "--config-file" || a == "-config-file" { + if i+1 < len(args) { + return args[i+1] + } + } + if v, ok := strings.CutPrefix(a, "--config-file="); ok { + return v + } + if v, ok := strings.CutPrefix(a, "-config-file="); ok { + return v + } + } + + if v := os.Getenv("CONFIG_FILE"); v != "" { + return v + } + + var configDir string + switch runtime.GOOS { + case "darwin": + configDir = filepath.Join(os.Getenv("HOME"), "Library", "Application Support", "newt-client") + case "windows": + configDir = filepath.Join(os.Getenv("PROGRAMDATA"), "newt", "newt-client") + default: // linux and others + configDir = filepath.Join(os.Getenv("HOME"), ".config", "newt-client") + } + + if err := os.MkdirAll(configDir, 0755); err != nil { + fmt.Printf("Warning: Failed to create config directory: %v\n", err) + } + + return filepath.Join(configDir, "config.json") +} + +// loadFileSettings reads and parses the config file. A missing or empty file +// is not an error (returns nil, nil) since the file may not exist yet. +func loadFileSettings(path string) (*fileSettings, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + if len(strings.TrimSpace(string(data))) == 0 { + return nil, nil + } + + var fs fileSettings + if err := json.Unmarshal(data, &fs); err != nil { + return nil, fmt.Errorf("failed to parse config file %s: %w", path, err) + } + return &fs, nil +} + +func applyStr(dst *string, v *string, key string, sources map[string]string, src configSource) { + if v != nil { + *dst = *v + sources[key] = string(src) + } +} + +func applyBool(dst *bool, v *bool, key string, sources map[string]string, src configSource) { + if v != nil { + *dst = *v + sources[key] = string(src) + } +} + +func applyEnvStr(dst *string, envName, key string, sources map[string]string) { + if v := os.Getenv(envName); v != "" { + *dst = v + sources[key] = string(sourceEnv) + } +} + +func applyEnvBool(dst *bool, envName, key string, sources map[string]string) { + if v := os.Getenv(envName); v != "" { + *dst = v == "true" + sources[key] = string(sourceEnv) + } +} + // validateTLSConfig validates that TLS config fields are consistent and that // referenced files exist. func validateTLSConfig(cfg newtpkg.Config) error { @@ -77,256 +234,316 @@ func parseDurationEnvOrFlag(s string, defaultVal time.Duration, label string) ti return d } -// loadNewtConfig reads environment variables and command-line flags, then -// returns a populated newtpkg.Config. This function calls flag.Parse internally -// and will exit the process if --version is passed. +// loadNewtConfig resolves configuration with priority cli > env > file > +// default, then returns a populated newtpkg.Config. This function calls +// flag.Parse internally and will exit the process if --version or +// --show-config is passed. func loadNewtConfig() newtpkg.Config { - // ---- read environment variables first ---- + sources := make(map[string]string) + + configPath := resolveConfigFilePath(os.Args[1:]) + fileCfg, err := loadFileSettings(configPath) + if err != nil { + logger.Fatal("Failed to load config file: %v", err) + } + + // ---- defaults ---- cfg := newtpkg.Config{ Version: newtVersion, Platform: newtPlatform, - Endpoint: os.Getenv("PANGOLIN_ENDPOINT"), - ID: os.Getenv("NEWT_ID"), - Secret: os.Getenv("NEWT_SECRET"), - DNS: os.Getenv("DNS"), - LogLevel: os.Getenv("LOG_LEVEL"), - UpdownScript: os.Getenv("UPDOWN_SCRIPT"), - InterfaceName: os.Getenv("INTERFACE"), - DockerSocket: os.Getenv("DOCKER_SOCKET"), - HealthFile: os.Getenv("HEALTH_FILE"), - BlueprintFile: os.Getenv("BLUEPRINT_FILE"), - ConfigFile: os.Getenv("CONFIG_FILE"), - ProvisioningKey: os.Getenv("NEWT_PROVISIONING_KEY"), - NewtName: os.Getenv("NEWT_NAME"), - TLSClientCert: os.Getenv("TLS_CLIENT_CERT"), - TLSClientKey: os.Getenv("TLS_CLIENT_KEY"), - TLSPrivateKey: os.Getenv("TLS_CLIENT_CERT_PKCS12"), - - AuthDaemonKey: os.Getenv("AD_KEY"), - AuthDaemonPrincipalsFile: os.Getenv("AD_PRINCIPALS_FILE"), - AuthDaemonCACertPath: os.Getenv("AD_CA_CERT_PATH"), - - NativeMainInterfaceName: os.Getenv("INTERFACE_MAIN"), - ProvisioningBlueprintFile: os.Getenv("PROVISIONING_BLUEPRINT_FILE"), - Region: os.Getenv("NEWT_REGION"), - AdminAddr: os.Getenv("NEWT_ADMIN_ADDR"), + DNS: "9.9.9.9", + LogLevel: "INFO", + InterfaceName: "newt", + NativeMainInterfaceName: "newt", + AuthDaemonPrincipalsFile: "/var/run/auth-daemon/principals", + AuthDaemonCACertPath: "/etc/ssh/ca.pem", + AdminAddr: "127.0.0.1:2112", } - // Legacy PKCS12 backward-compat: fall back to TLS_CLIENT_CERT for PKCS12 - // when the newer env vars are not set. - if cfg.TLSPrivateKey == "" && cfg.TLSClientKey == "" && len(cfg.TLSClientCAs) == 0 { - cfg.TLSPrivateKey = os.Getenv("TLS_CLIENT_CERT") + mtuStr := "1280" + portStr := "" + pingIntervalStr := "15s" + pingTimeoutStr := "7s" + udpProxyIdleTimeoutStr := "90s" + dockerEnforceStr := "false" + + // ---- layer 1: config file ---- + if fileCfg != nil { + applyStr(&cfg.Endpoint, fileCfg.Endpoint, "endpoint", sources, sourceFile) + applyStr(&cfg.ID, fileCfg.ID, "id", sources, sourceFile) + applyStr(&cfg.Secret, fileCfg.Secret, "secret", sources, sourceFile) + applyStr(&cfg.ProvisioningKey, fileCfg.ProvisioningKey, "provisioning-key", sources, sourceFile) + applyStr(&cfg.NewtName, fileCfg.Name, "name", sources, sourceFile) + + applyStr(&cfg.DNS, fileCfg.DNS, "dns", sources, sourceFile) + applyStr(&cfg.LogLevel, fileCfg.LogLevel, "log-level", sources, sourceFile) + applyStr(&cfg.UpdownScript, fileCfg.UpdownScript, "updown", sources, sourceFile) + applyStr(&cfg.InterfaceName, fileCfg.InterfaceName, "interface", sources, sourceFile) + if fileCfg.MTU != nil { + mtuStr = strconv.Itoa(*fileCfg.MTU) + sources["mtu"] = string(sourceFile) + } + if fileCfg.Port != nil { + portStr = strconv.Itoa(*fileCfg.Port) + sources["port"] = string(sourceFile) + } + + applyBool(&cfg.UseNativeInterface, fileCfg.UseNativeInterface, "native", sources, sourceFile) + applyBool(&cfg.UseNativeMainInterface, fileCfg.UseNativeMainInterface, "native-main", sources, sourceFile) + applyStr(&cfg.NativeMainInterfaceName, fileCfg.NativeMainInterfaceName, "interface-main", sources, sourceFile) + applyBool(&cfg.NoCloud, fileCfg.NoCloud, "no-cloud", sources, sourceFile) + applyStr(&cfg.PreferEndpoint, fileCfg.PreferEndpoint, "prefer-endpoint", sources, sourceFile) + + applyStr(&pingIntervalStr, fileCfg.PingInterval, "ping-interval", sources, sourceFile) + applyStr(&pingTimeoutStr, fileCfg.PingTimeout, "ping-timeout", sources, sourceFile) + applyStr(&udpProxyIdleTimeoutStr, fileCfg.UDPProxyIdleTimeout, "udp-proxy-idle-timeout", sources, sourceFile) + + applyBool(&cfg.DisableClients, fileCfg.DisableClients, "disable-clients", sources, sourceFile) + applyBool(&cfg.DisableSSH, fileCfg.DisableSSH, "disable-ssh", sources, sourceFile) + applyBool(&cfg.EnforceHealthcheckCert, fileCfg.EnforceHealthcheckCert, "enforce-hc-cert", sources, sourceFile) + applyStr(&cfg.HealthFile, fileCfg.HealthFile, "health-file", sources, sourceFile) + applyStr(&cfg.BlueprintFile, fileCfg.BlueprintFile, "blueprint-file", sources, sourceFile) + applyStr(&cfg.ProvisioningBlueprintFile, fileCfg.ProvisioningBlueprintFile, "provisioning-blueprint-file", sources, sourceFile) + + applyStr(&cfg.DockerSocket, fileCfg.DockerSocket, "docker-socket", sources, sourceFile) + if fileCfg.DockerEnforceNetworkValidation != nil { + dockerEnforceStr = strconv.FormatBool(*fileCfg.DockerEnforceNetworkValidation) + sources["docker-enforce-network-validation"] = string(sourceFile) + } + + applyStr(&cfg.AuthDaemonKey, fileCfg.AuthDaemonKey, "ad-pre-shared-key", sources, sourceFile) + applyStr(&cfg.AuthDaemonPrincipalsFile, fileCfg.AuthDaemonPrincipalsFile, "ad-principals-file", sources, sourceFile) + applyStr(&cfg.AuthDaemonCACertPath, fileCfg.AuthDaemonCACertPath, "ad-ca-cert-path", sources, sourceFile) + applyBool(&cfg.AuthDaemonGenerateRandomPassword, fileCfg.AuthDaemonGenerateRandomPassword, "ad-generate-random-password", sources, sourceFile) + + applyStr(&cfg.TLSClientCert, fileCfg.TLSClientCert, "tls-client-cert-file", sources, sourceFile) + applyStr(&cfg.TLSClientKey, fileCfg.TLSClientKey, "tls-client-key", sources, sourceFile) + if len(fileCfg.TLSClientCAs) > 0 { + cfg.TLSClientCAs = append(cfg.TLSClientCAs, fileCfg.TLSClientCAs...) + sources["tls-client-ca"] = string(sourceFile) + } + applyStr(&cfg.TLSPrivateKey, fileCfg.TLSPrivateKey, "tls-client-cert", sources, sourceFile) + + applyBool(&cfg.MetricsEnabled, fileCfg.MetricsEnabled, "metrics", sources, sourceFile) + applyBool(&cfg.OTLPEnabled, fileCfg.OTLPEnabled, "otlp", sources, sourceFile) + applyStr(&cfg.AdminAddr, fileCfg.AdminAddr, "metrics-admin-addr", sources, sourceFile) + applyStr(&cfg.Region, fileCfg.Region, "region", sources, sourceFile) + applyBool(&cfg.MetricsAsyncBytes, fileCfg.MetricsAsyncBytes, "metrics-async-bytes", sources, sourceFile) + applyBool(&cfg.PprofEnabled, fileCfg.PprofEnabled, "pprof", sources, sourceFile) } - // TLS CA files: comma-separated list from env + // ---- layer 2: environment variables ---- + applyEnvStr(&cfg.Endpoint, "PANGOLIN_ENDPOINT", "endpoint", sources) + applyEnvStr(&cfg.ID, "NEWT_ID", "id", sources) + applyEnvStr(&cfg.Secret, "NEWT_SECRET", "secret", sources) + applyEnvStr(&cfg.ProvisioningKey, "NEWT_PROVISIONING_KEY", "provisioning-key", sources) + applyEnvStr(&cfg.NewtName, "NEWT_NAME", "name", sources) + + applyEnvStr(&cfg.DNS, "DNS", "dns", sources) + applyEnvStr(&cfg.LogLevel, "LOG_LEVEL", "log-level", sources) + applyEnvStr(&cfg.UpdownScript, "UPDOWN_SCRIPT", "updown", sources) + applyEnvStr(&cfg.InterfaceName, "INTERFACE", "interface", sources) + applyEnvStr(&mtuStr, "MTU", "mtu", sources) + applyEnvStr(&portStr, "PORT", "port", sources) + + applyEnvBool(&cfg.UseNativeInterface, "USE_NATIVE_INTERFACE", "native", sources) + applyEnvBool(&cfg.UseNativeMainInterface, "USE_NATIVE_MAIN_INTERFACE", "native-main", sources) + applyEnvStr(&cfg.NativeMainInterfaceName, "INTERFACE_MAIN", "interface-main", sources) + applyEnvBool(&cfg.NoCloud, "NO_CLOUD", "no-cloud", sources) + + applyEnvStr(&pingIntervalStr, "PING_INTERVAL", "ping-interval", sources) + applyEnvStr(&pingTimeoutStr, "PING_TIMEOUT", "ping-timeout", sources) + applyEnvStr(&udpProxyIdleTimeoutStr, "NEWT_UDP_PROXY_IDLE_TIMEOUT", "udp-proxy-idle-timeout", sources) + + applyEnvBool(&cfg.DisableClients, "DISABLE_CLIENTS", "disable-clients", sources) + applyEnvBool(&cfg.DisableSSH, "DISABLE_SSH", "disable-ssh", sources) + applyEnvBool(&cfg.EnforceHealthcheckCert, "ENFORCE_HC_CERT", "enforce-hc-cert", sources) + applyEnvStr(&cfg.HealthFile, "HEALTH_FILE", "health-file", sources) + applyEnvStr(&cfg.BlueprintFile, "BLUEPRINT_FILE", "blueprint-file", sources) + applyEnvStr(&cfg.ProvisioningBlueprintFile, "PROVISIONING_BLUEPRINT_FILE", "provisioning-blueprint-file", sources) + + applyEnvStr(&cfg.DockerSocket, "DOCKER_SOCKET", "docker-socket", sources) + applyEnvStr(&dockerEnforceStr, "DOCKER_ENFORCE_NETWORK_VALIDATION", "docker-enforce-network-validation", sources) + + applyEnvStr(&cfg.AuthDaemonKey, "AD_KEY", "ad-pre-shared-key", sources) + applyEnvStr(&cfg.AuthDaemonPrincipalsFile, "AD_PRINCIPALS_FILE", "ad-principals-file", sources) + applyEnvStr(&cfg.AuthDaemonCACertPath, "AD_CA_CERT_PATH", "ad-ca-cert-path", sources) + if v, err := strconv.ParseBool(os.Getenv("AD_GENERATE_RANDOM_PASSWORD")); err == nil { + cfg.AuthDaemonGenerateRandomPassword = v + sources["ad-generate-random-password"] = string(sourceEnv) + } + + applyEnvStr(&cfg.TLSClientCert, "TLS_CLIENT_CERT", "tls-client-cert-file", sources) + applyEnvStr(&cfg.TLSClientKey, "TLS_CLIENT_KEY", "tls-client-key", sources) if tlsClientCAsEnv := os.Getenv("TLS_CLIENT_CAS"); tlsClientCAsEnv != "" { for _, ca := range strings.Split(tlsClientCAsEnv, ",") { cfg.TLSClientCAs = append(cfg.TLSClientCAs, strings.TrimSpace(ca)) } + sources["tls-client-ca"] = string(sourceEnv) + } + applyEnvStr(&cfg.TLSPrivateKey, "TLS_CLIENT_CERT_PKCS12", "tls-client-cert", sources) + + // Legacy PKCS12 backward-compat: fall back to the (already layered) + // separate-cert-file value for PKCS12 when the newer fields are unset. + if cfg.TLSPrivateKey == "" && cfg.TLSClientKey == "" && len(cfg.TLSClientCAs) == 0 && cfg.TLSClientCert != "" { + cfg.TLSPrivateKey = cfg.TLSClientCert + sources["tls-client-cert"] = sources["tls-client-cert-file"] } - // Boolean env vars - disableClientsEnv := os.Getenv("DISABLE_CLIENTS") - disableSSHEnv := os.Getenv("DISABLE_SSH") - useNativeInterfaceEnv := os.Getenv("USE_NATIVE_INTERFACE") - useNativeMainInterfaceEnv := os.Getenv("USE_NATIVE_MAIN_INTERFACE") - enforceHealthcheckCertEnv := os.Getenv("ENFORCE_HC_CERT") - noCloudEnv := os.Getenv("NO_CLOUD") - adGenerateRandomPasswordEnv := os.Getenv("AD_GENERATE_RANDOM_PASSWORD") - - cfg.DisableClients = disableClientsEnv == "true" - cfg.DisableSSH = disableSSHEnv == "true" - cfg.UseNativeInterface = useNativeInterfaceEnv == "true" - cfg.UseNativeMainInterface = useNativeMainInterfaceEnv == "true" - cfg.EnforceHealthcheckCert = enforceHealthcheckCertEnv == "true" - cfg.NoCloud = noCloudEnv == "true" - - if v, err := strconv.ParseBool(adGenerateRandomPasswordEnv); err == nil { - cfg.AuthDaemonGenerateRandomPassword = v - } - - // Metrics env vars (parsing happens below after flag.Parse) - metricsEnabledEnv := os.Getenv("NEWT_METRICS_PROMETHEUS_ENABLED") - otlpEnabledEnv := os.Getenv("NEWT_METRICS_OTLP_ENABLED") - asyncBytesEnv := os.Getenv("NEWT_METRICS_ASYNC_BYTES") - pprofEnabledEnv := os.Getenv("NEWT_PPROF_ENABLED") - - if metricsEnabledEnv != "" { + if metricsEnabledEnv := os.Getenv("NEWT_METRICS_PROMETHEUS_ENABLED"); metricsEnabledEnv != "" { if v, err := strconv.ParseBool(metricsEnabledEnv); err == nil { cfg.MetricsEnabled = v } else { cfg.MetricsEnabled = true } + sources["metrics"] = string(sourceEnv) } - if v, err := strconv.ParseBool(otlpEnabledEnv); err == nil { - cfg.OTLPEnabled = v - } - if v, err := strconv.ParseBool(asyncBytesEnv); err == nil { - cfg.MetricsAsyncBytes = v - } - if v, err := strconv.ParseBool(pprofEnabledEnv); err == nil { - cfg.PprofEnabled = v - } + applyEnvBool(&cfg.OTLPEnabled, "NEWT_METRICS_OTLP_ENABLED", "otlp", sources) + applyEnvStr(&cfg.AdminAddr, "NEWT_ADMIN_ADDR", "metrics-admin-addr", sources) + applyEnvStr(&cfg.Region, "NEWT_REGION", "region", sources) + applyEnvBool(&cfg.MetricsAsyncBytes, "NEWT_METRICS_ASYNC_BYTES", "metrics-async-bytes", sources) + applyEnvBool(&cfg.PprofEnabled, "NEWT_PPROF_ENABLED", "pprof", sources) - // Numeric / duration env vars (kept as strings; parsed after flag.Parse) - mtuStr := os.Getenv("MTU") - portStr := os.Getenv("PORT") - pingIntervalStr := os.Getenv("PING_INTERVAL") - pingTimeoutStr := os.Getenv("PING_TIMEOUT") - udpProxyIdleTimeoutStr := os.Getenv("NEWT_UDP_PROXY_IDLE_TIMEOUT") - dockerEnforceStr := os.Getenv("DOCKER_ENFORCE_NETWORK_VALIDATION") + // ---- layer 3: CLI flags (always registered; default = file/env-resolved value) ---- + origEndpoint, origID, origSecret := cfg.Endpoint, cfg.ID, cfg.Secret + origMTU, origDNS, origLogLevel := mtuStr, cfg.DNS, cfg.LogLevel + origUpdown, origInterface, origPort := cfg.UpdownScript, cfg.InterfaceName, portStr + origNative, origNativeMain, origInterfaceMain := cfg.UseNativeInterface, cfg.UseNativeMainInterface, cfg.NativeMainInterfaceName + origDisableClients, origDisableSSH, origEnforceHC := cfg.DisableClients, cfg.DisableSSH, cfg.EnforceHealthcheckCert + origDockerSocket, origPingInterval, origPingTimeout := cfg.DockerSocket, pingIntervalStr, pingTimeoutStr + origUDPIdle, origProvisioningKey, origName := udpProxyIdleTimeoutStr, cfg.ProvisioningKey, cfg.NewtName + origTLSCert, origTLSKey, origDockerEnforce := cfg.TLSClientCert, cfg.TLSClientKey, dockerEnforceStr + origHealthFile, origBlueprintFile, origProvBlueprintFile := cfg.HealthFile, cfg.BlueprintFile, cfg.ProvisioningBlueprintFile + origNoCloud, origTLSPrivateKey := cfg.NoCloud, cfg.TLSPrivateKey + origMetrics, origOTLP, origAdminAddr := cfg.MetricsEnabled, cfg.OTLPEnabled, cfg.AdminAddr + origMetricsAsync, origPprof, origRegion := cfg.MetricsAsyncBytes, cfg.PprofEnabled, cfg.Region + origADKey, origADPrincipals, origADCACert := cfg.AuthDaemonKey, cfg.AuthDaemonPrincipalsFile, cfg.AuthDaemonCACertPath + origADRandomPass := cfg.AuthDaemonGenerateRandomPassword - // ---- register CLI flags (only when env was not set) ---- - - if cfg.Endpoint == "" { - flag.StringVar(&cfg.Endpoint, "endpoint", "", "Endpoint of your pangolin server") - } - if cfg.ID == "" { - flag.StringVar(&cfg.ID, "id", "", "Newt ID") - } - if cfg.Secret == "" { - flag.StringVar(&cfg.Secret, "secret", "", "Newt secret") - } - if mtuStr == "" { - flag.StringVar(&mtuStr, "mtu", "1280", "MTU to use") - } - if cfg.DNS == "" { - flag.StringVar(&cfg.DNS, "dns", "9.9.9.9", "DNS server to use") - } - if cfg.LogLevel == "" { - flag.StringVar(&cfg.LogLevel, "log-level", "INFO", "Log level (DEBUG, INFO, WARN, ERROR, FATAL)") - } - if cfg.UpdownScript == "" { - flag.StringVar(&cfg.UpdownScript, "updown", "", "Path to updown script to be called when targets are added or removed") - } - if cfg.InterfaceName == "" { - flag.StringVar(&cfg.InterfaceName, "interface", "newt", "Name of the WireGuard interface") - } - if portStr == "" { - flag.StringVar(&portStr, "port", "", "Port for client WireGuard interface") - } - if useNativeInterfaceEnv == "" { - flag.BoolVar(&cfg.UseNativeInterface, "native", false, "Use native WireGuard interface for client tunnels") - } - if useNativeMainInterfaceEnv == "" { - flag.BoolVar(&cfg.UseNativeMainInterface, "native-main", false, "Use native WireGuard interface for the main tunnel (instead of netstack)") - } - if cfg.NativeMainInterfaceName == "" { - // makeing this the same as above should prevent them from running together - flag.StringVar(&cfg.NativeMainInterfaceName, "interface-main", "newt", "Name of the native main tunnel WireGuard interface (used with --native-main)") - } - if disableClientsEnv == "" { - flag.BoolVar(&cfg.DisableClients, "disable-clients", false, "Disable clients on the WireGuard interface") - } - if disableSSHEnv == "" { - flag.BoolVar(&cfg.DisableSSH, "disable-ssh", false, "Disable SSH auth daemon and native SSH mode (remote auth daemon still works)") - } - if enforceHealthcheckCertEnv == "" { - flag.BoolVar(&cfg.EnforceHealthcheckCert, "enforce-hc-cert", false, "Enforce certificate validation for health checks (default: false, accepts any cert)") - } - if cfg.DockerSocket == "" { - flag.StringVar(&cfg.DockerSocket, "docker-socket", "", "Path or address to Docker socket (typically unix:///var/run/docker.sock)") - } - if pingIntervalStr == "" { - flag.StringVar(&pingIntervalStr, "ping-interval", "15s", "Interval for pinging the server (default 15s)") - } - if pingTimeoutStr == "" { - flag.StringVar(&pingTimeoutStr, "ping-timeout", "7s", "Timeout for each ping (default 7s)") - } - if udpProxyIdleTimeoutStr == "" { - flag.StringVar(&udpProxyIdleTimeoutStr, "udp-proxy-idle-timeout", "90s", "Idle timeout for UDP proxied client flows before cleanup") - } - flag.StringVar(&cfg.PreferEndpoint, "prefer-endpoint", "", "Prefer this endpoint for the connection (if set, will override the endpoint from the server)") - if cfg.ProvisioningKey == "" { - flag.StringVar(&cfg.ProvisioningKey, "provisioning-key", "", "One-time provisioning key used to obtain a newt ID and secret from the server") - } - if cfg.NewtName == "" { - flag.StringVar(&cfg.NewtName, "name", "", "Name for the site created during provisioning (supports {{env.VAR}} interpolation)") - } - if cfg.ConfigFile == "" { - flag.StringVar(&cfg.ConfigFile, "config-file", "", "Path to config file (overrides CONFIG_FILE env var and default location)") - } - if cfg.TLSClientCert == "" { - flag.StringVar(&cfg.TLSClientCert, "tls-client-cert-file", "", "Path to client certificate file (PEM/DER format)") - } - if cfg.TLSClientKey == "" { - flag.StringVar(&cfg.TLSClientKey, "tls-client-key", "", "Path to client private key file (PEM/DER format)") - } + flag.StringVar(&cfg.Endpoint, "endpoint", cfg.Endpoint, "Endpoint of your pangolin server") + flag.StringVar(&cfg.ID, "id", cfg.ID, "Newt ID") + flag.StringVar(&cfg.Secret, "secret", cfg.Secret, "Newt secret") + flag.StringVar(&mtuStr, "mtu", mtuStr, "MTU to use") + flag.StringVar(&cfg.DNS, "dns", cfg.DNS, "DNS server to use") + flag.StringVar(&cfg.LogLevel, "log-level", cfg.LogLevel, "Log level (DEBUG, INFO, WARN, ERROR, FATAL)") + flag.StringVar(&cfg.UpdownScript, "updown", cfg.UpdownScript, "Path to updown script to be called when targets are added or removed") + flag.StringVar(&cfg.InterfaceName, "interface", cfg.InterfaceName, "Name of the WireGuard interface") + flag.StringVar(&portStr, "port", portStr, "Port for client WireGuard interface") + flag.BoolVar(&cfg.UseNativeInterface, "native", cfg.UseNativeInterface, "Use native WireGuard interface for client tunnels") + flag.BoolVar(&cfg.UseNativeMainInterface, "native-main", cfg.UseNativeMainInterface, "Use native WireGuard interface for the main tunnel (instead of netstack)") + // making this the same as above should prevent them from running together + flag.StringVar(&cfg.NativeMainInterfaceName, "interface-main", cfg.NativeMainInterfaceName, "Name of the native main tunnel WireGuard interface (used with --native-main)") + flag.BoolVar(&cfg.DisableClients, "disable-clients", cfg.DisableClients, "Disable clients on the WireGuard interface") + flag.BoolVar(&cfg.DisableSSH, "disable-ssh", cfg.DisableSSH, "Disable SSH auth daemon and native SSH mode (remote auth daemon still works)") + flag.BoolVar(&cfg.EnforceHealthcheckCert, "enforce-hc-cert", cfg.EnforceHealthcheckCert, "Enforce certificate validation for health checks (default: false, accepts any cert)") + flag.StringVar(&cfg.DockerSocket, "docker-socket", cfg.DockerSocket, "Path or address to Docker socket (typically unix:///var/run/docker.sock)") + flag.StringVar(&pingIntervalStr, "ping-interval", pingIntervalStr, "Interval for pinging the server (default 15s)") + flag.StringVar(&pingTimeoutStr, "ping-timeout", pingTimeoutStr, "Timeout for each ping (default 7s)") + flag.StringVar(&udpProxyIdleTimeoutStr, "udp-proxy-idle-timeout", udpProxyIdleTimeoutStr, "Idle timeout for UDP proxied client flows before cleanup") + flag.StringVar(&cfg.PreferEndpoint, "prefer-endpoint", cfg.PreferEndpoint, "Prefer this endpoint for the connection (if set, will override the endpoint from the server)") + flag.StringVar(&cfg.ProvisioningKey, "provisioning-key", cfg.ProvisioningKey, "One-time provisioning key used to obtain a newt ID and secret from the server") + flag.StringVar(&cfg.NewtName, "name", cfg.NewtName, "Name for the site created during provisioning (supports {{env.VAR}} interpolation)") + flag.StringVar(&cfg.ConfigFile, "config-file", configPath, "Path to config file (overrides CONFIG_FILE env var and default location)") + flag.StringVar(&cfg.TLSClientCert, "tls-client-cert-file", cfg.TLSClientCert, "Path to client certificate file (PEM/DER format)") + flag.StringVar(&cfg.TLSClientKey, "tls-client-key", cfg.TLSClientKey, "Path to client private key file (PEM/DER format)") // Backward-compat dummy flag (auth daemon is always enabled now) flag.Bool("auth-daemon", false, "Enable auth daemon mode (deprecated, always enabled)") var tlsClientCAsFlag stringSlice flag.Var(&tlsClientCAsFlag, "tls-client-ca", "Path to CA certificate file for validating remote certificates (can be specified multiple times)") - if cfg.TLSPrivateKey == "" { - flag.StringVar(&cfg.TLSPrivateKey, "tls-client-cert", "", "Path to client certificate (PKCS12 format) - DEPRECATED: use --tls-client-cert-file and --tls-client-key instead") - } - if dockerEnforceStr == "" { - flag.StringVar(&dockerEnforceStr, "docker-enforce-network-validation", "false", "Enforce validation of container on newt network (true or false)") - } - if cfg.HealthFile == "" { - flag.StringVar(&cfg.HealthFile, "health-file", "", "Path to health file (if unset, health file won't be written)") - } - if cfg.BlueprintFile == "" { - flag.StringVar(&cfg.BlueprintFile, "blueprint-file", "", "Path to blueprint file (if unset, no blueprint will be applied)") - } - if cfg.ProvisioningBlueprintFile == "" { - flag.StringVar(&cfg.ProvisioningBlueprintFile, "provisioning-blueprint-file", "", "Path to blueprint file applied once after a provisioning credential exchange (if unset, no provisioning blueprint will be applied)") - } - if noCloudEnv == "" { - flag.BoolVar(&cfg.NoCloud, "no-cloud", false, "Disable cloud failover") - } - if metricsEnabledEnv == "" { - flag.BoolVar(&cfg.MetricsEnabled, "metrics", false, "Enable Prometheus metrics exporter") - } - if otlpEnabledEnv == "" { - flag.BoolVar(&cfg.OTLPEnabled, "otlp", false, "Enable OTLP exporters (metrics/traces) to OTEL_EXPORTER_OTLP_ENDPOINT") - } - if cfg.AdminAddr == "" { - flag.StringVar(&cfg.AdminAddr, "metrics-admin-addr", "127.0.0.1:2112", "Admin/metrics bind address") - } - if asyncBytesEnv == "" { - flag.BoolVar(&cfg.MetricsAsyncBytes, "metrics-async-bytes", false, "Enable async bytes counting (background flush; lower hot path overhead)") - } - if pprofEnabledEnv == "" { - flag.BoolVar(&cfg.PprofEnabled, "pprof", false, "Enable pprof debug endpoints on admin server") - } - if cfg.Region == "" { - flag.StringVar(&cfg.Region, "region", "", "Optional region resource attribute (also NEWT_REGION)") - } - if cfg.AuthDaemonKey == "" { - flag.StringVar(&cfg.AuthDaemonKey, "ad-pre-shared-key", "", "Pre-shared key for auth daemon authentication") - } - if cfg.AuthDaemonPrincipalsFile == "" { - flag.StringVar(&cfg.AuthDaemonPrincipalsFile, "ad-principals-file", "/var/run/auth-daemon/principals", "Path to the principals file for auth daemon") - } - if cfg.AuthDaemonCACertPath == "" { - flag.StringVar(&cfg.AuthDaemonCACertPath, "ad-ca-cert-path", "/etc/ssh/ca.pem", "Path to the CA certificate file for auth daemon") - } - if adGenerateRandomPasswordEnv == "" { - flag.BoolVar(&cfg.AuthDaemonGenerateRandomPassword, "ad-generate-random-password", false, "Generate a random password for authenticated users") - } + flag.StringVar(&cfg.TLSPrivateKey, "tls-client-cert", cfg.TLSPrivateKey, "Path to client certificate (PKCS12 format) - DEPRECATED: use --tls-client-cert-file and --tls-client-key instead") + flag.StringVar(&dockerEnforceStr, "docker-enforce-network-validation", dockerEnforceStr, "Enforce validation of container on newt network (true or false)") + flag.StringVar(&cfg.HealthFile, "health-file", cfg.HealthFile, "Path to health file (if unset, health file won't be written)") + flag.StringVar(&cfg.BlueprintFile, "blueprint-file", cfg.BlueprintFile, "Path to blueprint file (if unset, no blueprint will be applied)") + flag.StringVar(&cfg.ProvisioningBlueprintFile, "provisioning-blueprint-file", cfg.ProvisioningBlueprintFile, "Path to blueprint file applied once after a provisioning credential exchange (if unset, no provisioning blueprint will be applied)") + flag.BoolVar(&cfg.NoCloud, "no-cloud", cfg.NoCloud, "Disable cloud failover") + flag.BoolVar(&cfg.MetricsEnabled, "metrics", cfg.MetricsEnabled, "Enable Prometheus metrics exporter") + flag.BoolVar(&cfg.OTLPEnabled, "otlp", cfg.OTLPEnabled, "Enable OTLP exporters (metrics/traces) to OTEL_EXPORTER_OTLP_ENDPOINT") + flag.StringVar(&cfg.AdminAddr, "metrics-admin-addr", cfg.AdminAddr, "Admin/metrics bind address") + flag.BoolVar(&cfg.MetricsAsyncBytes, "metrics-async-bytes", cfg.MetricsAsyncBytes, "Enable async bytes counting (background flush; lower hot path overhead)") + flag.BoolVar(&cfg.PprofEnabled, "pprof", cfg.PprofEnabled, "Enable pprof debug endpoints on admin server") + flag.StringVar(&cfg.Region, "region", cfg.Region, "Optional region resource attribute (also NEWT_REGION)") + flag.StringVar(&cfg.AuthDaemonKey, "ad-pre-shared-key", cfg.AuthDaemonKey, "Pre-shared key for auth daemon authentication") + flag.StringVar(&cfg.AuthDaemonPrincipalsFile, "ad-principals-file", cfg.AuthDaemonPrincipalsFile, "Path to the principals file for auth daemon") + flag.StringVar(&cfg.AuthDaemonCACertPath, "ad-ca-cert-path", cfg.AuthDaemonCACertPath, "Path to the CA certificate file for auth daemon") + flag.BoolVar(&cfg.AuthDaemonGenerateRandomPassword, "ad-generate-random-password", cfg.AuthDaemonGenerateRandomPassword, "Generate a random password for authenticated users") version := flag.Bool("version", false, "Print the version") + showConfig := flag.Bool("show-config", false, "Show configuration values and their sources, then exit") flag.Parse() // ---- post-parse processing ---- - // Merge CLI CA files with env CA files + // Merge CLI CA files onto whatever file/env already contributed. if len(tlsClientCAsFlag) > 0 { cfg.TLSClientCAs = append(cfg.TLSClientCAs, tlsClientCAsFlag...) + sources["tls-client-ca"] = string(sourceCLI) + } + + markCLI := func(key string, changed bool) { + if changed { + sources[key] = string(sourceCLI) + } + } + markCLI("endpoint", cfg.Endpoint != origEndpoint) + markCLI("id", cfg.ID != origID) + markCLI("secret", cfg.Secret != origSecret) + markCLI("mtu", mtuStr != origMTU) + markCLI("dns", cfg.DNS != origDNS) + markCLI("log-level", cfg.LogLevel != origLogLevel) + markCLI("updown", cfg.UpdownScript != origUpdown) + markCLI("interface", cfg.InterfaceName != origInterface) + markCLI("port", portStr != origPort) + markCLI("native", cfg.UseNativeInterface != origNative) + markCLI("native-main", cfg.UseNativeMainInterface != origNativeMain) + markCLI("interface-main", cfg.NativeMainInterfaceName != origInterfaceMain) + markCLI("disable-clients", cfg.DisableClients != origDisableClients) + markCLI("disable-ssh", cfg.DisableSSH != origDisableSSH) + markCLI("enforce-hc-cert", cfg.EnforceHealthcheckCert != origEnforceHC) + markCLI("docker-socket", cfg.DockerSocket != origDockerSocket) + markCLI("ping-interval", pingIntervalStr != origPingInterval) + markCLI("ping-timeout", pingTimeoutStr != origPingTimeout) + markCLI("udp-proxy-idle-timeout", udpProxyIdleTimeoutStr != origUDPIdle) + markCLI("provisioning-key", cfg.ProvisioningKey != origProvisioningKey) + markCLI("name", cfg.NewtName != origName) + markCLI("tls-client-cert-file", cfg.TLSClientCert != origTLSCert) + markCLI("tls-client-key", cfg.TLSClientKey != origTLSKey) + markCLI("tls-client-cert", cfg.TLSPrivateKey != origTLSPrivateKey) + markCLI("docker-enforce-network-validation", dockerEnforceStr != origDockerEnforce) + markCLI("health-file", cfg.HealthFile != origHealthFile) + markCLI("blueprint-file", cfg.BlueprintFile != origBlueprintFile) + markCLI("provisioning-blueprint-file", cfg.ProvisioningBlueprintFile != origProvBlueprintFile) + markCLI("no-cloud", cfg.NoCloud != origNoCloud) + markCLI("metrics", cfg.MetricsEnabled != origMetrics) + markCLI("otlp", cfg.OTLPEnabled != origOTLP) + markCLI("metrics-admin-addr", cfg.AdminAddr != origAdminAddr) + markCLI("metrics-async-bytes", cfg.MetricsAsyncBytes != origMetricsAsync) + markCLI("pprof", cfg.PprofEnabled != origPprof) + markCLI("region", cfg.Region != origRegion) + markCLI("ad-pre-shared-key", cfg.AuthDaemonKey != origADKey) + markCLI("ad-principals-file", cfg.AuthDaemonPrincipalsFile != origADPrincipals) + markCLI("ad-ca-cert-path", cfg.AuthDaemonCACertPath != origADCACert) + markCLI("ad-generate-random-password", cfg.AuthDaemonGenerateRandomPassword != origADRandomPass) + if cfg.ConfigFile != configPath { + sources["config-file"] = string(sourceCLI) } // Version check (exits process) if *version { fmt.Println("Newt version " + newtVersion) os.Exit(0) - } else { - logger.Info("Newt version %s", newtVersion) } + if *showConfig { + printShowConfig(cfg, sources, configPath, mtuStr, portStr, pingIntervalStr, pingTimeoutStr, udpProxyIdleTimeoutStr, dockerEnforceStr) + os.Exit(0) + } + + logger.Info("Newt version %s", newtVersion) + // Parse port if portStr != "" { portInt, err := strconv.Atoi(portStr) @@ -348,13 +565,11 @@ func loadNewtConfig() newtpkg.Config { cfg.MTU = mtuInt // Parse docker network validation flag - if dockerEnforceStr != "" { - if v, err := strconv.ParseBool(dockerEnforceStr); err == nil { - cfg.DockerEnforceNetworkValidation = v - } else { - logger.Info("Docker enforce network validation cannot be parsed. Defaulting to 'false'") - cfg.DockerEnforceNetworkValidation = false - } + if v, err := strconv.ParseBool(dockerEnforceStr); err == nil { + cfg.DockerEnforceNetworkValidation = v + } else { + logger.Info("Docker enforce network validation cannot be parsed. Defaulting to 'false'") + cfg.DockerEnforceNetworkValidation = false } // Parse durations (after flag.Parse so CLI flags take effect) @@ -364,3 +579,103 @@ func loadNewtConfig() newtpkg.Config { return cfg } + +// printShowConfig prints the resolved configuration and the source of each value +func printShowConfig(cfg newtpkg.Config, sources map[string]string, configPath, mtuStr, portStr, pingIntervalStr, pingTimeoutStr, udpProxyIdleTimeoutStr, dockerEnforceStr string) { + getSource := func(key string) string { + if s, ok := sources[key]; ok && s != "" { + return s + } + return string(sourceDefault) + } + mask := func(key, value string) string { + if key == "secret" && value != "" { + if len(value) > 8 { + return value[:4] + "****" + value[len(value)-4:] + } + return "****" + } + if value == "" { + return "(not set)" + } + return value + } + + fmt.Print("\n=== Newt Configuration ===\n\n") + fmt.Printf("Config File: %s\n", configPath) + if _, err := os.Stat(configPath); err == nil { + fmt.Printf("Config File Status: exists\n") + } else { + fmt.Printf("Config File Status: not found\n") + } + + fmt.Println("\n--- Configuration Values ---") + fmt.Print("(Format: Setting = Value [source])\n\n") + + fmt.Println("Connection:") + fmt.Printf(" endpoint = %s [%s]\n", mask("endpoint", cfg.Endpoint), getSource("endpoint")) + fmt.Printf(" id = %s [%s]\n", mask("id", cfg.ID), getSource("id")) + fmt.Printf(" secret = %s [%s]\n", mask("secret", cfg.Secret), getSource("secret")) + fmt.Printf(" provisioning-key = %s [%s]\n", mask("provisioning-key", cfg.ProvisioningKey), getSource("provisioning-key")) + fmt.Printf(" name = %s [%s]\n", mask("name", cfg.NewtName), getSource("name")) + fmt.Printf(" prefer-endpoint = %s [%s]\n", mask("prefer-endpoint", cfg.PreferEndpoint), getSource("prefer-endpoint")) + + fmt.Println("\nNetwork:") + fmt.Printf(" mtu = %s [%s]\n", mtuStr, getSource("mtu")) + fmt.Printf(" dns = %s [%s]\n", cfg.DNS, getSource("dns")) + fmt.Printf(" interface = %s [%s]\n", cfg.InterfaceName, getSource("interface")) + fmt.Printf(" port = %s [%s]\n", mask("port", portStr), getSource("port")) + fmt.Printf(" native = %v [%s]\n", cfg.UseNativeInterface, getSource("native")) + fmt.Printf(" native-main = %v [%s]\n", cfg.UseNativeMainInterface, getSource("native-main")) + fmt.Printf(" interface-main = %s [%s]\n", cfg.NativeMainInterfaceName, getSource("interface-main")) + fmt.Printf(" no-cloud = %v [%s]\n", cfg.NoCloud, getSource("no-cloud")) + + fmt.Println("\nLogging:") + fmt.Printf(" log-level = %s [%s]\n", cfg.LogLevel, getSource("log-level")) + + fmt.Println("\nTiming:") + fmt.Printf(" ping-interval = %s [%s]\n", pingIntervalStr, getSource("ping-interval")) + fmt.Printf(" ping-timeout = %s [%s]\n", pingTimeoutStr, getSource("ping-timeout")) + fmt.Printf(" udp-proxy-idle-timeout = %s [%s]\n", udpProxyIdleTimeoutStr, getSource("udp-proxy-idle-timeout")) + + fmt.Println("\nFeatures:") + fmt.Printf(" disable-clients = %v [%s]\n", cfg.DisableClients, getSource("disable-clients")) + fmt.Printf(" disable-ssh = %v [%s]\n", cfg.DisableSSH, getSource("disable-ssh")) + fmt.Printf(" enforce-hc-cert = %v [%s]\n", cfg.EnforceHealthcheckCert, getSource("enforce-hc-cert")) + fmt.Printf(" health-file = %s [%s]\n", mask("health-file", cfg.HealthFile), getSource("health-file")) + fmt.Printf(" blueprint-file = %s [%s]\n", mask("blueprint-file", cfg.BlueprintFile), getSource("blueprint-file")) + fmt.Printf(" provisioning-blueprint-file = %s [%s]\n", mask("provisioning-blueprint-file", cfg.ProvisioningBlueprintFile), getSource("provisioning-blueprint-file")) + fmt.Printf(" updown = %s [%s]\n", mask("updown", cfg.UpdownScript), getSource("updown")) + + fmt.Println("\nDocker:") + fmt.Printf(" docker-socket = %s [%s]\n", mask("docker-socket", cfg.DockerSocket), getSource("docker-socket")) + fmt.Printf(" docker-enforce-network-validation = %s [%s]\n", dockerEnforceStr, getSource("docker-enforce-network-validation")) + + fmt.Println("\nAuth Daemon:") + fmt.Printf(" ad-pre-shared-key = %s [%s]\n", mask("ad-pre-shared-key", cfg.AuthDaemonKey), getSource("ad-pre-shared-key")) + fmt.Printf(" ad-principals-file = %s [%s]\n", cfg.AuthDaemonPrincipalsFile, getSource("ad-principals-file")) + fmt.Printf(" ad-ca-cert-path = %s [%s]\n", cfg.AuthDaemonCACertPath, getSource("ad-ca-cert-path")) + fmt.Printf(" ad-generate-random-password = %v [%s]\n", cfg.AuthDaemonGenerateRandomPassword, getSource("ad-generate-random-password")) + + fmt.Println("\nTLS:") + fmt.Printf(" tls-client-cert-file = %s [%s]\n", mask("tls-client-cert-file", cfg.TLSClientCert), getSource("tls-client-cert-file")) + fmt.Printf(" tls-client-key = %s [%s]\n", mask("tls-client-key", cfg.TLSClientKey), getSource("tls-client-key")) + fmt.Printf(" tls-client-ca = %v [%s]\n", cfg.TLSClientCAs, getSource("tls-client-ca")) + fmt.Printf(" tls-client-cert = %s [%s] (deprecated PKCS12 path)\n", mask("tls-client-cert", cfg.TLSPrivateKey), getSource("tls-client-cert")) + + fmt.Println("\nMetrics/Observability:") + fmt.Printf(" metrics = %v [%s]\n", cfg.MetricsEnabled, getSource("metrics")) + fmt.Printf(" otlp = %v [%s]\n", cfg.OTLPEnabled, getSource("otlp")) + fmt.Printf(" metrics-admin-addr = %s [%s]\n", cfg.AdminAddr, getSource("metrics-admin-addr")) + fmt.Printf(" metrics-async-bytes = %v [%s]\n", cfg.MetricsAsyncBytes, getSource("metrics-async-bytes")) + fmt.Printf(" pprof = %v [%s]\n", cfg.PprofEnabled, getSource("pprof")) + fmt.Printf(" region = %s [%s]\n", cfg.Region, getSource("region")) + + fmt.Println("\n--- Source Legend ---") + fmt.Println(" default = Built-in default value") + fmt.Println(" file = Loaded from config file") + fmt.Println(" environment = Set via environment variable") + fmt.Println(" cli = Provided as command-line argument") + fmt.Println("\nPriority: cli > environment > file > default") + fmt.Println() +} diff --git a/config.json b/config.json new file mode 100644 index 0000000..8beadcd --- /dev/null +++ b/config.json @@ -0,0 +1,7 @@ +{ + "endpoint": "https://app.pangolin.dev.fosrl.io", + "id": "uglmdfj33iuxfab", + "loglevel": "debug", + "nativeMain": true, + "secret": "mus3dletfjw2pss7iu3drle7m3vyxv4r4dzqgunx" +} \ No newline at end of file diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..13f83a0 --- /dev/null +++ b/config_test.go @@ -0,0 +1,157 @@ +package main + +import ( + "flag" + "os" + "path/filepath" + "testing" +) + +// resetFlags allows flag.Parse() to be called again in each test, since +// loadNewtConfig registers flags on the global flag.CommandLine. +func resetFlags(t *testing.T) { + t.Helper() + oldArgs := os.Args + oldCommandLine := flag.CommandLine + t.Cleanup(func() { + os.Args = oldArgs + flag.CommandLine = oldCommandLine + }) + flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError) +} + +func clearNewtEnv(t *testing.T) { + t.Helper() + for _, k := range []string{ + "PANGOLIN_ENDPOINT", "NEWT_ID", "NEWT_SECRET", "DNS", "LOG_LEVEL", + "MTU", "CONFIG_FILE", "NEWT_PROVISIONING_KEY", "NEWT_NAME", + "DISABLE_SSH", "DISABLE_CLIENTS", + } { + t.Setenv(k, "") + } +} + +func TestLoadNewtConfig_Defaults(t *testing.T) { + resetFlags(t) + clearNewtEnv(t) + os.Args = []string{"newt", "--config-file", filepath.Join(t.TempDir(), "missing.json")} + + cfg := loadNewtConfig() + + if cfg.DNS != "9.9.9.9" { + t.Errorf("expected default dns, got %q", cfg.DNS) + } + if cfg.MTU != 1280 { + t.Errorf("expected default mtu 1280, got %d", cfg.MTU) + } + if cfg.LogLevel != "INFO" { + t.Errorf("expected default log level INFO, got %q", cfg.LogLevel) + } +} + +func TestLoadNewtConfig_FileOverridesDefault(t *testing.T) { + resetFlags(t) + clearNewtEnv(t) + + configPath := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(configPath, []byte(`{"dns":"1.1.1.1","mtu":1300,"disableSsh":true}`), 0o644); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + os.Args = []string{"newt", "--config-file", configPath} + + cfg := loadNewtConfig() + + if cfg.DNS != "1.1.1.1" { + t.Errorf("expected dns from file, got %q", cfg.DNS) + } + if cfg.MTU != 1300 { + t.Errorf("expected mtu from file, got %d", cfg.MTU) + } + if !cfg.DisableSSH { + t.Errorf("expected disableSsh from file to be true") + } +} + +func TestLoadNewtConfig_EnvOverridesFile(t *testing.T) { + resetFlags(t) + clearNewtEnv(t) + + configPath := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(configPath, []byte(`{"dns":"1.1.1.1"}`), 0o644); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + t.Setenv("DNS", "8.8.4.4") + os.Args = []string{"newt", "--config-file", configPath} + + cfg := loadNewtConfig() + + if cfg.DNS != "8.8.4.4" { + t.Errorf("expected env to override file dns, got %q", cfg.DNS) + } +} + +func TestLoadNewtConfig_CLIOverridesEnv(t *testing.T) { + resetFlags(t) + clearNewtEnv(t) + + configPath := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(configPath, []byte(`{"dns":"1.1.1.1"}`), 0o644); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + t.Setenv("DNS", "8.8.4.4") + os.Args = []string{"newt", "--config-file", configPath, "--dns", "4.2.2.2"} + + cfg := loadNewtConfig() + + if cfg.DNS != "4.2.2.2" { + t.Errorf("expected cli to override env dns, got %q", cfg.DNS) + } +} + +func TestLoadNewtConfig_TLSClientCAMergesAcrossSources(t *testing.T) { + resetFlags(t) + clearNewtEnv(t) + + tmpDir := t.TempDir() + caFromFile := filepath.Join(tmpDir, "file-ca.pem") + caFromEnv := filepath.Join(tmpDir, "env-ca.pem") + caFromCLI := filepath.Join(tmpDir, "cli-ca.pem") + + configPath := filepath.Join(tmpDir, "config.json") + if err := os.WriteFile(configPath, []byte(`{"tlsClientCa":["`+caFromFile+`"]}`), 0o644); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + t.Setenv("TLS_CLIENT_CAS", caFromEnv) + os.Args = []string{"newt", "--config-file", configPath, "--tls-client-ca", caFromCLI} + + cfg := loadNewtConfig() + + want := map[string]bool{caFromFile: true, caFromEnv: true, caFromCLI: true} + if len(cfg.TLSClientCAs) != len(want) { + t.Fatalf("expected %d CA entries, got %v", len(want), cfg.TLSClientCAs) + } + for _, ca := range cfg.TLSClientCAs { + if !want[ca] { + t.Errorf("unexpected CA entry: %s", ca) + } + } +} + +func TestResolveConfigFilePath_Precedence(t *testing.T) { + t.Setenv("CONFIG_FILE", "") + t.Setenv("HOME", t.TempDir()) + + // CLI flag wins over env. + t.Setenv("CONFIG_FILE", "/env/path/config.json") + if got := resolveConfigFilePath([]string{"--config-file", "/cli/path/config.json"}); got != "/cli/path/config.json" { + t.Errorf("expected cli path to win, got %q", got) + } + if got := resolveConfigFilePath([]string{"--config-file=/cli/eq/config.json"}); got != "/cli/eq/config.json" { + t.Errorf("expected cli = path to win, got %q", got) + } + + // Env wins over default when no CLI flag given. + if got := resolveConfigFilePath([]string{}); got != "/env/path/config.json" { + t.Errorf("expected env path, got %q", got) + } +} diff --git a/newt/newt.go b/newt/newt.go index 85dd6b5..4710569 100644 --- a/newt/newt.go +++ b/newt/newt.go @@ -133,11 +133,15 @@ func Init(ctx context.Context, cfg Config) (*Newt, error) { } n.client = client - if cfg.ProvisioningKey != "" && client.GetConfig().ProvisioningKey == "" { - client.GetConfig().ProvisioningKey = cfg.ProvisioningKey - } - if cfg.NewtName != "" && client.GetConfig().Name == "" { - client.GetConfig().Name = cfg.NewtName + client.GetConfig().ProvisioningKey = cfg.ProvisioningKey + client.GetConfig().Name = cfg.NewtName + + // Resolve provisioning synchronously so ID/Secret are final before + // setupClients() bakes them into the WireGuard service / hole-punch + // manager. Connect() only provisions lazily in the background, which + // would otherwise race setupClients() on first run. + if err := client.EnsureProvisioned(); err != nil { + return nil, fmt.Errorf("provision newt credentials: %w", err) } // Update config from resolved client values (provisioning / config file). diff --git a/websocket/config.go b/websocket/config.go index b503449..3bff8d6 100644 --- a/websocket/config.go +++ b/websocket/config.go @@ -19,6 +19,10 @@ import ( "github.com/fosrl/newt/logger" ) +// getConfigPath returns the resolved config/credentials file path. Path +// resolution (CLI flag / env var / OS default) is now owned by the calling +// binary's root config loader; this is a fallback used only when a caller +// (e.g. a test) doesn't supply an explicit path via WithConfigFile. func getConfigPath(clientType string, overridePath string) string { if overridePath != "" { return overridePath @@ -46,21 +50,14 @@ func getConfigPath(clientType string, overridePath string) string { return configFile } +// loadConfig no longer merges credentials in from the file: the caller +// resolves ID/Secret/Endpoint/TlsClientCert/ProvisioningKey/Name (from its +// own settings file, env, and CLI flags) before constructing the Client. This +// only figures out whether the file needs to be (re)written so that the +// caller-provided values get cached to disk on first successful connect. func (c *Client) loadConfig() error { - originalConfig := *c.config // Store original config to detect changes configPath := getConfigPath(c.clientType, c.configFilePath) - if c.config.ID != "" && c.config.Secret != "" && c.config.Endpoint != "" { - logger.Debug("Config already provided, skipping loading from file") - // Check if config file exists, if not, we should save it - if _, err := os.Stat(configPath); os.IsNotExist(err) { - logger.Info("Config file does not exist at %s, will create it", configPath) - c.configNeedsSave = true - } - return nil - } - - logger.Info("Loading config from: %s", configPath) data, err := os.ReadFile(configPath) if err != nil { if os.IsNotExist(err) { @@ -73,57 +70,16 @@ func (c *Client) loadConfig() error { if len(bytes.TrimSpace(data)) == 0 { logger.Info("Config file at %s is empty, will initialize it with provided values", configPath) c.configNeedsSave = true - return nil } - var config Config - if err := json.Unmarshal(data, &config); err != nil { - return err - } - - // Track what was loaded from file vs provided by CLI - fileHadID := c.config.ID == "" - fileHadSecret := c.config.Secret == "" - fileHadCert := c.config.TlsClientCert == "" - fileHadEndpoint := c.config.Endpoint == "" - - if c.config.ID == "" { - c.config.ID = config.ID - } - if c.config.Secret == "" { - c.config.Secret = config.Secret - } - if c.config.TlsClientCert == "" { - c.config.TlsClientCert = config.TlsClientCert - } - if c.config.Endpoint == "" { - c.config.Endpoint = config.Endpoint - c.baseURL = config.Endpoint - } - // Always load the provisioning key from the file if not already set - if c.config.ProvisioningKey == "" { - c.config.ProvisioningKey = config.ProvisioningKey - } - // Always load the name from the file if not already set - if c.config.Name == "" { - c.config.Name = config.Name - } - - // Check if CLI args provided values that override file values - if (!fileHadID && originalConfig.ID != "") || - (!fileHadSecret && originalConfig.Secret != "") || - (!fileHadCert && originalConfig.TlsClientCert != "") || - (!fileHadEndpoint && originalConfig.Endpoint != "") { - logger.Info("CLI arguments provided, config will be updated") - c.configNeedsSave = true - } - - logger.Debug("Loaded config from %s", configPath) - logger.Debug("Config: %+v", c.config) - return nil } +// saveConfig persists the credential fields (id, secret, endpoint, +// tlsClientCert, provisioningKey, name) into the config file. It's a +// read-modify-write against the raw JSON so that any other settings a user +// has placed in the same file (mtu, dns, etc.) are preserved rather than +// clobbered. func (c *Client) saveConfig() error { if !c.configNeedsSave { logger.Debug("Config has not changed, skipping save") @@ -131,7 +87,31 @@ func (c *Client) saveConfig() error { } configPath := getConfigPath(c.clientType, c.configFilePath) - data, err := json.MarshalIndent(c.config, "", " ") + + existing := map[string]interface{}{} + if data, err := os.ReadFile(configPath); err == nil && len(bytes.TrimSpace(data)) > 0 { + if err := json.Unmarshal(data, &existing); err != nil { + logger.Warn("Existing config file at %s is not valid JSON, other settings in it will be lost: %v", configPath, err) + existing = map[string]interface{}{} + } + } + + existing["id"] = c.config.ID + existing["secret"] = c.config.Secret + existing["endpoint"] = c.config.Endpoint + + setOrDelete := func(key, value string) { + if value != "" { + existing[key] = value + } else { + delete(existing, key) + } + } + setOrDelete("tlsClientCert", c.config.TlsClientCert) + setOrDelete("provisioningKey", c.config.ProvisioningKey) + setOrDelete("name", c.config.Name) + + data, err := json.MarshalIndent(existing, "", " ") if err != nil { return err } @@ -159,6 +139,15 @@ func interpolateString(s string) string { }) } +// EnsureProvisioned exchanges a provisioning key for permanent credentials +// synchronously, if one is configured and credentials aren't already present. +// Callers that need the resolved ID/Secret before Connect() runs (which only +// provisions lazily, in the background, on first connection attempt) should +// call this first. +func (c *Client) EnsureProvisioned() error { + return c.provisionIfNeeded() +} + // provisionIfNeeded checks whether a provisioning key is present and, if so, // exchanges it for a newt ID and secret by calling the registration endpoint. // On success the config is updated in-place and flagged for saving so that diff --git a/websocket/config_test.go b/websocket/config_test.go index b2d8a24..93d6ac4 100644 --- a/websocket/config_test.go +++ b/websocket/config_test.go @@ -1,6 +1,7 @@ package websocket import ( + "encoding/json" "os" "path/filepath" "testing" @@ -33,3 +34,63 @@ func TestLoadConfig_EmptyFileMarksConfigForSave(t *testing.T) { } } +func TestSaveConfig_PreservesUnrelatedSettings(t *testing.T) { + t.Setenv("CONFIG_FILE", "") + + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + initial := `{ + "mtu": 1300, + "dns": "1.1.1.1", + "provisioningKey": "spk-test" +}` + if err := os.WriteFile(configPath, []byte(initial), 0o644); err != nil { + t.Fatalf("failed to create config file: %v", err) + } + + client := &Client{ + config: &Config{ + ID: "newt-id", + Secret: "newt-secret", + Endpoint: "https://example.com", + // ProvisioningKey cleared, simulating a completed provisioning exchange. + }, + clientType: "newt", + configFilePath: configPath, + configNeedsSave: true, + } + + if err := client.saveConfig(); err != nil { + t.Fatalf("saveConfig returned error: %v", err) + } + + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("failed to read saved config: %v", err) + } + + var saved map[string]interface{} + if err := json.Unmarshal(data, &saved); err != nil { + t.Fatalf("saved config is not valid JSON: %v", err) + } + + if saved["mtu"] != float64(1300) { + t.Errorf("expected mtu to be preserved, got %v", saved["mtu"]) + } + if saved["dns"] != "1.1.1.1" { + t.Errorf("expected dns to be preserved, got %v", saved["dns"]) + } + if saved["id"] != "newt-id" { + t.Errorf("expected id to be updated, got %v", saved["id"]) + } + if saved["secret"] != "newt-secret" { + t.Errorf("expected secret to be updated, got %v", saved["secret"]) + } + if _, ok := saved["provisioningKey"]; ok { + t.Errorf("expected provisioningKey to be cleared after provisioning, got %v", saved["provisioningKey"]) + } + if client.configNeedsSave { + t.Error("expected configNeedsSave to be reset after a successful save") + } +} +