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 9ddc143..0000000 --- a/clients.go +++ /dev/null @@ -1,120 +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 - -// checkNativeMainPermissions returns an error if the process lacks the -// privileges needed to create a native TUN interface for the main tunnel. -func checkNativeMainPermissions() error { - return permissions.CheckNativeInterfacePermissions() -} - -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/common.go b/common.go deleted file mode 100644 index ce69d0f..0000000 --- a/common.go +++ /dev/null @@ -1,692 +0,0 @@ -package main - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net" - "os" - "os/exec" - "regexp" - "runtime" - "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" -) - -// pingFunc is a function that sends one ICMP echo to dst and returns the RTT. -type pingFunc func(dst string, timeout time.Duration) (time.Duration, error) - -// pingNative tests reachability of dst using the host OS ping command. -// Used when the main tunnel is a native TUN interface with no netstack. -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 -} - -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(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++ { - // 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 := fn(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(fn pingFunc, 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 := fn(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 := fn(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(fn pingFunc, 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(fn, 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..e3e96a7 --- /dev/null +++ b/config.go @@ -0,0 +1,365 @@ +package main + +import ( + "flag" + "fmt" + "os" + "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 +} + +// 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 reads environment variables and command-line flags, then +// returns a populated newtpkg.Config. This function calls flag.Parse internally +// and will exit the process if --version is passed. +func loadNewtConfig() newtpkg.Config { + // ---- read environment variables first ---- + cfg := newtpkg.Config{ + Version: newtVersion, + Platform: newtPlatform, + + Endpoint: os.Getenv("PANGOLIN_ENDPOINT"), + ID: os.Getenv("NEWT_ID"), + Secret: os.Getenv("NEWT_SECRET"), + DNS: os.Getenv("DNS"), + LogLevel: os.Getenv("LOG_LEVEL"), + UpdownScript: os.Getenv("UPDOWN_SCRIPT"), + InterfaceName: os.Getenv("INTERFACE"), + DockerSocket: os.Getenv("DOCKER_SOCKET"), + HealthFile: os.Getenv("HEALTH_FILE"), + BlueprintFile: os.Getenv("BLUEPRINT_FILE"), + ConfigFile: os.Getenv("CONFIG_FILE"), + ProvisioningKey: os.Getenv("NEWT_PROVISIONING_KEY"), + NewtName: os.Getenv("NEWT_NAME"), + TLSClientCert: os.Getenv("TLS_CLIENT_CERT"), + TLSClientKey: os.Getenv("TLS_CLIENT_KEY"), + TLSPrivateKey: os.Getenv("TLS_CLIENT_CERT_PKCS12"), + + AuthDaemonKey: os.Getenv("AD_KEY"), + AuthDaemonPrincipalsFile: os.Getenv("AD_PRINCIPALS_FILE"), + AuthDaemonCACertPath: os.Getenv("AD_CA_CERT_PATH"), + + NativeMainInterfaceName: os.Getenv("INTERFACE_MAIN"), + ProvisioningBlueprintFile: os.Getenv("PROVISIONING_BLUEPRINT_FILE"), + Region: os.Getenv("NEWT_REGION"), + AdminAddr: os.Getenv("NEWT_ADMIN_ADDR"), + } + + // Legacy PKCS12 backward-compat: fall back to TLS_CLIENT_CERT for PKCS12 + // when the newer env vars are not set. + if cfg.TLSPrivateKey == "" && cfg.TLSClientKey == "" && len(cfg.TLSClientCAs) == 0 { + cfg.TLSPrivateKey = os.Getenv("TLS_CLIENT_CERT") + } + + // TLS CA files: comma-separated list from env + if tlsClientCAsEnv := os.Getenv("TLS_CLIENT_CAS"); tlsClientCAsEnv != "" { + for _, ca := range strings.Split(tlsClientCAsEnv, ",") { + cfg.TLSClientCAs = append(cfg.TLSClientCAs, strings.TrimSpace(ca)) + } + } + + // Boolean env vars + disableClientsEnv := os.Getenv("DISABLE_CLIENTS") + disableSSHEnv := os.Getenv("DISABLE_SSH") + useNativeInterfaceEnv := os.Getenv("USE_NATIVE_INTERFACE") + useNativeMainInterfaceEnv := os.Getenv("USE_NATIVE_MAIN_INTERFACE") + enforceHealthcheckCertEnv := os.Getenv("ENFORCE_HC_CERT") + noCloudEnv := os.Getenv("NO_CLOUD") + adGenerateRandomPasswordEnv := os.Getenv("AD_GENERATE_RANDOM_PASSWORD") + + cfg.DisableClients = disableClientsEnv == "true" + cfg.DisableSSH = disableSSHEnv == "true" + cfg.UseNativeInterface = useNativeInterfaceEnv == "true" + cfg.UseNativeMainInterface = useNativeMainInterfaceEnv == "true" + cfg.EnforceHealthcheckCert = enforceHealthcheckCertEnv == "true" + cfg.NoCloud = noCloudEnv == "true" + + if v, err := strconv.ParseBool(adGenerateRandomPasswordEnv); err == nil { + cfg.AuthDaemonGenerateRandomPassword = v + } + + // Metrics env vars (parsing happens below after flag.Parse) + metricsEnabledEnv := os.Getenv("NEWT_METRICS_PROMETHEUS_ENABLED") + otlpEnabledEnv := os.Getenv("NEWT_METRICS_OTLP_ENABLED") + asyncBytesEnv := os.Getenv("NEWT_METRICS_ASYNC_BYTES") + pprofEnabledEnv := os.Getenv("NEWT_PPROF_ENABLED") + + if metricsEnabledEnv != "" { + if v, err := strconv.ParseBool(metricsEnabledEnv); err == nil { + cfg.MetricsEnabled = v + } else { + cfg.MetricsEnabled = true + } + } + if v, err := strconv.ParseBool(otlpEnabledEnv); err == nil { + cfg.OTLPEnabled = v + } + if v, err := strconv.ParseBool(asyncBytesEnv); err == nil { + cfg.MetricsAsyncBytes = v + } + if v, err := strconv.ParseBool(pprofEnabledEnv); err == nil { + cfg.PprofEnabled = v + } + + // Numeric / duration env vars (kept as strings; parsed after flag.Parse) + mtuStr := os.Getenv("MTU") + portStr := os.Getenv("PORT") + pingIntervalStr := os.Getenv("PING_INTERVAL") + pingTimeoutStr := os.Getenv("PING_TIMEOUT") + udpProxyIdleTimeoutStr := os.Getenv("NEWT_UDP_PROXY_IDLE_TIMEOUT") + dockerEnforceStr := os.Getenv("DOCKER_ENFORCE_NETWORK_VALIDATION") + + // ---- register CLI flags (only when env was not set) ---- + + if cfg.Endpoint == "" { + flag.StringVar(&cfg.Endpoint, "endpoint", "", "Endpoint of your pangolin server") + } + if cfg.ID == "" { + flag.StringVar(&cfg.ID, "id", "", "Newt ID") + } + if cfg.Secret == "" { + flag.StringVar(&cfg.Secret, "secret", "", "Newt secret") + } + if mtuStr == "" { + flag.StringVar(&mtuStr, "mtu", "1280", "MTU to use") + } + if cfg.DNS == "" { + flag.StringVar(&cfg.DNS, "dns", "9.9.9.9", "DNS server to use") + } + if cfg.LogLevel == "" { + flag.StringVar(&cfg.LogLevel, "log-level", "INFO", "Log level (DEBUG, INFO, WARN, ERROR, FATAL)") + } + if cfg.UpdownScript == "" { + flag.StringVar(&cfg.UpdownScript, "updown", "", "Path to updown script to be called when targets are added or removed") + } + if cfg.InterfaceName == "" { + flag.StringVar(&cfg.InterfaceName, "interface", "newt", "Name of the WireGuard interface") + } + if portStr == "" { + flag.StringVar(&portStr, "port", "", "Port for client WireGuard interface") + } + if useNativeInterfaceEnv == "" { + flag.BoolVar(&cfg.UseNativeInterface, "native", false, "Use native WireGuard interface for client tunnels") + } + if useNativeMainInterfaceEnv == "" { + flag.BoolVar(&cfg.UseNativeMainInterface, "native-main", false, "Use native WireGuard interface for the main tunnel (instead of netstack)") + } + if cfg.NativeMainInterfaceName == "" { + flag.StringVar(&cfg.NativeMainInterfaceName, "interface-main", "newtm", "Name of the native main tunnel WireGuard interface (used with --native-main)") + } + if disableClientsEnv == "" { + flag.BoolVar(&cfg.DisableClients, "disable-clients", false, "Disable clients on the WireGuard interface") + } + if disableSSHEnv == "" { + flag.BoolVar(&cfg.DisableSSH, "disable-ssh", false, "Disable SSH auth daemon and native SSH mode (remote auth daemon still works)") + } + if enforceHealthcheckCertEnv == "" { + flag.BoolVar(&cfg.EnforceHealthcheckCert, "enforce-hc-cert", false, "Enforce certificate validation for health checks (default: false, accepts any cert)") + } + if cfg.DockerSocket == "" { + flag.StringVar(&cfg.DockerSocket, "docker-socket", "", "Path or address to Docker socket (typically unix:///var/run/docker.sock)") + } + if pingIntervalStr == "" { + flag.StringVar(&pingIntervalStr, "ping-interval", "15s", "Interval for pinging the server (default 15s)") + } + if pingTimeoutStr == "" { + flag.StringVar(&pingTimeoutStr, "ping-timeout", "7s", "Timeout for each ping (default 7s)") + } + if udpProxyIdleTimeoutStr == "" { + flag.StringVar(&udpProxyIdleTimeoutStr, "udp-proxy-idle-timeout", "90s", "Idle timeout for UDP proxied client flows before cleanup") + } + flag.StringVar(&cfg.PreferEndpoint, "prefer-endpoint", "", "Prefer this endpoint for the connection (if set, will override the endpoint from the server)") + if cfg.ProvisioningKey == "" { + flag.StringVar(&cfg.ProvisioningKey, "provisioning-key", "", "One-time provisioning key used to obtain a newt ID and secret from the server") + } + if cfg.NewtName == "" { + flag.StringVar(&cfg.NewtName, "name", "", "Name for the site created during provisioning (supports {{env.VAR}} interpolation)") + } + if cfg.ConfigFile == "" { + flag.StringVar(&cfg.ConfigFile, "config-file", "", "Path to config file (overrides CONFIG_FILE env var and default location)") + } + if cfg.TLSClientCert == "" { + flag.StringVar(&cfg.TLSClientCert, "tls-client-cert-file", "", "Path to client certificate file (PEM/DER format)") + } + if cfg.TLSClientKey == "" { + flag.StringVar(&cfg.TLSClientKey, "tls-client-key", "", "Path to client private key file (PEM/DER format)") + } + // Backward-compat dummy flag (auth daemon is always enabled now) + flag.Bool("auth-daemon", false, "Enable auth daemon mode (deprecated, always enabled)") + + var tlsClientCAsFlag stringSlice + flag.Var(&tlsClientCAsFlag, "tls-client-ca", "Path to CA certificate file for validating remote certificates (can be specified multiple times)") + + if cfg.TLSPrivateKey == "" { + flag.StringVar(&cfg.TLSPrivateKey, "tls-client-cert", "", "Path to client certificate (PKCS12 format) - DEPRECATED: use --tls-client-cert-file and --tls-client-key instead") + } + if dockerEnforceStr == "" { + flag.StringVar(&dockerEnforceStr, "docker-enforce-network-validation", "false", "Enforce validation of container on newt network (true or false)") + } + if cfg.HealthFile == "" { + flag.StringVar(&cfg.HealthFile, "health-file", "", "Path to health file (if unset, health file won't be written)") + } + if cfg.BlueprintFile == "" { + flag.StringVar(&cfg.BlueprintFile, "blueprint-file", "", "Path to blueprint file (if unset, no blueprint will be applied)") + } + if cfg.ProvisioningBlueprintFile == "" { + flag.StringVar(&cfg.ProvisioningBlueprintFile, "provisioning-blueprint-file", "", "Path to blueprint file applied once after a provisioning credential exchange (if unset, no provisioning blueprint will be applied)") + } + if noCloudEnv == "" { + flag.BoolVar(&cfg.NoCloud, "no-cloud", false, "Disable cloud failover") + } + if metricsEnabledEnv == "" { + flag.BoolVar(&cfg.MetricsEnabled, "metrics", false, "Enable Prometheus metrics exporter") + } + if otlpEnabledEnv == "" { + flag.BoolVar(&cfg.OTLPEnabled, "otlp", false, "Enable OTLP exporters (metrics/traces) to OTEL_EXPORTER_OTLP_ENDPOINT") + } + if cfg.AdminAddr == "" { + flag.StringVar(&cfg.AdminAddr, "metrics-admin-addr", "127.0.0.1:2112", "Admin/metrics bind address") + } + if asyncBytesEnv == "" { + flag.BoolVar(&cfg.MetricsAsyncBytes, "metrics-async-bytes", false, "Enable async bytes counting (background flush; lower hot path overhead)") + } + if pprofEnabledEnv == "" { + flag.BoolVar(&cfg.PprofEnabled, "pprof", false, "Enable pprof debug endpoints on admin server") + } + if cfg.Region == "" { + flag.StringVar(&cfg.Region, "region", "", "Optional region resource attribute (also NEWT_REGION)") + } + if cfg.AuthDaemonKey == "" { + flag.StringVar(&cfg.AuthDaemonKey, "ad-pre-shared-key", "", "Pre-shared key for auth daemon authentication") + } + if cfg.AuthDaemonPrincipalsFile == "" { + flag.StringVar(&cfg.AuthDaemonPrincipalsFile, "ad-principals-file", "/var/run/auth-daemon/principals", "Path to the principals file for auth daemon") + } + if cfg.AuthDaemonCACertPath == "" { + flag.StringVar(&cfg.AuthDaemonCACertPath, "ad-ca-cert-path", "/etc/ssh/ca.pem", "Path to the CA certificate file for auth daemon") + } + if adGenerateRandomPasswordEnv == "" { + flag.BoolVar(&cfg.AuthDaemonGenerateRandomPassword, "ad-generate-random-password", false, "Generate a random password for authenticated users") + } + + version := flag.Bool("version", false, "Print the version") + + flag.Parse() + + // ---- post-parse processing ---- + + // Merge CLI CA files with env CA files + if len(tlsClientCAsFlag) > 0 { + cfg.TLSClientCAs = append(cfg.TLSClientCAs, tlsClientCAsFlag...) + } + + // Version check (exits process) + if *version { + fmt.Println("Newt version " + newtVersion) + os.Exit(0) + } else { + 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 dockerEnforceStr != "" { + if v, err := strconv.ParseBool(dockerEnforceStr); err == nil { + cfg.DockerEnforceNetworkValidation = v + } else { + logger.Info("Docker enforce network validation cannot be parsed. Defaulting to 'false'") + cfg.DockerEnforceNetworkValidation = false + } + } + + // 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 +} diff --git a/main.go b/main.go index 06049b1..38c0583 100644 --- a/main.go +++ b/main.go @@ -1,582 +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" - "runtime" - "strconv" - "strings" - "sync/atomic" "syscall" "time" - "github.com/fosrl/newt/authdaemon" - "github.com/fosrl/newt/browsergateway" - newtDevice "github.com/fosrl/newt/device" - "github.com/fosrl/newt/docker" - "github.com/fosrl/newt/healthcheck" - "github.com/fosrl/newt/logger" - "github.com/fosrl/newt/network" - "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" - wtun "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"` - 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"` -} - -// 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 - - // Native main tunnel flags - useNativeMainInterface bool - nativeMainInterfaceName string - - // Subnets currently routed through the main tunnel (beyond the server IP) - activeRemoteSubnets []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") + logger.Init(nil) - // 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") + cfg := loadNewtConfig() - 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" - useNativeMainInterfaceEnv := os.Getenv("USE_NATIVE_MAIN_INTERFACE") - useNativeMainInterface = useNativeMainInterfaceEnv == "true" - nativeMainInterfaceName = os.Getenv("INTERFACE_MAIN") - 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 for client tunnels") - } - if useNativeMainInterfaceEnv == "" { - flag.BoolVar(&useNativeMainInterface, "native-main", false, "Use native WireGuard interface for the main tunnel (instead of netstack)") - } - if nativeMainInterfaceName == "" { - flag.StringVar(&nativeMainInterfaceName, "interface-main", "newtm", "Name of the native main tunnel WireGuard interface (used with --native-main)") - } - 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) - } - - if useNativeMainInterface { - if err := checkNativeMainPermissions(); err != nil { + if cfg.UseNativeMainInterface { + if err := permissions.CheckNativeInterfacePermissions(); err != nil { logger.Fatal("Insufficient permissions for native main tunnel interface: %v", err) } } - 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) - } + if err := validateTLSConfig(cfg); err != nil { + logger.Fatal("TLS configuration error: %v", err) } - logger.GetLogger().SetLevel(loggerLevel) + 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) + } - // Initialize telemetry after flags are parsed (so flags override env) + 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 = metricsEnabled - tcfg.OTLPEnabled = otlpEnabled - if adminAddr != "" { - tcfg.AdminAddr = adminAddr + tcfg.PromEnabled = resolvedCfg.MetricsEnabled + tcfg.OTLPEnabled = resolvedCfg.OTLPEnabled + if resolvedCfg.AdminAddr != "" { + tcfg.AdminAddr = resolvedCfg.AdminAddr } - // Resource attributes (if available) - tcfg.SiteID = id - tcfg.Region = region - // Build info + tcfg.SiteID = resolvedCfg.ID + tcfg.Region = resolvedCfg.Region tcfg.BuildVersion = newtVersion tcfg.BuildCommit = os.Getenv("NEWT_COMMIT") @@ -584,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) @@ -614,108 +161,35 @@ 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) @@ -725,9 +199,8 @@ func runNewtMain(ctx context.Context) { } } 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 { @@ -740,1913 +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 wtun.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 - } - - // Remove native main tunnel routes before closing the device - if useNativeMainInterface { - toRemove := make([]string, 0, len(activeRemoteSubnets)+1) - if wgData.ServerIP != "" { - toRemove = append(toRemove, wgData.ServerIP+"/32") - } - toRemove = append(toRemove, activeRemoteSubnets...) - if len(toRemove) > 0 { - if err := network.RemoveRoutes(toRemove); err != nil { - logger.Warn("Failed to remove native main tunnel routes: %v", err) - } - } - activeRemoteSubnets = 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) - - if useNativeMainInterface { - mainIfName := 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 - } - } - tun, err = wtun.CreateTUN(mainIfName, mtuInt) - if err != nil { - logger.Error("Failed to create native main TUN device: %v", err) - regResult = "failure" - return - } - if realName, nameErr := tun.Name(); nameErr == nil { - mainIfName = realName - } - tnet = nil - nativeMainInterfaceName = mainIfName - } else { - 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" - } - - if useNativeMainInterface { - if cfgErr := network.ConfigureInterface(nativeMainInterfaceName, wgData.TunnelIP+"/32", mtuInt); cfgErr != nil { - logger.Error("Failed to configure native main tunnel interface: %v", cfgErr) - } - if routeErr := network.AddRoutes([]string{wgData.ServerIP + "/32"}, nativeMainInterfaceName); routeErr != nil { - logger.Warn("Failed to add route for main tunnel server IP: %v", routeErr) - } - // Set up UAPI so wg(8) can inspect the main tunnel interface - if fileUAPI, uapiErr := newtDevice.UapiOpen(nativeMainInterfaceName); uapiErr != nil { - logger.Warn("Main tunnel UAPI open error: %v", uapiErr) - } else if uapiListener, uapiListenErr := newtDevice.UapiListen(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 dev.IpcHandle(c) - } - }() - logger.Debug("Main tunnel UAPI listener started on %s", nativeMainInterfaceName) - } - } - - // Add remote exit node subnets: update WireGuard AllowedIPs and (for native - // mode) add kernel routes via the tunnel interface. - activeRemoteSubnets = nil - if len(wgData.RemoteExitNodeSubnets) > 0 { - for _, subnet := range wgData.RemoteExitNodeSubnets { - subnetCfg := fmt.Sprintf("public_key=%s\nallowed_ip=%s", util.FixKey(wgData.PublicKey), subnet) - if err := dev.IpcSet(subnetCfg); err != nil { - logger.Warn("Failed to add AllowedIP %s to main tunnel: %v", subnet, err) - } - } - if useNativeMainInterface { - if routeErr := network.AddRoutes(wgData.RemoteExitNodeSubnets, nativeMainInterfaceName); routeErr != nil { - logger.Warn("Failed to add routes for remote exit node subnets: %v", routeErr) - } - } - activeRemoteSubnets = append([]string{}, wgData.RemoteExitNodeSubnets...) - logger.Debug("Added %d remote exit node subnets", len(wgData.RemoteExitNodeSubnets)) - } - - 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 - } - - // Choose ping implementation: native OS ping for native-main, netstack for normal. - var pinger pingFunc - if useNativeMainInterface { - pinger = pingNative - } else { - pinger = func(dst string, timeout time.Duration) (time.Duration, error) { - return ping(tnet, dst, timeout) - } - } - - // Use reliable ping for initial connection test - logger.Debug("Testing initial connection with reliable ping...") - lat, err := reliablePing(pinger, 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(pinger, 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(pinger, wgData.ServerIP, client, wgData.PublicKey) - } - - // Create proxy manager - if useNativeMainInterface { - pm = proxy.NewProxyManagerNative(wgData.TunnelIP) - } else { - 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). - // Not needed for native main – the kernel routes UDP packets directly. - if !useNativeMainInterface { - 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) - - var ln net.Listener - var bgErr error - if useNativeMainInterface { - ln, bgErr = net.Listen("tcp", fmt.Sprintf("%s:%d", wgData.TunnelIP, browsergateway.ListenPort)) - } else { - 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) - // } - } - }) - - 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 || dev == nil { - return - } - - for _, subnet := range data.Subnets { - subnetCfg := fmt.Sprintf("public_key=%s\nallowed_ip=%s", util.FixKey(wgData.PublicKey), subnet) - if err := dev.IpcSet(subnetCfg); err != nil { - logger.Warn("Failed to add AllowedIP %s to main tunnel: %v", subnet, err) - } - } - if useNativeMainInterface { - if err := network.AddRoutes(data.Subnets, nativeMainInterfaceName); err != nil { - logger.Warn("Failed to add routes for subnets: %v", err) - } - } - activeRemoteSubnets = append(activeRemoteSubnets, data.Subnets...) - logger.Info("Added %d remote exit node subnets", len(data.Subnets)) - }) - - // newt/wg/subnets/update replaces the entire remote subnet list atomically. - // Matches the server-side "set resources" semantics used by setRemoteExitNodeResources. - 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 dev == nil { - return - } - - // Remove kernel routes for subnets no longer in the list (native mode) - if useNativeMainInterface && len(activeRemoteSubnets) > 0 { - toRemove := make([]string, 0) - newSet := make(map[string]bool, len(data.Subnets)) - for _, s := range data.Subnets { - newSet[s] = true - } - for _, s := range 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) - } - } - } - - // Replace WireGuard AllowedIPs with serverIP/32 + new subnet list - if wgData.PublicKey != "" { - lines := fmt.Sprintf("public_key=%s\nreplace_allowed_ips=true\nallowed_ip=%s/32", - util.FixKey(wgData.PublicKey), wgData.ServerIP) - for _, s := range data.Subnets { - lines += "\nallowed_ip=" + s - } - if err := dev.IpcSet(lines); err != nil { - logger.Warn("Failed to update WireGuard AllowedIPs: %v", err) - } - } - - // Add kernel routes for any newly added subnets (native mode) - if useNativeMainInterface && len(data.Subnets) > 0 { - existing := make(map[string]bool, len(activeRemoteSubnets)) - for _, s := range activeRemoteSubnets { - existing[s] = true - } - toAdd := make([]string, 0) - for _, s := range data.Subnets { - if !existing[s] { - toAdd = append(toAdd, s) - } - } - if len(toAdd) > 0 { - if err := network.AddRoutes(toAdd, nativeMainInterfaceName); err != nil { - logger.Warn("Failed to add new subnet routes: %v", err) - } - } - } - - activeRemoteSubnets = append([]string{}, data.Subnets...) - logger.Info("Updated remote exit node subnets: %d total", len(data.Subnets)) - }) - - 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 useNativeMainInterface { - if err := network.RemoveRoutes(data.Subnets); err != nil { - logger.Warn("Failed to remove routes for subnets: %v", err) - } - } - - // Rebuild WireGuard AllowedIPs without the removed subnets - toRemove := make(map[string]bool, len(data.Subnets)) - for _, s := range data.Subnets { - toRemove[s] = true - } - remaining := activeRemoteSubnets[:0] - for _, s := range activeRemoteSubnets { - if !toRemove[s] { - remaining = append(remaining, s) - } - } - activeRemoteSubnets = remaining - - if dev != nil && wgData.PublicKey != "" { - lines := fmt.Sprintf("public_key=%s\nreplace_allowed_ips=true\nallowed_ip=%s/32", - util.FixKey(wgData.PublicKey), wgData.ServerIP) - for _, s := range remaining { - lines += "\nallowed_ip=" + s - } - if err := 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)) - }) - - // 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 || useNativeMainInterface) { - browserGateway = browsergateway.New(browsergateway.Config{SSHCredentials: sshCredStore}) - var ln net.Listener - var bgErr error - if useNativeMainInterface { - ln, bgErr = net.Listen("tcp", fmt.Sprintf("%s:%d", wgData.TunnelIP, browsergateway.ListenPort)) - } else { - 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/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/handlers.go b/newt/handlers.go new file mode 100644 index 0000000..a9b58d7 --- /dev/null +++ b/newt/handlers.go @@ -0,0 +1,1438 @@ +package newt + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "net" + "net/http" + "net/netip" + "os" + "os/signal" + "runtime" + "strings" + "syscall" + "time" + + "github.com/fosrl/newt/authdaemon" + "github.com/fosrl/newt/browsergateway" + newtDevice "github.com/fosrl/newt/device" + "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/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" +) + +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) { + n.client.RegisterHandler("newt/wg/connect", func(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 { + if n.browserGatewayStop != nil { + n.browserGatewayStop() + n.browserGatewayStop = nil + } + + bgTargets := make([]browsergateway.Target, 0, len(n.wgData.BrowserGatewayTargets)) + for _, t := range n.wgData.BrowserGatewayTargets { + bgTargets = append(bgTargets, browsergateway.Target{ + ID: t.ID, + Type: t.Type, + Destination: t.Destination, + DestinationPort: t.DestinationPort, + AuthToken: t.AuthToken, + }) + } + + n.browserGateway = browsergateway.New(browsergateway.Config{SSHCredentials: n.sshCredStore}) + n.browserGateway.SetTargets(bgTargets) + + 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) + } 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) + } + }() + } + } + }) + + 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) { + 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/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 + } + + if n.config.UseNativeMainInterface && len(n.activeRemoteSubnets) > 0 { + toRemove := make([]string, 0) + newSet := make(map[string]bool, len(data.Subnets)) + for _, s := range data.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.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 data.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(data.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 data.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{}, data.Subnets...) + logger.Info("Updated remote exit node subnets: %d total", len(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/sync", func(msg websocket.WSMessage) { + logger.Info("Received sync message") + + if n.wgData.TunnelIP == "" || n.pm == nil { + logger.Info(msgNoTunnelOrProxy) + return + } + + 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)) + + logger.Info("Sync complete") + }) + + 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..85dd6b5 --- /dev/null +++ b/newt/newt.go @@ -0,0 +1,287 @@ +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 + + if cfg.ProvisioningKey != "" && client.GetConfig().ProvisioningKey == "" { + client.GetConfig().ProvisioningKey = cfg.ProvisioningKey + } + if cfg.NewtName != "" && client.GetConfig().Name == "" { + client.GetConfig().Name = cfg.NewtName + } + + // 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..521d0cb --- /dev/null +++ b/newt/tunnel.go @@ -0,0 +1,51 @@ +package newt + +import ( + "github.com/fosrl/newt/logger" + "github.com/fosrl/newt/network" +) + +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..5a10ce4 --- /dev/null +++ b/newt/types.go @@ -0,0 +1,61 @@ +package newt + +import "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"` +}