diff --git a/authdaemon.go b/authdaemon.go index bd4b29f..5acb50c 100644 --- a/authdaemon.go +++ b/authdaemon.go @@ -1,14 +1,10 @@ package main import ( - "context" - "errors" "fmt" "os" - "runtime" "github.com/fosrl/newt/authdaemon" - "github.com/fosrl/newt/logger" ) const ( @@ -16,64 +12,6 @@ const ( defaultCACertPath = "/etc/ssh/ca.pem" ) -var ( - errPresharedKeyRequired = errors.New("auth-daemon-key is required when --auth-daemon is enabled") - errRootRequired = errors.New("auth-daemon must be run as root (use sudo)") - authDaemonServer *authdaemon.Server // Global auth daemon server instance -) - -// startAuthDaemon initializes and starts the auth daemon in the background. -// It validates requirements (Linux, root, preshared key) and starts the server -// in a goroutine so it runs alongside normal newt operation. -func startAuthDaemon(ctx context.Context) error { - // Validation - if runtime.GOOS != "linux" { - return fmt.Errorf("auth-daemon is only supported on Linux, not %s", runtime.GOOS) - } - if os.Geteuid() != 0 { - return errRootRequired - } - - // Use defaults if not set - principalsFile := authDaemonPrincipalsFile - if principalsFile == "" { - principalsFile = defaultPrincipalsPath - } - caCertPath := authDaemonCACertPath - if caCertPath == "" { - caCertPath = defaultCACertPath - } - - // Create auth daemon server - cfg := authdaemon.Config{ - DisableHTTPS: true, // We run without HTTP server in newt - PresharedKey: "this-key-is-not-used", // Not used in embedded mode, but set to non-empty to satisfy validation - PrincipalsFilePath: principalsFile, - CACertPath: caCertPath, - Force: true, - GenerateRandomPassword: authDaemonGenerateRandomPassword, - } - - srv, err := authdaemon.NewServer(cfg) - if err != nil { - return fmt.Errorf("create auth daemon server: %w", err) - } - - authDaemonServer = srv - - // Start the auth daemon in a goroutine so it runs alongside newt - go func() { - logger.Debug("Auth daemon starting (native mode, no HTTP server)") - if err := srv.Run(ctx); err != nil { - logger.Error("Auth daemon error: %v", err) - } - logger.Info("Auth daemon stopped") - }() - - return nil -} - -// runPrincipalsCmd executes the principals subcommand logic func runPrincipalsCmd(args []string) { opts := struct { PrincipalsFile string @@ -82,7 +20,6 @@ func runPrincipalsCmd(args []string) { PrincipalsFile: defaultPrincipalsPath, } - // Parse flags manually for i := 0; i < len(args); i++ { switch args[i] { case "--principals-file": @@ -109,14 +46,12 @@ func runPrincipalsCmd(args []string) { } } - // Validation if opts.Username == "" { fmt.Fprintf(os.Stderr, "Error: username is required\n") printPrincipalsHelp() os.Exit(1) } - // Get principals list, err := authdaemon.GetPrincipals(opts.PrincipalsFile, opts.Username) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) diff --git a/clients.go b/clients.go deleted file mode 100644 index de96f2c..0000000 --- a/clients.go +++ /dev/null @@ -1,114 +0,0 @@ -package main - -import ( - "strings" - - "github.com/fosrl/newt/clients" - wgnetstack "github.com/fosrl/newt/clients" - "github.com/fosrl/newt/clients/permissions" - "github.com/fosrl/newt/logger" - "github.com/fosrl/newt/nativessh" - "github.com/fosrl/newt/websocket" - "golang.zx2c4.com/wireguard/tun/netstack" -) - -var wgService *clients.WireGuardService -var ready bool - -func setupClients(client *websocket.Client, credStore *nativessh.CredentialStore) { - var host = endpoint - if strings.HasPrefix(host, "http://") { - host = strings.TrimPrefix(host, "http://") - } else if strings.HasPrefix(host, "https://") { - host = strings.TrimPrefix(host, "https://") - } - - host = strings.TrimSuffix(host, "/") - - logger.Debug("Setting up clients with netstack2...") - - // if useNativeInterface is true make sure we have permission to use native interface - if useNativeInterface { - logger.Debug("Checking permissions for native interface") - err := permissions.CheckNativeInterfacePermissions() - if err != nil { - logger.Fatal("Insufficient permissions to create native TUN interface: %v", err) - return - } - } - - // Create WireGuard service - wgService, err = wgnetstack.NewWireGuardService(interfaceName, port, mtuInt, host, id, client, dns, useNativeInterface) - if err != nil { - logger.Fatal("Failed to create WireGuard service: %v", err) - } - - wgService.SetCredentialStore(credStore) - - client.OnTokenUpdate(func(token string) { - wgService.SetToken(token) - }) - - ready = true -} - -func setDownstreamTNetstack(tnet *netstack.Net) { - if wgService != nil { - wgService.SetOthertnet(tnet) - } -} - -func closeClients() { - logger.Info("Closing clients...") - if wgService != nil { - wgService.Close() - wgService = nil - } -} - -// setClientsBlocked enables or disables connection blocking on the WireGuard service. -func setClientsBlocked(v bool) { - if wgService != nil { - wgService.SetBlocked(v) - } -} - -func clientsHandleNewtConnection(publicKey string, endpoint string, relayPort uint16) { - if !ready { - return - } - - // split off the port from the endpoint - parts := strings.Split(endpoint, ":") - if len(parts) < 2 { - logger.Error("Invalid endpoint format: %s", endpoint) - return - } - endpoint = strings.Join(parts[:len(parts)-1], ":") - - if wgService != nil { - wgService.StartHolepunch(publicKey, endpoint, relayPort) - } -} - -func clientsOnConnect() { - if !ready { - return - } - if wgService != nil { - wgService.LoadRemoteConfig() - } -} - -// clientsStartDirectRelay starts a direct UDP relay from the main tunnel netstack -// to the clients' WireGuard, bypassing the proxy for better performance. -func clientsStartDirectRelay(tunnelIP string) { - if !ready { - return - } - if wgService != nil { - if err := wgService.StartDirectUDPRelay(tunnelIP); err != nil { - logger.Error("Failed to start direct UDP relay: %v", err) - } - } -} diff --git a/clients/clients.go b/clients/clients.go index 4568e09..13d629f 100644 --- a/clients/clients.go +++ b/clients/clients.go @@ -196,7 +196,6 @@ func NewWireGuardService(interfaceName string, port uint16, mtu int, host string wsClient.RegisterHandler("newt/wg/targets/add", service.handleAddTarget) wsClient.RegisterHandler("newt/wg/targets/remove", service.handleRemoveTarget) wsClient.RegisterHandler("newt/wg/targets/update", service.handleUpdateTarget) - wsClient.RegisterHandler("newt/wg/sync", service.handleSyncConfig) return service, nil } @@ -568,37 +567,15 @@ func (s *WireGuardService) handleConfig(msg websocket.WSMessage) { logger.Info("Client connectivity setup. Ready to accept connections from clients!") } -// SyncConfig represents the configuration sent from server for syncing -type SyncConfig struct { - Targets []Target `json:"targets"` - Peers []Peer `json:"peers"` -} - -func (s *WireGuardService) handleSyncConfig(msg websocket.WSMessage) { - var syncConfig SyncConfig - - logger.Debug("Received sync message: %v", msg) - logger.Info("Received sync configuration from remote server") - - jsonData, err := json.Marshal(msg.Data) - if err != nil { - logger.Error("Error marshaling sync data: %v", err) - return +// Sync synchronizes the clients WireGuard peers and targets with the desired state +// received as part of the main newt/sync message. +func (s *WireGuardService) Sync(peers []Peer, targets []Target) { + if err := s.syncPeers(peers); err != nil { + logger.Error("Failed to sync client peers: %v", err) } - if err := json.Unmarshal(jsonData, &syncConfig); err != nil { - logger.Error("Error unmarshaling sync data: %v", err) - return - } - - // Sync peers - if err := s.syncPeers(syncConfig.Peers); err != nil { - logger.Error("Failed to sync peers: %v", err) - } - - // Sync targets - if err := s.syncTargets(syncConfig.Targets); err != nil { - logger.Error("Failed to sync targets: %v", err) + if err := s.syncTargets(targets); err != nil { + logger.Error("Failed to sync client targets: %v", err) } } @@ -665,8 +642,12 @@ func (s *WireGuardService) syncPeers(desiredPeers []Peer) error { return nil } -// syncTargets synchronizes the current targets with the desired state -// It removes targets not in the desired list and adds missing ones +// syncTargets synchronizes the current targets with the desired state. +// A sync represents the full authoritative state from the server, so rather +// than diffing against the currently installed rules (which can miss +// changes to a rule's contents when its source/dest prefix key is +// unchanged - e.g. a RewriteTo update), we just rebuild the entire rule set +// from scratch on every sync. This guarantees no stale rule can survive. func (s *WireGuardService) syncTargets(desiredTargets []Target) error { if s.tnet == nil { // Native interface mode - proxy features not available, skip silently @@ -674,70 +655,31 @@ func (s *WireGuardService) syncTargets(desiredTargets []Target) error { return nil } - // Get current rules from the proxy handler - currentRules := s.tnet.GetProxySubnetRules() - - // Build a map of current rules by source+dest prefix - type ruleKey struct { - sourcePrefix string - destPrefix string - } - currentRuleMap := make(map[ruleKey]bool) - for _, rule := range currentRules { - key := ruleKey{ - sourcePrefix: rule.SourcePrefix.String(), - destPrefix: rule.DestPrefix.String(), - } - currentRuleMap[key] = true - } - - // Build a map of desired targets - desiredTargetMap := make(map[ruleKey]Target) + var rules []netstack2.SubnetRule for _, target := range desiredTargets { - key := ruleKey{ - sourcePrefix: target.SourcePrefix, - destPrefix: target.DestPrefix, + destPrefix, err := netip.ParsePrefix(target.DestPrefix) + if err != nil { + logger.Warn("Invalid dest prefix %s during sync: %v", target.DestPrefix, err) + continue } - desiredTargetMap[key] = target - } - // Remove targets that are not in the desired list - for _, rule := range currentRules { - key := ruleKey{ - sourcePrefix: rule.SourcePrefix.String(), - destPrefix: rule.DestPrefix.String(), + var portRanges []netstack2.PortRange + for _, pr := range target.PortRange { + portRanges = append(portRanges, netstack2.PortRange{ + Min: pr.Min, + Max: pr.Max, + Protocol: pr.Protocol, + }) } - if _, exists := desiredTargetMap[key]; !exists { - s.tnet.RemoveProxySubnetRule(rule.SourcePrefix, rule.DestPrefix) - logger.Info("Removed target %s -> %s during sync", rule.SourcePrefix.String(), rule.DestPrefix.String()) - } - } - // Add targets that are missing - for key, target := range desiredTargetMap { - if _, exists := currentRuleMap[key]; !exists { - sourcePrefix, err := netip.ParsePrefix(target.SourcePrefix) + for _, sp := range resolveSourcePrefixes(target) { + sourcePrefix, err := netip.ParsePrefix(sp) if err != nil { - logger.Warn("Invalid source prefix %s during sync: %v", target.SourcePrefix, err) + logger.Warn("Invalid source prefix %s during sync: %v", sp, err) continue } - destPrefix, err := netip.ParsePrefix(target.DestPrefix) - if err != nil { - logger.Warn("Invalid dest prefix %s during sync: %v", target.DestPrefix, err) - continue - } - - var portRanges []netstack2.PortRange - for _, pr := range target.PortRange { - portRanges = append(portRanges, netstack2.PortRange{ - Min: pr.Min, - Max: pr.Max, - Protocol: pr.Protocol, - }) - } - - s.tnet.AddProxySubnetRule(netstack2.SubnetRule{ + rules = append(rules, netstack2.SubnetRule{ SourcePrefix: sourcePrefix, DestPrefix: destPrefix, RewriteTo: target.RewriteTo, @@ -749,10 +691,12 @@ func (s *WireGuardService) syncTargets(desiredTargets []Target) error { TLSCert: target.TLSCert, TLSKey: target.TLSKey, }) - logger.Info("Added target %s -> %s during sync", target.SourcePrefix, target.DestPrefix) } } + s.tnet.ReplaceProxySubnetRules(rules) + logger.Info("Synced targets: %d rules installed", len(rules)) + return nil } diff --git a/common.go b/common.go deleted file mode 100644 index fdc9f03..0000000 --- a/common.go +++ /dev/null @@ -1,661 +0,0 @@ -package main - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net" - "os" - "os/exec" - "regexp" - "strings" - "time" - - "math/rand" - - "github.com/fosrl/newt/internal/telemetry" - "github.com/fosrl/newt/logger" - "github.com/fosrl/newt/proxy" - "github.com/fosrl/newt/websocket" - "github.com/fsnotify/fsnotify" - "golang.org/x/net/icmp" - "golang.org/x/net/ipv4" - "golang.zx2c4.com/wireguard/tun/netstack" - "gopkg.in/yaml.v3" -) - -const msgHealthFileWriteFailed = "Failed to write health file: %v" - -func ping(tnet *netstack.Net, dst string, timeout time.Duration) (time.Duration, error) { - // logger.Debug("Pinging %s", dst) - socket, err := tnet.Dial("ping4", dst) - if err != nil { - return 0, fmt.Errorf("failed to create ICMP socket: %w", err) - } - defer socket.Close() - - // Set socket buffer sizes to handle high bandwidth scenarios - if tcpConn, ok := socket.(interface{ SetReadBuffer(int) error }); ok { - tcpConn.SetReadBuffer(64 * 1024) - } - if tcpConn, ok := socket.(interface{ SetWriteBuffer(int) error }); ok { - tcpConn.SetWriteBuffer(64 * 1024) - } - - requestPing := icmp.Echo{ - Seq: rand.Intn(1 << 16), - Data: []byte("newtping"), - } - - icmpBytes, err := (&icmp.Message{Type: ipv4.ICMPTypeEcho, Code: 0, Body: &requestPing}).Marshal(nil) - if err != nil { - return 0, fmt.Errorf("failed to marshal ICMP message: %w", err) - } - - if err := socket.SetReadDeadline(time.Now().Add(timeout)); err != nil { - return 0, fmt.Errorf("failed to set read deadline: %w", err) - } - - start := time.Now() - _, err = socket.Write(icmpBytes) - if err != nil { - return 0, fmt.Errorf("failed to write ICMP packet: %w", err) - } - - // Use larger buffer for reading to handle potential network congestion - readBuffer := make([]byte, 1500) - n, err := socket.Read(readBuffer) - if err != nil { - return 0, fmt.Errorf("failed to read ICMP packet: %w", err) - } - - replyPacket, err := icmp.ParseMessage(1, readBuffer[:n]) - if err != nil { - return 0, fmt.Errorf("failed to parse ICMP packet: %w", err) - } - - replyPing, ok := replyPacket.Body.(*icmp.Echo) - if !ok { - return 0, fmt.Errorf("invalid reply type: got %T, want *icmp.Echo", replyPacket.Body) - } - - if !bytes.Equal(replyPing.Data, requestPing.Data) || replyPing.Seq != requestPing.Seq { - return 0, fmt.Errorf("invalid ping reply: got seq=%d data=%q, want seq=%d data=%q", - replyPing.Seq, replyPing.Data, requestPing.Seq, requestPing.Data) - } - - latency := time.Since(start) - - // logger.Debug("Ping to %s successful, latency: %v", dst, latency) - - return latency, nil -} - -// reliablePing performs multiple ping attempts with adaptive timeout -func reliablePing(tnet *netstack.Net, dst string, baseTimeout time.Duration, maxAttempts int) (time.Duration, error) { - var lastErr error - var totalLatency time.Duration - successCount := 0 - - for attempt := 1; attempt <= maxAttempts; attempt++ { - // Adaptive timeout: increase timeout for later attempts - timeout := baseTimeout + time.Duration(attempt-1)*500*time.Millisecond - - // Add jitter to prevent thundering herd - jitter := time.Duration(rand.Intn(100)) * time.Millisecond - timeout += jitter - - latency, err := ping(tnet, dst, timeout) - if err != nil { - lastErr = err - logger.Debug("Ping attempt %d/%d failed: %v", attempt, maxAttempts, err) - - // Brief pause between attempts with exponential backoff - if attempt < maxAttempts { - backoff := time.Duration(attempt) * 50 * time.Millisecond - time.Sleep(backoff) - } - continue - } - - totalLatency += latency - successCount++ - - // Return on first success - return totalLatency / time.Duration(successCount), nil - } - - return 0, fmt.Errorf("all %d ping attempts failed, last error: %v", maxAttempts, lastErr) -} - -func pingWithRetry(tnet *netstack.Net, dst string, timeout time.Duration) (stopChan chan struct{}, err error) { - - if healthFile != "" { - err = os.Remove(healthFile) - if err != nil { - logger.Error("Failed to remove health file: %v", err) - } - } - - const ( - initialMaxAttempts = 5 - initialRetryDelay = 2 * time.Second - maxRetryDelay = 60 * time.Second // Cap the maximum delay - ) - - stopChan = make(chan struct{}) - attempt := 1 - retryDelay := initialRetryDelay - - // First try with the initial parameters - logger.Debug("Ping attempt %d", attempt) - if latency, err := ping(tnet, dst, timeout); err == nil { - // Successful ping - logger.Debug("Ping latency: %v", latency) - logger.Info("Tunnel connection to server established successfully!") - if healthFile != "" { - err := os.WriteFile(healthFile, []byte("ok"), 0644) - if err != nil { - logger.Warn(msgHealthFileWriteFailed, err) - } - } - return stopChan, nil - } else { - logger.Warn("Ping attempt %d failed: %v", attempt, err) - } - - // Start a goroutine that will attempt pings indefinitely with increasing delays - go func() { - attempt = 2 // Continue from attempt 2 - - for { - select { - case <-stopChan: - return - default: - logger.Debug("Ping attempt %d", attempt) - - if latency, err := ping(tnet, dst, timeout); err != nil { - logger.Warn("Ping attempt %d failed: %v", attempt, err) - - // Increase delay after certain thresholds but cap it - if attempt%5 == 0 && retryDelay < maxRetryDelay { - retryDelay = time.Duration(float64(retryDelay) * 1.5) - if retryDelay > maxRetryDelay { - retryDelay = maxRetryDelay - } - logger.Info("Increasing ping retry delay to %v", retryDelay) - } - - time.Sleep(retryDelay) - attempt++ - } else { - // Successful ping - logger.Debug("Ping succeeded after %d attempts", attempt) - logger.Debug("Ping latency: %v", latency) - logger.Info("Tunnel connection to server established successfully!") - if healthFile != "" { - err := os.WriteFile(healthFile, []byte("ok"), 0644) - if err != nil { - logger.Warn(msgHealthFileWriteFailed, err) - } - } - return - } - case <-pingStopChan: - // Stop the goroutine when signaled - return - } - } - }() - - // Return an error for the first batch of attempts (to maintain compatibility with existing code) - return stopChan, fmt.Errorf("initial ping attempts failed, continuing in background") -} - -// shouldFireRecovery decides whether the data-plane recovery flow in -// startPingCheck should run on this tick. Recovery fires once when the -// consecutive-failure counter first crosses the threshold; the connectionLost -// flag prevents re-firing until a successful ping resets the state. -// -// This condition was previously inlined into startPingCheck and AND-ed with -// `currentInterval < maxInterval`, which silently broke recovery once -// pingInterval's default was bumped to 15s while maxInterval stayed at 6s -// (commit 8161fa6, March 2026): the gate became permanently false on default -// settings, so the recovery code never executed and ping failures climbed -// forever — the proximate cause of fosrl/newt#284, #310 and pangolin#1004. -// -// Recovery and backoff are independent concerns; the backoff ramp is now -// computed separately in the caller. Do not re-introduce currentInterval -// here. -func shouldFireRecovery(consecutiveFailures, failureThreshold int, connectionLost bool) bool { - return consecutiveFailures >= failureThreshold && !connectionLost -} - -func startPingCheck(tnet *netstack.Net, serverIP string, client *websocket.Client, tunnelID string) chan struct{} { - maxInterval := 6 * time.Second - currentInterval := pingInterval - consecutiveFailures := 0 - connectionLost := false - - // Track recent latencies for adaptive timeout calculation - recentLatencies := make([]time.Duration, 0, 10) - - pingStopChan := make(chan struct{}) - - go func() { - ticker := time.NewTicker(currentInterval) - defer ticker.Stop() - for { - select { - case <-ticker.C: - // Calculate adaptive timeout based on recent latencies - adaptiveTimeout := pingTimeout - if len(recentLatencies) > 0 { - var sum time.Duration - for _, lat := range recentLatencies { - sum += lat - } - avgLatency := sum / time.Duration(len(recentLatencies)) - // Use 3x average latency as timeout, with minimum of pingTimeout - adaptiveTimeout = avgLatency * 3 - if adaptiveTimeout < pingTimeout { - adaptiveTimeout = pingTimeout - } - if adaptiveTimeout > 15*time.Second { - adaptiveTimeout = 15 * time.Second - } - } - - // Use reliable ping with multiple attempts - maxAttempts := 2 - if consecutiveFailures > 4 { - maxAttempts = 4 // More attempts when connection is unstable - } - - latency, err := reliablePing(tnet, serverIP, adaptiveTimeout, maxAttempts) - if err != nil { - consecutiveFailures++ - - // Track recent latencies (add a high value for failures) - recentLatencies = append(recentLatencies, adaptiveTimeout) - if len(recentLatencies) > 10 { - recentLatencies = recentLatencies[1:] - } - - if consecutiveFailures < 2 { - logger.Debug("Periodic ping failed (%d consecutive failures): %v", consecutiveFailures, err) - } else { - logger.Warn("Periodic ping failed (%d consecutive failures): %v", consecutiveFailures, err) - } - - // More lenient threshold for declaring connection lost under load - failureThreshold := 4 - if shouldFireRecovery(consecutiveFailures, failureThreshold, connectionLost) { - connectionLost = true - logger.Warn("Connection to server lost after %d failures. Continuous reconnection attempts will be made.", consecutiveFailures) - if tunnelID != "" { - telemetry.IncReconnect(context.Background(), tunnelID, "client", telemetry.ReasonTimeout) - } - pingChainId := generateChainId() - pendingPingChainId = pingChainId - stopFunc = client.SendMessageInterval("newt/ping/request", map[string]interface{}{ - "chainId": pingChainId, - }, 3*time.Second) - // Send registration message to the server for backward compatibility - bcChainId := generateChainId() - pendingRegisterChainId = bcChainId - err := client.SendMessage("newt/wg/register", map[string]interface{}{ - "publicKey": publicKey.String(), - "backwardsCompatible": true, - "chainId": bcChainId, - }) - if err != nil { - logger.Error("Failed to send registration message: %v", err) - } - if healthFile != "" { - err = os.Remove(healthFile) - if err != nil { - logger.Error("Failed to remove health file: %v", err) - } - } - } - // Backoff: ramp the periodic-ping interval up while we are - // past the failure threshold, capped at maxInterval. Kept - // independent of the recovery trigger above so the trigger - // fires on every outage regardless of pingInterval. - if consecutiveFailures >= failureThreshold && currentInterval < maxInterval { - currentInterval = time.Duration(float64(currentInterval) * 1.3) - if currentInterval > maxInterval { - currentInterval = maxInterval - } - } - } else { - // Track recent latencies - recentLatencies = append(recentLatencies, latency) - // Record tunnel latency (limit sampling to this periodic check) - if tunnelID != "" { - telemetry.ObserveTunnelLatency(context.Background(), tunnelID, "wireguard", latency.Seconds()) - } - if len(recentLatencies) > 10 { - recentLatencies = recentLatencies[1:] - } - - if connectionLost { - connectionLost = false - logger.Info("Connection to server restored after %d failures!", consecutiveFailures) - if healthFile != "" { - err := os.WriteFile(healthFile, []byte("ok"), 0644) - if err != nil { - logger.Warn("Failed to write health file: %v", err) - } - } - } - if currentInterval > pingInterval { - currentInterval = time.Duration(float64(currentInterval) * 0.9) // Slower decrease - if currentInterval < pingInterval { - currentInterval = pingInterval - } - ticker.Reset(currentInterval) - logger.Debug("Decreased ping check interval to %v after successful ping", currentInterval) - } - consecutiveFailures = 0 - } - case <-pingStopChan: - logger.Info("Stopping ping check") - return - } - } - }() - - return pingStopChan -} - -func parseTargetData(data interface{}) (TargetData, error) { - var targetData TargetData - jsonData, err := json.Marshal(data) - if err != nil { - logger.Info("Error marshaling data: %v", err) - return targetData, err - } - - if err := json.Unmarshal(jsonData, &targetData); err != nil { - logger.Info("Error unmarshaling target data: %v", err) - return targetData, err - } - return targetData, nil -} - -// parseTargetString parses a target string in the format "listenPort:host:targetPort" -// It properly handles IPv6 addresses which must be in brackets: "listenPort:[ipv6]:targetPort" -// Examples: -// - IPv4: "3001:192.168.1.1:80" -// - IPv6: "3001:[::1]:8080" or "3001:[fd70:1452:b736:4dd5:caca:7db9:c588:f5b3]:80" -// -// Returns listenPort, targetAddress (in host:port format suitable for net.Dial), and error -func parseTargetString(target string) (int, string, error) { - // Find the first colon to extract the listen port - firstColon := strings.Index(target, ":") - if firstColon == -1 { - return 0, "", fmt.Errorf("invalid target format, no colon found: %s", target) - } - - listenPortStr := target[:firstColon] - var listenPort int - _, err := fmt.Sscanf(listenPortStr, "%d", &listenPort) - if err != nil { - return 0, "", fmt.Errorf("invalid listen port: %s", listenPortStr) - } - if listenPort <= 0 || listenPort > 65535 { - return 0, "", fmt.Errorf("listen port out of range: %d", listenPort) - } - - // The remainder is host:targetPort - use net.SplitHostPort which handles IPv6 brackets - remainder := target[firstColon+1:] - host, targetPort, err := net.SplitHostPort(remainder) - if err != nil { - return 0, "", fmt.Errorf("invalid host:port format '%s': %w", remainder, err) - } - - // Reject empty host or target port - if host == "" { - return 0, "", fmt.Errorf("empty host in target: %s", target) - } - if targetPort == "" { - return 0, "", fmt.Errorf("empty target port in target: %s", target) - } - - // Reconstruct the target address using JoinHostPort (handles IPv6 properly) - targetAddr := net.JoinHostPort(host, targetPort) - - return listenPort, targetAddr, nil -} - -func updateTargets(pm *proxy.ProxyManager, action string, tunnelIP string, proto string, targetData TargetData) error { - for _, t := range targetData.Targets { - // Parse the target string, handling both IPv4 and IPv6 addresses - port, target, err := parseTargetString(t) - if err != nil { - logger.Info("Invalid target format: %s (%v)", t, err) - continue - } - - switch action { - case "add": - // Call updown script if provided - processedTarget := target - if updownScript != "" { - newTarget, err := executeUpdownScript(action, proto, target) - if err != nil { - logger.Warn("Updown script error: %v", err) - } else if newTarget != "" { - processedTarget = newTarget - } - } - - // Only remove the specific target if it exists - err := pm.RemoveTarget(proto, tunnelIP, port) - if err != nil { - // Ignore "target not found" errors as this is expected for new targets - if !strings.Contains(err.Error(), "target not found") { - logger.Error("Failed to remove existing target: %v", err) - } - } - - // Add the new target - pm.AddTarget(proto, tunnelIP, port, processedTarget) - - case "remove": - logger.Info("Removing target with port %d", port) - - // Call updown script if provided - if updownScript != "" { - _, err := executeUpdownScript(action, proto, target) - if err != nil { - logger.Warn("Updown script error: %v", err) - } - } - - err = pm.RemoveTarget(proto, tunnelIP, port) - if err != nil { - logger.Error("Failed to remove target: %v", err) - return err - } - default: - logger.Info("Unknown action: %s", action) - } - } - - return nil -} - -func executeUpdownScript(action, proto, target string) (string, error) { - if updownScript == "" { - return target, nil - } - - // Split the updownScript in case it contains spaces (like "/usr/bin/python3 script.py") - parts := strings.Fields(updownScript) - if len(parts) == 0 { - return target, fmt.Errorf("invalid updown script command") - } - - var cmd *exec.Cmd - if len(parts) == 1 { - // If it's a single executable - logger.Info("Executing updown script: %s %s %s %s", updownScript, action, proto, target) - cmd = exec.Command(parts[0], action, proto, target) - } else { - // If it includes interpreter and script - args := append(parts[1:], action, proto, target) - logger.Info("Executing updown script: %s %s %s %s %s", parts[0], strings.Join(parts[1:], " "), action, proto, target) - cmd = exec.Command(parts[0], args...) - } - - output, err := cmd.Output() - if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - return "", fmt.Errorf("updown script execution failed (exit code %d): %s", - exitErr.ExitCode(), string(exitErr.Stderr)) - } - return "", fmt.Errorf("updown script execution failed: %v", err) - } - - // If the script returns a new target, use it - newTarget := strings.TrimSpace(string(output)) - if newTarget != "" { - logger.Info("Updown script returned new target: %s", newTarget) - return newTarget, nil - } - - return target, nil -} - -// interpolateBlueprint finds all {{...}} tokens in the raw blueprint bytes and -// replaces recognised schemes with their resolved values. Currently supported: -// -// - env. – replaced with the value of the named environment variable -// -// Any token that does not match a supported scheme is left as-is so that -// future schemes (e.g. tag., api.) are preserved rather than silently dropped. -func interpolateBlueprint(data []byte) []byte { - re := regexp.MustCompile(`\{\{([^}]+)\}\}`) - return re.ReplaceAllFunc(data, func(match []byte) []byte { - // strip the surrounding {{ }} - inner := strings.TrimSpace(string(match[2 : len(match)-2])) - - if strings.HasPrefix(inner, "env.") { - varName := strings.TrimPrefix(inner, "env.") - return []byte(os.Getenv(varName)) - } - - // unrecognised scheme – leave the token untouched - return match - }) -} - -func sendBlueprint(client *websocket.Client, file string) error { - if file == "" { - return nil - } - // try to read the blueprint file - blueprintData, err := os.ReadFile(file) - if err != nil { - logger.Error("Failed to read blueprint file: %v", err) - } else { - // interpolate {{env.VAR}} (and any future schemes) before parsing - blueprintData = interpolateBlueprint(blueprintData) - - // first we should convert the yaml to json and error if the yaml is bad - var yamlObj interface{} - var blueprintJsonData string - - err = yaml.Unmarshal(blueprintData, &yamlObj) - if err != nil { - logger.Error("Failed to parse blueprint YAML: %v", err) - } else { - // convert to json - jsonBytes, err := json.Marshal(yamlObj) - if err != nil { - logger.Error("Failed to convert blueprint to JSON: %v", err) - } else { - blueprintJsonData = string(jsonBytes) - logger.Debug("Converted blueprint to JSON: %s", blueprintJsonData) - } - } - - // if we have valid json data, we can send it to the server - if blueprintJsonData == "" { - logger.Error("No valid blueprint JSON data to send to server") - return nil - } - - logger.Info("Sending blueprint to server for application") - - // send the blueprint data to the server - err = client.SendMessage("newt/blueprint/apply", map[string]interface{}{ - "blueprint": blueprintJsonData, - }) - } - - return nil -} - -func watchBlueprintFile(ctx context.Context, filePath string, send func() error) { - watcher, err := fsnotify.NewWatcher() - if err != nil { - logger.Error("blueprint watcher: failed to create: %v", err) - return - } - defer watcher.Close() - - if err := watcher.Add(filePath); err != nil { - logger.Error("blueprint watcher: failed to watch %s: %v", filePath, err) - return - } - - logger.Info("Watching blueprint file for changes: %s", filePath) - - var debounce *time.Timer - scheduleSend := func() { - if debounce != nil { - debounce.Stop() - } - debounce = time.AfterFunc(500*time.Millisecond, func() { - logger.Info("Blueprint file changed, resending...") - if err := send(); err != nil { - logger.Error("blueprint watcher: resend failed: %v", err) - } - }) - } - - for { - select { - case <-ctx.Done(): - if debounce != nil { - debounce.Stop() - } - return - case event, ok := <-watcher.Events: - if !ok { - return - } - switch { - case event.Has(fsnotify.Write) || event.Has(fsnotify.Create): - if event.Has(fsnotify.Create) { - _ = watcher.Add(filePath) - } - scheduleSend() - case event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename): - _ = watcher.Add(filePath) - scheduleSend() - } - case err, ok := <-watcher.Errors: - if !ok { - return - } - logger.Error("blueprint watcher: %v", err) - } - } -} diff --git a/config.go b/config.go new file mode 100644 index 0000000..59f69eb --- /dev/null +++ b/config.go @@ -0,0 +1,681 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" + + "github.com/fosrl/newt/logger" + newtpkg "github.com/fosrl/newt/newt" +) + +type stringSlice []string + +func (s *stringSlice) String() string { + return strings.Join(*s, ",") +} + +func (s *stringSlice) Set(value string) error { + *s = append(*s, value) + 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 { + pkcs12Specified := cfg.TLSPrivateKey != "" + separateFilesSpecified := cfg.TLSClientCert != "" || cfg.TLSClientKey != "" || len(cfg.TLSClientCAs) > 0 + + if pkcs12Specified && separateFilesSpecified { + return fmt.Errorf("cannot use both PKCS12 format (--tls-client-cert) and separate certificate files (--tls-client-cert-file, --tls-client-key, --tls-client-ca)") + } + + if (cfg.TLSClientCert != "" && cfg.TLSClientKey == "") || (cfg.TLSClientCert == "" && cfg.TLSClientKey != "") { + return fmt.Errorf("both --tls-client-cert-file and --tls-client-key must be specified together") + } + + if cfg.TLSClientCert != "" { + if _, err := os.Stat(cfg.TLSClientCert); os.IsNotExist(err) { + return fmt.Errorf("client certificate file does not exist: %s", cfg.TLSClientCert) + } + } + + if cfg.TLSClientKey != "" { + if _, err := os.Stat(cfg.TLSClientKey); os.IsNotExist(err) { + return fmt.Errorf("client key file does not exist: %s", cfg.TLSClientKey) + } + } + + for _, caFile := range cfg.TLSClientCAs { + if _, err := os.Stat(caFile); os.IsNotExist(err) { + return fmt.Errorf("CA certificate file does not exist: %s", caFile) + } + } + + if cfg.TLSPrivateKey != "" { + if _, err := os.Stat(cfg.TLSPrivateKey); os.IsNotExist(err) { + return fmt.Errorf("PKCS12 certificate file does not exist: %s", cfg.TLSPrivateKey) + } + } + + return nil +} + +// parseDurationEnvOrFlag parses s as a duration, using defaultVal on failure. +func parseDurationEnvOrFlag(s string, defaultVal time.Duration, label string) time.Duration { + if s == "" { + return defaultVal + } + d, err := time.ParseDuration(s) + if err != nil || d <= 0 { + fmt.Printf("Invalid %s value: %s, using default %v\n", label, s, defaultVal) + return defaultVal + } + return d +} + +// 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 { + 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, + + 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", + } + + 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) + } + + // ---- 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"] + } + + 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) + } + 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) + + // ---- 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 + + 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)") + + 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 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) + } + + 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) + if err != nil { + logger.Warn("Failed to parse PORT, choosing a random port") + } else { + cfg.Port = uint16(portInt) + } + } + + // Parse MTU + if mtuStr == "" { + mtuStr = "1280" + } + mtuInt, err := strconv.Atoi(mtuStr) + if err != nil { + logger.Fatal("Failed to parse MTU: %v", err) + } + cfg.MTU = mtuInt + + // Parse docker network validation flag + 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) + cfg.PingInterval = parseDurationEnvOrFlag(pingIntervalStr, 15*time.Second, "PING_INTERVAL") + cfg.PingTimeout = parseDurationEnvOrFlag(pingTimeoutStr, 7*time.Second, "PING_TIMEOUT") + cfg.UDPProxyIdleTimeout = parseDurationEnvOrFlag(udpProxyIdleTimeoutStr, 90*time.Second, "NEWT_UDP_PROXY_IDLE_TIMEOUT") + + 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_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/holepunch/holepunch.go b/holepunch/holepunch.go index 8000837..d7e1c14 100644 --- a/holepunch/holepunch.go +++ b/holepunch/holepunch.go @@ -27,17 +27,17 @@ type ExitNode struct { // Manager handles UDP hole punching operations type Manager struct { - mu sync.Mutex - running bool - stopChan chan struct{} - sharedBind *bind.SharedBind - ID string - token string - publicKey string - clientType string - exitNodes map[string]ExitNode // key is endpoint - updateChan chan struct{} // signals the goroutine to refresh exit nodes - publicDNS []string + mu sync.Mutex + running bool + stopChan chan struct{} + sharedBind *bind.SharedBind + ID string + token string + publicKey string + clientType string + exitNodes map[string]ExitNode // key is endpoint + updateChan chan struct{} // signals the goroutine to refresh exit nodes + publicDNS []string sendHolepunchInterval time.Duration sendHolepunchIntervalMin time.Duration @@ -56,7 +56,7 @@ func NewManager(sharedBind *bind.SharedBind, ID string, clientType string, publi ID: ID, clientType: clientType, publicKey: publicKey, - publicDNS: publicDNS, + publicDNS: publicDNS, exitNodes: make(map[string]ExitNode), sendHolepunchInterval: defaultSendHolepunchIntervalMin, sendHolepunchIntervalMin: defaultSendHolepunchIntervalMin, @@ -73,6 +73,14 @@ func (m *Manager) SetToken(token string) { m.token = token } +// SetPublicDNS replaces the list of DNS servers used to resolve exit-node +// endpoints. The servers must be in "host:port" format (e.g. "8.8.8.8:53"). +func (m *Manager) SetPublicDNS(servers []string) { + m.mu.Lock() + defer m.mu.Unlock() + m.publicDNS = servers +} + // IsRunning returns whether hole punching is currently active func (m *Manager) IsRunning() bool { m.mu.Lock() diff --git a/holepunch/tester.go b/holepunch/tester.go index 85b8c89..6fa2934 100644 --- a/holepunch/tester.go +++ b/holepunch/tester.go @@ -43,17 +43,17 @@ func DefaultTestOptions() TestConnectionOptions { // cachedAddr holds a cached resolved UDP address type cachedAddr struct { - addr *net.UDPAddr + addr *net.UDPAddr resolvedAt time.Time } // HolepunchTester monitors holepunch connectivity using magic packets type HolepunchTester struct { - sharedBind *bind.SharedBind - publicDNS []string - mu sync.RWMutex - running bool - stopChan chan struct{} + sharedBind *bind.SharedBind + publicDNS []string + mu sync.RWMutex + running bool + stopChan chan struct{} // Pending requests waiting for responses (key: echo data as string) pendingRequests sync.Map // map[string]*pendingRequest @@ -88,12 +88,24 @@ type pendingRequest struct { func NewHolepunchTester(sharedBind *bind.SharedBind, publicDNS []string) *HolepunchTester { return &HolepunchTester{ sharedBind: sharedBind, - publicDNS: publicDNS, + publicDNS: publicDNS, addrCache: make(map[string]*cachedAddr), addrCacheTTL: 5 * time.Minute, // Cache addresses for 5 minutes } } +// SetPublicDNS replaces the list of DNS servers used to resolve peer endpoints. +// The servers must be in "host:port" format (e.g. "8.8.8.8:53"). +func (t *HolepunchTester) SetPublicDNS(servers []string) { + t.mu.Lock() + defer t.mu.Unlock() + t.publicDNS = servers + // Invalidate the address cache so endpoints are re-resolved with the new DNS. + t.addrCacheMu.Lock() + t.addrCache = make(map[string]*cachedAddr) + t.addrCacheMu.Unlock() +} + // SetCallback sets the callback for connection status changes func (t *HolepunchTester) SetCallback(callback HolepunchStatusCallback) { t.mu.Lock() diff --git a/main.go b/main.go index 8f5d0ce..38c0583 100644 --- a/main.go +++ b/main.go @@ -1,556 +1,130 @@ package main import ( - "bytes" "context" - "crypto/rand" "crypto/tls" - "encoding/hex" - "encoding/json" "errors" - "flag" "fmt" - "net" "net/http" "net/http/pprof" - "net/netip" "os" "os/signal" - "strconv" - "strings" - "sync/atomic" "syscall" "time" - "github.com/fosrl/newt/authdaemon" - "github.com/fosrl/newt/browsergateway" - "github.com/fosrl/newt/docker" - "github.com/fosrl/newt/healthcheck" - "github.com/fosrl/newt/logger" - "github.com/fosrl/newt/nativessh" - "github.com/fosrl/newt/proxy" - "github.com/fosrl/newt/updates" - "github.com/fosrl/newt/util" - "github.com/fosrl/newt/websocket" - - "github.com/fosrl/newt/internal/state" + "github.com/fosrl/newt/clients/permissions" "github.com/fosrl/newt/internal/telemetry" + "github.com/fosrl/newt/logger" + newtpkg "github.com/fosrl/newt/newt" + "github.com/fosrl/newt/updates" + "github.com/fosrl/newt/websocket" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" - "golang.zx2c4.com/wireguard/conn" - "golang.zx2c4.com/wireguard/device" - "golang.zx2c4.com/wireguard/tun" - "golang.zx2c4.com/wireguard/tun/netstack" - "golang.zx2c4.com/wireguard/wgctrl/wgtypes" -) - -type BrowserGatewayTarget struct { - ID int `json:"id"` - Type string `json:"type"` - Destination string `json:"destination"` - DestinationPort int `json:"destinationPort"` - AuthToken string `json:"authToken"` -} - -type WgData struct { - Endpoint string `json:"endpoint"` - RelayPort uint16 `json:"relayPort"` - PublicKey string `json:"publicKey"` - ServerIP string `json:"serverIP"` - TunnelIP string `json:"tunnelIP"` - Targets TargetsByType `json:"targets"` - HealthCheckTargets []healthcheck.Config `json:"healthCheckTargets"` - BrowserGatewayTargets []BrowserGatewayTarget `json:"browserGatewayTargets"` - ChainId string `json:"chainId"` -} - -type TargetsByType struct { - UDP []string `json:"udp"` - TCP []string `json:"tcp"` -} - -type TargetData struct { - Targets []string `json:"targets"` -} - -type ExitNodeData struct { - ExitNodes []ExitNode `json:"exitNodes"` - ChainId string `json:"chainId"` -} - -// ExitNode represents an exit node with an ID, endpoint, and weight. -type ExitNode struct { - ID int `json:"exitNodeId"` - Name string `json:"exitNodeName"` - Endpoint string `json:"endpoint"` - Weight float64 `json:"weight"` - WasPreviouslyConnected bool `json:"wasPreviouslyConnected"` -} - -type ExitNodePingResult struct { - ExitNodeID int `json:"exitNodeId"` - LatencyMs int64 `json:"latencyMs"` - Weight float64 `json:"weight"` - Error string `json:"error,omitempty"` - Name string `json:"exitNodeName"` - Endpoint string `json:"endpoint"` - WasPreviouslyConnected bool `json:"wasPreviouslyConnected"` -} - -type BlueprintResult struct { - Success bool `json:"success"` - Message string `json:"message,omitempty"` -} - -// Custom flag type for multiple CA files -type stringSlice []string - -func (s *stringSlice) String() string { - return strings.Join(*s, ",") -} - -func (s *stringSlice) Set(value string) error { - *s = append(*s, value) - return nil -} - -const ( - fmtErrMarshaling = "Error marshaling data: %v" - fmtReceivedMsg = "Received: %+v" - topicWGRegister = "newt/wg/register" - msgNoTunnelOrProxy = "No tunnel IP or proxy manager available" - fmtErrParsingTargetData = "Error parsing target data: %v" ) var ( - endpoint string - id string - secret string - mtu string - mtuInt int - dns string - privateKey wgtypes.Key - err error - logLevel string - interfaceName string - port uint16 - portStr string - disableClients bool - disableSSH bool - updownScript string - dockerSocket string - dockerEnforceNetworkValidation string - dockerEnforceNetworkValidationBool bool - pingInterval time.Duration - pingTimeout time.Duration - udpProxyIdleTimeout time.Duration - publicKey wgtypes.Key - pingStopChan chan struct{} - stopFunc func() - pendingRegisterChainId string - browserGateway *browsergateway.Gateway - browserGatewayStop func() - pendingPingChainId string - healthFile string - useNativeInterface bool - authorizedKeysFile string - preferEndpoint string - healthMonitor *healthcheck.Monitor - enforceHealthcheckCert bool - authDaemonKey string - authDaemonPrincipalsFile string - authDaemonCACertPath string - authDaemonGenerateRandomPassword bool - // Build/version (can be overridden via -ldflags "-X main.newtVersion=...") newtVersion = "version_replaceme" - newtPlatform = "" // embedded at build time via -X main.newtPlatform=_ - - // Observability/metrics flags - metricsEnabled bool - otlpEnabled bool - adminAddr string - region string - metricsAsyncBytes bool - pprofEnabled bool - blueprintFile string - provisioningBlueprintFile string - noCloud bool - - // New mTLS configuration variables - tlsClientCert string - tlsClientKey string - tlsClientCAs []string - - // Legacy PKCS12 support (deprecated) - tlsPrivateKey string - - // Provisioning key – exchanged once for a permanent newt ID + secret - provisioningKey string - - // Optional name for the site created during provisioning - newtName string - - // Path to config file (overrides CONFIG_FILE env var and default location) - configFile string + newtPlatform = "" ) -// generateChainId generates a random chain ID for deduplicating round-trip messages. -func generateChainId() string { - b := make([]byte, 8) - _, _ = rand.Read(b) - return hex.EncodeToString(b) -} - func main() { - // Check for subcommands first (only principals exits early) + // Subcommand dispatch if len(os.Args) > 1 { switch os.Args[1] { case "auth-daemon": - // Run principals subcommand only if the next argument is "principals" if len(os.Args) > 2 && os.Args[2] == "principals" { runPrincipalsCmd(os.Args[3:]) return } - - // auth-daemon subcommand without "principals" - show help fmt.Println("Error: auth-daemon subcommand requires 'principals' argument") fmt.Println() fmt.Println("Usage:") fmt.Println(" newt auth-daemon principals [options]") fmt.Println() - - // If not "principals", exit the switch to continue with normal execution return } } - // Check if we're running as a Windows service if isWindowsService() { runService("NewtWireguardService", false, os.Args[1:]) return } - // Handle service management commands on Windows (install, remove, start, stop, etc.) if handleServiceCommand() { return } - // Prepare context for graceful shutdown and signal handling ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - // Run the main newt logic runNewtMain(ctx) } -// runNewtMain contains the main newt logic, extracted for service support func runNewtMain(ctx context.Context) { - // if PANGOLIN_ENDPOINT, NEWT_ID, and NEWT_SECRET are set as environment variables, they will be used as default values - endpoint = os.Getenv("PANGOLIN_ENDPOINT") - id = os.Getenv("NEWT_ID") - secret = os.Getenv("NEWT_SECRET") - mtu = os.Getenv("MTU") - dns = os.Getenv("DNS") - logLevel = os.Getenv("LOG_LEVEL") - updownScript = os.Getenv("UPDOWN_SCRIPT") - interfaceName = os.Getenv("INTERFACE") - portStr = os.Getenv("PORT") - authDaemonKey = os.Getenv("AD_KEY") - authDaemonPrincipalsFile = os.Getenv("AD_PRINCIPALS_FILE") - authDaemonCACertPath = os.Getenv("AD_CA_CERT_PATH") - authDaemonGenerateRandomPasswordEnv := os.Getenv("AD_GENERATE_RANDOM_PASSWORD") - - // Metrics/observability env mirrors - metricsEnabledEnv := os.Getenv("NEWT_METRICS_PROMETHEUS_ENABLED") - otlpEnabledEnv := os.Getenv("NEWT_METRICS_OTLP_ENABLED") - adminAddrEnv := os.Getenv("NEWT_ADMIN_ADDR") - regionEnv := os.Getenv("NEWT_REGION") - asyncBytesEnv := os.Getenv("NEWT_METRICS_ASYNC_BYTES") - pprofEnabledEnv := os.Getenv("NEWT_PPROF_ENABLED") - - disableClientsEnv := os.Getenv("DISABLE_CLIENTS") - disableClients = disableClientsEnv == "true" - disableSSHEnv := os.Getenv("DISABLE_SSH") - disableSSH = disableSSHEnv == "true" - useNativeInterfaceEnv := os.Getenv("USE_NATIVE_INTERFACE") - useNativeInterface = useNativeInterfaceEnv == "true" - enforceHealthcheckCertEnv := os.Getenv("ENFORCE_HC_CERT") - enforceHealthcheckCert = enforceHealthcheckCertEnv == "true" - dockerSocket = os.Getenv("DOCKER_SOCKET") - pingIntervalStr := os.Getenv("PING_INTERVAL") - pingTimeoutStr := os.Getenv("PING_TIMEOUT") - udpProxyIdleTimeoutStr := os.Getenv("NEWT_UDP_PROXY_IDLE_TIMEOUT") - dockerEnforceNetworkValidation = os.Getenv("DOCKER_ENFORCE_NETWORK_VALIDATION") - healthFile = os.Getenv("HEALTH_FILE") - // authorizedKeysFile = os.Getenv("AUTHORIZED_KEYS_FILE") - authorizedKeysFile = "" - - // Read new mTLS environment variables - tlsClientCert = os.Getenv("TLS_CLIENT_CERT") - tlsClientKey = os.Getenv("TLS_CLIENT_KEY") - tlsClientCAsEnv := os.Getenv("TLS_CLIENT_CAS") - if tlsClientCAsEnv != "" { - tlsClientCAs = strings.Split(tlsClientCAsEnv, ",") - // Trim spaces from each CA file path - for i, ca := range tlsClientCAs { - tlsClientCAs[i] = strings.TrimSpace(ca) - } - } - - // Legacy PKCS12 support (deprecated) - tlsPrivateKey = os.Getenv("TLS_CLIENT_CERT_PKCS12") - // Keep backward compatibility with old environment variable name - if tlsPrivateKey == "" && tlsClientKey == "" && len(tlsClientCAs) == 0 { - tlsPrivateKey = os.Getenv("TLS_CLIENT_CERT") - } - blueprintFile = os.Getenv("BLUEPRINT_FILE") - provisioningBlueprintFile = os.Getenv("PROVISIONING_BLUEPRINT_FILE") - noCloudEnv := os.Getenv("NO_CLOUD") - noCloud = noCloudEnv == "true" - provisioningKey = os.Getenv("NEWT_PROVISIONING_KEY") - newtName = os.Getenv("NEWT_NAME") - configFile = os.Getenv("CONFIG_FILE") - - if endpoint == "" { - flag.StringVar(&endpoint, "endpoint", "", "Endpoint of your pangolin server") - } - if id == "" { - flag.StringVar(&id, "id", "", "Newt ID") - } - if secret == "" { - flag.StringVar(&secret, "secret", "", "Newt secret") - } - if mtu == "" { - flag.StringVar(&mtu, "mtu", "1280", "MTU to use") - } - if dns == "" { - flag.StringVar(&dns, "dns", "9.9.9.9", "DNS server to use") - } - if logLevel == "" { - flag.StringVar(&logLevel, "log-level", "INFO", "Log level (DEBUG, INFO, WARN, ERROR, FATAL)") - } - if updownScript == "" { - flag.StringVar(&updownScript, "updown", "", "Path to updown script to be called when targets are added or removed") - } - if interfaceName == "" { - flag.StringVar(&interfaceName, "interface", "newt", "Name of the WireGuard interface") - } - if portStr == "" { - flag.StringVar(&portStr, "port", "", "Port for client WireGuard interface") - } - if useNativeInterfaceEnv == "" { - flag.BoolVar(&useNativeInterface, "native", false, "Use native WireGuard interface") - } - if disableClientsEnv == "" { - flag.BoolVar(&disableClients, "disable-clients", false, "Disable clients on the WireGuard interface") - } - if disableSSHEnv == "" { - flag.BoolVar(&disableSSH, "disable-ssh", false, "Disable SSH auth daemon and native SSH mode (remote auth daemon still works)") - } - if enforceHealthcheckCertEnv == "" { - flag.BoolVar(&enforceHealthcheckCert, "enforce-hc-cert", false, "Enforce certificate validation for health checks (default: false, accepts any cert)") - } - if dockerSocket == "" { - flag.StringVar(&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") - } - // load the prefer endpoint just as a flag - flag.StringVar(&preferEndpoint, "prefer-endpoint", "", "Prefer this endpoint for the connection (if set, will override the endpoint from the server)") - if provisioningKey == "" { - flag.StringVar(&provisioningKey, "provisioning-key", "", "One-time provisioning key used to obtain a newt ID and secret from the server") - } - if newtName == "" { - flag.StringVar(&newtName, "name", "", "Name for the site created during provisioning (supports {{env.VAR}} interpolation)") - } - if configFile == "" { - flag.StringVar(&configFile, "config-file", "", "Path to config file (overrides CONFIG_FILE env var and default location)") - } - - // Add new mTLS flags - if tlsClientCert == "" { - flag.StringVar(&tlsClientCert, "tls-client-cert-file", "", "Path to client certificate file (PEM/DER format)") - } - if tlsClientKey == "" { - flag.StringVar(&tlsClientKey, "tls-client-key", "", "Path to client private key file (PEM/DER format)") - } - // add a dummy input for --auth-daemon but ignore it since the auth daemon is always enabled now and this is just for backward compatibility with older versions - flag.Bool("auth-daemon", false, "Enable auth daemon mode (deprecated, always enabled)") - - // Handle multiple CA files - var tlsClientCAsFlag stringSlice - flag.Var(&tlsClientCAsFlag, "tls-client-ca", "Path to CA certificate file for validating remote certificates (can be specified multiple times)") - - // Legacy PKCS12 flag (deprecated) - if tlsPrivateKey == "" { - flag.StringVar(&tlsPrivateKey, "tls-client-cert", "", "Path to client certificate (PKCS12 format) - DEPRECATED: use --tls-client-cert-file and --tls-client-key instead") - } - - if pingIntervalStr != "" { - pingInterval, err = time.ParseDuration(pingIntervalStr) - if err != nil { - fmt.Printf("Invalid PING_INTERVAL value: %s, using default 15 seconds\n", pingIntervalStr) - pingInterval = 15 * time.Second - } - } else { - pingInterval = 15 * time.Second - } - - if pingTimeoutStr != "" { - pingTimeout, err = time.ParseDuration(pingTimeoutStr) - if err != nil { - fmt.Printf("Invalid PING_TIMEOUT value: %s, using default 7 seconds\n", pingTimeoutStr) - pingTimeout = 7 * time.Second - } - } else { - pingTimeout = 7 * time.Second - } - - if udpProxyIdleTimeoutStr != "" { - udpProxyIdleTimeout, err = time.ParseDuration(udpProxyIdleTimeoutStr) - if err != nil || udpProxyIdleTimeout <= 0 { - fmt.Printf("Invalid NEWT_UDP_PROXY_IDLE_TIMEOUT/--udp-proxy-idle-timeout value: %s, using default 90 seconds\n", udpProxyIdleTimeoutStr) - udpProxyIdleTimeout = 90 * time.Second - } - } else { - udpProxyIdleTimeout = 90 * time.Second - } - - if dockerEnforceNetworkValidation == "" { - flag.StringVar(&dockerEnforceNetworkValidation, "docker-enforce-network-validation", "false", "Enforce validation of container on newt network (true or false)") - } - if healthFile == "" { - flag.StringVar(&healthFile, "health-file", "", "Path to health file (if unset, health file won't be written)") - } - if blueprintFile == "" { - flag.StringVar(&blueprintFile, "blueprint-file", "", "Path to blueprint file (if unset, no blueprint will be applied)") - } - if provisioningBlueprintFile == "" { - flag.StringVar(&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(&noCloud, "no-cloud", false, "Disable cloud failover") - } - - // Metrics/observability flags (mirror ENV if unset) - if metricsEnabledEnv == "" { - flag.BoolVar(&metricsEnabled, "metrics", false, "Enable Prometheus metrics exporter") - } else { - if v, err := strconv.ParseBool(metricsEnabledEnv); err == nil { - metricsEnabled = v - } else { - metricsEnabled = true - } - } - if otlpEnabledEnv == "" { - flag.BoolVar(&otlpEnabled, "otlp", false, "Enable OTLP exporters (metrics/traces) to OTEL_EXPORTER_OTLP_ENDPOINT") - } else { - if v, err := strconv.ParseBool(otlpEnabledEnv); err == nil { - otlpEnabled = v - } - } - if adminAddrEnv == "" { - flag.StringVar(&adminAddr, "metrics-admin-addr", "127.0.0.1:2112", "Admin/metrics bind address") - } else { - adminAddr = adminAddrEnv - } - // Async bytes toggle - if asyncBytesEnv == "" { - flag.BoolVar(&metricsAsyncBytes, "metrics-async-bytes", false, "Enable async bytes counting (background flush; lower hot path overhead)") - } else { - if v, err := strconv.ParseBool(asyncBytesEnv); err == nil { - metricsAsyncBytes = v - } - } - // pprof debug endpoint toggle - if pprofEnabledEnv == "" { - flag.BoolVar(&pprofEnabled, "pprof", false, "Enable pprof debug endpoints on admin server") - } else { - if v, err := strconv.ParseBool(pprofEnabledEnv); err == nil { - pprofEnabled = v - } - } - // Optional region flag (resource attribute) - if regionEnv == "" { - flag.StringVar(®ion, "region", "", "Optional region resource attribute (also NEWT_REGION)") - } else { - region = regionEnv - } - - // Auth daemon flags - if authDaemonKey == "" { - flag.StringVar(&authDaemonKey, "ad-pre-shared-key", "", "Pre-shared key for auth daemon authentication") - } - if authDaemonPrincipalsFile == "" { - flag.StringVar(&authDaemonPrincipalsFile, "ad-principals-file", "/var/run/auth-daemon/principals", "Path to the principals file for auth daemon") - } - if authDaemonCACertPath == "" { - flag.StringVar(&authDaemonCACertPath, "ad-ca-cert-path", "/etc/ssh/ca.pem", "Path to the CA certificate file for auth daemon") - } - - if authDaemonGenerateRandomPasswordEnv == "" { - flag.BoolVar(&authDaemonGenerateRandomPassword, "ad-generate-random-password", false, "Generate a random password for authenticated users") - } else { - if v, err := strconv.ParseBool(authDaemonGenerateRandomPasswordEnv); err == nil { - authDaemonGenerateRandomPassword = v - } - } - - // do a --version check - version := flag.Bool("version", false, "Print the version") - - flag.Parse() - - // Merge command line CA flags with environment variable CAs - if len(tlsClientCAsFlag) > 0 { - tlsClientCAs = append(tlsClientCAs, tlsClientCAsFlag...) - } - - if portStr != "" { - portInt, err := strconv.Atoi(portStr) - if err != nil { - logger.Warn("Failed to parse PORT, choosing a random port") - } else { - port = uint16(portInt) - } - } - - if *version { - fmt.Println("Newt version " + newtVersion) - os.Exit(0) - } else { - logger.Info("Newt version %s", newtVersion) - } - logger.Init(nil) - loggerLevel := util.ParseLogLevel(logLevel) - // Start auth daemon if enabled - if !disableSSH { - if err := startAuthDaemon(ctx); err != nil { - logger.Warn("Did not start on site auth daemon: %v", err) + cfg := loadNewtConfig() + + if cfg.UseNativeMainInterface { + if err := permissions.CheckNativeInterfacePermissions(); err != nil { + logger.Fatal("Insufficient permissions for native main tunnel interface: %v", err) } } - logger.GetLogger().SetLevel(loggerLevel) - - // Initialize telemetry after flags are parsed (so flags override env) - tcfg := telemetry.FromEnv() - tcfg.PromEnabled = metricsEnabled - tcfg.OTLPEnabled = otlpEnabled - if adminAddr != "" { - tcfg.AdminAddr = adminAddr + if err := validateTLSConfig(cfg); err != nil { + logger.Fatal("TLS configuration error: %v", err) } - // Resource attributes (if available) - tcfg.SiteID = id - tcfg.Region = region - // Build info + + logger.Debug("Endpoint: %v", cfg.Endpoint) + logger.Debug("Log Level: %v", cfg.LogLevel) + logger.Debug("Docker Network Validation Enabled: %v", cfg.DockerEnforceNetworkValidation) + logger.Debug("Health Check Certificate Enforcement: %v", cfg.EnforceHealthcheckCert) + if cfg.TLSClientCert != "" { + logger.Debug("TLS Client Cert File: %v", cfg.TLSClientCert) + } + if cfg.TLSClientKey != "" { + logger.Debug("TLS Client Key File: %v", cfg.TLSClientKey) + } + if len(cfg.TLSClientCAs) > 0 { + logger.Debug("TLS CA Files: %v", cfg.TLSClientCAs) + } + if cfg.TLSPrivateKey != "" { + logger.Debug("TLS PKCS12 File: %v", cfg.TLSPrivateKey) + } + if cfg.DNS != "" { + logger.Debug("DNS: %v", cfg.DNS) + } + if cfg.DockerSocket != "" { + logger.Debug("Docker Socket: %v", cfg.DockerSocket) + } + if cfg.MTU != 0 { + logger.Debug("MTU: %v", cfg.MTU) + } + if cfg.UpdownScript != "" { + logger.Debug("Up Down Script: %v", cfg.UpdownScript) + } + + cfg.OnRestart = reexec + + n, err := newtpkg.Init(ctx, cfg) + if err != nil { + logger.Fatal("Failed to initialize newt: %v", err) + } + + resolvedCfg := n.GetConfig() + + if err := updates.CheckForUpdate("fosrl", "newt", newtVersion); err != nil { + logger.Error("Error checking for updates: %v\n", err) + } + + // Initialize telemetry + tcfg := telemetry.FromEnv() + tcfg.PromEnabled = resolvedCfg.MetricsEnabled + tcfg.OTLPEnabled = resolvedCfg.OTLPEnabled + if resolvedCfg.AdminAddr != "" { + tcfg.AdminAddr = resolvedCfg.AdminAddr + } + tcfg.SiteID = resolvedCfg.ID + tcfg.Region = resolvedCfg.Region tcfg.BuildVersion = newtVersion tcfg.BuildCommit = os.Getenv("NEWT_COMMIT") @@ -558,15 +132,14 @@ func runNewtMain(ctx context.Context) { if telErr != nil { logger.Warn("Telemetry init failed: %v", telErr) } - if tel != nil && (metricsEnabled || pprofEnabled) { - // Admin HTTP server (exposes /metrics when Prometheus exporter is enabled) + if tel != nil && (resolvedCfg.MetricsEnabled || resolvedCfg.PprofEnabled) { logger.Debug("Starting metrics server on %s", tcfg.AdminAddr) mux := http.NewServeMux() mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }) if tel.PrometheusHandler != nil { mux.Handle("/metrics", tel.PrometheusHandler) } - if pprofEnabled { + if resolvedCfg.PprofEnabled { mux.HandleFunc("/debug/pprof/", pprof.Index) mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) mux.HandleFunc("/debug/pprof/profile", pprof.Profile) @@ -588,116 +161,46 @@ func runNewtMain(ctx context.Context) { } }() defer func() { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - _ = admin.Shutdown(ctx) + _ = admin.Shutdown(shutCtx) }() defer func() { _ = tel.Shutdown(context.Background()) }() } - if err := updates.CheckForUpdate("fosrl", "newt", newtVersion); err != nil { - logger.Error("Error checking for updates: %v\n", err) + telemetry.UpdateSiteInfo(resolvedCfg.ID, resolvedCfg.Region) + + // Build TLS config for self-update (reuse same TLS parameters as websocket client) + var selfUpdateTLS *tls.Config + if resolvedCfg.TLSClientCert != "" || resolvedCfg.TLSPrivateKey != "" { + selfUpdateTLS, _ = websocket.BuildTLSConfig( + resolvedCfg.TLSClientCert, + resolvedCfg.TLSClientKey, + resolvedCfg.TLSClientCAs, + resolvedCfg.TLSPrivateKey, + ) } - // parse the mtu string into an int - mtuInt, err = strconv.Atoi(mtu) - if err != nil { - logger.Fatal("Failed to parse MTU: %v", err) - } - - // parse if we want to enforce container network validation - dockerEnforceNetworkValidationBool, err = strconv.ParseBool(dockerEnforceNetworkValidation) - if err != nil { - logger.Info("Docker enforce network validation cannot be parsed. Defaulting to 'false'") - dockerEnforceNetworkValidationBool = false - } - - // Add TLS configuration validation - if err := validateTLSConfig(); err != nil { - logger.Fatal("TLS configuration error: %v", err) - } - - // Show deprecation warning if using PKCS12 - if tlsPrivateKey != "" { - logger.Warn("Using deprecated PKCS12 format for mTLS. Consider migrating to separate certificate files using --tls-client-cert-file, --tls-client-key, and --tls-client-ca") - } - - privateKey, err = wgtypes.GeneratePrivateKey() - if err != nil { - logger.Fatal("Failed to generate private key: %v", err) - } - - // Create client option based on TLS configuration - var opt websocket.ClientOption - if tlsClientCert != "" && tlsClientKey != "" { - // Use new separate certificate configuration - opt = websocket.WithTLSConfig(websocket.TLSConfig{ - ClientCertFile: tlsClientCert, - ClientKeyFile: tlsClientKey, - CAFiles: tlsClientCAs, - }) - logger.Debug("Using separate certificate files for mTLS") - logger.Debug("Client cert: %s", tlsClientCert) - logger.Debug("Client key: %s", tlsClientKey) - logger.Debug("CA files: %v", tlsClientCAs) - } else if tlsPrivateKey != "" { - // Use existing PKCS12 configuration for backward compatibility - opt = websocket.WithTLSConfig(websocket.TLSConfig{ - PKCS12File: tlsPrivateKey, - }) - logger.Debug("Using PKCS12 file for mTLS: %s", tlsPrivateKey) - } - - // Create a new client - client, err := websocket.NewClient( - "newt", - id, // CLI arg takes precedence - secret, // CLI arg takes precedence - endpoint, - 30*time.Second, - opt, - websocket.WithConfigFile(configFile), - ) - if err != nil { - logger.Fatal("Failed to create client: %v", err) - } - // If a provisioning key was supplied via CLI / env and the config file did - // not already carry one, inject it now so provisionIfNeeded() can use it. - if provisioningKey != "" && client.GetConfig().ProvisioningKey == "" { - client.GetConfig().ProvisioningKey = provisioningKey - } - if newtName != "" && client.GetConfig().Name == "" { - client.GetConfig().Name = newtName - } - - endpoint = client.GetConfig().Endpoint // Update endpoint from config - id = client.GetConfig().ID // Update ID from config - secret = client.GetConfig().Secret // Update secret from config - // Update site labels for metrics with the resolved ID - telemetry.UpdateSiteInfo(id, region) - - var tlsCfg *tls.Config - if opt != nil { - // Reuse the TLS configuration already set up for the websocket client. - tlsCfg, _ = websocket.BuildTLSConfig(tlsClientCert, tlsClientKey, tlsClientCAs, tlsPrivateKey) - } doUpdate := func() { logger.Debug("checkAndSelfUpdate: running periodic update check") if err := updates.CheckAndSelfUpdate(updates.SelfUpdateConfig{ - Endpoint: endpoint, - NewtID: id, - Secret: secret, + Endpoint: resolvedCfg.Endpoint, + NewtID: resolvedCfg.ID, + Secret: resolvedCfg.Secret, CurrentVersion: newtVersion, Platform: newtPlatform, - TLSConfig: tlsCfg, + TLSConfig: selfUpdateTLS, }); err != nil { + if errors.Is(err, updates.ErrAutoUpdateUnsupportedInOfficialContainer) { + logger.Debug("checkAndSelfUpdate: auto-update skipped: %v", err) + return + } logger.Error("Auto-update check failed: %v", err) } } go func() { - // wait for 2 minutes after startup before the first check to avoid interfering with initial provisioning and registration time.Sleep(2 * time.Minute) - doUpdate() // run once at startup + doUpdate() ticker := time.NewTicker(6 * time.Hour) defer ticker.Stop() for { @@ -710,1639 +213,12 @@ func runNewtMain(ctx context.Context) { } }() - // output env var values if set - logger.Debug("Endpoint: %v", endpoint) - logger.Debug("Log Level: %v", logLevel) - logger.Debug("Docker Network Validation Enabled: %v", dockerEnforceNetworkValidationBool) - logger.Debug("Health Check Certificate Enforcement: %v", enforceHealthcheckCert) - - // Add new TLS debug logging - if tlsClientCert != "" { - logger.Debug("TLS Client Cert File: %v", tlsClientCert) - } - if tlsClientKey != "" { - logger.Debug("TLS Client Key File: %v", tlsClientKey) - } - if len(tlsClientCAs) > 0 { - logger.Debug("TLS CA Files: %v", tlsClientCAs) - } - if tlsPrivateKey != "" { - logger.Debug("TLS PKCS12 File: %v", tlsPrivateKey) - } - - if dns != "" { - logger.Debug("Dns: %v", dns) - } - if dockerSocket != "" { - logger.Debug("Docker Socket: %v", dockerSocket) - } - if mtu != "" { - logger.Debug("MTU: %v", mtu) - } - if updownScript != "" { - logger.Debug("Up Down Script: %v", updownScript) - } - - // Create TUN device and network stack - var tun tun.Device - var tnet *netstack.Net - var dev *device.Device - var pm *proxy.ProxyManager - var connected bool - var wgData WgData - var dockerEventMonitor *docker.EventMonitor - var connectionBlocked atomic.Bool - var currentPM atomic.Pointer[proxy.ProxyManager] - - // In-memory SSH credentials shared with the native SSH server started in - // the clients netstack once the WireGuard interface is ready. - var sshCredStore *nativessh.CredentialStore - if !disableSSH { - sshCredStore = nativessh.NewCredentialStore() - } - - if !disableClients { - setupClients(client, sshCredStore) - } - - // Initialize connection blocked state from config - connectionBlocked.Store(client.GetConfig().Blocked) - if connectionBlocked.Load() { - logger.Info("Connection blocking is enabled (from config)") - setClientsBlocked(true) - } - - // Initialize health check monitor with status change callback - healthMonitor = healthcheck.NewMonitor(func(targets map[int]*healthcheck.Target) { - logger.Debug("Health check status update for %d targets", len(targets)) - - // Send health status update to the server - healthStatuses := make(map[int]interface{}) - for id, target := range targets { - healthStatuses[id] = map[string]interface{}{ - "status": target.Status.String(), - "lastCheck": target.LastCheck.Format(time.RFC3339), - "checkCount": target.CheckCount, - "lastError": target.LastError, - "config": target.Config, - } - } - - // print the status of the targets - logger.Debug("Health check status: %+v", healthStatuses) - - err := client.SendMessage("newt/healthcheck/status", map[string]interface{}{ - "targets": healthStatuses, - }) - if err != nil { - logger.Error("Failed to send health check status update: %v", err) - } - }, enforceHealthcheckCert) - - var pingWithRetryStopChan chan struct{} - - closeWgTunnel := func() { - if pingStopChan != nil { - // Stop the ping check - close(pingStopChan) - pingStopChan = nil - } - - // Shutdown browser gateway if running - if browserGatewayStop != nil { - browserGatewayStop() - browserGatewayStop = nil - browserGateway = nil - } - - // Stop proxy manager if running - if pm != nil { - pm.Stop() - currentPM.Store(nil) - pm = nil - } - - // Close WireGuard device first - this will automatically close the TUN device - if dev != nil { - dev.Close() - dev = nil - } - - // Clear references but don't manually close since dev.Close() already did it - if tnet != nil { - tnet = nil - } - if tun != nil { - tun = nil // Don't call tun.Close() here since dev.Close() already closed it - } - - } - - // Register handlers for different message types - client.RegisterHandler("newt/wg/connect", func(msg websocket.WSMessage) { - logger.Debug("Received registration message") - regResult := "success" - defer func() { - telemetry.IncSiteRegistration(ctx, regResult) - }() - - // Deduplicate using chainId: if the server echoes back a chainId we have - // already consumed (or one that doesn't match our current pending request), - // throw the message away to avoid setting up the tunnel twice. - var chainData struct { - ChainId string `json:"chainId"` - } - if jsonBytes, err := json.Marshal(msg.Data); err == nil { - _ = json.Unmarshal(jsonBytes, &chainData) - } - if chainData.ChainId != "" { - if chainData.ChainId != pendingRegisterChainId { - logger.Debug("Discarding duplicate/stale newt/wg/connect (chainId=%s, expected=%s)", chainData.ChainId, pendingRegisterChainId) - return - } - pendingRegisterChainId = "" // consume – further duplicates with this id are rejected - } - - if stopFunc != nil { - stopFunc() // stop the ws from sending more requests - stopFunc = nil // reset stopFunc to nil to avoid double stopping - } - - if connected { - // Mark as disconnected - - closeWgTunnel() - - connected = false - } - - // print out the data - logger.Debug("Received registration message data: %+v", msg.Data) - - jsonData, err := json.Marshal(msg.Data) - if err != nil { - logger.Info(fmtErrMarshaling, err) - regResult = "failure" - return - } - - if err := json.Unmarshal(jsonData, &wgData); err != nil { - logger.Info("Error unmarshaling target data: %v", err) - regResult = "failure" - return - } - - logger.Debug(fmtReceivedMsg, msg) - tun, tnet, err = netstack.CreateNetTUN( - []netip.Addr{netip.MustParseAddr(wgData.TunnelIP)}, - []netip.Addr{netip.MustParseAddr(dns)}, - mtuInt) - if err != nil { - logger.Error("Failed to create TUN device: %v", err) - regResult = "failure" - } - - setDownstreamTNetstack(tnet) - - // Create WireGuard device - dev = device.NewDevice(tun, conn.NewDefaultBind(), device.NewLogger( - util.MapToWireGuardLogLevel(loggerLevel), - "gerbil-wireguard: ", - )) - - host, _, err := net.SplitHostPort(wgData.Endpoint) - if err != nil { - logger.Error("Failed to split endpoint: %v", err) - regResult = "failure" - return - } - - logger.Info("Connecting to endpoint: %s", host) - - endpoint, err := util.ResolveDomain(wgData.Endpoint) - if err != nil { - logger.Error("Failed to resolve endpoint: %v", err) - regResult = "failure" - return - } - - relayPort := wgData.RelayPort - if relayPort == 0 { - relayPort = 21820 - } - - clientsHandleNewtConnection(wgData.PublicKey, endpoint, relayPort) - - // Configure WireGuard - config := fmt.Sprintf(`private_key=%s -public_key=%s -allowed_ip=%s/32 -endpoint=%s -persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey(wgData.PublicKey), wgData.ServerIP, endpoint) - - err = dev.IpcSet(config) - if err != nil { - logger.Error("Failed to configure WireGuard device: %v", err) - regResult = "failure" - } - - // Bring up the device - err = dev.Up() - if err != nil { - logger.Error("Failed to bring up WireGuard device: %v", err) - regResult = "failure" - } - - logger.Debug("WireGuard device created. Lets ping the server now...") - - // Even if pingWithRetry returns an error, it will continue trying in the background - if pingWithRetryStopChan != nil { - // Stop the previous pingWithRetry if it exists - close(pingWithRetryStopChan) - pingWithRetryStopChan = nil - } - // Use reliable ping for initial connection test - logger.Debug("Testing initial connection with reliable ping...") - lat, err := reliablePing(tnet, wgData.ServerIP, pingTimeout, 5) - if err == nil && wgData.PublicKey != "" { - telemetry.ObserveTunnelLatency(ctx, wgData.PublicKey, "wireguard", lat.Seconds()) - } - if err != nil { - logger.Warn("Initial reliable ping failed, but continuing: %v", err) - regResult = "failure" - } else { - logger.Debug("Initial connection test successful") - } - - pingWithRetryStopChan, _ = pingWithRetry(tnet, wgData.ServerIP, pingTimeout) - - // Always mark as connected and start the proxy manager regardless of initial ping result - // as the pings will continue in the background - if !connected { - logger.Debug("Starting ping check") - pingStopChan = startPingCheck(tnet, wgData.ServerIP, client, wgData.PublicKey) - } - - // Create proxy manager - pm = proxy.NewProxyManager(tnet) - pm.SetAsyncBytes(metricsAsyncBytes) - pm.SetUDPIdleTimeout(udpProxyIdleTimeout) - // Set tunnel_id for metrics (WireGuard peer public key) - pm.SetTunnelID(wgData.PublicKey) - pm.SetBlocked(connectionBlocked.Load()) - currentPM.Store(pm) - - connected = true - - // add the targets if there are any - if len(wgData.Targets.TCP) > 0 { - updateTargets(pm, "add", wgData.TunnelIP, "tcp", TargetData{Targets: wgData.Targets.TCP}) - // Also update wgnetstack proxy manager - // if wgService != nil { - // updateTargets(wgService.GetProxyManager(), "add", wgData.TunnelIP, "tcp", TargetData{Targets: wgData.Targets.TCP}) - // } - } - - if len(wgData.Targets.UDP) > 0 { - updateTargets(pm, "add", wgData.TunnelIP, "udp", TargetData{Targets: wgData.Targets.UDP}) - // Also update wgnetstack proxy manager - // if wgService != nil { - // updateTargets(wgService.GetProxyManager(), "add", wgData.TunnelIP, "udp", TargetData{Targets: wgData.Targets.UDP}) - // } - } - - // Start direct UDP relay from main tunnel to clients' WireGuard (bypasses proxy) - clientsStartDirectRelay(wgData.TunnelIP) - - if err := healthMonitor.AddTargets(wgData.HealthCheckTargets); err != nil { - logger.Error("Failed to bulk add health check targets: %v", err) - } else { - logger.Debug("Successfully added %d health check targets", len(wgData.HealthCheckTargets)) - } - - err = pm.Start() - if err != nil { - logger.Error("Failed to start proxy manager: %v", err) - } - - // Start browser gateway if targets are present - if len(wgData.BrowserGatewayTargets) > 0 { - // Shutdown any existing gateway first - if browserGatewayStop != nil { - browserGatewayStop() - browserGatewayStop = nil - } - - bgTargets := make([]browsergateway.Target, 0, len(wgData.BrowserGatewayTargets)) - for _, t := range wgData.BrowserGatewayTargets { - bgTargets = append(bgTargets, browsergateway.Target{ - ID: t.ID, - Type: t.Type, - Destination: t.Destination, - DestinationPort: t.DestinationPort, - AuthToken: t.AuthToken, - }) - } - - browserGateway = browsergateway.New(browsergateway.Config{SSHCredentials: sshCredStore}) - browserGateway.SetTargets(bgTargets) - - ln, bgErr := tnet.ListenTCP(&net.TCPAddr{Port: browsergateway.ListenPort}) - if bgErr != nil { - logger.Error("Failed to start browser gateway listener: %v", bgErr) - } else { - browserGatewayStop = func() { _ = ln.Close() } - go func() { - logger.Debug("Browser gateway started on port %d", browsergateway.ListenPort) - if startErr := browserGateway.Start(ln); startErr != nil { - logger.Error("Browser gateway stopped with error: %v", startErr) - } - }() - } - } - }) - - client.RegisterHandler("newt/wg/reconnect", func(msg websocket.WSMessage) { - logger.Info("Received reconnect message") - if wgData.PublicKey != "" { - telemetry.IncReconnect(ctx, wgData.PublicKey, "server", telemetry.ReasonServerRequest) - } - - // Close the WireGuard device and TUN - closeWgTunnel() - - // Clear metrics attrs and sessions for the tunnel - if pm != nil { - pm.ClearTunnelID() - state.Global().ClearTunnel(wgData.PublicKey) - } - - // Mark as disconnected - connected = false - - if stopFunc != nil { - stopFunc() // stop the ws from sending more requests - stopFunc = nil // reset stopFunc to nil to avoid double stopping - } - - // Request exit nodes from the server - pingChainId := generateChainId() - pendingPingChainId = pingChainId - stopFunc = client.SendMessageInterval("newt/ping/request", map[string]interface{}{ - "noCloud": noCloud, - "chainId": pingChainId, - }, 3*time.Second) - - logger.Info("Tunnel destroyed, ready for reconnection") - }) - - client.RegisterHandler("newt/wg/restart", func(msg websocket.WSMessage) { - closeWgTunnel() - closeClients() - if healthMonitor != nil { - healthMonitor.Stop() - } - client.Close() - if err := reexec(); err != nil { - logger.Error("Failed to restart: %v", err) - os.Exit(1) - } - }) - - client.RegisterHandler("newt/wg/terminate", func(msg websocket.WSMessage) { - logger.Info("Received termination message") - if wgData.PublicKey != "" { - telemetry.IncReconnect(ctx, wgData.PublicKey, "server", telemetry.ReasonServerRequest) - } - - // Close the WireGuard device and TUN - closeWgTunnel() - closeClients() - - if stopFunc != nil { - stopFunc() // stop the ws from sending more requests - stopFunc = nil // reset stopFunc to nil to avoid double stopping - } - - // Mark as disconnected - connected = false - - logger.Info("Tunnel destroyed") - }) - - client.RegisterHandler("newt/ping/exitNodes", func(msg websocket.WSMessage) { - logger.Debug("Received ping message") - - if stopFunc != nil { - stopFunc() // stop the ws from sending more requests - stopFunc = nil // reset stopFunc to nil to avoid double stopping - } - - // Parse the incoming list of exit nodes - var exitNodeData ExitNodeData - - jsonData, err := json.Marshal(msg.Data) - if err != nil { - logger.Info(fmtErrMarshaling, err) - return - } - if err := json.Unmarshal(jsonData, &exitNodeData); err != nil { - logger.Info("Error unmarshaling exit node data: %v", err) - return - } - exitNodes := exitNodeData.ExitNodes - - if exitNodeData.ChainId != "" { - if exitNodeData.ChainId != pendingPingChainId { - logger.Debug("Discarding duplicate/stale newt/ping/exitNodes (chainId=%s, expected=%s)", exitNodeData.ChainId, pendingPingChainId) - return - } - pendingPingChainId = "" // consume – further duplicates with this id are rejected - } - - if len(exitNodes) == 0 { - logger.Info("No exit nodes provided") - return - } - - // If there is just one exit node, we can skip pinging it and use it directly - if len(exitNodes) == 1 || preferEndpoint != "" { - logger.Debug("Only one exit node available, using it directly: %s", exitNodes[0].Endpoint) - - // if the preferEndpoint is set, we will use it instead of the exit node endpoint. first you need to find the exit node with that endpoint in the list and send that one - if preferEndpoint != "" { - for _, node := range exitNodes { - if node.Endpoint == preferEndpoint { - exitNodes[0] = node - break - } - } - } - - // Prepare data to send to the cloud for selection - pingResults := []ExitNodePingResult{ - { - ExitNodeID: exitNodes[0].ID, - LatencyMs: 0, // No ping latency since we are using it directly - Weight: exitNodes[0].Weight, - Error: "", - Name: exitNodes[0].Name, - Endpoint: exitNodes[0].Endpoint, - WasPreviouslyConnected: exitNodes[0].WasPreviouslyConnected, - }, - } - - chainId := generateChainId() - pendingRegisterChainId = chainId - stopFunc = client.SendMessageInterval(topicWGRegister, map[string]interface{}{ - "publicKey": publicKey.String(), - "pingResults": pingResults, - "newtVersion": newtVersion, - "chainId": chainId, - }, 2*time.Second) - - return - } - - type nodeResult struct { - Node ExitNode - Latency time.Duration - Err error - } - - results := make([]nodeResult, len(exitNodes)) - const pingAttempts = 3 - for i, node := range exitNodes { - var totalLatency time.Duration - var lastErr error - successes := 0 - client := &http.Client{ - Timeout: 5 * time.Second, - } - url := node.Endpoint - if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { - url = "http://" + url - } - if !strings.HasSuffix(url, "/ping") { - url = strings.TrimRight(url, "/") + "/ping" - } - for j := 0; j < pingAttempts; j++ { - start := time.Now() - resp, err := client.Get(url) - latency := time.Since(start) - if err != nil { - lastErr = err - logger.Warn("Failed to ping exit node %d (%s) attempt %d: %v", node.ID, url, j+1, err) - continue - } - resp.Body.Close() - totalLatency += latency - successes++ - } - var avgLatency time.Duration - if successes > 0 { - avgLatency = totalLatency / time.Duration(successes) - } - if successes == 0 { - results[i] = nodeResult{Node: node, Latency: 0, Err: lastErr} - } else { - results[i] = nodeResult{Node: node, Latency: avgLatency, Err: nil} - } - } - - // Prepare data to send to the cloud for selection - var pingResults []ExitNodePingResult - for _, res := range results { - errMsg := "" - if res.Err != nil { - errMsg = res.Err.Error() - } - pingResults = append(pingResults, ExitNodePingResult{ - ExitNodeID: res.Node.ID, - LatencyMs: res.Latency.Milliseconds(), - Weight: res.Node.Weight, - Error: errMsg, - Name: res.Node.Name, - Endpoint: res.Node.Endpoint, - WasPreviouslyConnected: res.Node.WasPreviouslyConnected, - }) - } - - // If we were previously connected and there is at least one other good node, - // exclude the previously connected node from pingResults sent to the cloud so we don't try to reconnect to it - // This is to avoid issues where the previously connected node might be down or unreachable - if connected { - var filteredPingResults []ExitNodePingResult - previouslyConnectedNodeIdx := -1 - for i, res := range pingResults { - if res.WasPreviouslyConnected { - previouslyConnectedNodeIdx = i - } - } - // Count good nodes (latency > 0, no error, not previously connected) - goodNodeCount := 0 - for i, res := range pingResults { - if i != previouslyConnectedNodeIdx && res.LatencyMs > 0 && res.Error == "" { - goodNodeCount++ - } - } - if previouslyConnectedNodeIdx != -1 && goodNodeCount > 0 { - for i, res := range pingResults { - if i != previouslyConnectedNodeIdx { - filteredPingResults = append(filteredPingResults, res) - } - } - pingResults = filteredPingResults - logger.Info("Excluding previously connected exit node from ping results due to other available nodes") - } - } - - // Send the ping results to the cloud for selection - chainId := generateChainId() - pendingRegisterChainId = chainId - stopFunc = client.SendMessageInterval(topicWGRegister, map[string]interface{}{ - "publicKey": publicKey.String(), - "pingResults": pingResults, - "newtVersion": newtVersion, - "chainId": chainId, - }, 2*time.Second) - - logger.Debug("Sent exit node ping results to cloud for selection: pingResults=%+v", pingResults) - }) - - client.RegisterHandler("newt/tcp/add", func(msg websocket.WSMessage) { - logger.Debug(fmtReceivedMsg, msg) - - // if there is no wgData or pm, we can't add targets - if wgData.TunnelIP == "" || pm == nil { - logger.Info(msgNoTunnelOrProxy) - return - } - - targetData, err := parseTargetData(msg.Data) - if err != nil { - logger.Info(fmtErrParsingTargetData, err) - return - } - - if len(targetData.Targets) > 0 { - updateTargets(pm, "add", wgData.TunnelIP, "tcp", targetData) - - // Also update wgnetstack proxy manager - // if wgService != nil && wgService.GetNetstackNet() != nil && wgService.GetProxyManager() != nil { - // updateTargets(wgService.GetProxyManager(), "add", wgData.TunnelIP, "tcp", targetData) - // } - } - }) - - client.RegisterHandler("newt/udp/add", func(msg websocket.WSMessage) { - logger.Info(fmtReceivedMsg, msg) - - // if there is no wgData or pm, we can't add targets - if wgData.TunnelIP == "" || pm == nil { - logger.Info(msgNoTunnelOrProxy) - return - } - - targetData, err := parseTargetData(msg.Data) - if err != nil { - logger.Info(fmtErrParsingTargetData, err) - return - } - - if len(targetData.Targets) > 0 { - updateTargets(pm, "add", wgData.TunnelIP, "udp", targetData) - - // Also update wgnetstack proxy manager - // if wgService != nil && wgService.GetNetstackNet() != nil && wgService.GetProxyManager() != nil { - // updateTargets(wgService.GetProxyManager(), "add", wgData.TunnelIP, "udp", targetData) - // } - } - }) - - client.RegisterHandler("newt/udp/remove", func(msg websocket.WSMessage) { - logger.Info(fmtReceivedMsg, msg) - - // if there is no wgData or pm, we can't add targets - if wgData.TunnelIP == "" || pm == nil { - logger.Info(msgNoTunnelOrProxy) - return - } - - targetData, err := parseTargetData(msg.Data) - if err != nil { - logger.Info(fmtErrParsingTargetData, err) - return - } - - if len(targetData.Targets) > 0 { - updateTargets(pm, "remove", wgData.TunnelIP, "udp", targetData) - - // Also update wgnetstack proxy manager - // if wgService != nil && wgService.GetNetstackNet() != nil && wgService.GetProxyManager() != nil { - // updateTargets(wgService.GetProxyManager(), "remove", wgData.TunnelIP, "udp", targetData) - // } - } - }) - - client.RegisterHandler("newt/tcp/remove", func(msg websocket.WSMessage) { - logger.Info(fmtReceivedMsg, msg) - - // if there is no wgData or pm, we can't add targets - if wgData.TunnelIP == "" || pm == nil { - logger.Info(msgNoTunnelOrProxy) - return - } - - targetData, err := parseTargetData(msg.Data) - if err != nil { - logger.Info(fmtErrParsingTargetData, err) - return - } - - if len(targetData.Targets) > 0 { - updateTargets(pm, "remove", wgData.TunnelIP, "tcp", targetData) - - // Also update wgnetstack proxy manager - // if wgService != nil && wgService.GetNetstackNet() != nil && wgService.GetProxyManager() != nil { - // updateTargets(wgService.GetProxyManager(), "remove", wgData.TunnelIP, "tcp", targetData) - // } - } - }) - - // Register handler for syncing targets (TCP, UDP, and health checks) - client.RegisterHandler("newt/sync", func(msg websocket.WSMessage) { - logger.Info("Received sync message") - - // if there is no wgData or pm, we can't sync targets - if wgData.TunnelIP == "" || pm == nil { - logger.Info(msgNoTunnelOrProxy) - return - } - - // Define the sync data structure - type SyncData struct { - Targets TargetsByType `json:"targets"` - HealthCheckTargets []healthcheck.Config `json:"healthCheckTargets"` - } - - var syncData SyncData - jsonData, err := json.Marshal(msg.Data) - if err != nil { - logger.Error("Error marshaling sync data: %v", err) - return - } - - if err := json.Unmarshal(jsonData, &syncData); err != nil { - logger.Error("Error unmarshaling sync data: %v", err) - return - } - - logger.Debug("Sync data received: TCP targets=%d, UDP targets=%d, health check targets=%d", - len(syncData.Targets.TCP), len(syncData.Targets.UDP), len(syncData.HealthCheckTargets)) - - //TODO: TEST AND IMPLEMENT THIS - - // // Build sets of desired targets (port -> target string) - // desiredTCP := make(map[int]string) - // for _, t := range syncData.Targets.TCP { - // parts := strings.Split(t, ":") - // if len(parts) != 3 { - // logger.Warn("Invalid TCP target format: %s", t) - // continue - // } - // port := 0 - // if _, err := fmt.Sscanf(parts[0], "%d", &port); err != nil { - // logger.Warn("Invalid port in TCP target: %s", parts[0]) - // continue - // } - // desiredTCP[port] = parts[1] + ":" + parts[2] - // } - - // desiredUDP := make(map[int]string) - // for _, t := range syncData.Targets.UDP { - // parts := strings.Split(t, ":") - // if len(parts) != 3 { - // logger.Warn("Invalid UDP target format: %s", t) - // continue - // } - // port := 0 - // if _, err := fmt.Sscanf(parts[0], "%d", &port); err != nil { - // logger.Warn("Invalid port in UDP target: %s", parts[0]) - // continue - // } - // desiredUDP[port] = parts[1] + ":" + parts[2] - // } - - // // Get current targets from proxy manager - // currentTCP, currentUDP := pm.GetTargets() - - // // Sync TCP targets - // // Remove TCP targets not in desired set - // if tcpForIP, ok := currentTCP[wgData.TunnelIP]; ok { - // for port := range tcpForIP { - // if _, exists := desiredTCP[port]; !exists { - // logger.Info("Sync: removing TCP target on port %d", port) - // targetStr := fmt.Sprintf("%d:%s", port, tcpForIP[port]) - // updateTargets(pm, "remove", wgData.TunnelIP, "tcp", TargetData{Targets: []string{targetStr}}) - // } - // } - // } - - // // Add TCP targets that are missing - // for port, target := range desiredTCP { - // needsAdd := true - // if tcpForIP, ok := currentTCP[wgData.TunnelIP]; ok { - // if currentTarget, exists := tcpForIP[port]; exists { - // // Check if target address changed - // if currentTarget == target { - // needsAdd = false - // } else { - // // Target changed, remove old one first - // logger.Info("Sync: updating TCP target on port %d", port) - // targetStr := fmt.Sprintf("%d:%s", port, currentTarget) - // updateTargets(pm, "remove", wgData.TunnelIP, "tcp", TargetData{Targets: []string{targetStr}}) - // } - // } - // } - // if needsAdd { - // logger.Info("Sync: adding TCP target on port %d -> %s", port, target) - // targetStr := fmt.Sprintf("%d:%s", port, target) - // updateTargets(pm, "add", wgData.TunnelIP, "tcp", TargetData{Targets: []string{targetStr}}) - // } - // } - - // // Sync UDP targets - // // Remove UDP targets not in desired set - // if udpForIP, ok := currentUDP[wgData.TunnelIP]; ok { - // for port := range udpForIP { - // if _, exists := desiredUDP[port]; !exists { - // logger.Info("Sync: removing UDP target on port %d", port) - // targetStr := fmt.Sprintf("%d:%s", port, udpForIP[port]) - // updateTargets(pm, "remove", wgData.TunnelIP, "udp", TargetData{Targets: []string{targetStr}}) - // } - // } - // } - - // // Add UDP targets that are missing - // for port, target := range desiredUDP { - // needsAdd := true - // if udpForIP, ok := currentUDP[wgData.TunnelIP]; ok { - // if currentTarget, exists := udpForIP[port]; exists { - // // Check if target address changed - // if currentTarget == target { - // needsAdd = false - // } else { - // // Target changed, remove old one first - // logger.Info("Sync: updating UDP target on port %d", port) - // targetStr := fmt.Sprintf("%d:%s", port, currentTarget) - // updateTargets(pm, "remove", wgData.TunnelIP, "udp", TargetData{Targets: []string{targetStr}}) - // } - // } - // } - // if needsAdd { - // logger.Info("Sync: adding UDP target on port %d -> %s", port, target) - // targetStr := fmt.Sprintf("%d:%s", port, target) - // updateTargets(pm, "add", wgData.TunnelIP, "udp", TargetData{Targets: []string{targetStr}}) - // } - // } - - // // Sync health check targets - // if err := healthMonitor.SyncTargets(syncData.HealthCheckTargets); err != nil { - // logger.Error("Failed to sync health check targets: %v", err) - // } else { - // logger.Info("Successfully synced health check targets") - // } - - logger.Info("Sync complete") - }) - - // Register handler for Docker socket check - client.RegisterHandler("newt/socket/check", func(msg websocket.WSMessage) { - logger.Debug("Received Docker socket check request") - - if dockerSocket == "" { - logger.Debug("Docker socket path is not set") - err := client.SendMessage("newt/socket/status", map[string]interface{}{ - "available": false, - "socketPath": dockerSocket, - }) - if err != nil { - logger.Error("Failed to send Docker socket check response: %v", err) - } - return - } - - // Check if Docker socket is available - isAvailable := docker.CheckSocket(dockerSocket) - - // Send response back to server - err := client.SendMessage("newt/socket/status", map[string]interface{}{ - "available": isAvailable, - "socketPath": dockerSocket, - }) - if err != nil { - logger.Error("Failed to send Docker socket check response: %v", err) - } else { - logger.Debug("Docker socket check response sent: available=%t", isAvailable) - } - }) - - // Register handler for Docker container listing - client.RegisterHandler("newt/socket/fetch", func(msg websocket.WSMessage) { - logger.Debug("Received Docker container fetch request") - - if dockerSocket == "" { - logger.Debug("Docker socket path is not set") - return - } - - // List Docker containers - containers, err := docker.ListContainers(dockerSocket, dockerEnforceNetworkValidationBool) - if err != nil { - logger.Error("Failed to list Docker containers: %v", err) - return - } - - // Send container list back to server - err = client.SendMessage("newt/socket/containers", map[string]interface{}{ - "containers": containers, - }) - if err != nil { - logger.Error("Failed to send Docker container list: %v", err) - } else { - logger.Debug("Docker container list sent, count: %d", len(containers)) - } - }) - - // Register handler for adding health check targets - client.RegisterHandler("newt/healthcheck/add", func(msg websocket.WSMessage) { - logger.Debug("Received health check add request: %+v", msg) - - type HealthCheckConfig struct { - Targets []healthcheck.Config `json:"targets"` - } - - var config HealthCheckConfig - // add a bunch of targets at once - jsonData, err := json.Marshal(msg.Data) - if err != nil { - logger.Error("Error marshaling health check data: %v", err) - return - } - - if err := json.Unmarshal(jsonData, &config); err != nil { - logger.Error("Error unmarshaling health check config: %v", err) - return - } - - if err := healthMonitor.AddTargets(config.Targets); err != nil { - logger.Error("Failed to add health check targets: %v", err) - } else { - logger.Debug("Added %d health check targets", len(config.Targets)) - } - - logger.Debug("Health check targets added: %+v", config.Targets) - }) - - // Register handler for removing health check targets - client.RegisterHandler("newt/healthcheck/remove", func(msg websocket.WSMessage) { - logger.Debug("Received health check remove request: %+v", msg) - - type HealthCheckConfig struct { - IDs []int `json:"ids"` - } - - var requestData HealthCheckConfig - jsonData, err := json.Marshal(msg.Data) - if err != nil { - logger.Error("Error marshaling health check remove data: %v", err) - return - } - - if err := json.Unmarshal(jsonData, &requestData); err != nil { - logger.Error("Error unmarshaling health check remove request: %v", err) - return - } - - // Multiple target removal - if err := healthMonitor.RemoveTargets(requestData.IDs); err != nil { - logger.Error("Failed to remove health check targets %v: %v", requestData.IDs, err) - } else { - logger.Info("Removed %d health check targets: %v", len(requestData.IDs), requestData.IDs) - } - }) - - // Register handler for enabling health check targets - client.RegisterHandler("newt/healthcheck/enable", func(msg websocket.WSMessage) { - logger.Debug("Received health check enable request: %+v", msg) - - var requestData struct { - ID int `json:"id"` - } - jsonData, err := json.Marshal(msg.Data) - if err != nil { - logger.Error("Error marshaling health check enable data: %v", err) - return - } - - if err := json.Unmarshal(jsonData, &requestData); err != nil { - logger.Error("Error unmarshaling health check enable request: %v", err) - return - } - - if err := healthMonitor.EnableTarget(requestData.ID); err != nil { - logger.Error("Failed to enable health check target %d: %v", requestData.ID, err) - } else { - logger.Info("Enabled health check target: %d", requestData.ID) - } - }) - - // Register handler for disabling health check targets - client.RegisterHandler("newt/healthcheck/disable", func(msg websocket.WSMessage) { - logger.Debug("Received health check disable request: %+v", msg) - - var requestData struct { - ID int `json:"id"` - } - jsonData, err := json.Marshal(msg.Data) - if err != nil { - logger.Error("Error marshaling health check disable data: %v", err) - return - } - - if err := json.Unmarshal(jsonData, &requestData); err != nil { - logger.Error("Error unmarshaling health check disable request: %v", err) - return - } - - if err := healthMonitor.DisableTarget(requestData.ID); err != nil { - logger.Error("Failed to disable health check target %d: %v", requestData.ID, err) - } else { - logger.Info("Disabled health check target: %d", requestData.ID) - } - }) - - // Register handler for getting health check status - client.RegisterHandler("newt/healthcheck/status/request", func(msg websocket.WSMessage) { - logger.Debug("Received health check status request") - - targets := healthMonitor.GetTargets() - healthStatuses := make(map[int]interface{}) - for id, target := range targets { - healthStatuses[id] = map[string]interface{}{ - "status": target.Status.String(), - "lastCheck": target.LastCheck.Format(time.RFC3339), - "checkCount": target.CheckCount, - "lastError": target.LastError, - "config": target.Config, - } - } - - err := client.SendMessage("newt/healthcheck/status", map[string]interface{}{ - "targets": healthStatuses, - }) - if err != nil { - logger.Error("Failed to send health check status response: %v", err) - } - }) - - // Register handler for getting health check status - client.RegisterHandler("newt/blueprint/results", func(msg websocket.WSMessage) { - logger.Debug("Received blueprint results message") - - var blueprintResult BlueprintResult - - jsonData, err := json.Marshal(msg.Data) - if err != nil { - logger.Info("Error marshaling data: %v", err) - return - } - if err := json.Unmarshal(jsonData, &blueprintResult); err != nil { - logger.Info("Error unmarshaling config results data: %v", err) - return - } - - if blueprintResult.Success { - logger.Debug("Blueprint applied successfully!") - } else { - logger.Warn("Blueprint application failed: %s", blueprintResult.Message) - } - }) - - // Register handler for SSH certificate issued events - client.RegisterHandler("newt/pam/connection", func(msg websocket.WSMessage) { - logger.Debug("Received SSH certificate issued message") - - // Define the structure of the incoming message - type SSHCertData struct { - MessageId int `json:"messageId"` - AgentPort int `json:"agentPort"` - AgentHost string `json:"agentHost"` - ExternalAuthDaemon bool `json:"externalAuthDaemon"` - AuthDaemonMode string `json:"authDaemonMode"` // site, remote, native - CACert string `json:"caCert"` - Username string `json:"username"` - NiceID string `json:"niceId"` - Metadata struct { - SudoMode string `json:"sudoMode"` - SudoCommands []string `json:"sudoCommands"` - Homedir bool `json:"homedir"` - Groups []string `json:"groups"` - } `json:"metadata"` - } - - var certData SSHCertData - jsonData, err := json.Marshal(msg.Data) - if err != nil { - logger.Error("Error marshaling SSH cert data: %v", err) - return - } - - // print the received data for debugging - logger.Debug("Received SSH cert data: %s", string(jsonData)) - - if err := json.Unmarshal(jsonData, &certData); err != nil { - logger.Error("Error unmarshaling SSH cert data: %v", err) - return - } - var authDaemonMode = "site" - if certData.AuthDaemonMode != "" { - authDaemonMode = certData.AuthDaemonMode - } else if certData.ExternalAuthDaemon { // this is for backward compatibility with older server versions that don't send authDaemonMode - authDaemonMode = "remote" - } - - // Use a switch statement for AuthDaemonMode - switch authDaemonMode { - case "site": - // Call ProcessConnection directly when running internally - logger.Debug("Calling internal auth daemon ProcessConnection for user %s", certData.Username) - - if authDaemonServer != nil { - authDaemonServer.ProcessConnection(authdaemon.ConnectionRequest{ - CaCert: certData.CACert, - NiceId: certData.NiceID, - Username: certData.Username, - Metadata: authdaemon.ConnectionMetadata{ - SudoMode: certData.Metadata.SudoMode, - SudoCommands: certData.Metadata.SudoCommands, - Homedir: certData.Metadata.Homedir, - Groups: certData.Metadata.Groups, - }, - }) - // Send success response back to cloud - err = client.SendMessage("ws/round-trip/complete", map[string]interface{}{ - "messageId": certData.MessageId, - "complete": true, - }) - } else { - logger.Error("Auth daemon server is not initialized, cannot process connection") - // Send failure response back to cloud - err = client.SendMessage("ws/round-trip/complete", map[string]interface{}{ - "messageId": certData.MessageId, - "complete": true, - "error": "auth daemon server not initialized (enable by running Newt site connector as root)", - }) - if err != nil { - logger.Error("Failed to send SSH cert failure response: %v", err) - } - return - } - - logger.Info("Successfully processed connection via internal auth daemon for user %s", certData.Username) - case "remote": - // External auth daemon mode - make HTTP request - // Check if auth daemon key is configured - if authDaemonKey == "" { - logger.Error("Auth daemon key not configured, cannot communicate with daemon") - // Send failure response back to cloud - err := client.SendMessage("ws/round-trip/complete", map[string]interface{}{ - "messageId": certData.MessageId, - "complete": true, - "error": "auth daemon key not configured", - }) - if err != nil { - logger.Error("Failed to send SSH cert failure response: %v", err) - } - return - } - - // Prepare the request body for the auth daemon - requestBody := map[string]interface{}{ - "caCert": certData.CACert, - "niceId": certData.NiceID, - "username": certData.Username, - "metadata": map[string]interface{}{ - "sudoMode": certData.Metadata.SudoMode, - "sudoCommands": certData.Metadata.SudoCommands, - "homedir": certData.Metadata.Homedir, - "groups": certData.Metadata.Groups, - }, - } - - requestJSON, err := json.Marshal(requestBody) - if err != nil { - logger.Error("Failed to marshal auth daemon request: %v", err) - // Send failure response - client.SendMessage("ws/round-trip/complete", map[string]interface{}{ - "messageId": certData.MessageId, - "complete": true, - "error": fmt.Sprintf("failed to marshal request: %v", err), - }) - return - } - - // Create HTTPS client that skips certificate verification - // (auth daemon uses self-signed cert) - httpClient := &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, - }, - }, - Timeout: 10 * time.Second, - } - - // Make the request to the auth daemon - url := fmt.Sprintf("https://%s:%d/connection", certData.AgentHost, certData.AgentPort) - req, err := http.NewRequest("POST", url, bytes.NewBuffer(requestJSON)) - if err != nil { - logger.Error("Failed to create auth daemon request: %v", err) - client.SendMessage("ws/round-trip/complete", map[string]interface{}{ - "messageId": certData.MessageId, - "complete": true, - "error": fmt.Sprintf("failed to create request: %v", err), - }) - return - } - - // Set headers - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+authDaemonKey) - - logger.Debug("Sending SSH cert to auth daemon at %s", url) - - // Send the request - resp, err := httpClient.Do(req) - if err != nil { - logger.Error("Failed to connect to auth daemon: %v", err) - client.SendMessage("ws/round-trip/complete", map[string]interface{}{ - "messageId": certData.MessageId, - "complete": true, - "error": fmt.Sprintf("failed to connect to auth daemon: %v", err), - }) - return - } - defer resp.Body.Close() - - // Check response status - if resp.StatusCode != http.StatusOK { - logger.Error("Auth daemon returned non-OK status: %d", resp.StatusCode) - client.SendMessage("ws/round-trip/complete", map[string]interface{}{ - "messageId": certData.MessageId, - "complete": true, - "error": fmt.Sprintf("auth daemon returned status %d", resp.StatusCode), - }) - return - } - - logger.Info("Successfully registered SSH certificate with external auth daemon for user %s", certData.Username) - case "native": - logger.Debug("Processing SSH cert for native SSH server for user %s", certData.Username) - if authDaemonServer != nil && sshCredStore != nil { - authDaemonServer.ProcessConnection(authdaemon.ConnectionRequest{ - CaCert: "", // dont write the cert to the host - NiceId: "", // dont write the cert to the host - Username: certData.Username, - Metadata: authdaemon.ConnectionMetadata{ // but push the user - SudoMode: certData.Metadata.SudoMode, - SudoCommands: certData.Metadata.SudoCommands, - Homedir: certData.Metadata.Homedir, - Groups: certData.Metadata.Groups, - }, - }) - - // Update in-memory credentials used by the native SSH server. - if err := sshCredStore.SetCAKey(certData.CACert); err != nil { - logger.Error("nativessh: failed to set CA key: %v", err) - } - sshCredStore.AddPrincipals(certData.Username, certData.NiceID) - logger.Info("nativessh: updated credentials for user %s (niceId=%s)", certData.Username, certData.NiceID) - } else { - logger.Error("Auth daemon server or SSH credential store not initialized, cannot process connection") - // Send failure response back to cloud - err = client.SendMessage("ws/round-trip/complete", map[string]interface{}{ - "messageId": certData.MessageId, - "complete": true, - "error": "auth daemon server or SSH credential store not initialized", - }) - if err != nil { - logger.Error("Failed to send SSH cert failure response: %v", err) - } - return - } - default: - logger.Error("Unknown auth daemon mode: %s", certData.AuthDaemonMode) - client.SendMessage("ws/round-trip/complete", map[string]interface{}{ - "messageId": certData.MessageId, - "complete": true, - "error": fmt.Sprintf("unknown auth daemon mode: %s", certData.AuthDaemonMode), - }) - return - } - - // Send success response back to cloud - err = client.SendMessage("ws/round-trip/complete", map[string]interface{}{ - "messageId": certData.MessageId, - "complete": true, - }) - if err != nil { - logger.Error("Failed to send SSH cert success response: %v", err) - } - }) - - // Register handler for adding browser gateway targets dynamically - client.RegisterHandler("newt/browsergateway/add", func(msg websocket.WSMessage) { - logger.Debug("Received browser gateway add message") - - type BrowserGatewayAddData struct { - Targets []BrowserGatewayTarget `json:"targets"` - } - - var addData BrowserGatewayAddData - jsonData, err := json.Marshal(msg.Data) - if err != nil { - logger.Error("Error marshaling browser gateway add data: %v", err) - return - } - if err := json.Unmarshal(jsonData, &addData); err != nil { - logger.Error("Error unmarshaling browser gateway add data: %v", err) - return - } - - if len(addData.Targets) == 0 { - return - } - - // If the gateway doesn't exist yet but we have a tunnel, start it - if browserGateway == nil && tnet != nil { - browserGateway = browsergateway.New(browsergateway.Config{SSHCredentials: sshCredStore}) - ln, bgErr := tnet.ListenTCP(&net.TCPAddr{Port: browsergateway.ListenPort}) - if bgErr != nil { - logger.Error("Failed to start browser gateway listener: %v", bgErr) - browserGateway = nil - } else { - browserGatewayStop = func() { _ = ln.Close() } - go func() { - logger.Debug("Browser gateway started on port %d", browsergateway.ListenPort) - if startErr := browserGateway.Start(ln); startErr != nil { - logger.Error("Browser gateway stopped with error: %v", startErr) - } - }() - } - } - - if browserGateway == nil { - logger.Warn("Browser gateway not available, cannot add targets") - return - } - - for _, t := range addData.Targets { - browserGateway.AddTarget(browsergateway.Target{ - ID: t.ID, - Type: t.Type, - Destination: t.Destination, - DestinationPort: t.DestinationPort, - AuthToken: t.AuthToken, - }) - logger.Debug("Added browser gateway target %d", t.ID) - } - }) - - // Register handler for removing browser gateway targets dynamically - client.RegisterHandler("newt/browsergateway/remove", func(msg websocket.WSMessage) { - logger.Debug("Received browser gateway remove message") - - type BrowserGatewayRemoveData struct { - IDs []int `json:"ids"` - } - - var removeData BrowserGatewayRemoveData - jsonData, err := json.Marshal(msg.Data) - if err != nil { - logger.Error("Error marshaling browser gateway remove data: %v", err) - return - } - if err := json.Unmarshal(jsonData, &removeData); err != nil { - logger.Error("Error unmarshaling browser gateway remove data: %v", err) - return - } - - if browserGateway == nil { - logger.Warn("Browser gateway not available, cannot remove targets") - return - } - - for _, id := range removeData.IDs { - browserGateway.RemoveTarget(id) - logger.Debug("Removed browser gateway target %d", id) - } - }) - - client.OnConnect(func() error { - publicKey = privateKey.PublicKey() - logger.Debug("Public key: %s", publicKey) - logger.Info("Websocket connected") - - if !connected { - // make sure the stop function is called - if stopFunc != nil { - stopFunc() - } - // request from the server the list of nodes to ping - pingChainId := generateChainId() - pendingPingChainId = pingChainId - stopFunc = client.SendMessageInterval("newt/ping/request", map[string]interface{}{ - "noCloud": noCloud, - "chainId": pingChainId, - }, 3*time.Second) - logger.Debug("Requesting exit nodes from server") - - if client.GetServerVersion() != "" { // to prevent issues with running newt > 1.7 versions with older servers - clientsOnConnect() - } else { - logger.Warn("CLIENTS WILL NOT WORK ON THIS VERSION OF NEWT WITH THIS VERSION OF PANGOLIN, PLEASE UPDATE THE SERVER TO 1.13 OR HIGHER OR DOWNGRADE NEWT") - } - - sendBlueprint(client, blueprintFile) - if client.WasJustProvisioned() { - logger.Info("Provisioning detected – sending provisioning blueprint") - sendBlueprint(client, provisioningBlueprintFile) - } - } else { - // Resend current health check status for all targets in case the server - // missed updates while newt was disconnected. - targets := healthMonitor.GetTargets() - if len(targets) > 0 { - healthStatuses := make(map[int]interface{}) - for id, target := range targets { - healthStatuses[id] = map[string]interface{}{ - "status": target.Status.String(), - "lastCheck": target.LastCheck.Format(time.RFC3339), - "checkCount": target.CheckCount, - "lastError": target.LastError, - "config": target.Config, - } - } - logger.Debug("Reconnected: resending health check status for %d targets", len(healthStatuses)) - if err := client.SendMessage("newt/healthcheck/status", map[string]interface{}{ - "targets": healthStatuses, - }); err != nil { - logger.Error("Failed to resend health check status on reconnect: %v", err) - } - } - } - - // Send registration message to the server for backward compatibility - bcChainId := generateChainId() - pendingRegisterChainId = bcChainId - err := client.SendMessage(topicWGRegister, map[string]interface{}{ - "publicKey": publicKey.String(), - "newtVersion": newtVersion, - "backwardsCompatible": true, - "chainId": bcChainId, - }) - - if err != nil { - logger.Error("Failed to send registration message: %v", err) - return err - } - - return nil - }) - - // Handle SIGHUP for config reload - sighupChan := make(chan os.Signal, 1) - signal.Notify(sighupChan, syscall.SIGHUP) - go func() { - defer signal.Stop(sighupChan) - for { - select { - case <-sighupChan: - logger.Info("SIGHUP received, reloading config...") - cfgPath := client.GetConfigFilePath() - data, err := os.ReadFile(cfgPath) - if err != nil { - logger.Error("Failed to read config file on SIGHUP: %v", err) - continue - } - var newCfg websocket.Config - if err := json.Unmarshal(data, &newCfg); err != nil { - logger.Error("Failed to parse config file on SIGHUP: %v", err) - continue - } - oldCfg := client.GetConfig() - // If credentials changed, clean up and re-exec ourselves with the same args - if newCfg.Endpoint != oldCfg.Endpoint || newCfg.ID != oldCfg.ID || newCfg.Secret != oldCfg.Secret { - logger.Info("Config credentials changed (endpoint/id/secret), restarting...") - closeWgTunnel() - closeClients() - if healthMonitor != nil { - healthMonitor.Stop() - } - client.Close() - if err := reexec(); err != nil { - logger.Error("Failed to restart: %v", err) - os.Exit(1) - } - } - // If blocked state changed, apply in-place without restart - if newCfg.Blocked != connectionBlocked.Load() { - connectionBlocked.Store(newCfg.Blocked) - if newCfg.Blocked { - logger.Debug("Config reload: connection blocking enabled") - } else { - logger.Debug("Config reload: connection blocking disabled") - } - if p := currentPM.Load(); p != nil { - p.SetBlocked(newCfg.Blocked) - } - setClientsBlocked(newCfg.Blocked) - } else { - logger.Debug("Config reload: no relevant changes detected") - } - case <-ctx.Done(): - return - } - } - }() - - // Connect to the WebSocket server - if err := client.Connect(); err != nil { - logger.Fatal("Failed to connect to server: %v", err) - } - defer client.Close() - - // Initialize Docker event monitoring if Docker socket is available and monitoring is enabled - if dockerSocket != "" { - logger.Debug("Initializing Docker event monitoring") - dockerEventMonitor, err = docker.NewEventMonitor(dockerSocket, dockerEnforceNetworkValidationBool, func(containers []docker.Container) { - // Send updated container list via websocket when Docker events occur - logger.Debug("Docker event detected, sending updated container list (%d containers)", len(containers)) - err := client.SendMessage("newt/socket/containers", map[string]interface{}{ - "containers": containers, - }) - if err != nil { - logger.Error("Failed to send updated container list after Docker event: %v", err) - } else { - logger.Debug("Updated container list sent successfully") - } - }) - - if err != nil { - logger.Error("Failed to create Docker event monitor: %v", err) - } else { - err = dockerEventMonitor.Start() - if err != nil { - logger.Error("Failed to start Docker event monitoring: %v", err) - } else { - logger.Debug("Docker event monitoring started successfully") - } - } - } - - if blueprintFile != "" { - go watchBlueprintFile(ctx, blueprintFile, func() error { - return sendBlueprint(client, blueprintFile) - }) - } - - // Wait for context cancellation (from signal or service stop) - <-ctx.Done() - - // Close clients first (including WGTester) - closeClients() - - if dockerEventMonitor != nil { - dockerEventMonitor.Stop() - } - - if healthMonitor != nil { - healthMonitor.Stop() - } - - if dev != nil { - dev.Close() - } - - if pm != nil { - pm.Stop() - } - - client.SendMessage("newt/disconnecting", map[string]any{}) - - if client != nil { - client.Close() - } - logger.Info("Exiting...") + n.Start(ctx) } -// runNewtMainWithArgs is used by the Windows service to run newt with specific arguments -// It sets os.Args and then calls runNewtMain +// runNewtMainWithArgs is used by the Windows service runner. func runNewtMainWithArgs(ctx context.Context, args []string) { - // Set os.Args to include the program name plus the provided args - // This allows flag parsing to work correctly os.Args = append([]string{os.Args[0]}, args...) - - // Setup Windows logging if running as a service setupWindowsEventLog() - - // Run the main newt logic runNewtMain(ctx) } - -// validateTLSConfig validates the TLS configuration -func validateTLSConfig() error { - // Check for conflicting configurations - pkcs12Specified := tlsPrivateKey != "" - separateFilesSpecified := tlsClientCert != "" || tlsClientKey != "" || len(tlsClientCAs) > 0 - - if pkcs12Specified && separateFilesSpecified { - return fmt.Errorf("cannot use both PKCS12 format (--tls-client-cert) and separate certificate files (--tls-client-cert-file, --tls-client-key, --tls-client-ca)") - } - - // If using separate files, both cert and key are required - if (tlsClientCert != "" && tlsClientKey == "") || (tlsClientCert == "" && tlsClientKey != "") { - return fmt.Errorf("both --tls-client-cert-file and --tls-client-key must be specified together") - } - - // Validate certificate files exist - if tlsClientCert != "" { - if _, err := os.Stat(tlsClientCert); os.IsNotExist(err) { - return fmt.Errorf("client certificate file does not exist: %s", tlsClientCert) - } - } - - if tlsClientKey != "" { - if _, err := os.Stat(tlsClientKey); os.IsNotExist(err) { - return fmt.Errorf("client key file does not exist: %s", tlsClientKey) - } - } - - // Validate CA files exist - for _, caFile := range tlsClientCAs { - if _, err := os.Stat(caFile); os.IsNotExist(err) { - return fmt.Errorf("CA certificate file does not exist: %s", caFile) - } - } - - // Validate PKCS12 file exists if specified - if tlsPrivateKey != "" { - if _, err := os.Stat(tlsPrivateKey); os.IsNotExist(err) { - return fmt.Errorf("PKCS12 certificate file does not exist: %s", tlsPrivateKey) - } - } - - return nil -} diff --git a/netstack2/handlers.go b/netstack2/handlers.go index e6ea62e..8426685 100644 --- a/netstack2/handlers.go +++ b/netstack2/handlers.go @@ -166,6 +166,13 @@ func (h *TCPHandler) handleTCPConn(netstackConn *gonet.TCPConn, id stack.Transpo defer netstackConn.Close() + // Release this connection's NAT state once it fully closes, so a rule + // change (e.g. RewriteTo) takes effect for the next connection on this + // tuple instead of being masked by a stale cached resolution forever. + if h.proxyHandler != nil { + defer h.proxyHandler.releaseConnectionState(srcIP, srcPort, dstIP, dstPort, uint8(tcp.ProtocolNumber)) + } + logger.Info("TCP Forwarder: Handling connection %s:%d -> %s:%d", srcIP, srcPort, dstIP, dstPort) // Check if there's a destination rewrite for this connection (e.g., localhost targets) @@ -315,6 +322,13 @@ func (h *UDPHandler) handleUDPConn(netstackConn *gonet.UDPConn, id stack.Transpo dstIP := id.LocalAddress.String() dstPort := id.LocalPort + // Release this session's NAT state once it fully closes (session end or + // idle timeout), so a rule change takes effect for the next session on + // this tuple instead of being masked by a stale cached resolution. + if h.proxyHandler != nil { + defer h.proxyHandler.releaseConnectionState(srcIP, srcPort, dstIP, dstPort, uint8(udp.ProtocolNumber)) + } + logger.Info("UDP Forwarder: Handling connection %s:%d -> %s:%d", srcIP, srcPort, dstIP, dstPort) // Drop connection if blocking is enabled diff --git a/netstack2/proxy.go b/netstack2/proxy.go index 00d6763..be5f23f 100644 --- a/netstack2/proxy.go +++ b/netstack2/proxy.go @@ -272,6 +272,18 @@ func (p *ProxyHandler) RemoveSubnetRule(sourcePrefix, destPrefix netip.Prefix) { p.subnetLookup.RemoveSubnet(sourcePrefix, destPrefix) } +// ReplaceAllSubnetRules atomically replaces the full set of subnet rules. +// Intended for full-state syncs where the desired rule set is authoritative, +// so any stale rule is guaranteed to be cleared even if its key +// (SourcePrefix, DestPrefix) matches a still-desired rule but its contents +// (e.g. RewriteTo) have changed. +func (p *ProxyHandler) ReplaceAllSubnetRules(rules []SubnetRule) { + if p == nil || !p.enabled { + return + } + p.subnetLookup.ReplaceAll(rules) +} + // GetAllRules returns all subnet rules from the proxy handler func (p *ProxyHandler) GetAllRules() []SubnetRule { if p == nil || !p.enabled { @@ -335,6 +347,51 @@ func (p *ProxyHandler) SetHTTPRequestLogSender(fn SendFunc) { p.httpRequestLogger.SetSendFunc(fn) } +// releaseConnectionState removes the per-connection NAT state for a single +// (srcIP, srcPort, dstIP, dstPort, proto) tuple. Callers must only invoke +// this once that exact connection has fully closed (both directions torn +// down), since a new connection can never be accepted on the same 5-tuple +// before then. +// +// This intentionally does NOT touch destRewriteTable/resourceTable: those +// are keyed without srcPort (destKey), so they are shared across every +// concurrent connection from the same source to the same destination +// service. They don't need connection-scoped cleanup - each new connection's +// first packet already refreshes them via HandleIncomingPacket - and +// deleting them here on a single connection's close could break other +// connections still in flight to the same destination. +func (p *ProxyHandler) releaseConnectionState(srcIP string, srcPort uint16, dstIP string, dstPort uint16, proto uint8) { + if p == nil || !p.enabled { + return + } + + key := connKey{ + srcIP: srcIP, + srcPort: srcPort, + dstIP: dstIP, + dstPort: dstPort, + proto: proto, + } + + p.natMu.Lock() + defer p.natMu.Unlock() + + entry, ok := p.natTable[key] + if !ok { + return + } + delete(p.natTable, key) + + reverseKey := reverseConnKey{ + rewrittenTo: entry.rewrittenTo.String(), + originalSrcIP: srcIP, + originalSrcPort: srcPort, + originalDstPort: dstPort, + proto: proto, + } + delete(p.reverseNatTable, reverseKey) +} + // LookupDestinationRewrite looks up the rewritten destination for a connection // This is used by TCP/UDP handlers to find the actual target address func (p *ProxyHandler) LookupDestinationRewrite(srcIP, dstIP string, dstPort uint16, proto uint8) (netip.Addr, bool) { diff --git a/netstack2/subnet_lookup.go b/netstack2/subnet_lookup.go index 757908a..c07162c 100644 --- a/netstack2/subnet_lookup.go +++ b/netstack2/subnet_lookup.go @@ -52,6 +52,13 @@ func (sl *SubnetLookup) AddSubnet(rule SubnetRule) { sl.mu.Lock() defer sl.mu.Unlock() + sl.addSubnetLocked(rule) +} + +// addSubnetLocked is the lock-free body of AddSubnet, factored out so +// ReplaceAll can insert many rules under a single lock acquisition. +// Callers must hold sl.mu for writing. +func (sl *SubnetLookup) addSubnetLocked(rule SubnetRule) { rulePtr := &rule // Canonicalize source prefix to handle host bits correctly @@ -89,6 +96,21 @@ func (sl *SubnetLookup) AddSubnet(rule SubnetRule) { destTriePtr.rules = newRules } +// ReplaceAll atomically replaces the entire rule set with the given rules. +// This guarantees no stale rule can survive a sync, even when a rule's key +// (SourcePrefix, DestPrefix) is unchanged but other fields (e.g. RewriteTo) +// differ - a case that an add/remove diff keyed only on prefixes would miss. +func (sl *SubnetLookup) ReplaceAll(rules []SubnetRule) { + sl.mu.Lock() + defer sl.mu.Unlock() + + sl.sourceTrie = &bart.Table[*destTrie]{} + + for _, rule := range rules { + sl.addSubnetLocked(rule) + } +} + // RemoveSubnet removes a subnet rule from the lookup table func (sl *SubnetLookup) RemoveSubnet(sourcePrefix, destPrefix netip.Prefix) { sl.mu.Lock() diff --git a/netstack2/tun.go b/netstack2/tun.go index d104f2e..49f74d4 100644 --- a/netstack2/tun.go +++ b/netstack2/tun.go @@ -364,6 +364,15 @@ func (net *Net) RemoveProxySubnetRule(sourcePrefix, destPrefix netip.Prefix) { } } +// ReplaceProxySubnetRules atomically replaces the full set of subnet rules +// on the proxy handler with the given rules. +func (net *Net) ReplaceProxySubnetRules(rules []SubnetRule) { + tun := (*netTun)(net) + if tun.proxyHandler != nil { + tun.proxyHandler.ReplaceAllSubnetRules(rules) + } +} + // GetProxySubnetRules returns all subnet rules from the proxy handler func (net *Net) GetProxySubnetRules() []SubnetRule { tun := (*netTun)(net) diff --git a/newt/authdaemon.go b/newt/authdaemon.go new file mode 100644 index 0000000..83fc5c4 --- /dev/null +++ b/newt/authdaemon.go @@ -0,0 +1,60 @@ +package newt + +import ( + "context" + "fmt" + "os" + "runtime" + + "github.com/fosrl/newt/authdaemon" + "github.com/fosrl/newt/logger" +) + +const ( + defaultPrincipalsPath = "/var/run/auth-daemon/principals" + defaultCACertPath = "/etc/ssh/ca.pem" +) + +func (n *Newt) startAuthDaemon(ctx context.Context) error { + if runtime.GOOS != "linux" { + return fmt.Errorf("auth-daemon is only supported on Linux, not %s", runtime.GOOS) + } + if os.Geteuid() != 0 { + return fmt.Errorf("auth-daemon must be run as root (use sudo)") + } + + principalsFile := n.config.AuthDaemonPrincipalsFile + if principalsFile == "" { + principalsFile = defaultPrincipalsPath + } + caCertPath := n.config.AuthDaemonCACertPath + if caCertPath == "" { + caCertPath = defaultCACertPath + } + + cfg := authdaemon.Config{ + DisableHTTPS: true, + PresharedKey: "this-key-is-not-used", + PrincipalsFilePath: principalsFile, + CACertPath: caCertPath, + Force: true, + GenerateRandomPassword: n.config.AuthDaemonGenerateRandomPassword, + } + + srv, err := authdaemon.NewServer(cfg) + if err != nil { + return fmt.Errorf("create auth daemon server: %w", err) + } + + n.authDaemonServer = srv + + go func() { + logger.Debug("Auth daemon starting (native mode, no HTTP server)") + if err := srv.Run(ctx); err != nil { + logger.Error("Auth daemon error: %v", err) + } + logger.Info("Auth daemon stopped") + }() + + return nil +} diff --git a/newt/blueprint.go b/newt/blueprint.go new file mode 100644 index 0000000..39109af --- /dev/null +++ b/newt/blueprint.go @@ -0,0 +1,127 @@ +package newt + +import ( + "context" + "encoding/json" + "os" + "regexp" + "strings" + "time" + + "github.com/fosrl/newt/logger" + "github.com/fosrl/newt/websocket" + "github.com/fsnotify/fsnotify" + "gopkg.in/yaml.v3" +) + +// interpolateBlueprint replaces {{env.VAR}} tokens with their environment variable values. +func interpolateBlueprint(data []byte) []byte { + re := regexp.MustCompile(`\{\{([^}]+)\}\}`) + return re.ReplaceAllFunc(data, func(match []byte) []byte { + inner := strings.TrimSpace(string(match[2 : len(match)-2])) + + if strings.HasPrefix(inner, "env.") { + varName := strings.TrimPrefix(inner, "env.") + return []byte(os.Getenv(varName)) + } + + return match + }) +} + +func sendBlueprint(client *websocket.Client, file string) error { + if file == "" { + return nil + } + blueprintData, err := os.ReadFile(file) + if err != nil { + logger.Error("Failed to read blueprint file: %v", err) + return nil + } + + blueprintData = interpolateBlueprint(blueprintData) + + var yamlObj interface{} + if err := yaml.Unmarshal(blueprintData, &yamlObj); err != nil { + logger.Error("Failed to parse blueprint YAML: %v", err) + return nil + } + + jsonBytes, err := json.Marshal(yamlObj) + if err != nil { + logger.Error("Failed to convert blueprint to JSON: %v", err) + return nil + } + + blueprintJsonData := string(jsonBytes) + logger.Debug("Converted blueprint to JSON: %s", blueprintJsonData) + + if blueprintJsonData == "" { + logger.Error("No valid blueprint JSON data to send to server") + return nil + } + + logger.Info("Sending blueprint to server for application") + + return client.SendMessage("newt/blueprint/apply", map[string]interface{}{ + "blueprint": blueprintJsonData, + }) +} + +func watchBlueprintFile(ctx context.Context, filePath string, send func() error) { + watcher, err := fsnotify.NewWatcher() + if err != nil { + logger.Error("blueprint watcher: failed to create: %v", err) + return + } + defer watcher.Close() + + if err := watcher.Add(filePath); err != nil { + logger.Error("blueprint watcher: failed to watch %s: %v", filePath, err) + return + } + + logger.Info("Watching blueprint file for changes: %s", filePath) + + var debounce *time.Timer + scheduleSend := func() { + if debounce != nil { + debounce.Stop() + } + debounce = time.AfterFunc(500*time.Millisecond, func() { + logger.Info("Blueprint file changed, resending...") + if err := send(); err != nil { + logger.Error("blueprint watcher: resend failed: %v", err) + } + }) + } + + for { + select { + case <-ctx.Done(): + if debounce != nil { + debounce.Stop() + } + return + case event, ok := <-watcher.Events: + if !ok { + return + } + switch { + case event.Has(fsnotify.Write) || event.Has(fsnotify.Create): + if event.Has(fsnotify.Create) { + _ = watcher.Add(filePath) + } + scheduleSend() + case event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename): + _ = watcher.Add(filePath) + scheduleSend() + } + case err, ok := <-watcher.Errors: + if !ok { + return + } + logger.Error("blueprint watcher: %v", err) + } + } +} diff --git a/newt/clients.go b/newt/clients.go new file mode 100644 index 0000000..73b9a03 --- /dev/null +++ b/newt/clients.go @@ -0,0 +1,114 @@ +package newt + +import ( + "strings" + + wgnetstack "github.com/fosrl/newt/clients" + "github.com/fosrl/newt/clients/permissions" + "github.com/fosrl/newt/logger" + "golang.zx2c4.com/wireguard/tun/netstack" +) + +func checkNativeMainPermissions() error { + return permissions.CheckNativeInterfacePermissions() +} + +func (n *Newt) setupClients() { + host := n.config.Endpoint + if strings.HasPrefix(host, "http://") { + host = strings.TrimPrefix(host, "http://") + } else if strings.HasPrefix(host, "https://") { + host = strings.TrimPrefix(host, "https://") + } + host = strings.TrimSuffix(host, "/") + + logger.Debug("Setting up clients with netstack2...") + + if n.config.UseNativeInterface { + logger.Debug("Checking permissions for native interface") + if err := permissions.CheckNativeInterfacePermissions(); err != nil { + logger.Fatal("Insufficient permissions to create native TUN interface: %v", err) + return + } + } + + var err error + n.wgService, err = wgnetstack.NewWireGuardService( + n.config.InterfaceName, + n.config.Port, + n.config.MTU, + host, + n.config.ID, + n.client, + n.config.DNS, + n.config.UseNativeInterface, + ) + if err != nil { + logger.Fatal("Failed to create WireGuard service: %v", err) + } + + n.wgService.SetCredentialStore(n.sshCredStore) + + n.client.OnTokenUpdate(func(token string) { + n.wgService.SetToken(token) + }) + + n.ready = true +} + +func (n *Newt) setDownstreamTNetstack(tnet *netstack.Net) { + if n.wgService != nil { + n.wgService.SetOthertnet(tnet) + } +} + +func (n *Newt) closeClients() { + logger.Info("Closing clients...") + if n.wgService != nil { + n.wgService.Close() + n.wgService = nil + } +} + +func (n *Newt) setClientsBlocked(v bool) { + if n.wgService != nil { + n.wgService.SetBlocked(v) + } +} + +func (n *Newt) clientsHandleNewtConnection(publicKey string, endpoint string, relayPort uint16) { + if !n.ready { + return + } + + parts := strings.Split(endpoint, ":") + if len(parts) < 2 { + logger.Error("Invalid endpoint format: %s", endpoint) + return + } + endpoint = strings.Join(parts[:len(parts)-1], ":") + + if n.wgService != nil { + n.wgService.StartHolepunch(publicKey, endpoint, relayPort) + } +} + +func (n *Newt) clientsOnConnect() { + if !n.ready { + return + } + if n.wgService != nil { + n.wgService.LoadRemoteConfig() + } +} + +func (n *Newt) clientsStartDirectRelay(tunnelIP string) { + if !n.ready { + return + } + if n.wgService != nil { + if err := n.wgService.StartDirectUDPRelay(tunnelIP); err != nil { + logger.Error("Failed to start direct UDP relay: %v", err) + } + } +} diff --git a/newt/config.go b/newt/config.go new file mode 100644 index 0000000..89d2001 --- /dev/null +++ b/newt/config.go @@ -0,0 +1,73 @@ +package newt + +import "time" + +// Config holds all runtime configuration for a Newt instance. +type Config struct { + // Build info + Version string + Platform string + + // Logging + LogLevel string + + // Connection + Endpoint string + ID string + Secret string + ProvisioningKey string + NewtName string + ConfigFile string + + // Network + MTU int + DNS string + InterfaceName string + Port uint16 + UseNativeInterface bool + UseNativeMainInterface bool + NativeMainInterfaceName string + NoCloud bool + PreferEndpoint string + + // Timing + PingInterval time.Duration + PingTimeout time.Duration + UDPProxyIdleTimeout time.Duration + + // Features + DisableClients bool + DisableSSH bool + EnforceHealthcheckCert bool + HealthFile string + BlueprintFile string + ProvisioningBlueprintFile string + UpdownScript string + + // Docker + DockerSocket string + DockerEnforceNetworkValidation bool + + // Auth daemon + AuthDaemonKey string + AuthDaemonPrincipalsFile string + AuthDaemonCACertPath string + AuthDaemonGenerateRandomPassword bool + + // TLS (mTLS) + TLSClientCert string + TLSClientKey string + TLSClientCAs []string + TLSPrivateKey string + + // Metrics/observability + MetricsEnabled bool + OTLPEnabled bool + AdminAddr string + Region string + MetricsAsyncBytes bool + PprofEnabled bool + + // Callbacks + OnRestart func() error +} diff --git a/newt/connect.go b/newt/connect.go new file mode 100644 index 0000000..1413abd --- /dev/null +++ b/newt/connect.go @@ -0,0 +1,351 @@ +package newt + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/netip" + "runtime" + "time" + + "github.com/fosrl/newt/browsergateway" + newtDevice "github.com/fosrl/newt/device" + "github.com/fosrl/newt/internal/telemetry" + "github.com/fosrl/newt/logger" + "github.com/fosrl/newt/network" + "github.com/fosrl/newt/proxy" + "github.com/fosrl/newt/util" + "github.com/fosrl/newt/websocket" + "golang.zx2c4.com/wireguard/conn" + "golang.zx2c4.com/wireguard/device" + wtun "golang.zx2c4.com/wireguard/tun" + "golang.zx2c4.com/wireguard/tun/netstack" +) + +func (n *Newt) handleConnect(ctx context.Context, msg websocket.WSMessage) { + logger.Debug("Received registration message") + regResult := "success" + defer func() { + telemetry.IncSiteRegistration(ctx, regResult) + }() + + var chainData struct { + ChainId string `json:"chainId"` + } + if jsonBytes, err := json.Marshal(msg.Data); err == nil { + _ = json.Unmarshal(jsonBytes, &chainData) + } + if chainData.ChainId != "" { + if chainData.ChainId != n.pendingRegisterChainId { + logger.Debug("Discarding duplicate/stale newt/wg/connect (chainId=%s, expected=%s)", chainData.ChainId, n.pendingRegisterChainId) + return + } + n.pendingRegisterChainId = "" + } + + if n.stopFunc != nil { + n.stopFunc() + n.stopFunc = nil + } + + if n.connected { + n.closeWgTunnel() + n.connected = false + } + + logger.Debug("Received registration message data: %+v", msg.Data) + + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Info(fmtErrMarshaling, err) + regResult = "failure" + return + } + + if err := json.Unmarshal(jsonData, &n.wgData); err != nil { + logger.Info("Error unmarshaling target data: %v", err) + regResult = "failure" + return + } + + logger.Debug(fmtReceivedMsg, msg) + + if n.config.UseNativeMainInterface { + mainIfName := n.config.NativeMainInterfaceName + if runtime.GOOS == "darwin" { + mainIfName, err = network.FindUnusedUTUN() + if err != nil { + logger.Error("Failed to find unused utun for main tunnel: %v", err) + regResult = "failure" + return + } + } + n.tun, err = wtun.CreateTUN(mainIfName, n.config.MTU) + if err != nil { + logger.Error("Failed to create native main TUN device: %v", err) + regResult = "failure" + return + } + if realName, nameErr := n.tun.Name(); nameErr == nil { + mainIfName = realName + } + n.tnet = nil + n.config.NativeMainInterfaceName = mainIfName + } else { + n.tun, n.tnet, err = netstack.CreateNetTUN( + []netip.Addr{netip.MustParseAddr(n.wgData.TunnelIP)}, + []netip.Addr{netip.MustParseAddr(n.config.DNS)}, + n.config.MTU) + if err != nil { + logger.Error("Failed to create TUN device: %v", err) + regResult = "failure" + } + } + + n.setDownstreamTNetstack(n.tnet) + + n.dev = device.NewDevice(n.tun, conn.NewDefaultBind(), device.NewLogger( + util.MapToWireGuardLogLevel(n.loggerLevel), + "gerbil-wireguard: ", + )) + + host, _, err := net.SplitHostPort(n.wgData.Endpoint) + if err != nil { + logger.Error("Failed to split endpoint: %v", err) + regResult = "failure" + return + } + + logger.Info("Connecting to endpoint: %s", host) + + resolvedEndpoint, err := util.ResolveDomain(n.wgData.Endpoint) + if err != nil { + logger.Error("Failed to resolve endpoint: %v", err) + regResult = "failure" + return + } + + relayPort := n.wgData.RelayPort + if relayPort == 0 { + relayPort = 21820 + } + + n.clientsHandleNewtConnection(n.wgData.PublicKey, resolvedEndpoint, relayPort) + + wgConfig := fmt.Sprintf(`private_key=%s +public_key=%s +allowed_ip=%s/32 +endpoint=%s +persistent_keepalive_interval=5`, util.FixKey(n.privateKey.String()), util.FixKey(n.wgData.PublicKey), n.wgData.ServerIP, resolvedEndpoint) + + if err = n.dev.IpcSet(wgConfig); err != nil { + logger.Error("Failed to configure WireGuard device: %v", err) + regResult = "failure" + } + + if err = n.dev.Up(); err != nil { + logger.Error("Failed to bring up WireGuard device: %v", err) + regResult = "failure" + } + + if n.config.UseNativeMainInterface { + if cfgErr := network.ConfigureInterface(n.config.NativeMainInterfaceName, n.wgData.TunnelIP+"/32", n.config.MTU); cfgErr != nil { + logger.Error("Failed to configure native main tunnel interface: %v", cfgErr) + } + if routeErr := network.AddRoutes([]string{n.wgData.ServerIP + "/32"}, n.config.NativeMainInterfaceName); routeErr != nil { + logger.Warn("Failed to add route for main tunnel server IP: %v", routeErr) + } + if fileUAPI, uapiErr := newtDevice.UapiOpen(n.config.NativeMainInterfaceName); uapiErr != nil { + logger.Warn("Main tunnel UAPI open error: %v", uapiErr) + } else if uapiListener, uapiListenErr := newtDevice.UapiListen(n.config.NativeMainInterfaceName, fileUAPI); uapiListenErr != nil { + logger.Warn("Main tunnel UAPI listen error: %v", uapiListenErr) + } else { + go func() { + for { + c, aErr := uapiListener.Accept() + if aErr != nil { + return + } + go n.dev.IpcHandle(c) + } + }() + logger.Debug("Main tunnel UAPI listener started on %s", n.config.NativeMainInterfaceName) + } + } + + n.activeRemoteSubnets = nil + if len(n.wgData.RemoteExitNodeSubnets) > 0 { + for _, subnet := range n.wgData.RemoteExitNodeSubnets { + subnetCfg := fmt.Sprintf("public_key=%s\nallowed_ip=%s", util.FixKey(n.wgData.PublicKey), subnet) + if err := n.dev.IpcSet(subnetCfg); err != nil { + logger.Warn("Failed to add AllowedIP %s to main tunnel: %v", subnet, err) + } + } + if n.config.UseNativeMainInterface { + if routeErr := network.AddRoutes(n.wgData.RemoteExitNodeSubnets, n.config.NativeMainInterfaceName); routeErr != nil { + logger.Warn("Failed to add routes for remote exit node subnets: %v", routeErr) + } + } + n.activeRemoteSubnets = append([]string{}, n.wgData.RemoteExitNodeSubnets...) + logger.Debug("Added %d remote exit node subnets", len(n.wgData.RemoteExitNodeSubnets)) + } + + logger.Debug("WireGuard device created. Lets ping the server now...") + + if n.pingWithRetryStopChan != nil { + close(n.pingWithRetryStopChan) + n.pingWithRetryStopChan = nil + } + + var pinger pingFunc + if n.config.UseNativeMainInterface { + pinger = pingNative + } else { + pinger = func(dst string, timeout time.Duration) (time.Duration, error) { + return ping(n.tnet, dst, timeout) + } + } + + logger.Debug("Testing initial connection with reliable ping...") + lat, err := reliablePing(pinger, n.wgData.ServerIP, n.config.PingTimeout, 5) + if err == nil && n.wgData.PublicKey != "" { + telemetry.ObserveTunnelLatency(ctx, n.wgData.PublicKey, "wireguard", lat.Seconds()) + } + if err != nil { + logger.Warn("Initial reliable ping failed, but continuing: %v", err) + regResult = "failure" + } else { + logger.Debug("Initial connection test successful") + } + + n.pingWithRetryStopChan, _ = n.pingWithRetry(pinger, n.wgData.ServerIP, n.config.PingTimeout) + + if !n.connected { + logger.Debug("Starting ping check") + n.pingStopChan = n.startPingCheck(pinger, n.wgData.ServerIP, n.wgData.PublicKey) + } + + if n.config.UseNativeMainInterface { + n.pm = proxy.NewProxyManagerNative(n.wgData.TunnelIP) + } else { + n.pm = proxy.NewProxyManager(n.tnet) + } + n.pm.SetAsyncBytes(n.config.MetricsAsyncBytes) + n.pm.SetUDPIdleTimeout(n.config.UDPProxyIdleTimeout) + n.pm.SetTunnelID(n.wgData.PublicKey) + n.pm.SetBlocked(n.connectionBlocked.Load()) + n.currentPM.Store(n.pm) + + n.connected = true + + if len(n.wgData.Targets.TCP) > 0 { + n.updateTargets(n.pm, "add", n.wgData.TunnelIP, "tcp", TargetData{Targets: n.wgData.Targets.TCP}) + } + if len(n.wgData.Targets.UDP) > 0 { + n.updateTargets(n.pm, "add", n.wgData.TunnelIP, "udp", TargetData{Targets: n.wgData.Targets.UDP}) + } + + if !n.config.UseNativeMainInterface { + n.clientsStartDirectRelay(n.wgData.TunnelIP) + } + + if err := n.healthMonitor.AddTargets(n.wgData.HealthCheckTargets); err != nil { + logger.Error("Failed to bulk add health check targets: %v", err) + } else { + logger.Debug("Successfully added %d health check targets", len(n.wgData.HealthCheckTargets)) + } + + if err = n.pm.Start(); err != nil { + logger.Error("Failed to start proxy manager: %v", err) + } + + if len(n.wgData.BrowserGatewayTargets) > 0 { + // The netstack is fresh on (re)connect, so any previously running + // gateway listener is bound to a now-defunct interface - tear it down. + if n.browserGatewayStop != nil { + n.browserGatewayStop() + n.browserGatewayStop = nil + n.browserGateway = nil + } + + if err := n.startBrowserGateway(); err != nil { + logger.Error("Failed to start browser gateway listener: %v", err) + } else { + n.browserGateway.SetTargets(toBrowserGatewayTargets(n.wgData.BrowserGatewayTargets)) + } + } +} + +// startBrowserGateway creates the browser gateway and its listener if one +// isn't already running. Callers that need to rebind to a fresh netstack +// (e.g. on reconnect) must stop and clear any existing gateway first. +func (n *Newt) startBrowserGateway() error { + if n.browserGateway != nil { + return nil + } + if n.tnet == nil && !n.config.UseNativeMainInterface { + return fmt.Errorf("netstack not ready") + } + + gateway := browsergateway.New(browsergateway.Config{SSHCredentials: n.sshCredStore}) + + var ln net.Listener + var err error + if n.config.UseNativeMainInterface { + ln, err = net.Listen("tcp", fmt.Sprintf("%s:%d", n.wgData.TunnelIP, browsergateway.ListenPort)) + } else { + ln, err = n.tnet.ListenTCP(&net.TCPAddr{Port: browsergateway.ListenPort}) + } + if err != nil { + return err + } + + n.browserGateway = gateway + n.browserGatewayStop = func() { _ = ln.Close() } + go func() { + logger.Debug("Browser gateway started on port %d", browsergateway.ListenPort) + if startErr := gateway.Start(ln); startErr != nil { + logger.Error("Browser gateway stopped with error: %v", startErr) + } + }() + + return nil +} + +// syncBrowserGatewayTargets reconciles the browser gateway's allowed +// destinations with the desired state received from a sync message. +// It lazily starts the gateway if targets are present and it isn't running +// yet, and clears the allow-list (without tearing down the listener) when +// no targets are desired. +func (n *Newt) syncBrowserGatewayTargets(targets []BrowserGatewayTarget) { + bgTargets := toBrowserGatewayTargets(targets) + + if len(bgTargets) == 0 { + if n.browserGateway != nil { + n.browserGateway.SetTargets(nil) + } + return + } + + if err := n.startBrowserGateway(); err != nil { + logger.Error("Failed to start browser gateway: %v", err) + return + } + + n.browserGateway.SetTargets(bgTargets) +} + +func toBrowserGatewayTargets(targets []BrowserGatewayTarget) []browsergateway.Target { + bgTargets := make([]browsergateway.Target, 0, len(targets)) + for _, t := range targets { + bgTargets = append(bgTargets, browsergateway.Target{ + ID: t.ID, + Type: t.Type, + Destination: t.Destination, + DestinationPort: t.DestinationPort, + AuthToken: t.AuthToken, + }) + } + return bgTargets +} diff --git a/newt/data.go b/newt/data.go new file mode 100644 index 0000000..719b707 --- /dev/null +++ b/newt/data.go @@ -0,0 +1,161 @@ +package newt + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/fosrl/newt/logger" + "github.com/fosrl/newt/websocket" +) + +func (n *Newt) handleSync(msg websocket.WSMessage) { + logger.Info("Received sync message") + + // if there is no wgData or pm, we can't sync targets + if n.wgData.TunnelIP == "" || n.pm == nil { + logger.Info(msgNoTunnelOrProxy) + return + } + + var syncData SyncData + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Error("Error marshaling sync data: %v", err) + return + } + + if err := json.Unmarshal(jsonData, &syncData); err != nil { + logger.Error("Error unmarshaling sync data: %v", err) + return + } + + logger.Debug("Sync data received: TCP targets=%d, UDP targets=%d, health check targets=%d", + len(syncData.Targets.TCP), len(syncData.Targets.UDP), len(syncData.HealthCheckTargets)) + + // Build sets of desired targets (port -> target string) + desiredTCP := make(map[int]string) + for _, t := range syncData.Targets.TCP { + parts := strings.Split(t, ":") + if len(parts) != 3 { + logger.Warn("Invalid TCP target format: %s", t) + continue + } + port := 0 + if _, err := fmt.Sscanf(parts[0], "%d", &port); err != nil { + logger.Warn("Invalid port in TCP target: %s", parts[0]) + continue + } + desiredTCP[port] = parts[1] + ":" + parts[2] + } + + desiredUDP := make(map[int]string) + for _, t := range syncData.Targets.UDP { + parts := strings.Split(t, ":") + if len(parts) != 3 { + logger.Warn("Invalid UDP target format: %s", t) + continue + } + port := 0 + if _, err := fmt.Sscanf(parts[0], "%d", &port); err != nil { + logger.Warn("Invalid port in UDP target: %s", parts[0]) + continue + } + desiredUDP[port] = parts[1] + ":" + parts[2] + } + + // Get current targets from proxy manager + currentTCP, currentUDP := n.pm.GetTargets() + + // Sync TCP targets + // Remove TCP targets not in desired set + if tcpForIP, ok := currentTCP[n.wgData.TunnelIP]; ok { + for port := range tcpForIP { + if _, exists := desiredTCP[port]; !exists { + logger.Info("Sync: removing TCP target on port %d", port) + targetStr := fmt.Sprintf("%d:%s", port, tcpForIP[port]) + n.updateTargets(n.pm, "remove", n.wgData.TunnelIP, "tcp", TargetData{Targets: []string{targetStr}}) + } + } + } + + // Add TCP targets that are missing + for port, target := range desiredTCP { + needsAdd := true + if tcpForIP, ok := currentTCP[n.wgData.TunnelIP]; ok { + if currentTarget, exists := tcpForIP[port]; exists { + // Check if target address changed + if currentTarget == target { + needsAdd = false + } else { + // Target changed, remove old one first + logger.Info("Sync: updating TCP target on port %d", port) + targetStr := fmt.Sprintf("%d:%s", port, currentTarget) + n.updateTargets(n.pm, "remove", n.wgData.TunnelIP, "tcp", TargetData{Targets: []string{targetStr}}) + } + } + } + if needsAdd { + logger.Info("Sync: adding TCP target on port %d -> %s", port, target) + targetStr := fmt.Sprintf("%d:%s", port, target) + n.updateTargets(n.pm, "add", n.wgData.TunnelIP, "tcp", TargetData{Targets: []string{targetStr}}) + } + } + + // Sync UDP targets + // Remove UDP targets not in desired set + if udpForIP, ok := currentUDP[n.wgData.TunnelIP]; ok { + for port := range udpForIP { + if _, exists := desiredUDP[port]; !exists { + logger.Info("Sync: removing UDP target on port %d", port) + targetStr := fmt.Sprintf("%d:%s", port, udpForIP[port]) + n.updateTargets(n.pm, "remove", n.wgData.TunnelIP, "udp", TargetData{Targets: []string{targetStr}}) + } + } + } + + // Add UDP targets that are missing + for port, target := range desiredUDP { + needsAdd := true + if udpForIP, ok := currentUDP[n.wgData.TunnelIP]; ok { + if currentTarget, exists := udpForIP[port]; exists { + // Check if target address changed + if currentTarget == target { + needsAdd = false + } else { + // Target changed, remove old one first + logger.Info("Sync: updating UDP target on port %d", port) + targetStr := fmt.Sprintf("%d:%s", port, currentTarget) + n.updateTargets(n.pm, "remove", n.wgData.TunnelIP, "udp", TargetData{Targets: []string{targetStr}}) + } + } + } + if needsAdd { + logger.Info("Sync: adding UDP target on port %d -> %s", port, target) + targetStr := fmt.Sprintf("%d:%s", port, target) + n.updateTargets(n.pm, "add", n.wgData.TunnelIP, "udp", TargetData{Targets: []string{targetStr}}) + } + } + + // Sync remote exit node subnets + if n.dev != nil { + n.updateRemoteExitNodeSubnets(syncData.RemoteExitNodeSubnets) + } + + // Sync clients WireGuard peers and targets, if clients are set up + if n.wgService != nil { + n.wgService.Sync(syncData.Peers, syncData.ClientTargets) + } + + // Sync browser gateway targets + n.syncBrowserGatewayTargets(syncData.BrowserGatewayTargets) + + // Sync health check targets + if err := n.healthMonitor.SyncTargets(syncData.HealthCheckTargets); err != nil { + logger.Error("Failed to sync health check targets: %v", err) + } else { + logger.Info("Successfully synced health check targets") + } + + logger.Info("Sync complete") +} diff --git a/newt/handlers.go b/newt/handlers.go new file mode 100644 index 0000000..0a448f1 --- /dev/null +++ b/newt/handlers.go @@ -0,0 +1,1082 @@ +package newt + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "net" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/fosrl/newt/authdaemon" + "github.com/fosrl/newt/browsergateway" + "github.com/fosrl/newt/docker" + "github.com/fosrl/newt/healthcheck" + "github.com/fosrl/newt/internal/state" + "github.com/fosrl/newt/internal/telemetry" + "github.com/fosrl/newt/logger" + "github.com/fosrl/newt/network" + "github.com/fosrl/newt/util" + "github.com/fosrl/newt/websocket" +) + +const ( + fmtErrMarshaling = "Error marshaling data: %v" + fmtReceivedMsg = "Received: %+v" + topicWGRegister = "newt/wg/register" + msgNoTunnelOrProxy = "No tunnel IP or proxy manager available" + fmtErrParsingTargetData = "Error parsing target data: %v" +) + +func (n *Newt) registerHandlers(ctx context.Context) { + //TODO: MOVE MORE OF THESE HANDLERS TO STANDALONE FUNCTIONS IN THE DATA.GO AND CONNECT.GO FILES + + n.client.RegisterHandler("newt/wg/connect", func(msg websocket.WSMessage) { + n.handleConnect(ctx, msg) + }) + + n.client.RegisterHandler("newt/wg/reconnect", func(msg websocket.WSMessage) { + logger.Info("Received reconnect message") + if n.wgData.PublicKey != "" { + telemetry.IncReconnect(ctx, n.wgData.PublicKey, "server", telemetry.ReasonServerRequest) + } + + n.closeWgTunnel() + + if n.pm != nil { + n.pm.ClearTunnelID() + state.Global().ClearTunnel(n.wgData.PublicKey) + } + + n.connected = false + + if n.stopFunc != nil { + n.stopFunc() + n.stopFunc = nil + } + + pingChainId := generateChainId() + n.pendingPingChainId = pingChainId + n.stopFunc = n.client.SendMessageInterval("newt/ping/request", map[string]interface{}{ + "noCloud": n.config.NoCloud, + "chainId": pingChainId, + }, 3*time.Second) + + logger.Info("Tunnel destroyed, ready for reconnection") + }) + + n.client.RegisterHandler("newt/wg/restart", func(msg websocket.WSMessage) { + logger.Info("Received restart message") + n.closeWgTunnel() + n.closeClients() + if n.healthMonitor != nil { + n.healthMonitor.Stop() + } + n.client.Close() + if n.config.OnRestart != nil { + if err := n.config.OnRestart(); err != nil { + logger.Error("Failed to restart: %v", err) + os.Exit(1) + } + } + }) + + n.client.RegisterHandler("newt/wg/terminate", func(msg websocket.WSMessage) { + logger.Info("Received termination message") + if n.wgData.PublicKey != "" { + telemetry.IncReconnect(ctx, n.wgData.PublicKey, "server", telemetry.ReasonServerRequest) + } + + n.closeWgTunnel() + n.closeClients() + + if n.stopFunc != nil { + n.stopFunc() + n.stopFunc = nil + } + + n.connected = false + + logger.Info("Tunnel destroyed") + }) + + n.client.RegisterHandler("newt/ping/exitNodes", func(msg websocket.WSMessage) { + logger.Debug("Received ping message") + + if n.stopFunc != nil { + n.stopFunc() + n.stopFunc = nil + } + + var exitNodeData ExitNodeData + + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Info(fmtErrMarshaling, err) + return + } + if err := json.Unmarshal(jsonData, &exitNodeData); err != nil { + logger.Info("Error unmarshaling exit node data: %v", err) + return + } + exitNodes := exitNodeData.ExitNodes + + if exitNodeData.ChainId != "" { + if exitNodeData.ChainId != n.pendingPingChainId { + logger.Debug("Discarding duplicate/stale newt/ping/exitNodes (chainId=%s, expected=%s)", exitNodeData.ChainId, n.pendingPingChainId) + return + } + n.pendingPingChainId = "" + } + + if len(exitNodes) == 0 { + logger.Info("No exit nodes provided") + return + } + + if len(exitNodes) == 1 || n.config.PreferEndpoint != "" { + logger.Debug("Only one exit node available, using it directly: %s", exitNodes[0].Endpoint) + + if n.config.PreferEndpoint != "" { + for _, node := range exitNodes { + if node.Endpoint == n.config.PreferEndpoint { + exitNodes[0] = node + break + } + } + } + + pingResults := []ExitNodePingResult{ + { + ExitNodeID: exitNodes[0].ID, + LatencyMs: 0, + Weight: exitNodes[0].Weight, + Error: "", + Name: exitNodes[0].Name, + Endpoint: exitNodes[0].Endpoint, + WasPreviouslyConnected: exitNodes[0].WasPreviouslyConnected, + }, + } + + chainId := generateChainId() + n.pendingRegisterChainId = chainId + n.stopFunc = n.client.SendMessageInterval(topicWGRegister, map[string]interface{}{ + "publicKey": n.publicKey.String(), + "pingResults": pingResults, + "newtVersion": n.config.Version, + "chainId": chainId, + }, 2*time.Second) + + return + } + + type nodeResult struct { + Node ExitNode + Latency time.Duration + Err error + } + + results := make([]nodeResult, len(exitNodes)) + const pingAttempts = 3 + for i, node := range exitNodes { + var totalLatency time.Duration + var lastErr error + successes := 0 + httpClient := &http.Client{ + Timeout: 5 * time.Second, + } + url := node.Endpoint + if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { + url = "http://" + url + } + if !strings.HasSuffix(url, "/ping") { + url = strings.TrimRight(url, "/") + "/ping" + } + for j := 0; j < pingAttempts; j++ { + start := time.Now() + resp, err := httpClient.Get(url) + latency := time.Since(start) + if err != nil { + lastErr = err + logger.Warn("Failed to ping exit node %d (%s) attempt %d: %v", node.ID, url, j+1, err) + continue + } + resp.Body.Close() + totalLatency += latency + successes++ + } + var avgLatency time.Duration + if successes > 0 { + avgLatency = totalLatency / time.Duration(successes) + } + if successes == 0 { + results[i] = nodeResult{Node: node, Latency: 0, Err: lastErr} + } else { + results[i] = nodeResult{Node: node, Latency: avgLatency, Err: nil} + } + } + + var pingResults []ExitNodePingResult + for _, res := range results { + errMsg := "" + if res.Err != nil { + errMsg = res.Err.Error() + } + pingResults = append(pingResults, ExitNodePingResult{ + ExitNodeID: res.Node.ID, + LatencyMs: res.Latency.Milliseconds(), + Weight: res.Node.Weight, + Error: errMsg, + Name: res.Node.Name, + Endpoint: res.Node.Endpoint, + WasPreviouslyConnected: res.Node.WasPreviouslyConnected, + }) + } + + if n.connected { + var filteredPingResults []ExitNodePingResult + previouslyConnectedNodeIdx := -1 + for i, res := range pingResults { + if res.WasPreviouslyConnected { + previouslyConnectedNodeIdx = i + } + } + goodNodeCount := 0 + for i, res := range pingResults { + if i != previouslyConnectedNodeIdx && res.LatencyMs > 0 && res.Error == "" { + goodNodeCount++ + } + } + if previouslyConnectedNodeIdx != -1 && goodNodeCount > 0 { + for i, res := range pingResults { + if i != previouslyConnectedNodeIdx { + filteredPingResults = append(filteredPingResults, res) + } + } + pingResults = filteredPingResults + logger.Info("Excluding previously connected exit node from ping results due to other available nodes") + } + } + + chainId := generateChainId() + n.pendingRegisterChainId = chainId + n.stopFunc = n.client.SendMessageInterval(topicWGRegister, map[string]interface{}{ + "publicKey": n.publicKey.String(), + "pingResults": pingResults, + "newtVersion": n.config.Version, + "chainId": chainId, + }, 2*time.Second) + + logger.Debug("Sent exit node ping results to cloud for selection: pingResults=%+v", pingResults) + }) + + n.client.RegisterHandler("newt/sync", n.handleSync) + + n.client.RegisterHandler("newt/tcp/add", func(msg websocket.WSMessage) { + logger.Debug(fmtReceivedMsg, msg) + + if n.wgData.TunnelIP == "" || n.pm == nil { + logger.Info(msgNoTunnelOrProxy) + return + } + + targetData, err := parseTargetData(msg.Data) + if err != nil { + logger.Info(fmtErrParsingTargetData, err) + return + } + + if len(targetData.Targets) > 0 { + n.updateTargets(n.pm, "add", n.wgData.TunnelIP, "tcp", targetData) + } + }) + + n.client.RegisterHandler("newt/udp/add", func(msg websocket.WSMessage) { + logger.Info(fmtReceivedMsg, msg) + + if n.wgData.TunnelIP == "" || n.pm == nil { + logger.Info(msgNoTunnelOrProxy) + return + } + + targetData, err := parseTargetData(msg.Data) + if err != nil { + logger.Info(fmtErrParsingTargetData, err) + return + } + + if len(targetData.Targets) > 0 { + n.updateTargets(n.pm, "add", n.wgData.TunnelIP, "udp", targetData) + } + }) + + n.client.RegisterHandler("newt/udp/remove", func(msg websocket.WSMessage) { + logger.Info(fmtReceivedMsg, msg) + + if n.wgData.TunnelIP == "" || n.pm == nil { + logger.Info(msgNoTunnelOrProxy) + return + } + + targetData, err := parseTargetData(msg.Data) + if err != nil { + logger.Info(fmtErrParsingTargetData, err) + return + } + + if len(targetData.Targets) > 0 { + n.updateTargets(n.pm, "remove", n.wgData.TunnelIP, "udp", targetData) + } + }) + + n.client.RegisterHandler("newt/tcp/remove", func(msg websocket.WSMessage) { + logger.Info(fmtReceivedMsg, msg) + + if n.wgData.TunnelIP == "" || n.pm == nil { + logger.Info(msgNoTunnelOrProxy) + return + } + + targetData, err := parseTargetData(msg.Data) + if err != nil { + logger.Info(fmtErrParsingTargetData, err) + return + } + + if len(targetData.Targets) > 0 { + n.updateTargets(n.pm, "remove", n.wgData.TunnelIP, "tcp", targetData) + } + }) + + n.client.RegisterHandler("newt/wg/subnets/add", func(msg websocket.WSMessage) { + logger.Debug("Received subnet add message") + + var data struct { + Subnets []string `json:"subnets"` + } + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Error("Error marshaling subnet add data: %v", err) + return + } + if err := json.Unmarshal(jsonData, &data); err != nil { + logger.Error("Error unmarshaling subnet add data: %v", err) + return + } + if len(data.Subnets) == 0 || n.dev == nil { + return + } + + for _, subnet := range data.Subnets { + subnetCfg := fmt.Sprintf("public_key=%s\nallowed_ip=%s", util.FixKey(n.wgData.PublicKey), subnet) + if err := n.dev.IpcSet(subnetCfg); err != nil { + logger.Warn("Failed to add AllowedIP %s to main tunnel: %v", subnet, err) + } + } + if n.config.UseNativeMainInterface { + if err := network.AddRoutes(data.Subnets, n.config.NativeMainInterfaceName); err != nil { + logger.Warn("Failed to add routes for subnets: %v", err) + } + } + n.activeRemoteSubnets = append(n.activeRemoteSubnets, data.Subnets...) + logger.Info("Added %d remote exit node subnets", len(data.Subnets)) + }) + + n.client.RegisterHandler("newt/wg/subnets/update", func(msg websocket.WSMessage) { + logger.Debug("Received subnet update message") + + var data struct { + Subnets []string `json:"subnets"` + } + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Error("Error marshaling subnet update data: %v", err) + return + } + if err := json.Unmarshal(jsonData, &data); err != nil { + logger.Error("Error unmarshaling subnet update data: %v", err) + return + } + if n.dev == nil { + return + } + + n.updateRemoteExitNodeSubnets(data.Subnets) + }) + + n.client.RegisterHandler("newt/wg/subnets/remove", func(msg websocket.WSMessage) { + logger.Debug("Received subnet remove message") + + var data struct { + Subnets []string `json:"subnets"` + } + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Error("Error marshaling subnet remove data: %v", err) + return + } + if err := json.Unmarshal(jsonData, &data); err != nil { + logger.Error("Error unmarshaling subnet remove data: %v", err) + return + } + if len(data.Subnets) == 0 { + return + } + + if n.config.UseNativeMainInterface { + if err := network.RemoveRoutes(data.Subnets); err != nil { + logger.Warn("Failed to remove routes for subnets: %v", err) + } + } + + toRemove := make(map[string]bool, len(data.Subnets)) + for _, s := range data.Subnets { + toRemove[s] = true + } + remaining := n.activeRemoteSubnets[:0] + for _, s := range n.activeRemoteSubnets { + if !toRemove[s] { + remaining = append(remaining, s) + } + } + n.activeRemoteSubnets = remaining + + if n.dev != nil && n.wgData.PublicKey != "" { + lines := fmt.Sprintf("public_key=%s\nreplace_allowed_ips=true\nallowed_ip=%s/32", + util.FixKey(n.wgData.PublicKey), n.wgData.ServerIP) + for _, s := range remaining { + lines += "\nallowed_ip=" + s + } + if err := n.dev.IpcSet(lines); err != nil { + logger.Warn("Failed to update WireGuard AllowedIPs after subnet removal: %v", err) + } + } + logger.Info("Removed %d remote exit node subnets", len(data.Subnets)) + }) + + n.client.RegisterHandler("newt/socket/check", func(msg websocket.WSMessage) { + logger.Debug("Received Docker socket check request") + + if n.config.DockerSocket == "" { + logger.Debug("Docker socket path is not set") + if err := n.client.SendMessage("newt/socket/status", map[string]interface{}{ + "available": false, + "socketPath": n.config.DockerSocket, + }); err != nil { + logger.Error("Failed to send Docker socket check response: %v", err) + } + return + } + + isAvailable := docker.CheckSocket(n.config.DockerSocket) + + if err := n.client.SendMessage("newt/socket/status", map[string]interface{}{ + "available": isAvailable, + "socketPath": n.config.DockerSocket, + }); err != nil { + logger.Error("Failed to send Docker socket check response: %v", err) + } else { + logger.Debug("Docker socket check response sent: available=%t", isAvailable) + } + }) + + n.client.RegisterHandler("newt/socket/fetch", func(msg websocket.WSMessage) { + logger.Debug("Received Docker container fetch request") + + if n.config.DockerSocket == "" { + logger.Debug("Docker socket path is not set") + return + } + + containers, err := docker.ListContainers(n.config.DockerSocket, n.config.DockerEnforceNetworkValidation) + if err != nil { + logger.Error("Failed to list Docker containers: %v", err) + return + } + + if err := n.client.SendMessage("newt/socket/containers", map[string]interface{}{ + "containers": containers, + }); err != nil { + logger.Error("Failed to send Docker container list: %v", err) + } else { + logger.Debug("Docker container list sent, count: %d", len(containers)) + } + }) + + n.client.RegisterHandler("newt/healthcheck/add", func(msg websocket.WSMessage) { + logger.Debug("Received health check add request: %+v", msg) + + type HealthCheckConfig struct { + Targets []healthcheck.Config `json:"targets"` + } + + var config HealthCheckConfig + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Error("Error marshaling health check data: %v", err) + return + } + + if err := json.Unmarshal(jsonData, &config); err != nil { + logger.Error("Error unmarshaling health check config: %v", err) + return + } + + if err := n.healthMonitor.AddTargets(config.Targets); err != nil { + logger.Error("Failed to add health check targets: %v", err) + } else { + logger.Debug("Added %d health check targets", len(config.Targets)) + } + + logger.Debug("Health check targets added: %+v", config.Targets) + }) + + n.client.RegisterHandler("newt/healthcheck/remove", func(msg websocket.WSMessage) { + logger.Debug("Received health check remove request: %+v", msg) + + type HealthCheckConfig struct { + IDs []int `json:"ids"` + } + + var requestData HealthCheckConfig + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Error("Error marshaling health check remove data: %v", err) + return + } + + if err := json.Unmarshal(jsonData, &requestData); err != nil { + logger.Error("Error unmarshaling health check remove request: %v", err) + return + } + + if err := n.healthMonitor.RemoveTargets(requestData.IDs); err != nil { + logger.Error("Failed to remove health check targets %v: %v", requestData.IDs, err) + } else { + logger.Info("Removed %d health check targets: %v", len(requestData.IDs), requestData.IDs) + } + }) + + n.client.RegisterHandler("newt/healthcheck/enable", func(msg websocket.WSMessage) { + logger.Debug("Received health check enable request: %+v", msg) + + var requestData struct { + ID int `json:"id"` + } + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Error("Error marshaling health check enable data: %v", err) + return + } + + if err := json.Unmarshal(jsonData, &requestData); err != nil { + logger.Error("Error unmarshaling health check enable request: %v", err) + return + } + + if err := n.healthMonitor.EnableTarget(requestData.ID); err != nil { + logger.Error("Failed to enable health check target %d: %v", requestData.ID, err) + } else { + logger.Info("Enabled health check target: %d", requestData.ID) + } + }) + + n.client.RegisterHandler("newt/healthcheck/disable", func(msg websocket.WSMessage) { + logger.Debug("Received health check disable request: %+v", msg) + + var requestData struct { + ID int `json:"id"` + } + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Error("Error marshaling health check disable data: %v", err) + return + } + + if err := json.Unmarshal(jsonData, &requestData); err != nil { + logger.Error("Error unmarshaling health check disable request: %v", err) + return + } + + if err := n.healthMonitor.DisableTarget(requestData.ID); err != nil { + logger.Error("Failed to disable health check target %d: %v", requestData.ID, err) + } else { + logger.Info("Disabled health check target: %d", requestData.ID) + } + }) + + n.client.RegisterHandler("newt/healthcheck/status/request", func(msg websocket.WSMessage) { + logger.Debug("Received health check status request") + + targets := n.healthMonitor.GetTargets() + healthStatuses := make(map[int]interface{}) + for id, target := range targets { + healthStatuses[id] = map[string]interface{}{ + "status": target.Status.String(), + "lastCheck": target.LastCheck.Format(time.RFC3339), + "checkCount": target.CheckCount, + "lastError": target.LastError, + "config": target.Config, + } + } + + if err := n.client.SendMessage("newt/healthcheck/status", map[string]interface{}{ + "targets": healthStatuses, + }); err != nil { + logger.Error("Failed to send health check status response: %v", err) + } + }) + + n.client.RegisterHandler("newt/blueprint/results", func(msg websocket.WSMessage) { + logger.Debug("Received blueprint results message") + + var blueprintResult BlueprintResult + + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Info("Error marshaling data: %v", err) + return + } + if err := json.Unmarshal(jsonData, &blueprintResult); err != nil { + logger.Info("Error unmarshaling config results data: %v", err) + return + } + + if blueprintResult.Success { + logger.Debug("Blueprint applied successfully!") + } else { + logger.Warn("Blueprint application failed: %s", blueprintResult.Message) + } + }) + + n.client.RegisterHandler("newt/pam/connection", func(msg websocket.WSMessage) { + logger.Debug("Received SSH certificate issued message") + + type SSHCertData struct { + MessageId int `json:"messageId"` + AgentPort int `json:"agentPort"` + AgentHost string `json:"agentHost"` + ExternalAuthDaemon bool `json:"externalAuthDaemon"` + AuthDaemonMode string `json:"authDaemonMode"` + CACert string `json:"caCert"` + Username string `json:"username"` + NiceID string `json:"niceId"` + Metadata struct { + SudoMode string `json:"sudoMode"` + SudoCommands []string `json:"sudoCommands"` + Homedir bool `json:"homedir"` + Groups []string `json:"groups"` + } `json:"metadata"` + } + + var certData SSHCertData + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Error("Error marshaling SSH cert data: %v", err) + return + } + + logger.Debug("Received SSH cert data: %s", string(jsonData)) + + if err := json.Unmarshal(jsonData, &certData); err != nil { + logger.Error("Error unmarshaling SSH cert data: %v", err) + return + } + + authDaemonMode := "site" + if certData.AuthDaemonMode != "" { + authDaemonMode = certData.AuthDaemonMode + } else if certData.ExternalAuthDaemon { + authDaemonMode = "remote" + } + + switch authDaemonMode { + case "site": + logger.Debug("Calling internal auth daemon ProcessConnection for user %s", certData.Username) + + if n.authDaemonServer != nil { + n.authDaemonServer.ProcessConnection(authdaemon.ConnectionRequest{ + CaCert: certData.CACert, + NiceId: certData.NiceID, + Username: certData.Username, + Metadata: authdaemon.ConnectionMetadata{ + SudoMode: certData.Metadata.SudoMode, + SudoCommands: certData.Metadata.SudoCommands, + Homedir: certData.Metadata.Homedir, + Groups: certData.Metadata.Groups, + }, + }) + err = n.client.SendMessage("ws/round-trip/complete", map[string]interface{}{ + "messageId": certData.MessageId, + "complete": true, + }) + } else { + logger.Error("Auth daemon server is not initialized, cannot process connection") + err = n.client.SendMessage("ws/round-trip/complete", map[string]interface{}{ + "messageId": certData.MessageId, + "complete": true, + "error": "auth daemon server not initialized (enable by running Newt site connector as root)", + }) + if err != nil { + logger.Error("Failed to send SSH cert failure response: %v", err) + } + return + } + + logger.Info("Successfully processed connection via internal auth daemon for user %s", certData.Username) + + case "remote": + if n.config.AuthDaemonKey == "" { + logger.Error("Auth daemon key not configured, cannot communicate with daemon") + if err := n.client.SendMessage("ws/round-trip/complete", map[string]interface{}{ + "messageId": certData.MessageId, + "complete": true, + "error": "auth daemon key not configured", + }); err != nil { + logger.Error("Failed to send SSH cert failure response: %v", err) + } + return + } + + requestBody := map[string]interface{}{ + "caCert": certData.CACert, + "niceId": certData.NiceID, + "username": certData.Username, + "metadata": map[string]interface{}{ + "sudoMode": certData.Metadata.SudoMode, + "sudoCommands": certData.Metadata.SudoCommands, + "homedir": certData.Metadata.Homedir, + "groups": certData.Metadata.Groups, + }, + } + + requestJSON, err := json.Marshal(requestBody) + if err != nil { + logger.Error("Failed to marshal auth daemon request: %v", err) + n.client.SendMessage("ws/round-trip/complete", map[string]interface{}{ + "messageId": certData.MessageId, + "complete": true, + "error": fmt.Sprintf("failed to marshal request: %v", err), + }) + return + } + + httpClient := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + }, + Timeout: 10 * time.Second, + } + + url := fmt.Sprintf("https://%s:%d/connection", certData.AgentHost, certData.AgentPort) + req, err := http.NewRequest("POST", url, bytes.NewBuffer(requestJSON)) + if err != nil { + logger.Error("Failed to create auth daemon request: %v", err) + n.client.SendMessage("ws/round-trip/complete", map[string]interface{}{ + "messageId": certData.MessageId, + "complete": true, + "error": fmt.Sprintf("failed to create request: %v", err), + }) + return + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+n.config.AuthDaemonKey) + + logger.Debug("Sending SSH cert to auth daemon at %s", url) + + resp, err := httpClient.Do(req) + if err != nil { + logger.Error("Failed to connect to auth daemon: %v", err) + n.client.SendMessage("ws/round-trip/complete", map[string]interface{}{ + "messageId": certData.MessageId, + "complete": true, + "error": fmt.Sprintf("failed to connect to auth daemon: %v", err), + }) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + logger.Error("Auth daemon returned non-OK status: %d", resp.StatusCode) + n.client.SendMessage("ws/round-trip/complete", map[string]interface{}{ + "messageId": certData.MessageId, + "complete": true, + "error": fmt.Sprintf("auth daemon returned status %d", resp.StatusCode), + }) + return + } + + logger.Info("Successfully registered SSH certificate with external auth daemon for user %s", certData.Username) + + case "native": + logger.Debug("Processing SSH cert for native SSH server for user %s", certData.Username) + if n.authDaemonServer != nil && n.sshCredStore != nil { + n.authDaemonServer.ProcessConnection(authdaemon.ConnectionRequest{ + CaCert: "", + NiceId: "", + Username: certData.Username, + Metadata: authdaemon.ConnectionMetadata{ + SudoMode: certData.Metadata.SudoMode, + SudoCommands: certData.Metadata.SudoCommands, + Homedir: certData.Metadata.Homedir, + Groups: certData.Metadata.Groups, + }, + }) + + if err := n.sshCredStore.SetCAKey(certData.CACert); err != nil { + logger.Error("nativessh: failed to set CA key: %v", err) + } + n.sshCredStore.AddPrincipals(certData.Username, certData.NiceID) + logger.Info("nativessh: updated credentials for user %s (niceId=%s)", certData.Username, certData.NiceID) + } else { + logger.Error("Auth daemon server or SSH credential store not initialized, cannot process connection") + err = n.client.SendMessage("ws/round-trip/complete", map[string]interface{}{ + "messageId": certData.MessageId, + "complete": true, + "error": "auth daemon server or SSH credential store not initialized", + }) + if err != nil { + logger.Error("Failed to send SSH cert failure response: %v", err) + } + return + } + + default: + logger.Error("Unknown auth daemon mode: %s", certData.AuthDaemonMode) + n.client.SendMessage("ws/round-trip/complete", map[string]interface{}{ + "messageId": certData.MessageId, + "complete": true, + "error": fmt.Sprintf("unknown auth daemon mode: %s", certData.AuthDaemonMode), + }) + return + } + + if err = n.client.SendMessage("ws/round-trip/complete", map[string]interface{}{ + "messageId": certData.MessageId, + "complete": true, + }); err != nil { + logger.Error("Failed to send SSH cert success response: %v", err) + } + }) + + n.client.RegisterHandler("newt/browsergateway/add", func(msg websocket.WSMessage) { + logger.Debug("Received browser gateway add message") + + type BrowserGatewayAddData struct { + Targets []BrowserGatewayTarget `json:"targets"` + } + + var addData BrowserGatewayAddData + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Error("Error marshaling browser gateway add data: %v", err) + return + } + if err := json.Unmarshal(jsonData, &addData); err != nil { + logger.Error("Error unmarshaling browser gateway add data: %v", err) + return + } + + if len(addData.Targets) == 0 { + return + } + + if n.browserGateway == nil && (n.tnet != nil || n.config.UseNativeMainInterface) { + n.browserGateway = browsergateway.New(browsergateway.Config{SSHCredentials: n.sshCredStore}) + var ln net.Listener + var bgErr error + if n.config.UseNativeMainInterface { + ln, bgErr = net.Listen("tcp", fmt.Sprintf("%s:%d", n.wgData.TunnelIP, browsergateway.ListenPort)) + } else { + ln, bgErr = n.tnet.ListenTCP(&net.TCPAddr{Port: browsergateway.ListenPort}) + } + if bgErr != nil { + logger.Error("Failed to start browser gateway listener: %v", bgErr) + n.browserGateway = nil + } else { + n.browserGatewayStop = func() { _ = ln.Close() } + go func() { + logger.Debug("Browser gateway started on port %d", browsergateway.ListenPort) + if startErr := n.browserGateway.Start(ln); startErr != nil { + logger.Error("Browser gateway stopped with error: %v", startErr) + } + }() + } + } + + if n.browserGateway == nil { + logger.Warn("Browser gateway not available, cannot add targets") + return + } + + for _, t := range addData.Targets { + n.browserGateway.AddTarget(browsergateway.Target{ + ID: t.ID, + Type: t.Type, + Destination: t.Destination, + DestinationPort: t.DestinationPort, + AuthToken: t.AuthToken, + }) + logger.Debug("Added browser gateway target %d", t.ID) + } + }) + + n.client.RegisterHandler("newt/browsergateway/remove", func(msg websocket.WSMessage) { + logger.Debug("Received browser gateway remove message") + + type BrowserGatewayRemoveData struct { + IDs []int `json:"ids"` + } + + var removeData BrowserGatewayRemoveData + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Error("Error marshaling browser gateway remove data: %v", err) + return + } + if err := json.Unmarshal(jsonData, &removeData); err != nil { + logger.Error("Error unmarshaling browser gateway remove data: %v", err) + return + } + + if n.browserGateway == nil { + logger.Warn("Browser gateway not available, cannot remove targets") + return + } + + for _, id := range removeData.IDs { + n.browserGateway.RemoveTarget(id) + logger.Debug("Removed browser gateway target %d", id) + } + }) + + n.client.OnConnect(func() error { + n.publicKey = n.privateKey.PublicKey() + logger.Debug("Public key: %s", n.publicKey) + logger.Info("Websocket connected") + + if !n.connected { + if n.stopFunc != nil { + n.stopFunc() + } + pingChainId := generateChainId() + n.pendingPingChainId = pingChainId + n.stopFunc = n.client.SendMessageInterval("newt/ping/request", map[string]interface{}{ + "noCloud": n.config.NoCloud, + "chainId": pingChainId, + }, 3*time.Second) + logger.Debug("Requesting exit nodes from server") + + if n.client.GetServerVersion() != "" { + n.clientsOnConnect() + } else { + logger.Warn("CLIENTS WILL NOT WORK ON THIS VERSION OF NEWT WITH THIS VERSION OF PANGOLIN, PLEASE UPDATE THE SERVER TO 1.13 OR HIGHER OR DOWNGRADE NEWT") + } + + sendBlueprint(n.client, n.config.BlueprintFile) + if n.client.WasJustProvisioned() { + logger.Info("Provisioning detected – sending provisioning blueprint") + sendBlueprint(n.client, n.config.ProvisioningBlueprintFile) + } + } else { + targets := n.healthMonitor.GetTargets() + if len(targets) > 0 { + healthStatuses := make(map[int]interface{}) + for id, target := range targets { + healthStatuses[id] = map[string]interface{}{ + "status": target.Status.String(), + "lastCheck": target.LastCheck.Format(time.RFC3339), + "checkCount": target.CheckCount, + "lastError": target.LastError, + "config": target.Config, + } + } + logger.Debug("Reconnected: resending health check status for %d targets", len(healthStatuses)) + if err := n.client.SendMessage("newt/healthcheck/status", map[string]interface{}{ + "targets": healthStatuses, + }); err != nil { + logger.Error("Failed to resend health check status on reconnect: %v", err) + } + } + } + + bcChainId := generateChainId() + n.pendingRegisterChainId = bcChainId + if err := n.client.SendMessage(topicWGRegister, map[string]interface{}{ + "publicKey": n.publicKey.String(), + "newtVersion": n.config.Version, + "backwardsCompatible": true, + "chainId": bcChainId, + }); err != nil { + logger.Error("Failed to send registration message: %v", err) + return err + } + + return nil + }) + + // SIGHUP: reload config file and apply credential changes in place + sighupChan := make(chan os.Signal, 1) + signal.Notify(sighupChan, syscall.SIGHUP) + go func() { + defer signal.Stop(sighupChan) + for { + select { + case <-sighupChan: + logger.Info("SIGHUP received, reloading config...") + cfgPath := n.client.GetConfigFilePath() + data, err := os.ReadFile(cfgPath) + if err != nil { + logger.Error("Failed to read config file on SIGHUP: %v", err) + continue + } + var newCfg websocket.Config + if err := json.Unmarshal(data, &newCfg); err != nil { + logger.Error("Failed to parse config file on SIGHUP: %v", err) + continue + } + oldCfg := n.client.GetConfig() + if newCfg.Endpoint != oldCfg.Endpoint || newCfg.ID != oldCfg.ID || newCfg.Secret != oldCfg.Secret { + logger.Info("Config credentials changed (endpoint/id/secret), restarting...") + n.closeWgTunnel() + n.closeClients() + if n.healthMonitor != nil { + n.healthMonitor.Stop() + } + n.client.Close() + if n.config.OnRestart != nil { + if err := n.config.OnRestart(); err != nil { + logger.Error("Failed to restart: %v", err) + os.Exit(1) + } + } + } + if newCfg.Blocked != n.connectionBlocked.Load() { + n.connectionBlocked.Store(newCfg.Blocked) + if newCfg.Blocked { + logger.Debug("Config reload: connection blocking enabled") + } else { + logger.Debug("Config reload: connection blocking disabled") + } + if p := n.currentPM.Load(); p != nil { + p.SetBlocked(newCfg.Blocked) + } + n.setClientsBlocked(newCfg.Blocked) + } else { + logger.Debug("Config reload: no relevant changes detected") + } + case <-ctx.Done(): + return + } + } + }() +} diff --git a/newt/newt.go b/newt/newt.go new file mode 100644 index 0000000..4710569 --- /dev/null +++ b/newt/newt.go @@ -0,0 +1,291 @@ +package newt + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "sync/atomic" + "time" + + "github.com/fosrl/newt/authdaemon" + "github.com/fosrl/newt/browsergateway" + wgclients "github.com/fosrl/newt/clients" + "github.com/fosrl/newt/docker" + "github.com/fosrl/newt/healthcheck" + "github.com/fosrl/newt/logger" + "github.com/fosrl/newt/nativessh" + "github.com/fosrl/newt/proxy" + "github.com/fosrl/newt/util" + "github.com/fosrl/newt/websocket" + "golang.zx2c4.com/wireguard/device" + wtun "golang.zx2c4.com/wireguard/tun" + "golang.zx2c4.com/wireguard/tun/netstack" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" +) + +// Newt holds all runtime state for a newt tunnel instance. +type Newt struct { + config Config + client *websocket.Client + privateKey wgtypes.Key + publicKey wgtypes.Key + loggerLevel logger.LogLevel + tlsOpt websocket.ClientOption + + // WireGuard tunnel state + tun wtun.Device + tnet *netstack.Net + dev *device.Device + + // Proxy / networking + pm *proxy.ProxyManager + currentPM atomic.Pointer[proxy.ProxyManager] + connectionBlocked atomic.Bool + activeRemoteSubnets []string + + // Ping state + pingStopChan chan struct{} + pingWithRetryStopChan chan struct{} + + // Connection / messaging state + connected bool + stopFunc func() + pendingRegisterChainId string + pendingPingChainId string + + // Browser gateway + browserGateway *browsergateway.Gateway + browserGatewayStop func() + + // Health monitoring + healthMonitor *healthcheck.Monitor + + // Downstream WireGuard client management + wgService *wgclients.WireGuardService + ready bool + sshCredStore *nativessh.CredentialStore + + // Auth daemon (Linux only) + authDaemonServer *authdaemon.Server + + // Docker monitoring + dockerEventMonitor *docker.EventMonitor + + // Current tunnel data + wgData WgData +} + +// Init creates and initialises a Newt instance. It sets up the websocket +// client, generates WireGuard keys, and starts the auth daemon if enabled. +// Callers should invoke Start after any additional setup (telemetry, etc.). +func Init(ctx context.Context, cfg Config) (*Newt, error) { + n := &Newt{config: cfg} + + n.loggerLevel = util.ParseLogLevel(cfg.LogLevel) + + if !cfg.DisableSSH { + if err := n.startAuthDaemon(ctx); err != nil { + logger.Warn("Did not start on site auth daemon: %v", err) + } + } + + logger.GetLogger().SetLevel(n.loggerLevel) + + if cfg.TLSPrivateKey != "" { + logger.Warn("Using deprecated PKCS12 format for mTLS. Consider migrating to separate certificate files using --tls-client-cert-file, --tls-client-key, and --tls-client-ca") + } + + privateKey, err := wgtypes.GeneratePrivateKey() + if err != nil { + return nil, fmt.Errorf("generate private key: %w", err) + } + n.privateKey = privateKey + + if cfg.TLSClientCert != "" && cfg.TLSClientKey != "" { + n.tlsOpt = websocket.WithTLSConfig(websocket.TLSConfig{ + ClientCertFile: cfg.TLSClientCert, + ClientKeyFile: cfg.TLSClientKey, + CAFiles: cfg.TLSClientCAs, + }) + logger.Debug("Using separate certificate files for mTLS") + logger.Debug("Client cert: %s", cfg.TLSClientCert) + logger.Debug("Client key: %s", cfg.TLSClientKey) + logger.Debug("CA files: %v", cfg.TLSClientCAs) + } else if cfg.TLSPrivateKey != "" { + n.tlsOpt = websocket.WithTLSConfig(websocket.TLSConfig{ + PKCS12File: cfg.TLSPrivateKey, + }) + logger.Debug("Using PKCS12 file for mTLS: %s", cfg.TLSPrivateKey) + } + + client, err := websocket.NewClient( + "newt", + cfg.ID, + cfg.Secret, + cfg.Endpoint, + 30*time.Second, + n.tlsOpt, + websocket.WithConfigFile(cfg.ConfigFile), + ) + if err != nil { + return nil, fmt.Errorf("create websocket client: %w", err) + } + n.client = client + + 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). + n.config.Endpoint = client.GetConfig().Endpoint + n.config.ID = client.GetConfig().ID + n.config.Secret = client.GetConfig().Secret + + if !cfg.DisableSSH { + n.sshCredStore = nativessh.NewCredentialStore() + } + + return n, nil +} + +// GetConfig returns the (potentially resolved) configuration. +func (n *Newt) GetConfig() Config { + return n.config +} + +// GetTLSClientOpt returns the websocket TLS option, so callers can reuse the +// same TLS configuration for other HTTP clients (e.g. self-update). +func (n *Newt) GetTLSClientOpt() websocket.ClientOption { + return n.tlsOpt +} + +// Start sets up all WebSocket handlers, connects to the server, and blocks +// until ctx is cancelled. +func (n *Newt) Start(ctx context.Context) { + if !n.config.DisableClients { + n.setupClients() + } + + n.connectionBlocked.Store(n.client.GetConfig().Blocked) + if n.connectionBlocked.Load() { + logger.Info("Connection blocking is enabled (from config)") + n.setClientsBlocked(true) + } + + n.healthMonitor = healthcheck.NewMonitor(func(targets map[int]*healthcheck.Target) { + logger.Debug("Health check status update for %d targets", len(targets)) + + healthStatuses := make(map[int]interface{}) + for id, target := range targets { + healthStatuses[id] = map[string]interface{}{ + "status": target.Status.String(), + "lastCheck": target.LastCheck.Format(time.RFC3339), + "checkCount": target.CheckCount, + "lastError": target.LastError, + "config": target.Config, + } + } + + logger.Debug("Health check status: %+v", healthStatuses) + + if err := n.client.SendMessage("newt/healthcheck/status", map[string]interface{}{ + "targets": healthStatuses, + }); err != nil { + logger.Error("Failed to send health check status update: %v", err) + } + }, n.config.EnforceHealthcheckCert) + + n.registerHandlers(ctx) + + if err := n.client.Connect(); err != nil { + logger.Fatal("Failed to connect to server: %v", err) + } + defer n.client.Close() + + if n.config.DockerSocket != "" { + logger.Debug("Initializing Docker event monitoring") + var err error + n.dockerEventMonitor, err = docker.NewEventMonitor( + n.config.DockerSocket, + n.config.DockerEnforceNetworkValidation, + func(containers []docker.Container) { + logger.Debug("Docker event detected, sending updated container list (%d containers)", len(containers)) + if err := n.client.SendMessage("newt/socket/containers", map[string]interface{}{ + "containers": containers, + }); err != nil { + logger.Error("Failed to send updated container list after Docker event: %v", err) + } else { + logger.Debug("Updated container list sent successfully") + } + }) + if err != nil { + logger.Error("Failed to create Docker event monitor: %v", err) + } else { + if err := n.dockerEventMonitor.Start(); err != nil { + logger.Error("Failed to start Docker event monitoring: %v", err) + } else { + logger.Debug("Docker event monitoring started successfully") + } + } + } + + if n.config.BlueprintFile != "" { + go watchBlueprintFile(ctx, n.config.BlueprintFile, func() error { + return sendBlueprint(n.client, n.config.BlueprintFile) + }) + } + + <-ctx.Done() + + n.closeClients() + + if n.dockerEventMonitor != nil { + n.dockerEventMonitor.Stop() + } + + if n.healthMonitor != nil { + n.healthMonitor.Stop() + } + + if n.dev != nil { + n.dev.Close() + } + + if n.pm != nil { + n.pm.Stop() + } + + n.client.SendMessage("newt/disconnecting", map[string]any{}) + + if n.client != nil { + n.client.Close() + } + logger.Info("Exiting...") +} + +// Close performs an emergency shutdown: closes the tunnel, clients, health +// monitor, and websocket connection. Typically used before re-exec. +func (n *Newt) Close() { + n.closeWgTunnel() + n.closeClients() + if n.healthMonitor != nil { + n.healthMonitor.Stop() + } + if n.client != nil { + n.client.Close() + } +} + +func generateChainId() string { + b := make([]byte, 8) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/common_test.go b/newt/newt_test.go similarity index 92% rename from common_test.go rename to newt/newt_test.go index 82765da..26951ab 100644 --- a/common_test.go +++ b/newt/newt_test.go @@ -1,4 +1,4 @@ -package main +package newt import ( "context" @@ -317,7 +317,6 @@ func TestParseTargetString(t *testing.T) { } } -// TestParseTargetStringNetDialCompatibility verifies that the output is compatible with net.Dial. func TestParseTargetStringNetDialCompatibility(t *testing.T) { tests := []struct { name string @@ -345,18 +344,7 @@ func TestParseTargetStringNetDialCompatibility(t *testing.T) { // TestShouldFireRecovery is the regression guard for the broken trigger gate // that prevented data-plane recovery from ever firing under default settings -// (fosrl/newt#284, #310, pangolin#1004). The pre-fix condition was -// -// consecutiveFailures >= failureThreshold && currentInterval < maxInterval -// -// which became permanently false once pingInterval's default was bumped from -// 3s to 15s in commit 8161fa6 — currentInterval starts at pingInterval=15s, -// maxInterval stayed at 6s, so 15<6 is false and the recovery branch never -// executed. -// -// The fix is to drop currentInterval from the trigger condition entirely; -// backoff is a separate concern computed in the caller. The cases below -// exercise the documented contract. +// (fosrl/newt#284, #310, pangolin#1004). func TestShouldFireRecovery(t *testing.T) { const threshold = 4 cases := []struct { @@ -380,4 +368,4 @@ func TestShouldFireRecovery(t *testing.T) { } }) } -} \ No newline at end of file +} diff --git a/newt/ping.go b/newt/ping.go new file mode 100644 index 0000000..8ea7954 --- /dev/null +++ b/newt/ping.go @@ -0,0 +1,339 @@ +package newt + +import ( + "bytes" + "context" + "fmt" + "math/rand" + "os" + "os/exec" + "runtime" + "time" + + "github.com/fosrl/newt/internal/telemetry" + "github.com/fosrl/newt/logger" + "golang.org/x/net/icmp" + "golang.org/x/net/ipv4" + "golang.zx2c4.com/wireguard/tun/netstack" +) + +type pingFunc func(dst string, timeout time.Duration) (time.Duration, error) + +const msgHealthFileWriteFailed = "Failed to write health file: %v" + +func pingNative(dst string, timeout time.Duration) (time.Duration, error) { + timeoutSecs := int(timeout.Seconds()) + if timeoutSecs < 1 { + timeoutSecs = 1 + } + ctx, cancel := context.WithTimeout(context.Background(), timeout+time.Second) + defer cancel() + + var cmd *exec.Cmd + switch runtime.GOOS { + case "windows": + cmd = exec.CommandContext(ctx, "ping", "-n", "1", "-w", fmt.Sprintf("%d", int(timeout.Milliseconds())), dst) + case "darwin": + cmd = exec.CommandContext(ctx, "ping", "-c", "1", "-W", fmt.Sprintf("%d", int(timeout.Milliseconds())), dst) + default: + cmd = exec.CommandContext(ctx, "ping", "-c", "1", "-W", fmt.Sprintf("%d", timeoutSecs), dst) + } + + start := time.Now() + if err := cmd.Run(); err != nil { + return 0, fmt.Errorf("native ping to %s failed: %w", dst, err) + } + return time.Since(start), nil +} + +func ping(tnet *netstack.Net, dst string, timeout time.Duration) (time.Duration, error) { + socket, err := tnet.Dial("ping4", dst) + if err != nil { + return 0, fmt.Errorf("failed to create ICMP socket: %w", err) + } + defer socket.Close() + + if tcpConn, ok := socket.(interface{ SetReadBuffer(int) error }); ok { + tcpConn.SetReadBuffer(64 * 1024) + } + if tcpConn, ok := socket.(interface{ SetWriteBuffer(int) error }); ok { + tcpConn.SetWriteBuffer(64 * 1024) + } + + requestPing := icmp.Echo{ + Seq: rand.Intn(1 << 16), + Data: []byte("newtping"), + } + + icmpBytes, err := (&icmp.Message{Type: ipv4.ICMPTypeEcho, Code: 0, Body: &requestPing}).Marshal(nil) + if err != nil { + return 0, fmt.Errorf("failed to marshal ICMP message: %w", err) + } + + if err := socket.SetReadDeadline(time.Now().Add(timeout)); err != nil { + return 0, fmt.Errorf("failed to set read deadline: %w", err) + } + + start := time.Now() + _, err = socket.Write(icmpBytes) + if err != nil { + return 0, fmt.Errorf("failed to write ICMP packet: %w", err) + } + + readBuffer := make([]byte, 1500) + n, err := socket.Read(readBuffer) + if err != nil { + return 0, fmt.Errorf("failed to read ICMP packet: %w", err) + } + + replyPacket, err := icmp.ParseMessage(1, readBuffer[:n]) + if err != nil { + return 0, fmt.Errorf("failed to parse ICMP packet: %w", err) + } + + replyPing, ok := replyPacket.Body.(*icmp.Echo) + if !ok { + return 0, fmt.Errorf("invalid reply type: got %T, want *icmp.Echo", replyPacket.Body) + } + + if !bytes.Equal(replyPing.Data, requestPing.Data) || replyPing.Seq != requestPing.Seq { + return 0, fmt.Errorf("invalid ping reply: got seq=%d data=%q, want seq=%d data=%q", + replyPing.Seq, replyPing.Data, requestPing.Seq, requestPing.Data) + } + + return time.Since(start), nil +} + +func reliablePing(fn pingFunc, dst string, baseTimeout time.Duration, maxAttempts int) (time.Duration, error) { + var lastErr error + var totalLatency time.Duration + successCount := 0 + + for attempt := 1; attempt <= maxAttempts; attempt++ { + timeout := baseTimeout + time.Duration(attempt-1)*500*time.Millisecond + jitter := time.Duration(rand.Intn(100)) * time.Millisecond + timeout += jitter + + latency, err := fn(dst, timeout) + if err != nil { + lastErr = err + logger.Debug("Ping attempt %d/%d failed: %v", attempt, maxAttempts, err) + + if attempt < maxAttempts { + backoff := time.Duration(attempt) * 50 * time.Millisecond + time.Sleep(backoff) + } + continue + } + + totalLatency += latency + successCount++ + return totalLatency / time.Duration(successCount), nil + } + + return 0, fmt.Errorf("all %d ping attempts failed, last error: %v", maxAttempts, lastErr) +} + +// shouldFireRecovery decides whether the data-plane recovery flow should run on +// this tick. See startPingCheck for the rationale behind separating recovery +// from the backoff ramp. +func shouldFireRecovery(consecutiveFailures, failureThreshold int, connectionLost bool) bool { + return consecutiveFailures >= failureThreshold && !connectionLost +} + +func (n *Newt) pingWithRetry(fn pingFunc, dst string, timeout time.Duration) (stopChan chan struct{}, err error) { + if n.config.HealthFile != "" { + err = os.Remove(n.config.HealthFile) + if err != nil { + logger.Error("Failed to remove health file: %v", err) + } + } + + const ( + initialRetryDelay = 2 * time.Second + maxRetryDelay = 60 * time.Second + ) + + stopChan = make(chan struct{}) + attempt := 1 + retryDelay := initialRetryDelay + + logger.Debug("Ping attempt %d", attempt) + if latency, err := fn(dst, timeout); err == nil { + logger.Debug("Ping latency: %v", latency) + logger.Info("Tunnel connection to server established successfully!") + if n.config.HealthFile != "" { + if err := os.WriteFile(n.config.HealthFile, []byte("ok"), 0644); err != nil { + logger.Warn(msgHealthFileWriteFailed, err) + } + } + return stopChan, nil + } else { + logger.Warn("Ping attempt %d failed: %v", attempt, err) + } + + go func() { + attempt = 2 + + for { + select { + case <-stopChan: + return + default: + logger.Debug("Ping attempt %d", attempt) + + if latency, err := fn(dst, timeout); err != nil { + logger.Warn("Ping attempt %d failed: %v", attempt, err) + + if attempt%5 == 0 && retryDelay < maxRetryDelay { + retryDelay = time.Duration(float64(retryDelay) * 1.5) + if retryDelay > maxRetryDelay { + retryDelay = maxRetryDelay + } + logger.Info("Increasing ping retry delay to %v", retryDelay) + } + + time.Sleep(retryDelay) + attempt++ + } else { + logger.Debug("Ping succeeded after %d attempts", attempt) + logger.Debug("Ping latency: %v", latency) + logger.Info("Tunnel connection to server established successfully!") + if n.config.HealthFile != "" { + if err := os.WriteFile(n.config.HealthFile, []byte("ok"), 0644); err != nil { + logger.Warn(msgHealthFileWriteFailed, err) + } + } + return + } + case <-n.pingStopChan: + return + } + } + }() + + return stopChan, fmt.Errorf("initial ping attempts failed, continuing in background") +} + +func (n *Newt) startPingCheck(fn pingFunc, serverIP, tunnelID string) chan struct{} { + maxInterval := 6 * time.Second + currentInterval := n.config.PingInterval + consecutiveFailures := 0 + connectionLost := false + + recentLatencies := make([]time.Duration, 0, 10) + + pingStopChan := make(chan struct{}) + + go func() { + ticker := time.NewTicker(currentInterval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + adaptiveTimeout := n.config.PingTimeout + if len(recentLatencies) > 0 { + var sum time.Duration + for _, lat := range recentLatencies { + sum += lat + } + avgLatency := sum / time.Duration(len(recentLatencies)) + adaptiveTimeout = avgLatency * 3 + if adaptiveTimeout < n.config.PingTimeout { + adaptiveTimeout = n.config.PingTimeout + } + if adaptiveTimeout > 15*time.Second { + adaptiveTimeout = 15 * time.Second + } + } + + maxAttempts := 2 + if consecutiveFailures > 4 { + maxAttempts = 4 + } + + latency, err := reliablePing(fn, serverIP, adaptiveTimeout, maxAttempts) + if err != nil { + consecutiveFailures++ + + recentLatencies = append(recentLatencies, adaptiveTimeout) + if len(recentLatencies) > 10 { + recentLatencies = recentLatencies[1:] + } + + if consecutiveFailures < 2 { + logger.Debug("Periodic ping failed (%d consecutive failures): %v", consecutiveFailures, err) + } else { + logger.Warn("Periodic ping failed (%d consecutive failures): %v", consecutiveFailures, err) + } + + failureThreshold := 4 + if shouldFireRecovery(consecutiveFailures, failureThreshold, connectionLost) { + connectionLost = true + logger.Warn("Connection to server lost after %d failures. Continuous reconnection attempts will be made.", consecutiveFailures) + if tunnelID != "" { + telemetry.IncReconnect(context.Background(), tunnelID, "client", telemetry.ReasonTimeout) + } + pingChainId := generateChainId() + n.pendingPingChainId = pingChainId + n.stopFunc = n.client.SendMessageInterval("newt/ping/request", map[string]interface{}{ + "chainId": pingChainId, + }, 3*time.Second) + bcChainId := generateChainId() + n.pendingRegisterChainId = bcChainId + if err := n.client.SendMessage("newt/wg/register", map[string]interface{}{ + "publicKey": n.publicKey.String(), + "backwardsCompatible": true, + "chainId": bcChainId, + }); err != nil { + logger.Error("Failed to send registration message: %v", err) + } + if n.config.HealthFile != "" { + if err := os.Remove(n.config.HealthFile); err != nil { + logger.Error("Failed to remove health file: %v", err) + } + } + } + if consecutiveFailures >= failureThreshold && currentInterval < maxInterval { + currentInterval = time.Duration(float64(currentInterval) * 1.3) + if currentInterval > maxInterval { + currentInterval = maxInterval + } + } + } else { + recentLatencies = append(recentLatencies, latency) + if tunnelID != "" { + telemetry.ObserveTunnelLatency(context.Background(), tunnelID, "wireguard", latency.Seconds()) + } + if len(recentLatencies) > 10 { + recentLatencies = recentLatencies[1:] + } + + if connectionLost { + connectionLost = false + logger.Info("Connection to server restored after %d failures!", consecutiveFailures) + if n.config.HealthFile != "" { + if err := os.WriteFile(n.config.HealthFile, []byte("ok"), 0644); err != nil { + logger.Warn("Failed to write health file: %v", err) + } + } + } + if currentInterval > n.config.PingInterval { + currentInterval = time.Duration(float64(currentInterval) * 0.9) + if currentInterval < n.config.PingInterval { + currentInterval = n.config.PingInterval + } + ticker.Reset(currentInterval) + logger.Debug("Decreased ping check interval to %v after successful ping", currentInterval) + } + consecutiveFailures = 0 + } + case <-pingStopChan: + logger.Info("Stopping ping check") + return + } + } + }() + + return pingStopChan +} diff --git a/newt/targets.go b/newt/targets.go new file mode 100644 index 0000000..886738b --- /dev/null +++ b/newt/targets.go @@ -0,0 +1,150 @@ +package newt + +import ( + "encoding/json" + "fmt" + "net" + "os/exec" + "strings" + + "github.com/fosrl/newt/logger" + "github.com/fosrl/newt/proxy" +) + +func parseTargetData(data interface{}) (TargetData, error) { + var targetData TargetData + jsonData, err := json.Marshal(data) + if err != nil { + logger.Info("Error marshaling data: %v", err) + return targetData, err + } + + if err := json.Unmarshal(jsonData, &targetData); err != nil { + logger.Info("Error unmarshaling target data: %v", err) + return targetData, err + } + return targetData, nil +} + +// parseTargetString parses "listenPort:host:targetPort", handling IPv6 brackets. +func parseTargetString(target string) (int, string, error) { + firstColon := strings.Index(target, ":") + if firstColon == -1 { + return 0, "", fmt.Errorf("invalid target format, no colon found: %s", target) + } + + listenPortStr := target[:firstColon] + var listenPort int + _, err := fmt.Sscanf(listenPortStr, "%d", &listenPort) + if err != nil { + return 0, "", fmt.Errorf("invalid listen port: %s", listenPortStr) + } + if listenPort <= 0 || listenPort > 65535 { + return 0, "", fmt.Errorf("listen port out of range: %d", listenPort) + } + + remainder := target[firstColon+1:] + host, targetPort, err := net.SplitHostPort(remainder) + if err != nil { + return 0, "", fmt.Errorf("invalid host:port format '%s': %w", remainder, err) + } + + if host == "" { + return 0, "", fmt.Errorf("empty host in target: %s", target) + } + if targetPort == "" { + return 0, "", fmt.Errorf("empty target port in target: %s", target) + } + + return listenPort, net.JoinHostPort(host, targetPort), nil +} + +func (n *Newt) updateTargets(pm *proxy.ProxyManager, action string, tunnelIP string, proto string, targetData TargetData) error { + for _, t := range targetData.Targets { + port, target, err := parseTargetString(t) + if err != nil { + logger.Info("Invalid target format: %s (%v)", t, err) + continue + } + + switch action { + case "add": + processedTarget := target + if n.config.UpdownScript != "" { + newTarget, err := n.executeUpdownScript(action, proto, target) + if err != nil { + logger.Warn("Updown script error: %v", err) + } else if newTarget != "" { + processedTarget = newTarget + } + } + + err := pm.RemoveTarget(proto, tunnelIP, port) + if err != nil { + if !strings.Contains(err.Error(), "target not found") { + logger.Error("Failed to remove existing target: %v", err) + } + } + + pm.AddTarget(proto, tunnelIP, port, processedTarget) + + case "remove": + logger.Info("Removing target with port %d", port) + + if n.config.UpdownScript != "" { + _, err := n.executeUpdownScript(action, proto, target) + if err != nil { + logger.Warn("Updown script error: %v", err) + } + } + + err = pm.RemoveTarget(proto, tunnelIP, port) + if err != nil { + logger.Error("Failed to remove target: %v", err) + return err + } + default: + logger.Info("Unknown action: %s", action) + } + } + + return nil +} + +func (n *Newt) executeUpdownScript(action, proto, target string) (string, error) { + if n.config.UpdownScript == "" { + return target, nil + } + + parts := strings.Fields(n.config.UpdownScript) + if len(parts) == 0 { + return target, fmt.Errorf("invalid updown script command") + } + + var cmd *exec.Cmd + if len(parts) == 1 { + logger.Info("Executing updown script: %s %s %s %s", n.config.UpdownScript, action, proto, target) + cmd = exec.Command(parts[0], action, proto, target) + } else { + args := append(parts[1:], action, proto, target) + logger.Info("Executing updown script: %s %s %s %s %s", parts[0], strings.Join(parts[1:], " "), action, proto, target) + cmd = exec.Command(parts[0], args...) + } + + output, err := cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + return "", fmt.Errorf("updown script execution failed (exit code %d): %s", + exitErr.ExitCode(), string(exitErr.Stderr)) + } + return "", fmt.Errorf("updown script execution failed: %v", err) + } + + newTarget := strings.TrimSpace(string(output)) + if newTarget != "" { + logger.Info("Updown script returned new target: %s", newTarget) + return newTarget, nil + } + + return target, nil +} diff --git a/newt/tunnel.go b/newt/tunnel.go new file mode 100644 index 0000000..b396cce --- /dev/null +++ b/newt/tunnel.go @@ -0,0 +1,109 @@ +package newt + +import ( + "fmt" + + "github.com/fosrl/newt/logger" + "github.com/fosrl/newt/network" + "github.com/fosrl/newt/util" +) + +// updateRemoteExitNodeSubnets replaces the set of active remote exit node +// subnets with the given list, updating WireGuard AllowedIPs and native +// routes to match. +func (n *Newt) updateRemoteExitNodeSubnets(subnets []string) { + if n.config.UseNativeMainInterface && len(n.activeRemoteSubnets) > 0 { + toRemove := make([]string, 0) + newSet := make(map[string]bool, len(subnets)) + for _, s := range subnets { + newSet[s] = true + } + for _, s := range n.activeRemoteSubnets { + if !newSet[s] { + toRemove = append(toRemove, s) + } + } + if len(toRemove) > 0 { + if err := network.RemoveRoutes(toRemove); err != nil { + logger.Warn("Failed to remove old subnet routes: %v", err) + } + } + } + + if n.dev != nil && n.wgData.PublicKey != "" { + lines := fmt.Sprintf("public_key=%s\nreplace_allowed_ips=true\nallowed_ip=%s/32", + util.FixKey(n.wgData.PublicKey), n.wgData.ServerIP) + for _, s := range subnets { + lines += "\nallowed_ip=" + s + } + if err := n.dev.IpcSet(lines); err != nil { + logger.Warn("Failed to update WireGuard AllowedIPs: %v", err) + } + } + + if n.config.UseNativeMainInterface && len(subnets) > 0 { + existing := make(map[string]bool, len(n.activeRemoteSubnets)) + for _, s := range n.activeRemoteSubnets { + existing[s] = true + } + toAdd := make([]string, 0) + for _, s := range subnets { + if !existing[s] { + toAdd = append(toAdd, s) + } + } + if len(toAdd) > 0 { + if err := network.AddRoutes(toAdd, n.config.NativeMainInterfaceName); err != nil { + logger.Warn("Failed to add new subnet routes: %v", err) + } + } + } + + n.activeRemoteSubnets = append([]string{}, subnets...) + logger.Info("Updated remote exit node subnets: %d total", len(subnets)) +} + +func (n *Newt) closeWgTunnel() { + if n.pingStopChan != nil { + close(n.pingStopChan) + n.pingStopChan = nil + } + + if n.browserGatewayStop != nil { + n.browserGatewayStop() + n.browserGatewayStop = nil + n.browserGateway = nil + } + + if n.pm != nil { + n.pm.Stop() + n.currentPM.Store(nil) + n.pm = nil + } + + if n.config.UseNativeMainInterface { + toRemove := make([]string, 0, len(n.activeRemoteSubnets)+1) + if n.wgData.ServerIP != "" { + toRemove = append(toRemove, n.wgData.ServerIP+"/32") + } + toRemove = append(toRemove, n.activeRemoteSubnets...) + if len(toRemove) > 0 { + if err := network.RemoveRoutes(toRemove); err != nil { + logger.Warn("Failed to remove native main tunnel routes: %v", err) + } + } + n.activeRemoteSubnets = nil + } + + if n.dev != nil { + n.dev.Close() + n.dev = nil + } + + if n.tnet != nil { + n.tnet = nil + } + if n.tun != nil { + n.tun = nil + } +} diff --git a/newt/types.go b/newt/types.go new file mode 100644 index 0000000..b534713 --- /dev/null +++ b/newt/types.go @@ -0,0 +1,74 @@ +package newt + +import ( + wgclients "github.com/fosrl/newt/clients" + "github.com/fosrl/newt/healthcheck" +) + +type BrowserGatewayTarget struct { + ID int `json:"id"` + Type string `json:"type"` + Destination string `json:"destination"` + DestinationPort int `json:"destinationPort"` + AuthToken string `json:"authToken"` +} + +type WgData struct { + Endpoint string `json:"endpoint"` + RelayPort uint16 `json:"relayPort"` + PublicKey string `json:"publicKey"` + ServerIP string `json:"serverIP"` + TunnelIP string `json:"tunnelIP"` + Targets TargetsByType `json:"targets"` + HealthCheckTargets []healthcheck.Config `json:"healthCheckTargets"` + BrowserGatewayTargets []BrowserGatewayTarget `json:"browserGatewayTargets"` + RemoteExitNodeSubnets []string `json:"remoteExitNodeSubnets"` + ChainId string `json:"chainId"` +} + +type TargetsByType struct { + UDP []string `json:"udp"` + TCP []string `json:"tcp"` +} + +type TargetData struct { + Targets []string `json:"targets"` +} + +type ExitNodeData struct { + ExitNodes []ExitNode `json:"exitNodes"` + ChainId string `json:"chainId"` +} + +type ExitNode struct { + ID int `json:"exitNodeId"` + Name string `json:"exitNodeName"` + Endpoint string `json:"endpoint"` + Weight float64 `json:"weight"` + WasPreviouslyConnected bool `json:"wasPreviouslyConnected"` +} + +type ExitNodePingResult struct { + ExitNodeID int `json:"exitNodeId"` + LatencyMs int64 `json:"latencyMs"` + Weight float64 `json:"weight"` + Error string `json:"error,omitempty"` + Name string `json:"exitNodeName"` + Endpoint string `json:"endpoint"` + WasPreviouslyConnected bool `json:"wasPreviouslyConnected"` +} + +type BlueprintResult struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` +} + +// Define the sync data structure +type SyncData struct { + Targets TargetsByType `json:"proxyTargets"` + HealthCheckTargets []healthcheck.Config `json:"healthCheckTargets"` + RemoteExitNodeSubnets []string `json:"remoteExitNodeSubnets"` + Peers []wgclients.Peer `json:"peers"` + ClientTargets []wgclients.Target `json:"clientTargets"` + BrowserGatewayTargets []BrowserGatewayTarget `json:"browserGatewayTargets"` +} diff --git a/proxy/manager.go b/proxy/manager.go index 9203f18..df91a82 100644 --- a/proxy/manager.go +++ b/proxy/manager.go @@ -18,7 +18,6 @@ import ( "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" "golang.zx2c4.com/wireguard/tun/netstack" - "gvisor.dev/gvisor/pkg/tcpip/adapters/gonet" ) const ( @@ -54,15 +53,60 @@ type Target struct { Port int } +// managedListener wraps a net.Listener so an intentional Close() can be +// detected reliably by the accept loop. gVisor's netstack (unlike the +// stdlib) does not return net.ErrClosed from Accept() after Close() - it +// returns a generic "endpoint is in invalid state" error - so relying on +// errors.Is(err, net.ErrClosed) leaves the accept loop spinning forever. +type managedListener struct { + net.Listener + closed chan struct{} +} + +func newManagedListener(l net.Listener) *managedListener { + return &managedListener{Listener: l, closed: make(chan struct{})} +} + +func (m *managedListener) Close() error { + err := m.Listener.Close() + select { + case <-m.closed: + default: + close(m.closed) + } + return err +} + +// managedPacketConn is the net.PacketConn equivalent of managedListener. +type managedPacketConn struct { + net.PacketConn + closed chan struct{} +} + +func newManagedPacketConn(c net.PacketConn) *managedPacketConn { + return &managedPacketConn{PacketConn: c, closed: make(chan struct{})} +} + +func (m *managedPacketConn) Close() error { + err := m.PacketConn.Close() + select { + case <-m.closed: + default: + close(m.closed) + } + return err +} + // ProxyManager handles the creation and management of proxy connections type ProxyManager struct { - tnet *netstack.Net - tcpTargets map[string]map[int]string // map[listenIP]map[port]targetAddress - udpTargets map[string]map[int]string - listeners []*gonet.TCPListener - udpConns []*gonet.UDPConn - running bool - mutex sync.RWMutex + tnet *netstack.Net + tcpTargets map[string]map[int]string // map[listenIP]map[port]targetAddress + udpTargets map[string]map[int]string + listeners []net.Listener + udpConns []net.PacketConn + running bool + mutex sync.RWMutex + nativeListenIP string // when non-empty, use native OS listeners instead of netstack // telemetry (multi-tunnel) currentTunnelID string @@ -149,14 +193,28 @@ func classifyProxyError(err error) string { } } -// NewProxyManager creates a new proxy manager instance +// NewProxyManager creates a new proxy manager instance backed by a netstack. func NewProxyManager(tnet *netstack.Net) *ProxyManager { return &ProxyManager{ tnet: tnet, tcpTargets: make(map[string]map[int]string), udpTargets: make(map[string]map[int]string), - listeners: make([]*gonet.TCPListener, 0), - udpConns: make([]*gonet.UDPConn, 0), + listeners: make([]net.Listener, 0), + udpConns: make([]net.PacketConn, 0), + tunnels: make(map[string]*tunnelEntry), + udpIdleTimeout: defaultUDPIdleTimeout, + } +} + +// NewProxyManagerNative creates a proxy manager that binds listeners directly +// to the host network stack on the given IP address. +func NewProxyManagerNative(listenIP string) *ProxyManager { + return &ProxyManager{ + nativeListenIP: listenIP, + tcpTargets: make(map[string]map[int]string), + udpTargets: make(map[string]map[int]string), + listeners: make([]net.Listener, 0), + udpConns: make([]net.PacketConn, 0), tunnels: make(map[string]*tunnelEntry), udpIdleTimeout: defaultUDPIdleTimeout, } @@ -229,13 +287,14 @@ func (pm *ProxyManager) ClearTunnelID() { pm.currentTunnelID = "" } -// init function without tnet +// NewProxyManagerWithoutTNet creates a proxy manager with no backing network. +// Call SetTNet before starting. func NewProxyManagerWithoutTNet() *ProxyManager { return &ProxyManager{ tcpTargets: make(map[string]map[int]string), udpTargets: make(map[string]map[int]string), - listeners: make([]*gonet.TCPListener, 0), - udpConns: make([]*gonet.UDPConn, 0), + listeners: make([]net.Listener, 0), + udpConns: make([]net.PacketConn, 0), udpIdleTimeout: defaultUDPIdleTimeout, } } @@ -496,23 +555,46 @@ func (pm *ProxyManager) Stop() error { func (pm *ProxyManager) startTarget(proto, listenIP string, port int, targetAddr string) error { switch proto { case "tcp": - listener, err := pm.tnet.ListenTCP(&net.TCPAddr{Port: port}) - if err != nil { - return fmt.Errorf("failed to create TCP listener: %v", err) + var listener net.Listener + if pm.tnet != nil { + l, err := pm.tnet.ListenTCP(&net.TCPAddr{Port: port}) + if err != nil { + return fmt.Errorf("failed to create TCP listener: %v", err) + } + listener = l + } else if pm.nativeListenIP != "" { + l, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.ParseIP(pm.nativeListenIP), Port: port}) + if err != nil { + return fmt.Errorf("failed to create native TCP listener on %s:%d: %v", pm.nativeListenIP, port, err) + } + listener = l + } else { + return fmt.Errorf("proxy manager has no tnet or native IP configured") } - - pm.listeners = append(pm.listeners, listener) - go pm.handleTCPProxy(listener, targetAddr) + ml := newManagedListener(listener) + pm.listeners = append(pm.listeners, ml) + go pm.handleTCPProxy(ml, targetAddr) case "udp": - addr := &net.UDPAddr{Port: port} - conn, err := pm.tnet.ListenUDP(addr) - if err != nil { - return fmt.Errorf("failed to create UDP listener: %v", err) + var conn net.PacketConn + if pm.tnet != nil { + c, err := pm.tnet.ListenUDP(&net.UDPAddr{Port: port}) + if err != nil { + return fmt.Errorf("failed to create UDP listener: %v", err) + } + conn = c + } else if pm.nativeListenIP != "" { + c, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP(pm.nativeListenIP), Port: port}) + if err != nil { + return fmt.Errorf("failed to create native UDP listener on %s:%d: %v", pm.nativeListenIP, port, err) + } + conn = c + } else { + return fmt.Errorf("proxy manager has no tnet or native IP configured") } - - pm.udpConns = append(pm.udpConns, conn) - go pm.handleUDPProxy(conn, targetAddr) + mc := newManagedPacketConn(conn) + pm.udpConns = append(pm.udpConns, mc) + go pm.handleUDPProxy(mc, targetAddr) default: return fmt.Errorf(errUnsupportedProtoFmt, proto) @@ -532,11 +614,17 @@ func (pm *ProxyManager) getEntry(id string) *tunnelEntry { return e } -func (pm *ProxyManager) handleTCPProxy(listener net.Listener, targetAddr string) { +func (pm *ProxyManager) handleTCPProxy(listener *managedListener, targetAddr string) { for { conn, err := listener.Accept() if err != nil { telemetry.IncProxyAccept(context.Background(), pm.currentTunnelID, "tcp", "failure", classifyProxyError(err)) + select { + case <-listener.closed: + logger.Info("TCP listener closed, stopping proxy handler for %v", listener.Addr()) + return + default: + } if !pm.running { return } @@ -611,7 +699,7 @@ func (pm *ProxyManager) handleTCPProxy(listener net.Listener, targetAddr string) } } -func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) { +func (pm *ProxyManager) handleUDPProxy(conn *managedPacketConn, targetAddr string) { bufPtr := getUDPBuffer() defer putUDPBuffer(bufPtr) buffer := *bufPtr @@ -621,33 +709,41 @@ func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) { for { n, remoteAddr, err := conn.ReadFrom(buffer) if err != nil { - if !pm.running { - // Clean up all connections when stopping + closeAllClients := func() { clientsMutex.Lock() for _, targetConn := range clientConns { targetConn.Close() } clientConns = nil clientsMutex.Unlock() + } + + // Check for intentional closure first: netstack does not + // surface net.ErrClosed/io.EOF from ReadFrom() after Close(), + // so this channel is the only reliable signal. + select { + case <-conn.closed: + logger.Info("UDP connection closed, stopping proxy handler") + closeAllClients() + return + default: + } + + if !pm.running { + closeAllClients() return } // Check for connection closed conditions if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) { logger.Info("UDP connection closed, stopping proxy handler") - - // Clean up existing client connections - clientsMutex.Lock() - for _, targetConn := range clientConns { - targetConn.Close() - } - clientConns = nil - clientsMutex.Unlock() - + closeAllClients() return } logger.Error("Error reading UDP packet: %v", err) + // Avoid a tight busy-loop if this error persists. + time.Sleep(100 * time.Millisecond) continue } diff --git a/proxy/manager_test.go b/proxy/manager_test.go new file mode 100644 index 0000000..305287e --- /dev/null +++ b/proxy/manager_test.go @@ -0,0 +1,87 @@ +package proxy + +import ( + "context" + "net/netip" + "os" + "strings" + "testing" + "time" + + "github.com/fosrl/newt/internal/telemetry" + "github.com/fosrl/newt/logger" + "golang.zx2c4.com/wireguard/tun/netstack" +) + +// TestRemoveTargetStopsAcceptLoop verifies that removing a TCP target on a +// netstack-backed ProxyManager causes the accept loop goroutine to actually +// stop retrying, instead of spinning forever logging +// "Error accepting TCP connection: ... endpoint is in invalid state". +func TestRemoveTargetStopsAcceptLoop(t *testing.T) { + if _, err := telemetry.Init(context.Background(), telemetry.Config{ServiceName: "test"}); err != nil { + t.Fatalf("telemetry.Init: %v", err) + } + + logFile, err := os.CreateTemp(t.TempDir(), "newt-proxy-test-*.log") + if err != nil { + t.Fatalf("CreateTemp: %v", err) + } + defer logFile.Close() + logger.SetOutput(logFile) + defer logger.SetOutput(os.Stdout) + + _, tnet, err := netstack.CreateNetTUN( + []netip.Addr{netip.MustParseAddr("100.64.0.1")}, + []netip.Addr{}, + 1420, + ) + if err != nil { + t.Fatalf("CreateNetTUN: %v", err) + } + + pm := NewProxyManager(tnet) + const listenIP = "100.64.0.1" + const port = 53405 + + if err := pm.AddTarget("tcp", listenIP, port, "127.0.0.1:9999"); err != nil { + t.Fatalf("AddTarget: %v", err) + } + if err := pm.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + + if err := pm.RemoveTarget("tcp", listenIP, port); err != nil { + t.Fatalf("RemoveTarget: %v", err) + } + + // If the bug is present, the accept loop spins every 100ms logging an + // error forever. Sample the log twice, 400ms apart; a healthy accept + // loop logs the error/close message once (or zero times) and then goes + // silent, while the buggy loop keeps appending. + time.Sleep(200 * time.Millisecond) + countAt1 := countAcceptErrors(t, logFile.Name()) + + time.Sleep(400 * time.Millisecond) + countAt2 := countAcceptErrors(t, logFile.Name()) + + t.Logf("accept-error-ish log lines: at 200ms=%d, at 600ms=%d", countAt1, countAt2) + + if countAt2 > countAt1 { + t.Fatalf("accept loop kept logging after RemoveTarget (200ms=%d, 600ms=%d) -- it is spinning forever on the closed netstack listener instead of exiting", countAt1, countAt2) + } +} + +func countAcceptErrors(t *testing.T, path string) int { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + count := 0 + for _, line := range strings.Split(string(data), "\n") { + if strings.Contains(line, "Error accepting TCP connection") { + count++ + } + } + return count +} diff --git a/reexec_windows.go b/reexec_windows.go index 770f544..fb3700d 100644 --- a/reexec_windows.go +++ b/reexec_windows.go @@ -8,11 +8,21 @@ import ( "os/exec" ) -// reexec spawns a new copy of the current process with the same arguments and -// environment, then exits the current process. On Windows, execve is not -// available, so we start a child process and exit. On success it never returns -// (os.Exit terminates the current process). On failure it returns an error. +// reexec restarts newt. On Windows, execve is not available, so outside of +// service mode we start a child process and exit (on success this never +// returns since os.Exit terminates the current process). +// +// When running as a Windows service, spawning a detached child would orphan +// it from the Service Control Manager (the SCM only tracks processes it +// started itself), and os.Exit would end the process without ever reporting +// a clean status transition, making the service look like it crashed. So in +// that case we instead delegate to requestServiceRestart, which asks the SCM +// itself to relaunch the service. func reexec() error { + if isWindowsService() { + return requestServiceRestart() + } + exe, err := os.Executable() if err != nil { return fmt.Errorf("failed to get executable path: %w", err) diff --git a/service_windows.go b/service_windows.go index 3206d80..e03ec5b 100644 --- a/service_windows.go +++ b/service_windows.go @@ -11,6 +11,7 @@ import ( "os/signal" "path/filepath" "strings" + "sync/atomic" "syscall" "time" @@ -85,10 +86,44 @@ type newtService struct { args []string } +// activeService points at the newtService currently running under the SCM, +// so that requestServiceRestart (called from deep inside the app via +// cfg.OnRestart) can signal it without threading a reference through the +// newt package. restartRequested tells Execute's completion path whether the +// tunnel shut down because of a real Stop/Shutdown control request (report +// success) or because the app asked to be restarted (report a non-zero exit +// code so the SCM's configured recovery action relaunches the service as a +// fresh, SCM-tracked process). +var ( + activeService *newtService + restartRequested atomic.Bool +) + +// requestServiceRestart asks the Windows Service Control Manager to relaunch +// the service. Unlike reexec on other platforms, a Windows service cannot +// replace its own process image or spawn a detached child that the SCM would +// recognize - the SCM only respawns processes it started itself. So instead +// we gracefully stop the current run (same as a real Stop control request) +// and report a failure exit code on the way out, which - combined with the +// recovery actions configured in configureRecoveryActions - causes the SCM +// to start a brand new, properly tracked instance of the service. +func requestServiceRestart() error { + if activeService == nil || activeService.stop == nil { + return fmt.Errorf("newt service is not running") + } + restartRequested.Store(true) + activeService.elog.Info(1, "Restart requested; stopping tunnel and asking the service control manager to relaunch") + activeService.stop() + return nil +} + func (s *newtService) Execute(args []string, r <-chan svc.ChangeRequest, changes chan<- svc.Status) (bool, uint32) { const cmdsAccepted = svc.AcceptStop | svc.AcceptShutdown changes <- svc.Status{State: svc.StartPending} + activeService = s + restartRequested.Store(false) + s.elog.Info(1, fmt.Sprintf("Service Execute called with args: %v", args)) // Load saved service arguments @@ -169,8 +204,12 @@ func (s *newtService) Execute(args []string, r <-chan svc.ChangeRequest, changes s.elog.Error(1, fmt.Sprintf("Unexpected control request #%d", c)) } case <-newtDone: - s.elog.Info(1, "Main newt logic completed, stopping service") changes <- svc.Status{State: svc.StopPending} + if restartRequested.Load() { + s.elog.Info(1, "Main newt logic stopped for restart, reporting failure exit code so the SCM relaunches the service") + return false, 1 + } + s.elog.Info(1, "Main newt logic completed, stopping service") return false, 0 } } @@ -207,6 +246,52 @@ func (s *newtService) runNewt() { } } +// configureRecoveryActions tells the Service Control Manager to relaunch the +// service automatically when it stops with a non-zero exit code. This is +// what makes requestServiceRestart's approach (report failure, let the SCM +// respawn us) actually result in a restart. +func configureRecoveryActions(s *mgr.Service) error { + actions := []mgr.RecoveryAction{ + {Type: mgr.ServiceRestart, Delay: 5 * time.Second}, + {Type: mgr.ServiceRestart, Delay: 5 * time.Second}, + {Type: mgr.ServiceRestart, Delay: 30 * time.Second}, + } + if err := s.SetRecoveryActions(actions, 24*60*60); err != nil { + return fmt.Errorf("failed to set recovery actions: %v", err) + } + // By default the SCM only runs recovery actions when a service crashes. + // A restart-via-OnRestart is a controlled stop that merely reports a + // non-zero exit code, so this flag must be enabled for it to count. + if err := s.SetRecoveryActionsOnNonCrashFailures(true); err != nil { + return fmt.Errorf("failed to enable recovery actions on non-crash failures: %v", err) + } + return nil +} + +// ensureRecoveryActionsConfigured is a best-effort check run at service +// startup so that services installed by older versions of newt (before +// recovery actions existed) get them configured on their next start, without +// requiring a reinstall. +func ensureRecoveryActionsConfigured(name string, elog debug.Log) { + m, err := mgr.Connect() + if err != nil { + elog.Warning(1, fmt.Sprintf("Could not connect to service manager to verify recovery actions: %v", err)) + return + } + defer m.Disconnect() + + s, err := m.OpenService(name) + if err != nil { + elog.Warning(1, fmt.Sprintf("Could not open service to verify recovery actions: %v", err)) + return + } + defer s.Close() + + if err := configureRecoveryActions(s); err != nil { + elog.Warning(1, fmt.Sprintf("Could not configure recovery actions: %v", err)) + } +} + func runService(name string, isDebug bool, args []string) { var err error var elog debug.Log @@ -224,6 +309,7 @@ func runService(name string, isDebug bool, args []string) { defer elog.Close() elog.Info(1, fmt.Sprintf("Starting %s service", name)) + ensureRecoveryActionsConfigured(name, elog) run := svc.Run if isDebug { run = debug.Run @@ -283,6 +369,13 @@ func installService() error { return fmt.Errorf("failed to install event log: %v", err) } + if err := configureRecoveryActions(s); err != nil { + // Non-fatal: the service is installed and usable, it just won't + // auto-relaunch on an app-initiated restart until this is retried + // (ensureRecoveryActionsConfigured does so on every service start). + fmt.Printf("Warning: failed to configure service recovery actions: %v\n", err) + } + return nil } diff --git a/updates/selfupdate.go b/updates/selfupdate.go index c828236..2e12a5f 100644 --- a/updates/selfupdate.go +++ b/updates/selfupdate.go @@ -7,6 +7,7 @@ import ( "crypto/tls" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -50,6 +51,10 @@ type versionResponse struct { Message string `json:"message"` } +// ErrAutoUpdateUnsupportedInOfficialContainer indicates auto-update is not +// available when running inside official Fossorial container images. +var ErrAutoUpdateUnsupportedInOfficialContainer = errors.New("auto-update unsupported in official Fossorial container images") + // isOfficialContainer returns true when the process is running inside an // official Fossorial-built container image. The image sets // NEWT_SYSTEM_SUBSTRATE="CONTAINER" at build time; users running newt in their own @@ -113,7 +118,7 @@ func CheckAndSelfUpdate(cfg SelfUpdateConfig) error { if isOfficialContainer() { logger.Debug("checkAndSelfUpdate: running inside official container, skipping auto-update") - return fmt.Errorf("auto-update is not supported in official Fossorial container images; pull a new image tag instead") + return fmt.Errorf("%w; pull a new image tag instead", ErrAutoUpdateUnsupportedInOfficialContainer) } if cfg.CurrentVersion == "version_replaceme" { diff --git a/updates/updates.go b/updates/updates.go index 21666ac..10bf38e 100644 --- a/updates/updates.go +++ b/updates/updates.go @@ -9,11 +9,19 @@ import ( "time" ) -// GitHubRelease represents the GitHub API response for a release -type GitHubRelease struct { - TagName string `json:"tag_name"` - Name string `json:"name"` - HTMLURL string `json:"html_url"` +// VersionsAPIEntry represents one component's version payload. +type VersionsAPIEntry struct { + LatestVersion string `json:"latestVersion"` + ReleaseNotes string `json:"releaseNotes"` +} + +// VersionsAPIResponse represents the versions API response. +type VersionsAPIResponse struct { + Data map[string]VersionsAPIEntry `json:"data"` + Success bool `json:"success"` + Error bool `json:"error"` + Message string `json:"message"` + Status int `json:"status"` } // Version represents a semantic version @@ -75,14 +83,15 @@ func (v Version) String() string { return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) } -// CheckForUpdate checks GitHub for a newer version and prints an update banner if found +// CheckForUpdate checks the Fossorial versions API for a newer version and prints an update banner if found. func CheckForUpdate(owner, repo, currentVersion string) error { if currentVersion == "version_replaceme" { return nil } + _ = owner - // GitHub API URL for latest release - url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo) + // Versions API URL + url := "https://api.fossorial.io/api/v1/versions" // Create HTTP client with timeout client := &http.Client{ @@ -97,13 +106,18 @@ func CheckForUpdate(owner, repo, currentVersion string) error { defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return fmt.Errorf("GitHub API returned status: %d", resp.StatusCode) + return fmt.Errorf("versions API returned status: %d", resp.StatusCode) } - // Parse the JSON response - var release GitHubRelease - if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { - return fmt.Errorf("failed to parse release info: %w", err) + // Parse the JSON response. + var versionsResp VersionsAPIResponse + if err := json.NewDecoder(resp.Body).Decode(&versionsResp); err != nil { + return fmt.Errorf("failed to parse versions response: %w", err) + } + + componentVersion, ok := versionsResp.Data[repo] + if !ok { + return fmt.Errorf("component %q not found in versions API response", repo) } // Parse current and latest versions @@ -112,14 +126,18 @@ func CheckForUpdate(owner, repo, currentVersion string) error { return fmt.Errorf("invalid current version: %w", err) } - latestVer, err := parseVersion(release.TagName) + latestVer, err := parseVersion(componentVersion.LatestVersion) if err != nil { return fmt.Errorf("invalid latest version: %w", err) } // Check if update is available if currentVer.isNewer(latestVer) { - printUpdateBanner(currentVer.String(), latestVer.String(), "curl -fsSL https://static.pangolin.net/get-newt.sh | bash") + releaseNotes := componentVersion.ReleaseNotes + if releaseNotes == "" { + releaseNotes = "curl -fsSL https://static.pangolin.net/get-newt.sh | bash" + } + printUpdateBanner(currentVer.String(), latestVer.String(), releaseNotes) } return nil diff --git a/websocket/client.go b/websocket/client.go index 5e32e21..2fd036e 100644 --- a/websocket/client.go +++ b/websocket/client.go @@ -14,6 +14,7 @@ import ( "os" "strings" "sync" + "sync/atomic" "time" "software.sslmate.com/src/go-pkcs12" @@ -51,10 +52,11 @@ type Client struct { serverVersion string configVersion int64 // Latest config version received from server configVersionMux sync.RWMutex - processingMessage bool // Flag to track if a message is currently being processed - processingMux sync.RWMutex // Protects processingMessage - processingWg sync.WaitGroup // WaitGroup to wait for message processing to complete - justProvisioned bool // Set to true when provisionIfNeeded exchanges a key for permanent credentials + processingMessage bool // Flag to track if a message is currently being processed + processingMux sync.RWMutex // Protects processingMessage + processingWg sync.WaitGroup // WaitGroup to wait for message processing to complete + justProvisioned bool // Set to true when provisionIfNeeded exchanges a key for permanent credentials + consecutiveFailures atomic.Int32 // Counts consecutive connection failures for log suppression } type ClientOption func(*Client) @@ -419,7 +421,7 @@ func (c *Client) getToken() (string, error) { logger.Debug("Token response body: %s", string(body)) if resp.StatusCode != http.StatusOK { - logger.Error("Failed to get token with status code: %d", resp.StatusCode) + logger.Debug("Failed to get token with status code: %d", resp.StatusCode) telemetry.IncConnAttempt(ctx, "auth", "failure") etype := "io_error" if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { @@ -502,6 +504,12 @@ func classifyWSDisconnect(err error) (result, reason string) { } } +// consecutiveFailureThreshold is the number of consecutive failures before +// connection errors are promoted from Debug to Error. This suppresses the noisy +// transient errors that occur during routine server updates while still surfacing +// genuine connectivity problems. +const consecutiveFailureThreshold = 3 + func (c *Client) connectWithRetry() { for { select { @@ -510,10 +518,16 @@ func (c *Client) connectWithRetry() { default: err := c.establishConnection() if err != nil { - logger.Error("Failed to connect: %v. Retrying in %v...", err, c.reconnectInterval) + n := c.consecutiveFailures.Add(1) + if n >= consecutiveFailureThreshold { + logger.Error("Failed to connect: %v. Retrying in %v...", err, c.reconnectInterval) + } else { + logger.Debug("Failed to connect (attempt %d): %v. Retrying in %v...", n, err, c.reconnectInterval) + } time.Sleep(c.reconnectInterval) continue } + c.consecutiveFailures.Store(0) return } } 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") + } +} +