From 6d6717952c383a1c0976a088a272ebf3e289bdfc Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 2 Mar 2026 18:11:20 -0800 Subject: [PATCH 001/161] Support prefixes sent from server Former-commit-id: 039ae07b7b7b43d14a5a5fff4bed3b93d3e7544d --- clients/clients.go | 127 +++++++++++++++++++++++++-------------------- 1 file changed, 71 insertions(+), 56 deletions(-) diff --git a/clients/clients.go b/clients/clients.go index b7065fa..dff5025 100644 --- a/clients/clients.go +++ b/clients/clients.go @@ -37,11 +37,12 @@ type WgConfig struct { } type Target struct { - SourcePrefix string `json:"sourcePrefix"` - DestPrefix string `json:"destPrefix"` - RewriteTo string `json:"rewriteTo,omitempty"` - DisableIcmp bool `json:"disableIcmp,omitempty"` - PortRange []PortRange `json:"portRange,omitempty"` + SourcePrefix string `json:"sourcePrefix"` + SourcePrefixes []string `json:"sourcePrefixes"` + DestPrefix string `json:"destPrefix"` + RewriteTo string `json:"rewriteTo,omitempty"` + DisableIcmp bool `json:"disableIcmp,omitempty"` + PortRange []PortRange `json:"portRange,omitempty"` } type PortRange struct { @@ -277,7 +278,7 @@ func (s *WireGuardService) StartHolepunch(publicKey string, endpoint string, rel } if relayPort == 0 { - relayPort = 21820 + relayPort = 21820 } // Convert websocket.ExitNode to holepunch.ExitNode @@ -695,6 +696,19 @@ func (s *WireGuardService) ensureWireguardPeers(peers []Peer) error { return nil } +// resolveSourcePrefixes returns the effective list of source prefixes for a target, +// supporting both the legacy single SourcePrefix field and the new SourcePrefixes array. +// If SourcePrefixes is non-empty it takes precedence; otherwise SourcePrefix is used. +func resolveSourcePrefixes(target Target) []string { + if len(target.SourcePrefixes) > 0 { + return target.SourcePrefixes + } + if target.SourcePrefix != "" { + return []string{target.SourcePrefix} + } + return nil +} + func (s *WireGuardService) ensureTargets(targets []Target) error { if s.tnet == nil { // Native interface mode - proxy features not available, skip silently @@ -703,11 +717,6 @@ func (s *WireGuardService) ensureTargets(targets []Target) error { } for _, target := range targets { - sourcePrefix, err := netip.ParsePrefix(target.SourcePrefix) - if err != nil { - return fmt.Errorf("invalid CIDR %s: %v", target.SourcePrefix, err) - } - destPrefix, err := netip.ParsePrefix(target.DestPrefix) if err != nil { return fmt.Errorf("invalid CIDR %s: %v", target.DestPrefix, err) @@ -722,9 +731,14 @@ func (s *WireGuardService) ensureTargets(targets []Target) error { }) } - s.tnet.AddProxySubnetRule(sourcePrefix, destPrefix, target.RewriteTo, portRanges, target.DisableIcmp) - - logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", target.SourcePrefix, target.DestPrefix, target.RewriteTo, target.PortRange) + for _, sp := range resolveSourcePrefixes(target) { + sourcePrefix, err := netip.ParsePrefix(sp) + if err != nil { + return fmt.Errorf("invalid CIDR %s: %v", sp, err) + } + s.tnet.AddProxySubnetRule(sourcePrefix, destPrefix, target.RewriteTo, portRanges, target.DisableIcmp) + logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", sp, target.DestPrefix, target.RewriteTo, target.PortRange) + } } return nil @@ -1043,7 +1057,7 @@ func (s *WireGuardService) processPeerBandwidth(publicKey string, rxBytes, txByt BytesOut: bytesOutMB, } } - + return nil } } @@ -1094,12 +1108,6 @@ func (s *WireGuardService) handleAddTarget(msg websocket.WSMessage) { // Process all targets for _, target := range targets { - sourcePrefix, err := netip.ParsePrefix(target.SourcePrefix) - if err != nil { - logger.Info("Invalid CIDR %s: %v", target.SourcePrefix, err) - continue - } - destPrefix, err := netip.ParsePrefix(target.DestPrefix) if err != nil { logger.Info("Invalid CIDR %s: %v", target.DestPrefix, err) @@ -1109,15 +1117,21 @@ func (s *WireGuardService) handleAddTarget(msg websocket.WSMessage) { var portRanges []netstack2.PortRange for _, pr := range target.PortRange { portRanges = append(portRanges, netstack2.PortRange{ - Min: pr.Min, - Max: pr.Max, - Protocol: pr.Protocol, + Min: pr.Min, + Max: pr.Max, + Protocol: pr.Protocol, }) } - s.tnet.AddProxySubnetRule(sourcePrefix, destPrefix, target.RewriteTo, portRanges, target.DisableIcmp) - - logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", target.SourcePrefix, target.DestPrefix, target.RewriteTo, target.PortRange) + for _, sp := range resolveSourcePrefixes(target) { + sourcePrefix, err := netip.ParsePrefix(sp) + if err != nil { + logger.Info("Invalid CIDR %s: %v", sp, err) + continue + } + s.tnet.AddProxySubnetRule(sourcePrefix, destPrefix, target.RewriteTo, portRanges, target.DisableIcmp) + logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", sp, target.DestPrefix, target.RewriteTo, target.PortRange) + } } } @@ -1146,21 +1160,21 @@ func (s *WireGuardService) handleRemoveTarget(msg websocket.WSMessage) { // Process all targets for _, target := range targets { - sourcePrefix, err := netip.ParsePrefix(target.SourcePrefix) - if err != nil { - logger.Info("Invalid CIDR %s: %v", target.SourcePrefix, err) - continue - } - destPrefix, err := netip.ParsePrefix(target.DestPrefix) if err != nil { logger.Info("Invalid CIDR %s: %v", target.DestPrefix, err) continue } - s.tnet.RemoveProxySubnetRule(sourcePrefix, destPrefix) - - logger.Info("Removed target subnet %s with destination %s", target.SourcePrefix, target.DestPrefix) + for _, sp := range resolveSourcePrefixes(target) { + sourcePrefix, err := netip.ParsePrefix(sp) + if err != nil { + logger.Info("Invalid CIDR %s: %v", sp, err) + continue + } + s.tnet.RemoveProxySubnetRule(sourcePrefix, destPrefix) + logger.Info("Removed target subnet %s with destination %s", sp, target.DestPrefix) + } } } @@ -1194,30 +1208,24 @@ func (s *WireGuardService) handleUpdateTarget(msg websocket.WSMessage) { // Process all update requests for _, target := range requests.OldTargets { - sourcePrefix, err := netip.ParsePrefix(target.SourcePrefix) - if err != nil { - logger.Info("Invalid CIDR %s: %v", target.SourcePrefix, err) - continue - } - destPrefix, err := netip.ParsePrefix(target.DestPrefix) if err != nil { logger.Info("Invalid CIDR %s: %v", target.DestPrefix, err) continue } - s.tnet.RemoveProxySubnetRule(sourcePrefix, destPrefix) - logger.Info("Removed target subnet %s with destination %s", target.SourcePrefix, target.DestPrefix) + for _, sp := range resolveSourcePrefixes(target) { + sourcePrefix, err := netip.ParsePrefix(sp) + if err != nil { + logger.Info("Invalid CIDR %s: %v", sp, err) + continue + } + s.tnet.RemoveProxySubnetRule(sourcePrefix, destPrefix) + logger.Info("Removed target subnet %s with destination %s", sp, target.DestPrefix) + } } for _, target := range requests.NewTargets { - // Now add the new target - sourcePrefix, err := netip.ParsePrefix(target.SourcePrefix) - if err != nil { - logger.Info("Invalid CIDR %s: %v", target.SourcePrefix, err) - continue - } - destPrefix, err := netip.ParsePrefix(target.DestPrefix) if err != nil { logger.Info("Invalid CIDR %s: %v", target.DestPrefix, err) @@ -1227,14 +1235,21 @@ func (s *WireGuardService) handleUpdateTarget(msg websocket.WSMessage) { var portRanges []netstack2.PortRange for _, pr := range target.PortRange { portRanges = append(portRanges, netstack2.PortRange{ - Min: pr.Min, - Max: pr.Max, - Protocol: pr.Protocol, + Min: pr.Min, + Max: pr.Max, + Protocol: pr.Protocol, }) } - s.tnet.AddProxySubnetRule(sourcePrefix, destPrefix, target.RewriteTo, portRanges, target.DisableIcmp) - logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", target.SourcePrefix, target.DestPrefix, target.RewriteTo, target.PortRange) + for _, sp := range resolveSourcePrefixes(target) { + sourcePrefix, err := netip.ParsePrefix(sp) + if err != nil { + logger.Info("Invalid CIDR %s: %v", sp, err) + continue + } + s.tnet.AddProxySubnetRule(sourcePrefix, destPrefix, target.RewriteTo, portRanges, target.DisableIcmp) + logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", sp, target.DestPrefix, target.RewriteTo, target.PortRange) + } } } From 4daf103b5bddea887fb66143722caca684222644 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 19 Dec 2025 16:45:54 -0500 Subject: [PATCH 002/161] Add version and send it down Former-commit-id: 287eef0f446d6bf783e59664b4e1adc0a09a62d3 --- websocket/client.go | 35 ++++++++++++++++++++++++++++++++++- websocket/types.go | 5 +++-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/websocket/client.go b/websocket/client.go index da1fa88..c0fea18 100644 --- a/websocket/client.go +++ b/websocket/client.go @@ -47,6 +47,8 @@ type Client struct { metricsCtx context.Context configNeedsSave bool // Flag to track if config needs to be saved serverVersion string + configVersion int64 // Latest config version received from server + configVersionMux sync.RWMutex } type ClientOption func(*Client) @@ -154,6 +156,22 @@ func (c *Client) GetServerVersion() string { return c.serverVersion } +// GetConfigVersion returns the latest config version received from server +func (c *Client) GetConfigVersion() int64 { + c.configVersionMux.RLock() + defer c.configVersionMux.RUnlock() + return c.configVersion +} + +// setConfigVersion updates the config version if the new version is higher +func (c *Client) setConfigVersion(version int64) { + c.configVersionMux.Lock() + defer c.configVersionMux.Unlock() + if version > c.configVersion { + c.configVersion = version + } +} + // Connect establishes the WebSocket connection func (c *Client) Connect() error { go c.connectWithRetry() @@ -653,12 +671,22 @@ func (c *Client) pingMonitor() { if c.conn == nil { return } + + // Send application-level ping with config version + pingMsg := WSMessage{ + Type: "ping", + Data: map[string]interface{}{ + "configVersion": c.GetConfigVersion(), + }, + } + c.writeMux.Lock() - err := c.conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(c.pingTimeout)) + err := c.conn.WriteJSON(pingMsg) if err == nil { telemetry.IncWSMessage(c.metricsContext(), "out", "ping") } c.writeMux.Unlock() + if err != nil { // Check if we're shutting down before logging error and reconnecting select { @@ -737,6 +765,11 @@ func (c *Client) readPumpWithDisconnectDetection(started time.Time) { } } + // Extract and update config version from message if present + if msg.ConfigVersion > 0 { + c.setConfigVersion(msg.ConfigVersion) + } + c.handlersMux.RLock() if handler, ok := c.handlers[msg.Type]; ok { handler(msg) diff --git a/websocket/types.go b/websocket/types.go index 1196d64..381f7a1 100644 --- a/websocket/types.go +++ b/websocket/types.go @@ -17,6 +17,7 @@ type TokenResponse struct { } type WSMessage struct { - Type string `json:"type"` - Data interface{} `json:"data"` + Type string `json:"type"` + Data interface{} `json:"data"` + ConfigVersion int64 `json:"configVersion,omitempty"` } From 301bba3b08df25b8ac669de17863f64df5179672 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 21 Dec 2025 20:57:10 -0500 Subject: [PATCH 003/161] Working on message versioning Former-commit-id: 4e854b5f961e322573a29d0586dbc1f5578060c1 --- clients/clients.go | 178 +++++++++++++++++++++++++++++++++++++ healthcheck/healthcheck.go | 79 ++++++++++++++++ main.go | 145 ++++++++++++++++++++++++++++++ netstack2/proxy.go | 20 +++++ netstack2/tun.go | 9 ++ proxy/manager.go | 25 ++++++ websocket/client.go | 18 ++-- 7 files changed, 466 insertions(+), 8 deletions(-) diff --git a/clients/clients.go b/clients/clients.go index dff5025..4c64dbd 100644 --- a/clients/clients.go +++ b/clients/clients.go @@ -173,6 +173,7 @@ func NewWireGuardService(interfaceName string, port uint16, mtu int, host string wsClient.RegisterHandler("newt/wg/targets/add", service.handleAddTarget) wsClient.RegisterHandler("newt/wg/targets/remove", service.handleRemoveTarget) wsClient.RegisterHandler("newt/wg/targets/update", service.handleUpdateTarget) + wsClient.RegisterHandler("newt/wg/sync", service.handleSyncConfig) return service, nil } @@ -493,6 +494,183 @@ func (s *WireGuardService) handleConfig(msg websocket.WSMessage) { logger.Info("Client connectivity setup. Ready to accept connections from clients!") } +// SyncConfig represents the configuration sent from server for syncing +type SyncConfig struct { + Targets []Target `json:"targets"` + Peers []Peer `json:"peers"` +} + +func (s *WireGuardService) handleSyncConfig(msg websocket.WSMessage) { + var syncConfig SyncConfig + + logger.Debug("Received sync message: %v", msg) + logger.Info("Received sync configuration from remote server") + + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Error("Error marshaling sync data: %v", err) + return + } + + if err := json.Unmarshal(jsonData, &syncConfig); err != nil { + logger.Error("Error unmarshaling sync data: %v", err) + return + } + + // Sync peers + if err := s.syncPeers(syncConfig.Peers); err != nil { + logger.Error("Failed to sync peers: %v", err) + } + + // Sync targets + if err := s.syncTargets(syncConfig.Targets); err != nil { + logger.Error("Failed to sync targets: %v", err) + } +} + +// syncPeers synchronizes the current peers with the desired state +// It removes peers not in the desired list and adds missing ones +func (s *WireGuardService) syncPeers(desiredPeers []Peer) error { + if s.device == nil { + return fmt.Errorf("WireGuard device is not initialized") + } + + // Get current peers from the device + currentConfig, err := s.device.IpcGet() + if err != nil { + return fmt.Errorf("failed to get current device config: %v", err) + } + + // Parse current peer public keys + lines := strings.Split(currentConfig, "\n") + currentPeerKeys := make(map[string]bool) + for _, line := range lines { + if strings.HasPrefix(line, "public_key=") { + pubKey := strings.TrimPrefix(line, "public_key=") + currentPeerKeys[pubKey] = true + } + } + + // Build a map of desired peers by their public key (normalized) + desiredPeerMap := make(map[string]Peer) + for _, peer := range desiredPeers { + // Normalize the public key for comparison + pubKey, err := wgtypes.ParseKey(peer.PublicKey) + if err != nil { + logger.Warn("Invalid public key in desired peers: %s", peer.PublicKey) + continue + } + normalizedKey := util.FixKey(pubKey.String()) + desiredPeerMap[normalizedKey] = peer + } + + // Remove peers that are not in the desired list + for currentKey := range currentPeerKeys { + if _, exists := desiredPeerMap[currentKey]; !exists { + // Parse the key back to get the original format for removal + removeConfig := fmt.Sprintf("public_key=%s\nremove=true", currentKey) + if err := s.device.IpcSet(removeConfig); err != nil { + logger.Warn("Failed to remove peer %s during sync: %v", currentKey, err) + } else { + logger.Info("Removed peer %s during sync", currentKey) + } + } + } + + // Add peers that are missing + for normalizedKey, peer := range desiredPeerMap { + if _, exists := currentPeerKeys[normalizedKey]; !exists { + if err := s.addPeerToDevice(peer); err != nil { + logger.Warn("Failed to add peer %s during sync: %v", peer.PublicKey, err) + } else { + logger.Info("Added peer %s during sync", peer.PublicKey) + } + } + } + + return nil +} + +// syncTargets synchronizes the current targets with the desired state +// It removes targets not in the desired list and adds missing ones +func (s *WireGuardService) syncTargets(desiredTargets []Target) error { + if s.tnet == nil { + // Native interface mode - proxy features not available, skip silently + logger.Debug("Skipping target sync - using native interface (no proxy support)") + return nil + } + + // Get current rules from the proxy handler + currentRules := s.tnet.GetProxySubnetRules() + + // Build a map of current rules by source+dest prefix + type ruleKey struct { + sourcePrefix string + destPrefix string + } + currentRuleMap := make(map[ruleKey]bool) + for _, rule := range currentRules { + key := ruleKey{ + sourcePrefix: rule.SourcePrefix.String(), + destPrefix: rule.DestPrefix.String(), + } + currentRuleMap[key] = true + } + + // Build a map of desired targets + desiredTargetMap := make(map[ruleKey]Target) + for _, target := range desiredTargets { + key := ruleKey{ + sourcePrefix: target.SourcePrefix, + destPrefix: target.DestPrefix, + } + desiredTargetMap[key] = target + } + + // Remove targets that are not in the desired list + for _, rule := range currentRules { + key := ruleKey{ + sourcePrefix: rule.SourcePrefix.String(), + destPrefix: rule.DestPrefix.String(), + } + if _, exists := desiredTargetMap[key]; !exists { + s.tnet.RemoveProxySubnetRule(rule.SourcePrefix, rule.DestPrefix) + logger.Info("Removed target %s -> %s during sync", rule.SourcePrefix.String(), rule.DestPrefix.String()) + } + } + + // Add targets that are missing + for key, target := range desiredTargetMap { + if _, exists := currentRuleMap[key]; !exists { + sourcePrefix, err := netip.ParsePrefix(target.SourcePrefix) + if err != nil { + logger.Warn("Invalid source prefix %s during sync: %v", target.SourcePrefix, err) + continue + } + + destPrefix, err := netip.ParsePrefix(target.DestPrefix) + if err != nil { + logger.Warn("Invalid dest prefix %s during sync: %v", target.DestPrefix, err) + continue + } + + var portRanges []netstack2.PortRange + for _, pr := range target.PortRange { + portRanges = append(portRanges, netstack2.PortRange{ + Min: pr.Min, + Max: pr.Max, + Protocol: pr.Protocol, + }) + } + + s.tnet.AddProxySubnetRule(sourcePrefix, destPrefix, target.RewriteTo, portRanges, target.DisableIcmp) + logger.Info("Added target %s -> %s during sync", target.SourcePrefix, target.DestPrefix) + } + } + + return nil +} + func (s *WireGuardService) ensureWireguardInterface(wgconfig WgConfig) error { s.mu.Lock() diff --git a/healthcheck/healthcheck.go b/healthcheck/healthcheck.go index 9b23479..9889cc6 100644 --- a/healthcheck/healthcheck.go +++ b/healthcheck/healthcheck.go @@ -521,3 +521,82 @@ func (m *Monitor) DisableTarget(id int) error { return nil } + +// GetTargetIDs returns a slice of all current target IDs +func (m *Monitor) GetTargetIDs() []int { + m.mutex.RLock() + defer m.mutex.RUnlock() + + ids := make([]int, 0, len(m.targets)) + for id := range m.targets { + ids = append(ids, id) + } + return ids +} + +// SyncTargets synchronizes the current targets to match the desired set. +// It removes targets not in the desired set and adds targets that are missing. +func (m *Monitor) SyncTargets(desiredConfigs []Config) error { + m.mutex.Lock() + defer m.mutex.Unlock() + + logger.Info("Syncing health check targets: %d desired targets", len(desiredConfigs)) + + // Build a set of desired target IDs + desiredIDs := make(map[int]Config) + for _, config := range desiredConfigs { + desiredIDs[config.ID] = config + } + + // Find targets to remove (exist but not in desired set) + var toRemove []int + for id := range m.targets { + if _, exists := desiredIDs[id]; !exists { + toRemove = append(toRemove, id) + } + } + + // Remove targets that are not in the desired set + for _, id := range toRemove { + logger.Info("Sync: removing health check target %d", id) + if target, exists := m.targets[id]; exists { + target.cancel() + delete(m.targets, id) + } + } + + // Add or update targets from the desired set + var addedCount, updatedCount int + for id, config := range desiredIDs { + if existing, exists := m.targets[id]; exists { + // Target exists - check if config changed and update if needed + // For now, we'll replace it to ensure config is up to date + logger.Debug("Sync: updating health check target %d", id) + existing.cancel() + delete(m.targets, id) + if err := m.addTargetUnsafe(config); err != nil { + logger.Error("Sync: failed to update target %d: %v", id, err) + return fmt.Errorf("failed to update target %d: %v", id, err) + } + updatedCount++ + } else { + // Target doesn't exist - add it + logger.Debug("Sync: adding health check target %d", id) + if err := m.addTargetUnsafe(config); err != nil { + logger.Error("Sync: failed to add target %d: %v", id, err) + return fmt.Errorf("failed to add target %d: %v", id, err) + } + addedCount++ + } + } + + logger.Info("Sync complete: removed %d, added %d, updated %d targets", + len(toRemove), addedCount, updatedCount) + + // Notify callback if any changes were made + if (len(toRemove) > 0 || addedCount > 0 || updatedCount > 0) && m.callback != nil { + go m.callback(m.getAllTargetsUnsafe()) + } + + return nil +} diff --git a/main.go b/main.go index dee958a..b4175a2 100644 --- a/main.go +++ b/main.go @@ -1165,6 +1165,151 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( } }) + // 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)) + + // 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") diff --git a/netstack2/proxy.go b/netstack2/proxy.go index 388a3d1..2e2d763 100644 --- a/netstack2/proxy.go +++ b/netstack2/proxy.go @@ -48,6 +48,18 @@ type SubnetRule struct { PortRanges []PortRange // empty slice means all ports allowed } +// GetAllRules returns a copy of all subnet rules +func (sl *SubnetLookup) GetAllRules() []SubnetRule { + sl.mu.RLock() + defer sl.mu.RUnlock() + + rules := make([]SubnetRule, 0, len(sl.rules)) + for _, rule := range sl.rules { + rules = append(rules, *rule) + } + return rules +} + // connKey uniquely identifies a connection for NAT tracking type connKey struct { srcIP string @@ -200,6 +212,14 @@ func (p *ProxyHandler) RemoveSubnetRule(sourcePrefix, destPrefix netip.Prefix) { p.subnetLookup.RemoveSubnet(sourcePrefix, destPrefix) } +// GetAllRules returns all subnet rules from the proxy handler +func (p *ProxyHandler) GetAllRules() []SubnetRule { + if p == nil || !p.enabled { + return nil + } + return p.subnetLookup.GetAllRules() +} + // LookupDestinationRewrite looks up the rewritten destination for a connection // This is used by TCP/UDP handlers to find the actual target address func (p *ProxyHandler) LookupDestinationRewrite(srcIP, dstIP string, dstPort uint16, proto uint8) (netip.Addr, bool) { diff --git a/netstack2/tun.go b/netstack2/tun.go index e743f1e..b00faea 100644 --- a/netstack2/tun.go +++ b/netstack2/tun.go @@ -369,6 +369,15 @@ func (net *Net) RemoveProxySubnetRule(sourcePrefix, destPrefix netip.Prefix) { } } +// GetProxySubnetRules returns all subnet rules from the proxy handler +func (net *Net) GetProxySubnetRules() []SubnetRule { + tun := (*netTun)(net) + if tun.proxyHandler != nil { + return tun.proxyHandler.GetAllRules() + } + return nil +} + // GetProxyHandler returns the proxy handler (for advanced use cases) // Returns nil if proxy is not enabled func (net *Net) GetProxyHandler() *ProxyHandler { diff --git a/proxy/manager.go b/proxy/manager.go index cef5fa6..0619e80 100644 --- a/proxy/manager.go +++ b/proxy/manager.go @@ -736,3 +736,28 @@ func (pm *ProxyManager) PrintTargets() { } } } + +// GetTargets returns a copy of the current TCP and UDP targets +// Returns map[listenIP]map[port]targetAddress for both TCP and UDP +func (pm *ProxyManager) GetTargets() (tcpTargets map[string]map[int]string, udpTargets map[string]map[int]string) { + pm.mutex.RLock() + defer pm.mutex.RUnlock() + + tcpTargets = make(map[string]map[int]string) + for listenIP, targets := range pm.tcpTargets { + tcpTargets[listenIP] = make(map[int]string) + for port, targetAddr := range targets { + tcpTargets[listenIP][port] = targetAddr + } + } + + udpTargets = make(map[string]map[int]string) + for listenIP, targets := range pm.udpTargets { + udpTargets[listenIP] = make(map[int]string) + for port, targetAddr := range targets { + udpTargets[listenIP][port] = targetAddr + } + } + + return tcpTargets, udpTargets +} diff --git a/websocket/client.go b/websocket/client.go index c0fea18..8703b51 100644 --- a/websocket/client.go +++ b/websocket/client.go @@ -671,22 +671,24 @@ func (c *Client) pingMonitor() { if c.conn == nil { return } - - // Send application-level ping with config version + + c.configVersionMux.RLock() + configVersion := c.configVersion + c.configVersionMux.RUnlock() + pingMsg := WSMessage{ - Type: "ping", - Data: map[string]interface{}{ - "configVersion": c.GetConfigVersion(), - }, + Type: "ping", + Data: map[string]interface{}{}, + ConfigVersion: configVersion, } - + c.writeMux.Lock() err := c.conn.WriteJSON(pingMsg) if err == nil { telemetry.IncWSMessage(c.metricsContext(), "out", "ping") } c.writeMux.Unlock() - + if err != nil { // Check if we're shutting down before logging error and reconnecting select { From 7815fe40745b90589746b027f153552b430d195e Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 15 Jan 2026 21:33:11 -0800 Subject: [PATCH 004/161] Mutex on handlers, slight change to ping message and handler Former-commit-id: 15ea631b966d2175b3d03df85a8a6cee69a42f81 --- websocket/client.go | 40 ++++++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/websocket/client.go b/websocket/client.go index 8703b51..8b64e21 100644 --- a/websocket/client.go +++ b/websocket/client.go @@ -47,8 +47,11 @@ type Client struct { metricsCtx context.Context configNeedsSave bool // Flag to track if config needs to be saved serverVersion string - configVersion int64 // Latest config version received from server + configVersion int64 // Latest config version received from server configVersionMux sync.RWMutex + processingMessage bool // Flag to track if a message is currently being processed + processingMux sync.RWMutex // Protects processingMessage + processingWg sync.WaitGroup // WaitGroup to wait for message processing to complete } type ClientOption func(*Client) @@ -163,13 +166,11 @@ func (c *Client) GetConfigVersion() int64 { return c.configVersion } -// setConfigVersion updates the config version if the new version is higher +// setConfigVersion updates the config version func (c *Client) setConfigVersion(version int64) { c.configVersionMux.Lock() defer c.configVersionMux.Unlock() - if version > c.configVersion { - c.configVersion = version - } + c.configVersion = version } // Connect establishes the WebSocket connection @@ -672,12 +673,21 @@ func (c *Client) pingMonitor() { return } + // Skip ping if a message is currently being processed + c.processingMux.RLock() + isProcessing := c.processingMessage + c.processingMux.RUnlock() + if isProcessing { + logger.Debug("Skipping ping, message is being processed") + continue + } + c.configVersionMux.RLock() configVersion := c.configVersion c.configVersionMux.RUnlock() pingMsg := WSMessage{ - Type: "ping", + Type: "newt/ping", Data: map[string]interface{}{}, ConfigVersion: configVersion, } @@ -767,14 +777,24 @@ func (c *Client) readPumpWithDisconnectDetection(started time.Time) { } } - // Extract and update config version from message if present - if msg.ConfigVersion > 0 { - c.setConfigVersion(msg.ConfigVersion) - } + // Update config version from incoming message + c.setConfigVersion(msg.ConfigVersion) c.handlersMux.RLock() if handler, ok := c.handlers[msg.Type]; ok { + // Mark that we're processing a message + c.processingMux.Lock() + c.processingMessage = true + c.processingMux.Unlock() + c.processingWg.Add(1) + handler(msg) + + // Mark that we're done processing + c.processingWg.Done() + c.processingMux.Lock() + c.processingMessage = false + c.processingMux.Unlock() } c.handlersMux.RUnlock() } From d4a515cd76e0f19eb1afca3f15603f3fbb75d8f8 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 3 Mar 2026 16:11:32 -0800 Subject: [PATCH 005/161] Update the get all rules Former-commit-id: 6371e980d2e85570ada7013e91d34e4140092491 --- netstack2/proxy.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/netstack2/proxy.go b/netstack2/proxy.go index 2e2d763..1b34818 100644 --- a/netstack2/proxy.go +++ b/netstack2/proxy.go @@ -53,9 +53,14 @@ func (sl *SubnetLookup) GetAllRules() []SubnetRule { sl.mu.RLock() defer sl.mu.RUnlock() - rules := make([]SubnetRule, 0, len(sl.rules)) - for _, rule := range sl.rules { - rules = append(rules, *rule) + var rules []SubnetRule + for _, destTriePtr := range sl.sourceTrie.All() { + if destTriePtr == nil { + continue + } + for _, rule := range destTriePtr.rules { + rules = append(rules, *rule) + } } return rules } From 72e2ebc0b22bf2e2314e8b6aabd419796845116f Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 6 Mar 2026 15:14:48 -0800 Subject: [PATCH 006/161] Temp lets ignore the sync messages Former-commit-id: e68b65683fefd48b10198cca954f03b220a99934 --- main.go | 214 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 108 insertions(+), 106 deletions(-) diff --git a/main.go b/main.go index 0f4acd7..0141b77 100644 --- a/main.go +++ b/main.go @@ -565,7 +565,7 @@ func runNewtMain(ctx context.Context) { id, // CLI arg takes precedence secret, // CLI arg takes precedence endpoint, - pingInterval, + 30*time.Second, pingTimeout, opt, ) @@ -959,7 +959,7 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( "publicKey": publicKey.String(), "pingResults": pingResults, "newtVersion": newtVersion, - }, 1*time.Second) + }, 2*time.Second) return } @@ -1062,7 +1062,7 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( "publicKey": publicKey.String(), "pingResults": pingResults, "newtVersion": newtVersion, - }, 1*time.Second) + }, 2*time.Second) logger.Debug("Sent exit node ping results to cloud for selection: pingResults=%+v", pingResults) }) @@ -1198,116 +1198,118 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( logger.Debug("Sync data received: TCP targets=%d, UDP targets=%d, health check targets=%d", len(syncData.Targets.TCP), len(syncData.Targets.UDP), len(syncData.HealthCheckTargets)) - // Build sets of desired targets (port -> target string) - desiredTCP := make(map[int]string) - for _, t := range syncData.Targets.TCP { - parts := strings.Split(t, ":") - if len(parts) != 3 { - logger.Warn("Invalid TCP target format: %s", t) - continue - } - port := 0 - if _, err := fmt.Sscanf(parts[0], "%d", &port); err != nil { - logger.Warn("Invalid port in TCP target: %s", parts[0]) - continue - } - desiredTCP[port] = parts[1] + ":" + parts[2] - } + //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] - } + // 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() + // // 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}}) - } - } - } + // // 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}}) - } - } + // // 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}}) - } - } - } + // // 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}}) - } - } + // // 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") - } + // // 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") }) From 838f99686bd8035ddadd05544b8ffd05ce4ece1b Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 7 Mar 2026 10:17:14 -0800 Subject: [PATCH 007/161] Build full arn Former-commit-id: fac0f5b1978814bd98b2f8cf994efc79857c27fb --- .github/workflows/cicd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 815c400..4df4ba8 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -70,7 +70,7 @@ jobs: - name: Configure AWS credentials (OIDC) uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6.0.0 with: - role-to-assume: ${{ secrets.AWS_ROLE_ARN }} + role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }} role-duration-seconds: 3600 aws-region: ${{ secrets.AWS_REGION }} From a8063cdffe0bff08024941aeee2529739ebd510e Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 7 Mar 2026 10:36:18 -0800 Subject: [PATCH 008/161] Make sure to skip prepare Former-commit-id: 1bd1133ac2bbf54714de20baec5f4164d444e68a --- .github/workflows/cicd.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 4df4ba8..4d55ed4 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -136,7 +136,7 @@ jobs: build-amd: name: Build image (linux/amd64) needs: [pre-run, prepare] - if: ${{ needs.pre-run.result == 'success' && ((github.event_name == 'push' && github.actor != 'github-actions[bot]') || (github.event_name == 'workflow_dispatch' && needs.prepare.result == 'success')) }} + if: ${{ needs.pre-run.result == 'success' && ((github.event_name == 'push' && github.actor != 'github-actions[bot]') || (github.event_name == 'workflow_dispatch' && (needs.prepare.result == 'success' || needs.prepare.result == 'skipped'))) }} runs-on: [self-hosted, linux, x64] timeout-minutes: 120 env: @@ -293,7 +293,7 @@ jobs: build-arm: name: Build image (linux/arm64) needs: [pre-run, prepare] - if: ${{ needs.pre-run.result == 'success' && ((github.event_name == 'push' && github.actor != 'github-actions[bot]') || (github.event_name == 'workflow_dispatch' && needs.prepare.result == 'success')) }} + if: ${{ needs.pre-run.result == 'success' && ((github.event_name == 'push' && github.actor != 'github-actions[bot]') || (github.event_name == 'workflow_dispatch' && (needs.prepare.result == 'success' || needs.prepare.result == 'skipped'))) }} runs-on: [self-hosted, linux, arm64] # NOTE: ensure label exists on runner timeout-minutes: 120 env: @@ -417,7 +417,7 @@ jobs: build-armv7: name: Build image (linux/arm/v7) needs: [pre-run, prepare] - if: ${{ needs.pre-run.result == 'success' && ((github.event_name == 'push' && github.actor != 'github-actions[bot]') || (github.event_name == 'workflow_dispatch' && needs.prepare.result == 'success')) }} + if: ${{ needs.pre-run.result == 'success' && ((github.event_name == 'push' && github.actor != 'github-actions[bot]') || (github.event_name == 'workflow_dispatch' && (needs.prepare.result == 'success' || needs.prepare.result == 'skipped'))) }} runs-on: [self-hosted, linux, arm64] timeout-minutes: 120 env: @@ -919,7 +919,7 @@ jobs: - name: Configure AWS credentials (OIDC) uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6.0.0 with: - role-to-assume: ${{ secrets.AWS_ROLE_ARN }} + role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }} role-duration-seconds: 3600 aws-region: ${{ secrets.AWS_REGION }} From e03963003dd86c75854415168958b46100b76cb4 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 7 Mar 2026 12:32:49 -0800 Subject: [PATCH 009/161] Make sure to set version and fix prepare issue Former-commit-id: afdb1fc9772f376aefa4db05ed14faf083813176 --- .github/workflows/cicd.yml | 8 ++++---- Makefile | 23 +++++++++++++---------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 4d55ed4..d0af856 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -136,7 +136,7 @@ jobs: build-amd: name: Build image (linux/amd64) needs: [pre-run, prepare] - if: ${{ needs.pre-run.result == 'success' && ((github.event_name == 'push' && github.actor != 'github-actions[bot]') || (github.event_name == 'workflow_dispatch' && (needs.prepare.result == 'success' || needs.prepare.result == 'skipped'))) }} + if: ${{ needs.pre-run.result == 'success' && ((github.event_name == 'push' && github.actor != 'github-actions[bot]' && needs.prepare.result == 'skipped') || (github.event_name == 'workflow_dispatch' && (needs.prepare.result == 'success' || needs.prepare.result == 'skipped'))) }} runs-on: [self-hosted, linux, x64] timeout-minutes: 120 env: @@ -293,7 +293,7 @@ jobs: build-arm: name: Build image (linux/arm64) needs: [pre-run, prepare] - if: ${{ needs.pre-run.result == 'success' && ((github.event_name == 'push' && github.actor != 'github-actions[bot]') || (github.event_name == 'workflow_dispatch' && (needs.prepare.result == 'success' || needs.prepare.result == 'skipped'))) }} + if: ${{ needs.pre-run.result == 'success' && ((github.event_name == 'push' && github.actor != 'github-actions[bot]' && needs.prepare.result == 'skipped') || (github.event_name == 'workflow_dispatch' && (needs.prepare.result == 'success' || needs.prepare.result == 'skipped'))) }} runs-on: [self-hosted, linux, arm64] # NOTE: ensure label exists on runner timeout-minutes: 120 env: @@ -417,7 +417,7 @@ jobs: build-armv7: name: Build image (linux/arm/v7) needs: [pre-run, prepare] - if: ${{ needs.pre-run.result == 'success' && ((github.event_name == 'push' && github.actor != 'github-actions[bot]') || (github.event_name == 'workflow_dispatch' && (needs.prepare.result == 'success' || needs.prepare.result == 'skipped'))) }} + if: ${{ needs.pre-run.result == 'success' && ((github.event_name == 'push' && github.actor != 'github-actions[bot]' && needs.prepare.result == 'skipped') || (github.event_name == 'workflow_dispatch' && (needs.prepare.result == 'success' || needs.prepare.result == 'skipped'))) }} runs-on: [self-hosted, linux, arm64] timeout-minutes: 120 env: @@ -887,7 +887,7 @@ jobs: shell: bash run: | set -euo pipefail - make -j 10 go-build-release tag="${TAG}" + make -j 10 go-build-release VERSION="${TAG}" - name: Create GitHub Release (draft) uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2 diff --git a/Makefile b/Makefile index e720189..c35bbbf 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,9 @@ all: local +VERSION ?= dev +LDFLAGS = -X main.newtVersion=$(VERSION) + local: CGO_ENABLED=0 go build -o ./bin/newt @@ -40,31 +43,31 @@ go-build-release: \ go-build-release-freebsd-arm64 go-build-release-linux-arm64: - CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o bin/newt_linux_arm64 + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o bin/newt_linux_arm64 go-build-release-linux-arm32-v7: - CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -o bin/newt_linux_arm32 + CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags "$(LDFLAGS)" -o bin/newt_linux_arm32 go-build-release-linux-arm32-v6: - CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -o bin/newt_linux_arm32v6 + CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags "$(LDFLAGS)" -o bin/newt_linux_arm32v6 go-build-release-linux-amd64: - CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/newt_linux_amd64 + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/newt_linux_amd64 go-build-release-linux-riscv64: - CGO_ENABLED=0 GOOS=linux GOARCH=riscv64 go build -o bin/newt_linux_riscv64 + CGO_ENABLED=0 GOOS=linux GOARCH=riscv64 go build -ldflags "$(LDFLAGS)" -o bin/newt_linux_riscv64 go-build-release-darwin-arm64: - CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o bin/newt_darwin_arm64 + CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o bin/newt_darwin_arm64 go-build-release-darwin-amd64: - CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o bin/newt_darwin_amd64 + CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/newt_darwin_amd64 go-build-release-windows-amd64: - CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o bin/newt_windows_amd64.exe + CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/newt_windows_amd64.exe go-build-release-freebsd-amd64: - CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -o bin/newt_freebsd_amd64 + CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/newt_freebsd_amd64 go-build-release-freebsd-arm64: - CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -o bin/newt_freebsd_arm64 + CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o bin/newt_freebsd_arm64 \ No newline at end of file From 4ce7b433ff4825a35a75fe93ff5a7a1e2060d880 Mon Sep 17 00:00:00 2001 From: Laurence Date: Thu, 5 Mar 2026 15:12:47 +0000 Subject: [PATCH 010/161] Parse target strings with IPv6 support and strict validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add parseTargetString() for listenPort:host:targetPort using net.SplitHostPort/JoinHostPort. Replace manual split in updateTargets; fix err shadowing on remove. Validate listen port 1–65535 and reject empty host/port; use %w for errors. Add tests for IPv4, IPv6, hostnames, and invalid cases. Former-commit-id: 768415f90be5b8516eae674a325c8adaebdbfa85 --- common.go | 66 +++++++++++---- common_test.go | 212 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 262 insertions(+), 16 deletions(-) create mode 100644 common_test.go diff --git a/common.go b/common.go index 5fe0645..4701411 100644 --- a/common.go +++ b/common.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "net" "os" "os/exec" "strings" @@ -363,27 +364,62 @@ func parseTargetData(data interface{}) (TargetData, error) { 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 { - // Split the first number off of the target with : separator and use as the port - parts := strings.Split(t, ":") - if len(parts) != 3 { - logger.Info("Invalid target format: %s", t) - continue - } - - // Get the port as an int - port := 0 - _, err := fmt.Sscanf(parts[0], "%d", &port) + // Parse the target string, handling both IPv4 and IPv6 addresses + port, target, err := parseTargetString(t) if err != nil { - logger.Info("Invalid port: %s", parts[0]) + logger.Info("Invalid target format: %s (%v)", t, err) continue } switch action { case "add": - target := parts[1] + ":" + parts[2] - // Call updown script if provided processedTarget := target if updownScript != "" { @@ -410,8 +446,6 @@ func updateTargets(pm *proxy.ProxyManager, action string, tunnelIP string, proto case "remove": logger.Info("Removing target with port %d", port) - target := parts[1] + ":" + parts[2] - // Call updown script if provided if updownScript != "" { _, err := executeUpdownScript(action, proto, target) @@ -420,7 +454,7 @@ func updateTargets(pm *proxy.ProxyManager, action string, tunnelIP string, proto } } - err := pm.RemoveTarget(proto, tunnelIP, port) + err = pm.RemoveTarget(proto, tunnelIP, port) if err != nil { logger.Error("Failed to remove target: %v", err) return err diff --git a/common_test.go b/common_test.go new file mode 100644 index 0000000..a7e659a --- /dev/null +++ b/common_test.go @@ -0,0 +1,212 @@ +package main + +import ( + "net" + "testing" +) + +func TestParseTargetString(t *testing.T) { + tests := []struct { + name string + input string + wantListenPort int + wantTargetAddr string + wantErr bool + }{ + // IPv4 test cases + { + name: "valid IPv4 basic", + input: "3001:192.168.1.1:80", + wantListenPort: 3001, + wantTargetAddr: "192.168.1.1:80", + wantErr: false, + }, + { + name: "valid IPv4 localhost", + input: "8080:127.0.0.1:3000", + wantListenPort: 8080, + wantTargetAddr: "127.0.0.1:3000", + wantErr: false, + }, + { + name: "valid IPv4 same ports", + input: "443:10.0.0.1:443", + wantListenPort: 443, + wantTargetAddr: "10.0.0.1:443", + wantErr: false, + }, + + // IPv6 test cases + { + name: "valid IPv6 loopback", + input: "3001:[::1]:8080", + wantListenPort: 3001, + wantTargetAddr: "[::1]:8080", + wantErr: false, + }, + { + name: "valid IPv6 full address", + input: "80:[fd70:1452:b736:4dd5:caca:7db9:c588:f5b3]:8080", + wantListenPort: 80, + wantTargetAddr: "[fd70:1452:b736:4dd5:caca:7db9:c588:f5b3]:8080", + wantErr: false, + }, + { + name: "valid IPv6 link-local", + input: "443:[fe80::1]:443", + wantListenPort: 443, + wantTargetAddr: "[fe80::1]:443", + wantErr: false, + }, + { + name: "valid IPv6 all zeros compressed", + input: "8000:[::]:9000", + wantListenPort: 8000, + wantTargetAddr: "[::]:9000", + wantErr: false, + }, + { + name: "valid IPv6 mixed notation", + input: "5000:[::ffff:192.168.1.1]:6000", + wantListenPort: 5000, + wantTargetAddr: "[::ffff:192.168.1.1]:6000", + wantErr: false, + }, + + // Hostname test cases + { + name: "valid hostname", + input: "8080:example.com:80", + wantListenPort: 8080, + wantTargetAddr: "example.com:80", + wantErr: false, + }, + { + name: "valid hostname with subdomain", + input: "443:api.example.com:8443", + wantListenPort: 443, + wantTargetAddr: "api.example.com:8443", + wantErr: false, + }, + { + name: "valid localhost hostname", + input: "3000:localhost:3000", + wantListenPort: 3000, + wantTargetAddr: "localhost:3000", + wantErr: false, + }, + + // Error cases + { + name: "invalid - no colons", + input: "invalid", + wantErr: true, + }, + { + name: "invalid - empty string", + input: "", + wantErr: true, + }, + { + name: "invalid - non-numeric listen port", + input: "abc:192.168.1.1:80", + wantErr: true, + }, + { + name: "invalid - missing target port", + input: "3001:192.168.1.1", + wantErr: true, + }, + { + name: "invalid - IPv6 without brackets", + input: "3001:fd70:1452:b736:4dd5:caca:7db9:c588:f5b3:80", + wantErr: true, + }, + { + name: "invalid - only listen port", + input: "3001:", + wantErr: true, + }, + { + name: "invalid - missing host", + input: "3001::80", + wantErr: true, + }, + { + name: "invalid - IPv6 unclosed bracket", + input: "3001:[::1:80", + wantErr: true, + }, + { + name: "invalid - listen port zero", + input: "0:192.168.1.1:80", + wantErr: true, + }, + { + name: "invalid - listen port negative", + input: "-1:192.168.1.1:80", + wantErr: true, + }, + { + name: "invalid - listen port out of range", + input: "70000:192.168.1.1:80", + wantErr: true, + }, + { + name: "invalid - empty target port", + input: "3001:192.168.1.1:", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + listenPort, targetAddr, err := parseTargetString(tt.input) + + if (err != nil) != tt.wantErr { + t.Errorf("parseTargetString(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) + return + } + + if tt.wantErr { + return // Don't check other values if we expected an error + } + + if listenPort != tt.wantListenPort { + t.Errorf("parseTargetString(%q) listenPort = %d, want %d", tt.input, listenPort, tt.wantListenPort) + } + + if targetAddr != tt.wantTargetAddr { + t.Errorf("parseTargetString(%q) targetAddr = %q, want %q", tt.input, targetAddr, tt.wantTargetAddr) + } + }) + } +} + +// TestParseTargetStringNetDialCompatibility verifies that the output is compatible with net.Dial +func TestParseTargetStringNetDialCompatibility(t *testing.T) { + tests := []struct { + name string + input string + }{ + {"IPv4", "8080:127.0.0.1:80"}, + {"IPv6 loopback", "8080:[::1]:80"}, + {"IPv6 full", "8080:[2001:db8::1]:80"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, targetAddr, err := parseTargetString(tt.input) + if err != nil { + t.Fatalf("parseTargetString(%q) unexpected error: %v", tt.input, err) + } + + // Verify the format is valid for net.Dial by checking it can be split back + // This doesn't actually dial, just validates the format + _, _, err = net.SplitHostPort(targetAddr) + if err != nil { + t.Errorf("parseTargetString(%q) produced invalid net.Dial format %q: %v", tt.input, targetAddr, err) + } + }) + } +} From 199f936046b3bcaa5cca0f71583c908a63415f7e Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 8 Mar 2026 11:26:22 -0700 Subject: [PATCH 011/161] Set newt version in dockerfile Former-commit-id: accac75a5320c9c03c0143fc136032ae3c63a973 --- .github/workflows/cicd.yml | 3 +++ Dockerfile | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index d0af856..3082333 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -269,6 +269,7 @@ jobs: context: . push: true platforms: linux/amd64 + build-args: VERSION=${{ env.TAG }} tags: | ${{ env.GHCR_IMAGE }}:amd64-${{ env.TAG }} ${{ env.DOCKERHUB_IMAGE }}:amd64-${{ env.TAG }} @@ -393,6 +394,7 @@ jobs: context: . push: true platforms: linux/arm64 + build-args: VERSION=${{ env.TAG }} tags: | ${{ env.GHCR_IMAGE }}:arm64-${{ env.TAG }} ${{ env.DOCKERHUB_IMAGE }}:arm64-${{ env.TAG }} @@ -509,6 +511,7 @@ jobs: context: . push: true platforms: linux/arm/v7 + build-args: VERSION=${{ env.TAG }} tags: | ${{ env.GHCR_IMAGE }}:armv7-${{ env.TAG }} ${{ env.DOCKERHUB_IMAGE }}:armv7-${{ env.TAG }} diff --git a/Dockerfile b/Dockerfile index 25113a9..ea870c2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,7 +17,8 @@ RUN go mod download COPY . . # Build the application -RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /newt +ARG VERSION=dev +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.newtVersion=${VERSION}" -o /newt FROM public.ecr.aws/docker/library/alpine:3.23 AS runner From 4774ad9054e9d3651ca4b71f53c4efd2eb710787 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:27:55 +0000 Subject: [PATCH 012/161] chore(deps): bump aquasecurity/trivy-action from 0.34.2 to 0.35.0 Bumps [aquasecurity/trivy-action](https://github.com/aquasecurity/trivy-action) from 0.34.2 to 0.35.0. - [Release notes](https://github.com/aquasecurity/trivy-action/releases) - [Commits](https://github.com/aquasecurity/trivy-action/compare/97e0b3872f55f89b95b2f65b3dbab56962816478...57a97c7e7821a5776cebc9bb87c984fa69cba8f1) --- updated-dependencies: - dependency-name: aquasecurity/trivy-action dependency-version: 0.35.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Former-commit-id: 627ec2fdbc970930f66e2e2ef3e14d562b6f3854 --- .github/workflows/cicd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 3082333..517eb0c 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -759,7 +759,7 @@ jobs: cosign public-key --key env://COSIGN_PRIVATE_KEY >/dev/null - name: Generate SBOM (SPDX JSON) from GHCR digest - uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # v0.34.2 + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 with: image-ref: ${{ env.GHCR_REF }} format: spdx-json From 30ee82af2aba4dc2dd48454ad4cd7afebb44fbd2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:27:59 +0000 Subject: [PATCH 013/161] chore(deps): bump docker/login-action from 3.7.0 to 4.0.0 Bumps [docker/login-action](https://github.com/docker/login-action) from 3.7.0 to 4.0.0. - [Release notes](https://github.com/docker/login-action/releases) - [Commits](https://github.com/docker/login-action/compare/c94ce9fb468520275223c153574b00df6fe4bcc9...b45d80f862d83dbcd57f89517bcf500b2ab88fb2) --- updated-dependencies: - dependency-name: docker/login-action dependency-version: 4.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Former-commit-id: a76089db98933f21af265a25c2a2e0f8ef7e4d94 --- .github/workflows/cicd.yml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 3082333..ea16e8f 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -238,14 +238,14 @@ jobs: # uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Log in to Docker Hub - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: docker.io username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Log in to GHCR - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -363,14 +363,14 @@ jobs: echo "Checked out $(git rev-parse --short HEAD) for tag ${TAG}" - name: Log in to Docker Hub - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: docker.io username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Log in to GHCR - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -478,14 +478,14 @@ jobs: echo "Checked out $(git rev-parse --short HEAD) for tag ${TAG}" - name: Log in to Docker Hub - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: docker.io username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Log in to GHCR - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -551,14 +551,14 @@ jobs: #PUBLISH_MINOR: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_minor || vars.PUBLISH_MINOR }} steps: - name: Log in to Docker Hub - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: docker.io username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Log in to GHCR - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -656,14 +656,14 @@ jobs: go-version-file: go.mod - name: Log in to Docker Hub - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: docker.io username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Log in to GHCR - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: ghcr.io username: ${{ github.actor }} From 12a3e987b25876cae00f297e893f4151cd4ff050 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:28:02 +0000 Subject: [PATCH 014/161] chore(deps): bump docker/build-push-action from 6.19.2 to 7.0.0 Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.19.2 to 7.0.0. - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](https://github.com/docker/build-push-action/compare/10e90e3645eae34f1e60eeb005ba3a3d33f178e8...d08e5c354a6adb9ed34480a06d141179aa583294) --- updated-dependencies: - dependency-name: docker/build-push-action dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Former-commit-id: bc44ca1aba7188a8d59a61e014565221b7fba2bc --- .github/workflows/cicd.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 3082333..3f3eeb7 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -264,7 +264,7 @@ jobs: # Build ONLY amd64 and push arch-specific tag suffixes used later for manifest creation. - name: Build and push (amd64 -> *:amd64-TAG) id: build_amd - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 with: context: . push: true @@ -389,7 +389,7 @@ jobs: # Build ONLY arm64 and push arch-specific tag suffixes used later for manifest creation. - name: Build and push (arm64 -> *:arm64-TAG) id: build_arm - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 with: context: . push: true @@ -506,7 +506,7 @@ jobs: - name: Build and push (arm/v7 -> *:armv7-TAG) id: build_armv7 - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 with: context: . push: true From 59afb7da69287b8eb5aa184ca04fd1b55647cb0c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" Date: Mon, 9 Mar 2026 10:29:43 +0000 Subject: [PATCH 015/161] chore(nix): fix hash for updated go dependencies Former-commit-id: a13c7c6e653cc860acd48c9dcec87f6ac3dd7a31 --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 6c071ad..8fed719 100644 --- a/flake.nix +++ b/flake.nix @@ -35,7 +35,7 @@ inherit version; src = pkgs.nix-gitignore.gitignoreSource [ ] ./.; - vendorHash = "sha256-kmQM8Yy5TuOiNpMpUme/2gfE+vrhUK+0AphN+p71wGs="; + vendorHash = "sha256-vy6Dqjek7pLdASbCrM9snq5Dt9lbwNJ0AuQboy1JWNQ="; nativeInstallCheckInputs = [ pkgs.versionCheckHook ]; From cc7a19bace76174801262c5f13d6f24c113b0731 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" Date: Mon, 9 Mar 2026 10:29:45 +0000 Subject: [PATCH 016/161] chore(nix): fix hash for updated go dependencies Former-commit-id: 8e188933a20663ce6fb5988fcb2734122f9ec153 --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 6c071ad..8fed719 100644 --- a/flake.nix +++ b/flake.nix @@ -35,7 +35,7 @@ inherit version; src = pkgs.nix-gitignore.gitignoreSource [ ] ./.; - vendorHash = "sha256-kmQM8Yy5TuOiNpMpUme/2gfE+vrhUK+0AphN+p71wGs="; + vendorHash = "sha256-vy6Dqjek7pLdASbCrM9snq5Dt9lbwNJ0AuQboy1JWNQ="; nativeInstallCheckInputs = [ pkgs.versionCheckHook ]; From a5a0f42890f676c77f6baf323e56f359592a1c6b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" Date: Mon, 9 Mar 2026 10:29:50 +0000 Subject: [PATCH 017/161] chore(nix): fix hash for updated go dependencies Former-commit-id: d7741df514b0280b2885f1a3ee7d491c9a91bfb2 --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 6c071ad..8fed719 100644 --- a/flake.nix +++ b/flake.nix @@ -35,7 +35,7 @@ inherit version; src = pkgs.nix-gitignore.gitignoreSource [ ] ./.; - vendorHash = "sha256-kmQM8Yy5TuOiNpMpUme/2gfE+vrhUK+0AphN+p71wGs="; + vendorHash = "sha256-vy6Dqjek7pLdASbCrM9snq5Dt9lbwNJ0AuQboy1JWNQ="; nativeInstallCheckInputs = [ pkgs.versionCheckHook ]; From 1ba0d23a16822f128f23e9f123745d3c6be6141b Mon Sep 17 00:00:00 2001 From: Laurence Date: Tue, 10 Mar 2026 13:53:39 +0000 Subject: [PATCH 018/161] feat(installer): prefer /usr/local/bin and improve POSIX compatibility - Always install to /usr/local/bin instead of ~/.local/bin - Use sudo automatically when write access is needed - Replace bash-specific syntax with POSIX equivalents: - Change shebang from #!/bin/bash to #!/bin/sh - Replace [[ == *pattern* ]] with case statements - Replace echo -e with printf for colored output - Script now works with dash, ash, busybox sh, and bash Former-commit-id: d68a13ea1fe50a4f36ac74f52cf093999c1aaf5f --- get-newt.sh | 119 ++++++++++++++++++++++++++++++++-------------------- 1 file changed, 73 insertions(+), 46 deletions(-) diff --git a/get-newt.sh b/get-newt.sh index d57f69a..d4ddd3f 100644 --- a/get-newt.sh +++ b/get-newt.sh @@ -1,7 +1,7 @@ -#!/bin/bash +#!/bin/sh # Get Newt - Cross-platform installation script -# Usage: curl -fsSL https://raw.githubusercontent.com/fosrl/newt/refs/heads/main/get-newt.sh | bash +# Usage: curl -fsSL https://raw.githubusercontent.com/fosrl/newt/refs/heads/main/get-newt.sh | sh set -e @@ -17,15 +17,15 @@ GITHUB_API_URL="https://api.github.com/repos/${REPO}/releases/latest" # Function to print colored output print_status() { - echo -e "${GREEN}[INFO]${NC} $1" + printf '%b[INFO]%b %s\n' "${GREEN}" "${NC}" "$1" } print_warning() { - echo -e "${YELLOW}[WARN]${NC} $1" + printf '%b[WARN]%b %s\n' "${YELLOW}" "${NC}" "$1" } print_error() { - echo -e "${RED}[ERROR]${NC} $1" + printf '%b[ERROR]%b %s\n' "${RED}" "${NC}" "$1" } # Function to get latest version from GitHub API @@ -113,16 +113,34 @@ get_install_dir() { if [ "$OS" = "windows" ]; then echo "$HOME/bin" else - # Try to use a directory in PATH, fallback to ~/.local/bin - if echo "$PATH" | grep -q "/usr/local/bin"; then - if [ -w "/usr/local/bin" ] 2>/dev/null; then - echo "/usr/local/bin" - else - echo "$HOME/.local/bin" - fi + # Prefer /usr/local/bin for system-wide installation + echo "/usr/local/bin" + fi +} + +# Check if we need sudo for installation +needs_sudo() { + local install_dir="$1" + if [ -w "$install_dir" ] 2>/dev/null; then + return 1 # No sudo needed + else + return 0 # Sudo needed + fi +} + +# Get the appropriate command prefix (sudo or empty) +get_sudo_cmd() { + local install_dir="$1" + if needs_sudo "$install_dir"; then + if command -v sudo >/dev/null 2>&1; then + echo "sudo" else - echo "$HOME/.local/bin" + print_error "Cannot write to ${install_dir} and sudo is not available." + print_error "Please run this script as root or install sudo." + exit 1 fi + else + echo "" fi } @@ -130,21 +148,24 @@ get_install_dir() { install_newt() { local platform="$1" local install_dir="$2" + local sudo_cmd="$3" local binary_name="newt_${platform}" local exe_suffix="" - + # Add .exe suffix for Windows - if [[ "$platform" == *"windows"* ]]; then - binary_name="${binary_name}.exe" - exe_suffix=".exe" - fi - + case "$platform" in + *windows*) + binary_name="${binary_name}.exe" + exe_suffix=".exe" + ;; + esac + local download_url="${BASE_URL}/${binary_name}" local temp_file="/tmp/newt${exe_suffix}" local final_path="${install_dir}/newt${exe_suffix}" - + print_status "Downloading newt from ${download_url}" - + # Download the binary if command -v curl >/dev/null 2>&1; then curl -fsSL "$download_url" -o "$temp_file" @@ -154,18 +175,22 @@ install_newt() { print_error "Neither curl nor wget is available. Please install one of them." exit 1 fi - + + # Make executable before moving + chmod +x "$temp_file" + # Create install directory if it doesn't exist - mkdir -p "$install_dir" - - # Move binary to install directory - mv "$temp_file" "$final_path" - - # Make executable (not needed on Windows, but doesn't hurt) - chmod +x "$final_path" - + if [ -n "$sudo_cmd" ]; then + $sudo_cmd mkdir -p "$install_dir" + print_status "Using sudo to install to ${install_dir}" + $sudo_cmd mv "$temp_file" "$final_path" + else + mkdir -p "$install_dir" + mv "$temp_file" "$final_path" + fi + print_status "newt installed to ${final_path}" - + # Check if install directory is in PATH if ! echo "$PATH" | grep -q "$install_dir"; then print_warning "Install directory ${install_dir} is not in your PATH." @@ -179,9 +204,9 @@ verify_installation() { local install_dir="$1" local exe_suffix="" - if [[ "$PLATFORM" == *"windows"* ]]; then - exe_suffix=".exe" - fi + case "$PLATFORM" in + *windows*) exe_suffix=".exe" ;; + esac local newt_path="${install_dir}/newt${exe_suffix}" @@ -198,34 +223,36 @@ verify_installation() { # Main installation process main() { print_status "Installing latest version of newt..." - + # Get latest version print_status "Fetching latest version from GitHub..." VERSION=$(get_latest_version) print_status "Latest version: v${VERSION}" - + # Set base URL with the fetched version BASE_URL="https://github.com/${REPO}/releases/download/${VERSION}" - + # Detect platform PLATFORM=$(detect_platform) print_status "Detected platform: ${PLATFORM}" - + # Get install directory INSTALL_DIR=$(get_install_dir) print_status "Install directory: ${INSTALL_DIR}" - + + # Check if we need sudo + SUDO_CMD=$(get_sudo_cmd "$INSTALL_DIR") + if [ -n "$SUDO_CMD" ]; then + print_status "Root privileges required for installation to ${INSTALL_DIR}" + fi + # Install newt - install_newt "$PLATFORM" "$INSTALL_DIR" - + install_newt "$PLATFORM" "$INSTALL_DIR" "$SUDO_CMD" + # Verify installation if verify_installation "$INSTALL_DIR"; then print_status "newt is ready to use!" - if [[ "$PLATFORM" == *"windows"* ]]; then - print_status "Run 'newt --help' to get started" - else - print_status "Run 'newt --help' to get started" - fi + print_status "Run 'newt --help' to get started" else exit 1 fi From 82074919d1eab75db8086a2b65004379423fb3cb Mon Sep 17 00:00:00 2001 From: Laurence Date: Thu, 12 Mar 2026 09:22:50 +0000 Subject: [PATCH 019/161] feat(admin): Add pprof endpoints To aid us in debugging user issues with memory or leaks we need to be able for the user to configure pprof, wait and then provide us the output files to see where memory/leaks occur in actual runtimes Former-commit-id: 836144aebf9336143dfcb29bb1656df03385520f --- main.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/main.go b/main.go index 9c373b0..9637377 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,7 @@ import ( "fmt" "net" "net/http" + "net/http/pprof" "net/netip" "os" "os/signal" @@ -147,6 +148,7 @@ var ( adminAddr string region string metricsAsyncBytes bool + pprofEnabled bool blueprintFile string noCloud bool @@ -225,6 +227,7 @@ func runNewtMain(ctx context.Context) { adminAddrEnv := os.Getenv("NEWT_ADMIN_ADDR") regionEnv := os.Getenv("NEWT_REGION") asyncBytesEnv := os.Getenv("NEWT_METRICS_ASYNC_BYTES") + pprofEnabledEnv := os.Getenv("NEWT_PPROF_ENABLED") disableClientsEnv := os.Getenv("DISABLE_CLIENTS") disableClients = disableClientsEnv == "true" @@ -390,6 +393,14 @@ func runNewtMain(ctx context.Context) { 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)") @@ -485,6 +496,14 @@ func runNewtMain(ctx context.Context) { if tel.PrometheusHandler != nil { mux.Handle("/metrics", tel.PrometheusHandler) } + if pprofEnabled { + mux.HandleFunc("/debug/pprof/", pprof.Index) + mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) + mux.HandleFunc("/debug/pprof/profile", pprof.Profile) + mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol) + mux.HandleFunc("/debug/pprof/trace", pprof.Trace) + logger.Info("pprof debugging enabled on %s/debug/pprof/", tcfg.AdminAddr) + } admin := &http.Server{ Addr: tcfg.AdminAddr, Handler: otelhttp.NewHandler(mux, "newt-admin"), From b6ed0c7b57850fe08213e53f14ac5a84338bc786 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 12 Mar 2026 17:49:05 -0700 Subject: [PATCH 020/161] Add optional compression Former-commit-id: 539e595c4821107b26b8e2ad390b547076bc81df --- websocket/client.go | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/websocket/client.go b/websocket/client.go index da1fa88..c4daf5f 100644 --- a/websocket/client.go +++ b/websocket/client.go @@ -2,6 +2,7 @@ package websocket import ( "bytes" + "compress/gzip" "crypto/tls" "crypto/x509" "encoding/json" @@ -709,10 +710,13 @@ func (c *Client) readPumpWithDisconnectDetection(started time.Time) { disconnectResult = "success" return default: - var msg WSMessage - err := c.conn.ReadJSON(&msg) + msgType, p, err := c.conn.ReadMessage() if err == nil { - telemetry.IncWSMessage(c.metricsContext(), "in", "text") + if msgType == websocket.BinaryMessage { + telemetry.IncWSMessage(c.metricsContext(), "in", "binary") + } else { + telemetry.IncWSMessage(c.metricsContext(), "in", "text") + } } if err != nil { // Check if we're shutting down before logging error @@ -737,6 +741,29 @@ func (c *Client) readPumpWithDisconnectDetection(started time.Time) { } } + var data []byte + if msgType == websocket.BinaryMessage { + gr, err := gzip.NewReader(bytes.NewReader(p)) + if err != nil { + logger.Error("WebSocket failed to create gzip reader: %v", err) + continue + } + data, err = io.ReadAll(gr) + gr.Close() + if err != nil { + logger.Error("WebSocket failed to decompress message: %v", err) + continue + } + } else { + data = p + } + + var msg WSMessage + if err = json.Unmarshal(data, &msg); err != nil { + logger.Error("WebSocket failed to parse message: %v", err) + continue + } + c.handlersMux.RLock() if handler, ok := c.handlers[msg.Type]; ok { handler(msg) From ca22eb9a96d3571952dec5eb85dafe5f85e2412f Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 13 Mar 2026 11:45:36 -0700 Subject: [PATCH 021/161] Clean up previous logging Former-commit-id: c7b01288e0ccd0228cb67a4dac2fb087df301b4f --- clients/clients.go | 2 -- main.go | 2 -- 2 files changed, 4 deletions(-) diff --git a/clients/clients.go b/clients/clients.go index 05ed3cf..537848d 100644 --- a/clients/clients.go +++ b/clients/clients.go @@ -112,8 +112,6 @@ func NewWireGuardService(interfaceName string, port uint16, mtu int, host string return nil, fmt.Errorf("failed to generate private key: %v", err) } - logger.Debug("+++++++++++++++++++++++++++++++= the port is %d", port) - if port == 0 { // Find an available port portRandom, err := util.FindAvailableUDPPort(49152, 65535) diff --git a/main.go b/main.go index 9c373b0..fc6a890 100644 --- a/main.go +++ b/main.go @@ -619,8 +619,6 @@ func runNewtMain(ctx context.Context) { var wgData WgData var dockerEventMonitor *docker.EventMonitor - logger.Debug("++++++++++++++++++++++ the port is %d", port) - if !disableClients { setupClients(client) } From 7ddcfe968dca626d374e113cb1a88aa4f3cfb49b Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 14 Mar 2026 11:57:37 -0700 Subject: [PATCH 022/161] Clean up to match olm Former-commit-id: bf029b7bb295d41a9b9119332ca92cfb6228800e --- websocket/client.go | 95 ++++++++++++++++++++++++--------------------- 1 file changed, 51 insertions(+), 44 deletions(-) diff --git a/websocket/client.go b/websocket/client.go index 8b8893a..dd3f39a 100644 --- a/websocket/client.go +++ b/websocket/client.go @@ -661,7 +661,57 @@ func (c *Client) setupPKCS12TLS() (*tls.Config, error) { } // pingMonitor sends pings at a short interval and triggers reconnect on failure +func (c *Client) sendPing() { + if c.conn == nil { + return + } + + // Skip ping if a message is currently being processed + c.processingMux.RLock() + isProcessing := c.processingMessage + c.processingMux.RUnlock() + if isProcessing { + logger.Debug("Skipping ping, message is being processed") + return + } + + c.configVersionMux.RLock() + configVersion := c.configVersion + c.configVersionMux.RUnlock() + + pingMsg := WSMessage{ + Type: "newt/ping", + Data: map[string]interface{}{}, + ConfigVersion: configVersion, + } + + c.writeMux.Lock() + err := c.conn.WriteJSON(pingMsg) + if err == nil { + telemetry.IncWSMessage(c.metricsContext(), "out", "ping") + } + c.writeMux.Unlock() + + if err != nil { + // Check if we're shutting down before logging error and reconnecting + select { + case <-c.done: + // Expected during shutdown + return + default: + logger.Error("Ping failed: %v", err) + telemetry.IncWSKeepaliveFailure(c.metricsContext(), "ping_write") + telemetry.IncWSReconnect(c.metricsContext(), "ping_write") + c.reconnect() + return + } + } +} + func (c *Client) pingMonitor() { + // Send an immediate ping as soon as we connect + c.sendPing() + ticker := time.NewTicker(c.pingInterval) defer ticker.Stop() @@ -670,50 +720,7 @@ func (c *Client) pingMonitor() { case <-c.done: return case <-ticker.C: - if c.conn == nil { - return - } - - // Skip ping if a message is currently being processed - c.processingMux.RLock() - isProcessing := c.processingMessage - c.processingMux.RUnlock() - if isProcessing { - logger.Debug("Skipping ping, message is being processed") - continue - } - - c.configVersionMux.RLock() - configVersion := c.configVersion - c.configVersionMux.RUnlock() - - pingMsg := WSMessage{ - Type: "newt/ping", - Data: map[string]interface{}{}, - ConfigVersion: configVersion, - } - - c.writeMux.Lock() - err := c.conn.WriteJSON(pingMsg) - if err == nil { - telemetry.IncWSMessage(c.metricsContext(), "out", "ping") - } - c.writeMux.Unlock() - - if err != nil { - // Check if we're shutting down before logging error and reconnecting - select { - case <-c.done: - // Expected during shutdown - return - default: - logger.Error("Ping failed: %v", err) - telemetry.IncWSKeepaliveFailure(c.metricsContext(), "ping_write") - telemetry.IncWSReconnect(c.metricsContext(), "ping_write") - c.reconnect() - return - } - } + c.sendPing() } } } From acbbce787cbe2428ab12dd8316a70bf56750f1fd Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 15 Mar 2026 17:42:03 -0700 Subject: [PATCH 023/161] Send disconnecting message Former-commit-id: d4ebb3e2afcc50f5e12f62464de2f122d52897bd --- main.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/main.go b/main.go index 1bc1bce..b669af2 100644 --- a/main.go +++ b/main.go @@ -618,7 +618,7 @@ func runNewtMain(ctx context.Context) { var connected bool var wgData WgData var dockerEventMonitor *docker.EventMonitor - + if !disableClients { setupClients(client) } @@ -1197,7 +1197,7 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( 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 { @@ -1794,6 +1794,8 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( pm.Stop() } + client.SendMessage("newt/disconnecting", map[string]any{}) + if client != nil { client.Close() } From 3ccf13354cf48118fa55a90288b608778af0e9b5 Mon Sep 17 00:00:00 2001 From: Laurence Date: Mon, 16 Mar 2026 14:11:14 +0000 Subject: [PATCH 024/161] refactor(proxy): cleanup basics - constants, remove dead code, fix deprecated calls - Add maxUDPPacketSize constant to replace magic number 65507 - Remove commented-out code in Stop() - Replace deprecated ne.Temporary() with errors.Is(err, net.ErrClosed) - Use errors.As instead of type assertion for net.Error - Use errors.Is for closed connection checks instead of string matching - Handle closed connection gracefully when reading from UDP target Former-commit-id: 13448f76aa134c922c5111c80deccbe1f72bd11c --- proxy/manager.go | 35 +++++++++++++++-------------------- 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/proxy/manager.go b/proxy/manager.go index 0619e80..5566589 100644 --- a/proxy/manager.go +++ b/proxy/manager.go @@ -21,7 +21,10 @@ import ( "gvisor.dev/gvisor/pkg/tcpip/adapters/gonet" ) -const errUnsupportedProtoFmt = "unsupported protocol: %s" +const ( + errUnsupportedProtoFmt = "unsupported protocol: %s" + maxUDPPacketSize = 65507 +) // Target represents a proxy target with its address and port type Target struct { @@ -105,13 +108,9 @@ func classifyProxyError(err error) string { if errors.Is(err, net.ErrClosed) { return "closed" } - if ne, ok := err.(net.Error); ok { - if ne.Timeout() { - return "timeout" - } - if ne.Temporary() { - return "temporary" - } + var ne net.Error + if errors.As(err, &ne) && ne.Timeout() { + return "timeout" } msg := strings.ToLower(err.Error()) switch { @@ -437,14 +436,6 @@ func (pm *ProxyManager) Stop() error { pm.udpConns = append(pm.udpConns[:i], pm.udpConns[i+1:]...) } - // // Clear the target maps - // for k := range pm.tcpTargets { - // delete(pm.tcpTargets, k) - // } - // for k := range pm.udpTargets { - // delete(pm.udpTargets, k) - // } - // Give active connections a chance to close gracefully time.Sleep(100 * time.Millisecond) @@ -498,7 +489,7 @@ func (pm *ProxyManager) handleTCPProxy(listener net.Listener, targetAddr string) if !pm.running { return } - if ne, ok := err.(net.Error); ok && !ne.Temporary() { + if errors.Is(err, net.ErrClosed) { logger.Info("TCP listener closed, stopping proxy handler for %v", listener.Addr()) return } @@ -564,7 +555,7 @@ func (pm *ProxyManager) handleTCPProxy(listener net.Listener, targetAddr string) } func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) { - buffer := make([]byte, 65507) // Max UDP packet size + buffer := make([]byte, maxUDPPacketSize) // Max UDP packet size clientConns := make(map[string]*net.UDPConn) var clientsMutex sync.RWMutex @@ -583,7 +574,7 @@ func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) { } // Check for connection closed conditions - if err == io.EOF || strings.Contains(err.Error(), "use of closed network connection") { + if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) { logger.Info("UDP connection closed, stopping proxy handler") // Clean up existing client connections @@ -662,10 +653,14 @@ func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) { telemetry.IncProxyConnectionEvent(context.Background(), tunnelID, "udp", telemetry.ProxyConnectionClosed) }() - buffer := make([]byte, 65507) + buffer := make([]byte, maxUDPPacketSize) for { n, _, err := targetConn.ReadFromUDP(buffer) if err != nil { + // Connection closed is normal during cleanup + if errors.Is(err, net.ErrClosed) || errors.Is(err, io.EOF) { + return // defer will handle cleanup, result stays "success" + } logger.Error("Error reading from target: %v", err) result = "failure" return // defer will handle cleanup From 87d03bd5897b2815cac754a473ef6ad747b04c6d Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 16 Mar 2026 13:50:45 -0700 Subject: [PATCH 025/161] Remove redundant info Former-commit-id: 24dfb3a8a2c7be7cad59e3f976609c1b86d3ec4c --- main.go | 1 - websocket/client.go | 4 +--- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/main.go b/main.go index b669af2..d736153 100644 --- a/main.go +++ b/main.go @@ -566,7 +566,6 @@ func runNewtMain(ctx context.Context) { secret, // CLI arg takes precedence endpoint, 30*time.Second, - pingTimeout, opt, ) if err != nil { diff --git a/websocket/client.go b/websocket/client.go index dd3f39a..533771b 100644 --- a/websocket/client.go +++ b/websocket/client.go @@ -38,7 +38,6 @@ type Client struct { isConnected bool reconnectMux sync.RWMutex pingInterval time.Duration - pingTimeout time.Duration onConnect func() error onTokenUpdate func(token string) writeMux sync.Mutex @@ -117,7 +116,7 @@ func (c *Client) MetricsContext() context.Context { } // NewClient creates a new websocket client -func NewClient(clientType string, ID, secret string, endpoint string, pingInterval time.Duration, pingTimeout time.Duration, opts ...ClientOption) (*Client, error) { +func NewClient(clientType string, ID, secret string, endpoint string, pingInterval time.Duration, opts ...ClientOption) (*Client, error) { config := &Config{ ID: ID, Secret: secret, @@ -132,7 +131,6 @@ func NewClient(clientType string, ID, secret string, endpoint string, pingInterv reconnectInterval: 3 * time.Second, isConnected: false, pingInterval: pingInterval, - pingTimeout: pingTimeout, clientType: clientType, } From 824606e8fbed7e381b0054d9a8638be59415d415 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 16 Mar 2026 14:33:40 -0700 Subject: [PATCH 026/161] Bump ping interval up Former-commit-id: 8161fa6626a8c0f880ee0a2cf8c8bfe24b85a12f --- main.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/main.go b/main.go index d736153..c9e7d8d 100644 --- a/main.go +++ b/main.go @@ -302,10 +302,10 @@ func runNewtMain(ctx context.Context) { flag.StringVar(&dockerSocket, "docker-socket", "", "Path or address to Docker socket (typically unix:///var/run/docker.sock)") } if pingIntervalStr == "" { - flag.StringVar(&pingIntervalStr, "ping-interval", "3s", "Interval for pinging the server (default 3s)") + flag.StringVar(&pingIntervalStr, "ping-interval", "15s", "Interval for pinging the server (default 15s)") } if pingTimeoutStr == "" { - flag.StringVar(&pingTimeoutStr, "ping-timeout", "5s", " Timeout for each ping (default 5s)") + flag.StringVar(&pingTimeoutStr, "ping-timeout", "7s", " Timeout for each ping (default 7s)") } // 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)") @@ -330,21 +330,21 @@ func runNewtMain(ctx context.Context) { if pingIntervalStr != "" { pingInterval, err = time.ParseDuration(pingIntervalStr) if err != nil { - fmt.Printf("Invalid PING_INTERVAL value: %s, using default 3 seconds\n", pingIntervalStr) - pingInterval = 3 * time.Second + fmt.Printf("Invalid PING_INTERVAL value: %s, using default 15 seconds\n", pingIntervalStr) + pingInterval = 15 * time.Second } } else { - pingInterval = 3 * time.Second + pingInterval = 15 * time.Second } if pingTimeoutStr != "" { pingTimeout, err = time.ParseDuration(pingTimeoutStr) if err != nil { - fmt.Printf("Invalid PING_TIMEOUT value: %s, using default 5 seconds\n", pingTimeoutStr) - pingTimeout = 5 * time.Second + fmt.Printf("Invalid PING_TIMEOUT value: %s, using default 7 seconds\n", pingTimeoutStr) + pingTimeout = 7 * time.Second } } else { - pingTimeout = 5 * time.Second + pingTimeout = 7 * time.Second } if dockerEnforceNetworkValidation == "" { From 129ca0ad8724d3c49f5901cce69d2a7ccdc594be Mon Sep 17 00:00:00 2001 From: Laurence Date: Wed, 18 Mar 2026 13:37:31 +0000 Subject: [PATCH 027/161] fix(healthcheck): Support ipv6 healthchecks Currently we are doing fmt.sprintf on hostname and port which will not properly handle ipv6 addresses, instead of changing pangolin to send bracketed address a simply net.join can do this for us since we dont need to parse a formatted string Former-commit-id: 8fda35db4f2ee08e217ff698a26e3796c80846d3 --- healthcheck/healthcheck.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/healthcheck/healthcheck.go b/healthcheck/healthcheck.go index 9889cc6..f618803 100644 --- a/healthcheck/healthcheck.go +++ b/healthcheck/healthcheck.go @@ -5,7 +5,9 @@ import ( "crypto/tls" "encoding/json" "fmt" + "net" "net/http" + "strconv" "strings" "sync" "time" @@ -365,11 +367,12 @@ func (m *Monitor) performHealthCheck(target *Target) { target.LastCheck = time.Now() target.LastError = "" - // Build URL - url := fmt.Sprintf("%s://%s", target.Config.Scheme, target.Config.Hostname) + // Build URL (use net.JoinHostPort to properly handle IPv6 addresses with ports) + host := target.Config.Hostname if target.Config.Port > 0 { - url = fmt.Sprintf("%s:%d", url, target.Config.Port) + host = net.JoinHostPort(target.Config.Hostname, strconv.Itoa(target.Config.Port)) } + url := fmt.Sprintf("%s://%s", target.Config.Scheme, host) if target.Config.Path != "" { if !strings.HasPrefix(target.Config.Path, "/") { url += "/" From 3cdad1f37191067140b239fcf4df013f5fcc9808 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 02:14:44 +0000 Subject: [PATCH 028/161] chore(deps): bump google.golang.org/grpc from 1.79.1 to 1.79.3 Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.79.1 to 1.79.3. - [Release notes](https://github.com/grpc/grpc-go/releases) - [Commits](https://github.com/grpc/grpc-go/compare/v1.79.1...v1.79.3) --- updated-dependencies: - dependency-name: google.golang.org/grpc dependency-version: 1.79.3 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Former-commit-id: b045a0f5d43e0b54b091177f6b8a013507572464 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 2aa8f5e..e494319 100644 --- a/go.mod +++ b/go.mod @@ -24,7 +24,7 @@ require ( golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 golang.zx2c4.com/wireguard/windows v0.5.3 - google.golang.org/grpc v1.79.1 + google.golang.org/grpc v1.79.3 gopkg.in/yaml.v3 v3.0.1 gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c software.sslmate.com/src/go-pkcs12 v0.7.0 diff --git a/go.sum b/go.sum index d345b1d..0b75184 100644 --- a/go.sum +++ b/go.sum @@ -159,8 +159,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1: google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY= google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= -google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From 32a689151ee07335b368dc0318b4b3e94644395d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" Date: Thu, 19 Mar 2026 02:16:03 +0000 Subject: [PATCH 029/161] chore(nix): fix hash for updated go dependencies Former-commit-id: 212bdf765a592fa744d015ead611df79fea8d1f1 --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 6c071ad..ef1f52e 100644 --- a/flake.nix +++ b/flake.nix @@ -35,7 +35,7 @@ inherit version; src = pkgs.nix-gitignore.gitignoreSource [ ] ./.; - vendorHash = "sha256-kmQM8Yy5TuOiNpMpUme/2gfE+vrhUK+0AphN+p71wGs="; + vendorHash = "sha256-0eK4C42Upqpp01pfjW9+t3NKzadwVlGwwuWXhdpgDz4="; nativeInstallCheckInputs = [ pkgs.versionCheckHook ]; From d3fef2794901fe30e5b98fa911b2e37d1d4f5968 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 23 Mar 2026 16:39:01 -0700 Subject: [PATCH 030/161] Saving and sending access logs pass 1 Former-commit-id: 0f57985b6ffbd32d5755c78152b2a86a06145f37 --- clients/clients.go | 25 ++- netstack2/access_log.go | 355 +++++++++++++++++++++++++++++++++++++ netstack2/handlers.go | 48 +++++ netstack2/proxy.go | 82 ++++++++- netstack2/subnet_lookup.go | 3 +- netstack2/tun.go | 13 +- 6 files changed, 515 insertions(+), 11 deletions(-) create mode 100644 netstack2/access_log.go diff --git a/clients/clients.go b/clients/clients.go index 4c64dbd..9223262 100644 --- a/clients/clients.go +++ b/clients/clients.go @@ -43,6 +43,7 @@ type Target struct { RewriteTo string `json:"rewriteTo,omitempty"` DisableIcmp bool `json:"disableIcmp,omitempty"` PortRange []PortRange `json:"portRange,omitempty"` + ResourceId int `json:"resourceId,omitempty"` } type PortRange struct { @@ -196,6 +197,15 @@ func (s *WireGuardService) Close() { s.stopGetConfig = nil } + // Flush access logs before tearing down the tunnel + if s.tnet != nil { + if ph := s.tnet.GetProxyHandler(); ph != nil { + if al := ph.GetAccessLogger(); al != nil { + al.Close() + } + } + } + // Stop the direct UDP relay first s.StopDirectUDPRelay() @@ -663,7 +673,7 @@ func (s *WireGuardService) syncTargets(desiredTargets []Target) error { }) } - s.tnet.AddProxySubnetRule(sourcePrefix, destPrefix, target.RewriteTo, portRanges, target.DisableIcmp) + s.tnet.AddProxySubnetRule(sourcePrefix, destPrefix, target.RewriteTo, portRanges, target.DisableIcmp, target.ResourceId) logger.Info("Added target %s -> %s during sync", target.SourcePrefix, target.DestPrefix) } } @@ -794,6 +804,13 @@ func (s *WireGuardService) ensureWireguardInterface(wgconfig WgConfig) error { s.TunnelIP = tunnelIP.String() + // Configure the access log sender to ship compressed session logs via websocket + s.tnet.SetAccessLogSender(func(data string) error { + return s.client.SendMessageNoLog("newt/access-log", map[string]interface{}{ + "compressed": data, + }) + }) + // Create WireGuard device using the shared bind s.device = device.NewDevice(s.tun, s.sharedBind, device.NewLogger( device.LogLevelSilent, // Use silent logging by default - could be made configurable @@ -914,7 +931,7 @@ func (s *WireGuardService) ensureTargets(targets []Target) error { if err != nil { return fmt.Errorf("invalid CIDR %s: %v", sp, err) } - s.tnet.AddProxySubnetRule(sourcePrefix, destPrefix, target.RewriteTo, portRanges, target.DisableIcmp) + s.tnet.AddProxySubnetRule(sourcePrefix, destPrefix, target.RewriteTo, portRanges, target.DisableIcmp, target.ResourceId) logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", sp, target.DestPrefix, target.RewriteTo, target.PortRange) } } @@ -1307,7 +1324,7 @@ func (s *WireGuardService) handleAddTarget(msg websocket.WSMessage) { logger.Info("Invalid CIDR %s: %v", sp, err) continue } - s.tnet.AddProxySubnetRule(sourcePrefix, destPrefix, target.RewriteTo, portRanges, target.DisableIcmp) + s.tnet.AddProxySubnetRule(sourcePrefix, destPrefix, target.RewriteTo, portRanges, target.DisableIcmp, target.ResourceId) logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", sp, target.DestPrefix, target.RewriteTo, target.PortRange) } } @@ -1425,7 +1442,7 @@ func (s *WireGuardService) handleUpdateTarget(msg websocket.WSMessage) { logger.Info("Invalid CIDR %s: %v", sp, err) continue } - s.tnet.AddProxySubnetRule(sourcePrefix, destPrefix, target.RewriteTo, portRanges, target.DisableIcmp) + s.tnet.AddProxySubnetRule(sourcePrefix, destPrefix, target.RewriteTo, portRanges, target.DisableIcmp, target.ResourceId) logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", sp, target.DestPrefix, target.RewriteTo, target.PortRange) } } diff --git a/netstack2/access_log.go b/netstack2/access_log.go new file mode 100644 index 0000000..ab0db78 --- /dev/null +++ b/netstack2/access_log.go @@ -0,0 +1,355 @@ +package netstack2 + +import ( + "bytes" + "compress/zlib" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "sync" + "time" + + "github.com/fosrl/newt/logger" +) + +const ( + // flushInterval is how often the access logger flushes completed sessions to the server + flushInterval = 60 * time.Second + + // maxBufferedSessions is the max number of completed sessions to buffer before forcing a flush + maxBufferedSessions = 100 +) + +// SendFunc is a callback that sends compressed access log data to the server. +// The data is a base64-encoded zlib-compressed JSON array of AccessSession objects. +type SendFunc func(data string) error + +// AccessSession represents a tracked access session through the proxy +type AccessSession struct { + SessionID string `json:"sessionId"` + ResourceID int `json:"resourceId"` + SourceAddr string `json:"sourceAddr"` + DestAddr string `json:"destAddr"` + Protocol string `json:"protocol"` + StartedAt time.Time `json:"startedAt"` + EndedAt time.Time `json:"endedAt,omitempty"` + BytesTx int64 `json:"bytesTx"` + BytesRx int64 `json:"bytesRx"` +} + +// udpSessionKey identifies a unique UDP "session" by src -> dst +type udpSessionKey struct { + srcAddr string + dstAddr string + protocol string +} + +// AccessLogger tracks access sessions for resources and periodically +// flushes completed sessions to the server via a configurable SendFunc. +type AccessLogger struct { + mu sync.Mutex + sessions map[string]*AccessSession // active sessions: sessionID -> session + udpSessions map[udpSessionKey]*AccessSession // active UDP sessions for dedup + completedSessions []*AccessSession // completed sessions waiting to be flushed + udpTimeout time.Duration + sendFn SendFunc + stopCh chan struct{} + flushDone chan struct{} // closed after the flush goroutine exits +} + +// NewAccessLogger creates a new access logger. +// udpTimeout controls how long a UDP session is kept alive without traffic before being ended. +func NewAccessLogger(udpTimeout time.Duration) *AccessLogger { + al := &AccessLogger{ + sessions: make(map[string]*AccessSession), + udpSessions: make(map[udpSessionKey]*AccessSession), + completedSessions: make([]*AccessSession, 0), + udpTimeout: udpTimeout, + stopCh: make(chan struct{}), + flushDone: make(chan struct{}), + } + go al.backgroundLoop() + return al +} + +// SetSendFunc sets the callback used to send compressed access log batches +// to the server. This can be called after construction once the websocket +// client is available. +func (al *AccessLogger) SetSendFunc(fn SendFunc) { + al.mu.Lock() + defer al.mu.Unlock() + al.sendFn = fn +} + +// generateSessionID creates a random session identifier +func generateSessionID() string { + b := make([]byte, 8) + rand.Read(b) + return hex.EncodeToString(b) +} + +// StartTCPSession logs the start of a TCP session and returns a session ID. +func (al *AccessLogger) StartTCPSession(resourceID int, srcAddr, dstAddr string) string { + sessionID := generateSessionID() + now := time.Now() + + session := &AccessSession{ + SessionID: sessionID, + ResourceID: resourceID, + SourceAddr: srcAddr, + DestAddr: dstAddr, + Protocol: "tcp", + StartedAt: now, + } + + al.mu.Lock() + al.sessions[sessionID] = session + al.mu.Unlock() + + logger.Info("ACCESS START session=%s resource=%d proto=tcp src=%s dst=%s time=%s", + sessionID, resourceID, srcAddr, dstAddr, now.Format(time.RFC3339)) + + return sessionID +} + +// EndTCPSession logs the end of a TCP session and queues it for sending. +func (al *AccessLogger) EndTCPSession(sessionID string) { + now := time.Now() + + al.mu.Lock() + session, ok := al.sessions[sessionID] + if ok { + session.EndedAt = now + delete(al.sessions, sessionID) + al.completedSessions = append(al.completedSessions, session) + } + shouldFlush := len(al.completedSessions) >= maxBufferedSessions + al.mu.Unlock() + + if ok { + duration := now.Sub(session.StartedAt) + logger.Info("ACCESS END session=%s resource=%d proto=tcp src=%s dst=%s started=%s ended=%s duration=%s", + sessionID, session.ResourceID, session.SourceAddr, session.DestAddr, + session.StartedAt.Format(time.RFC3339), now.Format(time.RFC3339), duration) + } + + if shouldFlush { + al.flush() + } +} + +// TrackUDPSession starts or returns an existing UDP session. Returns the session ID. +func (al *AccessLogger) TrackUDPSession(resourceID int, srcAddr, dstAddr string) string { + key := udpSessionKey{ + srcAddr: srcAddr, + dstAddr: dstAddr, + protocol: "udp", + } + + al.mu.Lock() + defer al.mu.Unlock() + + if existing, ok := al.udpSessions[key]; ok { + return existing.SessionID + } + + sessionID := generateSessionID() + now := time.Now() + + session := &AccessSession{ + SessionID: sessionID, + ResourceID: resourceID, + SourceAddr: srcAddr, + DestAddr: dstAddr, + Protocol: "udp", + StartedAt: now, + } + + al.sessions[sessionID] = session + al.udpSessions[key] = session + + logger.Info("ACCESS START session=%s resource=%d proto=udp src=%s dst=%s time=%s", + sessionID, resourceID, srcAddr, dstAddr, now.Format(time.RFC3339)) + + return sessionID +} + +// EndUDPSession ends a UDP session and queues it for sending. +func (al *AccessLogger) EndUDPSession(sessionID string) { + now := time.Now() + + al.mu.Lock() + session, ok := al.sessions[sessionID] + if ok { + session.EndedAt = now + delete(al.sessions, sessionID) + key := udpSessionKey{ + srcAddr: session.SourceAddr, + dstAddr: session.DestAddr, + protocol: "udp", + } + delete(al.udpSessions, key) + al.completedSessions = append(al.completedSessions, session) + } + shouldFlush := len(al.completedSessions) >= maxBufferedSessions + al.mu.Unlock() + + if ok { + duration := now.Sub(session.StartedAt) + logger.Info("ACCESS END session=%s resource=%d proto=udp src=%s dst=%s started=%s ended=%s duration=%s", + sessionID, session.ResourceID, session.SourceAddr, session.DestAddr, + session.StartedAt.Format(time.RFC3339), now.Format(time.RFC3339), duration) + } + + if shouldFlush { + al.flush() + } +} + +// backgroundLoop handles periodic flushing and stale session reaping. +func (al *AccessLogger) backgroundLoop() { + defer close(al.flushDone) + + flushTicker := time.NewTicker(flushInterval) + defer flushTicker.Stop() + + reapTicker := time.NewTicker(30 * time.Second) + defer reapTicker.Stop() + + for { + select { + case <-al.stopCh: + return + case <-flushTicker.C: + al.flush() + case <-reapTicker.C: + al.reapStaleSessions() + } + } +} + +// reapStaleSessions cleans up UDP sessions that were not properly ended. +func (al *AccessLogger) reapStaleSessions() { + al.mu.Lock() + defer al.mu.Unlock() + + staleThreshold := time.Now().Add(-5 * time.Minute) + + for key, session := range al.udpSessions { + if session.StartedAt.Before(staleThreshold) && session.EndedAt.IsZero() { + now := time.Now() + session.EndedAt = now + duration := now.Sub(session.StartedAt) + logger.Info("ACCESS END (reaped) session=%s resource=%d proto=udp src=%s dst=%s started=%s ended=%s duration=%s", + session.SessionID, session.ResourceID, session.SourceAddr, session.DestAddr, + session.StartedAt.Format(time.RFC3339), now.Format(time.RFC3339), duration) + al.completedSessions = append(al.completedSessions, session) + delete(al.sessions, session.SessionID) + delete(al.udpSessions, key) + } + } +} + +// flush drains the completed sessions buffer, compresses with zlib, and sends via the SendFunc. +func (al *AccessLogger) flush() { + al.mu.Lock() + if len(al.completedSessions) == 0 { + al.mu.Unlock() + return + } + batch := al.completedSessions + al.completedSessions = make([]*AccessSession, 0) + sendFn := al.sendFn + al.mu.Unlock() + + if sendFn == nil { + logger.Debug("Access logger: no send function configured, discarding %d sessions", len(batch)) + return + } + + compressed, err := compressSessions(batch) + if err != nil { + logger.Error("Access logger: failed to compress %d sessions: %v", len(batch), err) + return + } + + if err := sendFn(compressed); err != nil { + logger.Error("Access logger: failed to send %d sessions: %v", len(batch), err) + // Re-queue the batch so we don't lose data + al.mu.Lock() + al.completedSessions = append(batch, al.completedSessions...) + // Cap re-queued data to prevent unbounded growth if server is unreachable + if len(al.completedSessions) > maxBufferedSessions*5 { + dropped := len(al.completedSessions) - maxBufferedSessions*5 + al.completedSessions = al.completedSessions[:maxBufferedSessions*5] + logger.Warn("Access logger: buffer overflow, dropped %d oldest sessions", dropped) + } + al.mu.Unlock() + return + } + + logger.Info("Access logger: sent %d sessions to server", len(batch)) +} + +// compressSessions JSON-encodes the sessions, compresses with zlib, and returns +// a base64-encoded string suitable for embedding in a JSON message. +func compressSessions(sessions []*AccessSession) (string, error) { + jsonData, err := json.Marshal(sessions) + if err != nil { + return "", err + } + + var buf bytes.Buffer + w, err := zlib.NewWriterLevel(&buf, zlib.BestCompression) + if err != nil { + return "", err + } + if _, err := w.Write(jsonData); err != nil { + w.Close() + return "", err + } + if err := w.Close(); err != nil { + return "", err + } + + return base64.StdEncoding.EncodeToString(buf.Bytes()), nil +} + +// Close shuts down the background loop, ends all active sessions, +// and performs one final flush to send everything to the server. +func (al *AccessLogger) Close() { + // Signal the background loop to stop + select { + case <-al.stopCh: + // Already closed + return + default: + close(al.stopCh) + } + + // Wait for the background loop to exit so we don't race on flush + <-al.flushDone + + al.mu.Lock() + now := time.Now() + + // End all active sessions and move them to the completed buffer + for _, session := range al.sessions { + if session.EndedAt.IsZero() { + session.EndedAt = now + duration := now.Sub(session.StartedAt) + logger.Info("ACCESS END (shutdown) session=%s resource=%d proto=%s src=%s dst=%s started=%s ended=%s duration=%s", + session.SessionID, session.ResourceID, session.Protocol, session.SourceAddr, session.DestAddr, + session.StartedAt.Format(time.RFC3339), now.Format(time.RFC3339), duration) + al.completedSessions = append(al.completedSessions, session) + } + } + + al.sessions = make(map[string]*AccessSession) + al.udpSessions = make(map[udpSessionKey]*AccessSession) + al.mu.Unlock() + + // Final flush to send all remaining sessions to the server + al.flush() +} \ No newline at end of file diff --git a/netstack2/handlers.go b/netstack2/handlers.go index 75c58b2..07c235f 100644 --- a/netstack2/handlers.go +++ b/netstack2/handlers.go @@ -158,6 +158,18 @@ func (h *TCPHandler) handleTCPConn(netstackConn *gonet.TCPConn, id stack.Transpo targetAddr := fmt.Sprintf("%s:%d", actualDstIP, dstPort) + // Look up resource ID and start access session if applicable + var accessSessionID string + if h.proxyHandler != nil { + resourceId := h.proxyHandler.LookupResourceId(srcIP, dstIP, dstPort, uint8(tcp.ProtocolNumber)) + if resourceId != 0 { + if al := h.proxyHandler.GetAccessLogger(); al != nil { + srcAddr := fmt.Sprintf("%s:%d", srcIP, srcPort) + accessSessionID = al.StartTCPSession(resourceId, srcAddr, targetAddr) + } + } + } + // Create context with timeout for connection establishment ctx, cancel := context.WithTimeout(context.Background(), tcpConnectTimeout) defer cancel() @@ -167,11 +179,26 @@ func (h *TCPHandler) handleTCPConn(netstackConn *gonet.TCPConn, id stack.Transpo targetConn, err := d.DialContext(ctx, "tcp", targetAddr) if err != nil { logger.Info("TCP Forwarder: Failed to connect to %s: %v", targetAddr, err) + // End access session on connection failure + if accessSessionID != "" { + if al := h.proxyHandler.GetAccessLogger(); al != nil { + al.EndTCPSession(accessSessionID) + } + } // Connection failed, netstack will handle RST return } defer targetConn.Close() + // End access session when connection closes + if accessSessionID != "" { + defer func() { + if al := h.proxyHandler.GetAccessLogger(); al != nil { + al.EndTCPSession(accessSessionID) + } + }() + } + logger.Info("TCP Forwarder: Successfully connected to %s, starting bidirectional copy", targetAddr) // Bidirectional copy between netstack and target @@ -280,6 +307,27 @@ func (h *UDPHandler) handleUDPConn(netstackConn *gonet.UDPConn, id stack.Transpo targetAddr := fmt.Sprintf("%s:%d", actualDstIP, dstPort) + // Look up resource ID and start access session if applicable + var accessSessionID string + if h.proxyHandler != nil { + resourceId := h.proxyHandler.LookupResourceId(srcIP, dstIP, dstPort, uint8(udp.ProtocolNumber)) + if resourceId != 0 { + if al := h.proxyHandler.GetAccessLogger(); al != nil { + srcAddr := fmt.Sprintf("%s:%d", srcIP, srcPort) + accessSessionID = al.TrackUDPSession(resourceId, srcAddr, targetAddr) + } + } + } + + // End access session when UDP handler returns (timeout or error) + if accessSessionID != "" { + defer func() { + if al := h.proxyHandler.GetAccessLogger(); al != nil { + al.EndUDPSession(accessSessionID) + } + }() + } + // Resolve target address remoteUDPAddr, err := net.ResolveUDPAddr("udp", targetAddr) if err != nil { diff --git a/netstack2/proxy.go b/netstack2/proxy.go index 1b34818..e383fc0 100644 --- a/netstack2/proxy.go +++ b/netstack2/proxy.go @@ -22,6 +22,12 @@ import ( "gvisor.dev/gvisor/pkg/tcpip/transport/udp" ) +const ( + // udpAccessSessionTimeout is how long a UDP access session stays alive without traffic + // before being considered ended by the access logger + udpAccessSessionTimeout = 120 * time.Second +) + // PortRange represents an allowed range of ports (inclusive) with optional protocol filtering // Protocol can be "tcp", "udp", or "" (empty string means both protocols) type PortRange struct { @@ -46,6 +52,7 @@ type SubnetRule struct { DisableIcmp bool // If true, ICMP traffic is blocked for this subnet RewriteTo string // Optional rewrite address for DNAT - can be IP/CIDR or domain name PortRanges []PortRange // empty slice means all ports allowed + ResourceId int // Optional resource ID from the server for access logging } // GetAllRules returns a copy of all subnet rules @@ -111,10 +118,12 @@ type ProxyHandler struct { natTable map[connKey]*natState reverseNatTable map[reverseConnKey]*natState // Reverse lookup map for O(1) reply packet NAT destRewriteTable map[destKey]netip.Addr // Maps original dest to rewritten dest for handler lookups + resourceTable map[destKey]int // Maps connection key to resource ID for access logging natMu sync.RWMutex enabled bool icmpReplies chan []byte // Channel for ICMP reply packets to be sent back through the tunnel notifiable channel.Notification // Notification handler for triggering reads + accessLogger *AccessLogger // Access logger for tracking sessions } // ProxyHandlerOptions configures the proxy handler @@ -137,7 +146,9 @@ func NewProxyHandler(options ProxyHandlerOptions) (*ProxyHandler, error) { natTable: make(map[connKey]*natState), reverseNatTable: make(map[reverseConnKey]*natState), destRewriteTable: make(map[destKey]netip.Addr), + resourceTable: make(map[destKey]int), icmpReplies: make(chan []byte, 256), // Buffer for ICMP reply packets + accessLogger: NewAccessLogger(udpAccessSessionTimeout), proxyEp: channel.New(1024, uint32(options.MTU), ""), proxyStack: stack.New(stack.Options{ NetworkProtocols: []stack.NetworkProtocolFactory{ @@ -202,11 +213,11 @@ func NewProxyHandler(options ProxyHandlerOptions) (*ProxyHandler, error) { // destPrefix: The IP prefix of the destination // rewriteTo: Optional address to rewrite destination to - can be IP/CIDR or domain name // If portRanges is nil or empty, all ports are allowed for this subnet -func (p *ProxyHandler) AddSubnetRule(sourcePrefix, destPrefix netip.Prefix, rewriteTo string, portRanges []PortRange, disableIcmp bool) { +func (p *ProxyHandler) AddSubnetRule(sourcePrefix, destPrefix netip.Prefix, rewriteTo string, portRanges []PortRange, disableIcmp bool, resourceId int) { if p == nil || !p.enabled { return } - p.subnetLookup.AddSubnet(sourcePrefix, destPrefix, rewriteTo, portRanges, disableIcmp) + p.subnetLookup.AddSubnet(sourcePrefix, destPrefix, rewriteTo, portRanges, disableIcmp, resourceId) } // RemoveSubnetRule removes a subnet from the proxy handler @@ -225,6 +236,43 @@ func (p *ProxyHandler) GetAllRules() []SubnetRule { return p.subnetLookup.GetAllRules() } +// LookupResourceId looks up the resource ID for a connection +// Returns 0 if no resource ID is associated with this connection +func (p *ProxyHandler) LookupResourceId(srcIP, dstIP string, dstPort uint16, proto uint8) int { + if p == nil || !p.enabled { + return 0 + } + + key := destKey{ + srcIP: srcIP, + dstIP: dstIP, + dstPort: dstPort, + proto: proto, + } + + p.natMu.RLock() + defer p.natMu.RUnlock() + + return p.resourceTable[key] +} + +// GetAccessLogger returns the access logger for session tracking +func (p *ProxyHandler) GetAccessLogger() *AccessLogger { + if p == nil { + return nil + } + return p.accessLogger +} + +// SetAccessLogSender configures the function used to send compressed access log +// batches to the server. This should be called once the websocket client is available. +func (p *ProxyHandler) SetAccessLogSender(fn SendFunc) { + if p == nil || !p.enabled || p.accessLogger == nil { + return + } + p.accessLogger.SetSendFunc(fn) +} + // LookupDestinationRewrite looks up the rewritten destination for a connection // This is used by TCP/UDP handlers to find the actual target address func (p *ProxyHandler) LookupDestinationRewrite(srcIP, dstIP string, dstPort uint16, proto uint8) (netip.Addr, bool) { @@ -387,8 +435,22 @@ func (p *ProxyHandler) HandleIncomingPacket(packet []byte) bool { // Check if the source IP, destination IP, port, and protocol match any subnet rule matchedRule := p.subnetLookup.Match(srcAddr, dstAddr, dstPort, protocol) if matchedRule != nil { - logger.Debug("HandleIncomingPacket: Matched rule for %s -> %s (proto=%d, port=%d)", - srcAddr, dstAddr, protocol, dstPort) + logger.Debug("HandleIncomingPacket: Matched rule for %s -> %s (proto=%d, port=%d, resourceId=%d)", + srcAddr, dstAddr, protocol, dstPort, matchedRule.ResourceId) + + // Store resource ID for connections without DNAT as well + if matchedRule.ResourceId != 0 && matchedRule.RewriteTo == "" { + dKey := destKey{ + srcIP: srcAddr.String(), + dstIP: dstAddr.String(), + dstPort: dstPort, + proto: uint8(protocol), + } + p.natMu.Lock() + p.resourceTable[dKey] = matchedRule.ResourceId + p.natMu.Unlock() + } + // Check if we need to perform DNAT if matchedRule.RewriteTo != "" { // Create connection tracking key using original destination @@ -420,6 +482,13 @@ func (p *ProxyHandler) HandleIncomingPacket(packet []byte) bool { proto: uint8(protocol), } + // Store resource ID for access logging if present + if matchedRule.ResourceId != 0 { + p.natMu.Lock() + p.resourceTable[dKey] = matchedRule.ResourceId + p.natMu.Unlock() + } + // Check if we already have a NAT entry for this connection p.natMu.RLock() existingEntry, exists := p.natTable[key] @@ -720,6 +789,11 @@ func (p *ProxyHandler) Close() error { return nil } + // Shut down access logger + if p.accessLogger != nil { + p.accessLogger.Close() + } + // Close ICMP replies channel if p.icmpReplies != nil { close(p.icmpReplies) diff --git a/netstack2/subnet_lookup.go b/netstack2/subnet_lookup.go index c6ad0d5..317f85c 100644 --- a/netstack2/subnet_lookup.go +++ b/netstack2/subnet_lookup.go @@ -47,7 +47,7 @@ func prefixEqual(a, b netip.Prefix) bool { // AddSubnet adds a subnet rule with source and destination prefixes and optional port restrictions // If portRanges is nil or empty, all ports are allowed for this subnet // rewriteTo can be either an IP/CIDR (e.g., "192.168.1.1/32") or a domain name (e.g., "example.com") -func (sl *SubnetLookup) AddSubnet(sourcePrefix, destPrefix netip.Prefix, rewriteTo string, portRanges []PortRange, disableIcmp bool) { +func (sl *SubnetLookup) AddSubnet(sourcePrefix, destPrefix netip.Prefix, rewriteTo string, portRanges []PortRange, disableIcmp bool, resourceId int) { sl.mu.Lock() defer sl.mu.Unlock() @@ -57,6 +57,7 @@ func (sl *SubnetLookup) AddSubnet(sourcePrefix, destPrefix netip.Prefix, rewrite DisableIcmp: disableIcmp, RewriteTo: rewriteTo, PortRanges: portRanges, + ResourceId: resourceId, } // Canonicalize source prefix to handle host bits correctly diff --git a/netstack2/tun.go b/netstack2/tun.go index b00faea..3183c36 100644 --- a/netstack2/tun.go +++ b/netstack2/tun.go @@ -354,10 +354,10 @@ func (net *Net) ListenUDP(laddr *net.UDPAddr) (*gonet.UDPConn, error) { // AddProxySubnetRule adds a subnet rule to the proxy handler // If portRanges is nil or empty, all ports are allowed for this subnet // rewriteTo can be either an IP/CIDR (e.g., "192.168.1.1/32") or a domain name (e.g., "example.com") -func (net *Net) AddProxySubnetRule(sourcePrefix, destPrefix netip.Prefix, rewriteTo string, portRanges []PortRange, disableIcmp bool) { +func (net *Net) AddProxySubnetRule(sourcePrefix, destPrefix netip.Prefix, rewriteTo string, portRanges []PortRange, disableIcmp bool, resourceId int) { tun := (*netTun)(net) if tun.proxyHandler != nil { - tun.proxyHandler.AddSubnetRule(sourcePrefix, destPrefix, rewriteTo, portRanges, disableIcmp) + tun.proxyHandler.AddSubnetRule(sourcePrefix, destPrefix, rewriteTo, portRanges, disableIcmp, resourceId) } } @@ -385,6 +385,15 @@ func (net *Net) GetProxyHandler() *ProxyHandler { return tun.proxyHandler } +// SetAccessLogSender configures the function used to send compressed access log +// batches to the server. This should be called once the websocket client is available. +func (net *Net) SetAccessLogSender(fn SendFunc) { + tun := (*netTun)(net) + if tun.proxyHandler != nil { + tun.proxyHandler.SetAccessLogSender(fn) + } +} + type PingConn struct { laddr PingAddr raddr PingAddr From 195fb57f035c4cb8781435b9a639be12dce0abcc Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 24 Mar 2026 17:26:44 -0700 Subject: [PATCH 031/161] Process log to form sessions Former-commit-id: 69019d565567b09012061183c6afa9851c01e1b5 --- netstack2/access_log.go | 179 +++++++- netstack2/access_log_test.go | 811 +++++++++++++++++++++++++++++++++++ 2 files changed, 980 insertions(+), 10 deletions(-) create mode 100644 netstack2/access_log_test.go diff --git a/netstack2/access_log.go b/netstack2/access_log.go index ab0db78..de71296 100644 --- a/netstack2/access_log.go +++ b/netstack2/access_log.go @@ -7,6 +7,8 @@ import ( "encoding/base64" "encoding/hex" "encoding/json" + "net" + "sort" "sync" "time" @@ -19,6 +21,15 @@ const ( // maxBufferedSessions is the max number of completed sessions to buffer before forcing a flush maxBufferedSessions = 100 + + // sessionGapThreshold is the maximum gap between the end of one connection + // and the start of the next for them to be considered part of the same session. + // If the gap exceeds this, a new consolidated session is created. + sessionGapThreshold = 5 * time.Second + + // minConnectionsToConsolidate is the minimum number of connections in a group + // before we bother consolidating. Groups smaller than this are sent as-is. + minConnectionsToConsolidate = 2 ) // SendFunc is a callback that sends compressed access log data to the server. @@ -27,15 +38,16 @@ type SendFunc func(data string) error // AccessSession represents a tracked access session through the proxy type AccessSession struct { - SessionID string `json:"sessionId"` - ResourceID int `json:"resourceId"` - SourceAddr string `json:"sourceAddr"` - DestAddr string `json:"destAddr"` - Protocol string `json:"protocol"` - StartedAt time.Time `json:"startedAt"` - EndedAt time.Time `json:"endedAt,omitempty"` - BytesTx int64 `json:"bytesTx"` - BytesRx int64 `json:"bytesRx"` + SessionID string `json:"sessionId"` + ResourceID int `json:"resourceId"` + SourceAddr string `json:"sourceAddr"` + DestAddr string `json:"destAddr"` + Protocol string `json:"protocol"` + StartedAt time.Time `json:"startedAt"` + EndedAt time.Time `json:"endedAt,omitempty"` + BytesTx int64 `json:"bytesTx"` + BytesRx int64 `json:"bytesRx"` + ConnectionCount int `json:"connectionCount,omitempty"` // number of raw connections merged into this session (0 or 1 = single) } // udpSessionKey identifies a unique UDP "session" by src -> dst @@ -45,6 +57,16 @@ type udpSessionKey struct { protocol string } +// consolidationKey groups connections that may be part of the same logical session. +// Source port is intentionally excluded so that many ephemeral-port connections +// from the same source IP to the same destination are grouped together. +type consolidationKey struct { + sourceIP string // IP only, no port + destAddr string // full host:port of the destination + protocol string + resourceID int +} + // AccessLogger tracks access sessions for resources and periodically // flushes completed sessions to the server via a configurable SendFunc. type AccessLogger struct { @@ -251,7 +273,137 @@ func (al *AccessLogger) reapStaleSessions() { } } -// flush drains the completed sessions buffer, compresses with zlib, and sends via the SendFunc. +// extractIP strips the port from an address string and returns just the IP. +// If the address has no port component it is returned as-is. +func extractIP(addr string) string { + host, _, err := net.SplitHostPort(addr) + if err != nil { + // Might already be a bare IP + return addr + } + return host +} + +// consolidateSessions takes a slice of completed sessions and merges bursts of +// short-lived connections from the same source IP to the same destination into +// single higher-level session entries. +// +// The algorithm: +// 1. Group sessions by (sourceIP, destAddr, protocol, resourceID). +// 2. Within each group, sort by StartedAt. +// 3. Walk through the sorted list and merge consecutive sessions whose gap +// (previous EndedAt → next StartedAt) is ≤ sessionGapThreshold. +// 4. For merged sessions the earliest StartedAt and latest EndedAt are kept, +// bytes are summed, and ConnectionCount records how many raw connections +// were folded in. If the merged connections used more than one source port, +// SourceAddr is set to just the IP (port omitted). +// 5. Groups with fewer than minConnectionsToConsolidate members are passed +// through unmodified. +func consolidateSessions(sessions []*AccessSession) []*AccessSession { + if len(sessions) <= 1 { + return sessions + } + + // Group sessions by consolidation key + groups := make(map[consolidationKey][]*AccessSession) + for _, s := range sessions { + key := consolidationKey{ + sourceIP: extractIP(s.SourceAddr), + destAddr: s.DestAddr, + protocol: s.Protocol, + resourceID: s.ResourceID, + } + groups[key] = append(groups[key], s) + } + + result := make([]*AccessSession, 0, len(sessions)) + + for key, group := range groups { + // Small groups don't need consolidation + if len(group) < minConnectionsToConsolidate { + result = append(result, group...) + continue + } + + // Sort the group by start time so we can detect gaps + sort.Slice(group, func(i, j int) bool { + return group[i].StartedAt.Before(group[j].StartedAt) + }) + + // Walk through and merge runs that are within the gap threshold + var merged []*AccessSession + cur := cloneSession(group[0]) + cur.ConnectionCount = 1 + sourcePorts := make(map[string]struct{}) + sourcePorts[cur.SourceAddr] = struct{}{} + + for i := 1; i < len(group); i++ { + s := group[i] + + // Determine the gap: from the latest end time we've seen so far to the + // start of the next connection. + gapRef := cur.EndedAt + if gapRef.IsZero() { + gapRef = cur.StartedAt + } + gap := s.StartedAt.Sub(gapRef) + + if gap <= sessionGapThreshold { + // Merge into the current consolidated session + cur.ConnectionCount++ + cur.BytesTx += s.BytesTx + cur.BytesRx += s.BytesRx + sourcePorts[s.SourceAddr] = struct{}{} + + // Extend EndedAt to the latest time + endTime := s.EndedAt + if endTime.IsZero() { + endTime = s.StartedAt + } + if endTime.After(cur.EndedAt) { + cur.EndedAt = endTime + } + } else { + // Gap exceeded — finalize the current session and start a new one + finalizeMergedSourceAddr(cur, key.sourceIP, sourcePorts) + merged = append(merged, cur) + + cur = cloneSession(s) + cur.ConnectionCount = 1 + sourcePorts = make(map[string]struct{}) + sourcePorts[s.SourceAddr] = struct{}{} + } + } + + // Finalize the last accumulated session + finalizeMergedSourceAddr(cur, key.sourceIP, sourcePorts) + merged = append(merged, cur) + + result = append(result, merged...) + } + + return result +} + +// cloneSession creates a shallow copy of an AccessSession. +func cloneSession(s *AccessSession) *AccessSession { + cp := *s + return &cp +} + +// finalizeMergedSourceAddr sets the SourceAddr on a consolidated session. +// If multiple distinct source addresses (ports) were seen, the port is +// stripped and only the IP is kept so the log isn't misleading. +func finalizeMergedSourceAddr(s *AccessSession, sourceIP string, ports map[string]struct{}) { + if len(ports) > 1 { + // Multiple source ports — just report the IP + s.SourceAddr = sourceIP + } + // Otherwise keep the original SourceAddr which already has ip:port +} + +// flush drains the completed sessions buffer, consolidates bursts of +// short-lived connections, compresses with zlib, and sends via the SendFunc. func (al *AccessLogger) flush() { al.mu.Lock() if len(al.completedSessions) == 0 { @@ -268,6 +420,13 @@ func (al *AccessLogger) flush() { return } + // Consolidate bursts of short-lived connections into higher-level sessions + originalCount := len(batch) + batch = consolidateSessions(batch) + if len(batch) != originalCount { + logger.Info("Access logger: consolidated %d raw connections into %d sessions", originalCount, len(batch)) + } + compressed, err := compressSessions(batch) if err != nil { logger.Error("Access logger: failed to compress %d sessions: %v", len(batch), err) diff --git a/netstack2/access_log_test.go b/netstack2/access_log_test.go new file mode 100644 index 0000000..fc98054 --- /dev/null +++ b/netstack2/access_log_test.go @@ -0,0 +1,811 @@ +package netstack2 + +import ( + "testing" + "time" +) + +func TestExtractIP(t *testing.T) { + tests := []struct { + name string + addr string + expected string + }{ + {"ipv4 with port", "192.168.1.1:12345", "192.168.1.1"}, + {"ipv4 without port", "192.168.1.1", "192.168.1.1"}, + {"ipv6 with port", "[::1]:12345", "::1"}, + {"ipv6 without port", "::1", "::1"}, + {"empty string", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractIP(tt.addr) + if result != tt.expected { + t.Errorf("extractIP(%q) = %q, want %q", tt.addr, result, tt.expected) + } + }) + } +} + +func TestConsolidateSessions_Empty(t *testing.T) { + result := consolidateSessions(nil) + if result != nil { + t.Errorf("expected nil, got %v", result) + } + + result = consolidateSessions([]*AccessSession{}) + if len(result) != 0 { + t.Errorf("expected empty slice, got %d items", len(result)) + } +} + +func TestConsolidateSessions_SingleSession(t *testing.T) { + now := time.Now() + sessions := []*AccessSession{ + { + SessionID: "abc123", + ResourceID: 1, + SourceAddr: "10.0.0.1:5000", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now, + EndedAt: now.Add(1 * time.Second), + }, + } + + result := consolidateSessions(sessions) + if len(result) != 1 { + t.Fatalf("expected 1 session, got %d", len(result)) + } + if result[0].SourceAddr != "10.0.0.1:5000" { + t.Errorf("expected source addr preserved, got %q", result[0].SourceAddr) + } +} + +func TestConsolidateSessions_MergesBurstFromSameSourceIP(t *testing.T) { + now := time.Now() + sessions := []*AccessSession{ + { + SessionID: "s1", + ResourceID: 1, + SourceAddr: "10.0.0.1:5000", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now, + EndedAt: now.Add(100 * time.Millisecond), + BytesTx: 100, + BytesRx: 200, + }, + { + SessionID: "s2", + ResourceID: 1, + SourceAddr: "10.0.0.1:5001", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now.Add(200 * time.Millisecond), + EndedAt: now.Add(300 * time.Millisecond), + BytesTx: 150, + BytesRx: 250, + }, + { + SessionID: "s3", + ResourceID: 1, + SourceAddr: "10.0.0.1:5002", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now.Add(400 * time.Millisecond), + EndedAt: now.Add(500 * time.Millisecond), + BytesTx: 50, + BytesRx: 75, + }, + } + + result := consolidateSessions(sessions) + if len(result) != 1 { + t.Fatalf("expected 1 consolidated session, got %d", len(result)) + } + + s := result[0] + if s.ConnectionCount != 3 { + t.Errorf("expected ConnectionCount=3, got %d", s.ConnectionCount) + } + if s.SourceAddr != "10.0.0.1" { + t.Errorf("expected source addr to be IP only (multiple ports), got %q", s.SourceAddr) + } + if s.DestAddr != "192.168.1.100:443" { + t.Errorf("expected dest addr preserved, got %q", s.DestAddr) + } + if s.StartedAt != now { + t.Errorf("expected StartedAt to be earliest time") + } + if s.EndedAt != now.Add(500*time.Millisecond) { + t.Errorf("expected EndedAt to be latest time") + } + expectedTx := int64(300) + expectedRx := int64(525) + if s.BytesTx != expectedTx { + t.Errorf("expected BytesTx=%d, got %d", expectedTx, s.BytesTx) + } + if s.BytesRx != expectedRx { + t.Errorf("expected BytesRx=%d, got %d", expectedRx, s.BytesRx) + } +} + +func TestConsolidateSessions_SameSourcePortPreserved(t *testing.T) { + now := time.Now() + sessions := []*AccessSession{ + { + SessionID: "s1", + ResourceID: 1, + SourceAddr: "10.0.0.1:5000", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now, + EndedAt: now.Add(100 * time.Millisecond), + }, + { + SessionID: "s2", + ResourceID: 1, + SourceAddr: "10.0.0.1:5000", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now.Add(200 * time.Millisecond), + EndedAt: now.Add(300 * time.Millisecond), + }, + } + + result := consolidateSessions(sessions) + if len(result) != 1 { + t.Fatalf("expected 1 session, got %d", len(result)) + } + if result[0].SourceAddr != "10.0.0.1:5000" { + t.Errorf("expected source addr with port preserved when all ports are the same, got %q", result[0].SourceAddr) + } + if result[0].ConnectionCount != 2 { + t.Errorf("expected ConnectionCount=2, got %d", result[0].ConnectionCount) + } +} + +func TestConsolidateSessions_GapSplitsSessions(t *testing.T) { + now := time.Now() + + // First burst + sessions := []*AccessSession{ + { + SessionID: "s1", + ResourceID: 1, + SourceAddr: "10.0.0.1:5000", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now, + EndedAt: now.Add(100 * time.Millisecond), + }, + { + SessionID: "s2", + ResourceID: 1, + SourceAddr: "10.0.0.1:5001", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now.Add(200 * time.Millisecond), + EndedAt: now.Add(300 * time.Millisecond), + }, + // Big gap here (10 seconds) + { + SessionID: "s3", + ResourceID: 1, + SourceAddr: "10.0.0.1:5002", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now.Add(10 * time.Second), + EndedAt: now.Add(10*time.Second + 100*time.Millisecond), + }, + { + SessionID: "s4", + ResourceID: 1, + SourceAddr: "10.0.0.1:5003", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now.Add(10*time.Second + 200*time.Millisecond), + EndedAt: now.Add(10*time.Second + 300*time.Millisecond), + }, + } + + result := consolidateSessions(sessions) + if len(result) != 2 { + t.Fatalf("expected 2 consolidated sessions (gap split), got %d", len(result)) + } + + // Find the sessions by their start time + var first, second *AccessSession + for _, s := range result { + if s.StartedAt.Equal(now) { + first = s + } else { + second = s + } + } + + if first == nil || second == nil { + t.Fatal("could not find both consolidated sessions") + } + + if first.ConnectionCount != 2 { + t.Errorf("first burst: expected ConnectionCount=2, got %d", first.ConnectionCount) + } + if second.ConnectionCount != 2 { + t.Errorf("second burst: expected ConnectionCount=2, got %d", second.ConnectionCount) + } +} + +func TestConsolidateSessions_DifferentDestinationsNotMerged(t *testing.T) { + now := time.Now() + sessions := []*AccessSession{ + { + SessionID: "s1", + ResourceID: 1, + SourceAddr: "10.0.0.1:5000", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now, + EndedAt: now.Add(100 * time.Millisecond), + }, + { + SessionID: "s2", + ResourceID: 1, + SourceAddr: "10.0.0.1:5001", + DestAddr: "192.168.1.100:8080", + Protocol: "tcp", + StartedAt: now.Add(200 * time.Millisecond), + EndedAt: now.Add(300 * time.Millisecond), + }, + } + + result := consolidateSessions(sessions) + // Each goes to a different dest port so they should not be merged + if len(result) != 2 { + t.Fatalf("expected 2 sessions (different destinations), got %d", len(result)) + } +} + +func TestConsolidateSessions_DifferentProtocolsNotMerged(t *testing.T) { + now := time.Now() + sessions := []*AccessSession{ + { + SessionID: "s1", + ResourceID: 1, + SourceAddr: "10.0.0.1:5000", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now, + EndedAt: now.Add(100 * time.Millisecond), + }, + { + SessionID: "s2", + ResourceID: 1, + SourceAddr: "10.0.0.1:5001", + DestAddr: "192.168.1.100:443", + Protocol: "udp", + StartedAt: now.Add(200 * time.Millisecond), + EndedAt: now.Add(300 * time.Millisecond), + }, + } + + result := consolidateSessions(sessions) + if len(result) != 2 { + t.Fatalf("expected 2 sessions (different protocols), got %d", len(result)) + } +} + +func TestConsolidateSessions_DifferentResourceIDsNotMerged(t *testing.T) { + now := time.Now() + sessions := []*AccessSession{ + { + SessionID: "s1", + ResourceID: 1, + SourceAddr: "10.0.0.1:5000", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now, + EndedAt: now.Add(100 * time.Millisecond), + }, + { + SessionID: "s2", + ResourceID: 2, + SourceAddr: "10.0.0.1:5001", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now.Add(200 * time.Millisecond), + EndedAt: now.Add(300 * time.Millisecond), + }, + } + + result := consolidateSessions(sessions) + if len(result) != 2 { + t.Fatalf("expected 2 sessions (different resource IDs), got %d", len(result)) + } +} + +func TestConsolidateSessions_DifferentSourceIPsNotMerged(t *testing.T) { + now := time.Now() + sessions := []*AccessSession{ + { + SessionID: "s1", + ResourceID: 1, + SourceAddr: "10.0.0.1:5000", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now, + EndedAt: now.Add(100 * time.Millisecond), + }, + { + SessionID: "s2", + ResourceID: 1, + SourceAddr: "10.0.0.2:5001", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now.Add(200 * time.Millisecond), + EndedAt: now.Add(300 * time.Millisecond), + }, + } + + result := consolidateSessions(sessions) + if len(result) != 2 { + t.Fatalf("expected 2 sessions (different source IPs), got %d", len(result)) + } +} + +func TestConsolidateSessions_OutOfOrderInput(t *testing.T) { + now := time.Now() + // Provide sessions out of chronological order to verify sorting + sessions := []*AccessSession{ + { + SessionID: "s3", + ResourceID: 1, + SourceAddr: "10.0.0.1:5002", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now.Add(400 * time.Millisecond), + EndedAt: now.Add(500 * time.Millisecond), + BytesTx: 30, + }, + { + SessionID: "s1", + ResourceID: 1, + SourceAddr: "10.0.0.1:5000", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now, + EndedAt: now.Add(100 * time.Millisecond), + BytesTx: 10, + }, + { + SessionID: "s2", + ResourceID: 1, + SourceAddr: "10.0.0.1:5001", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now.Add(200 * time.Millisecond), + EndedAt: now.Add(300 * time.Millisecond), + BytesTx: 20, + }, + } + + result := consolidateSessions(sessions) + if len(result) != 1 { + t.Fatalf("expected 1 consolidated session, got %d", len(result)) + } + + s := result[0] + if s.ConnectionCount != 3 { + t.Errorf("expected ConnectionCount=3, got %d", s.ConnectionCount) + } + if s.StartedAt != now { + t.Errorf("expected StartedAt to be earliest time") + } + if s.EndedAt != now.Add(500*time.Millisecond) { + t.Errorf("expected EndedAt to be latest time") + } + if s.BytesTx != 60 { + t.Errorf("expected BytesTx=60, got %d", s.BytesTx) + } +} + +func TestConsolidateSessions_ExactlyAtGapThreshold(t *testing.T) { + now := time.Now() + sessions := []*AccessSession{ + { + SessionID: "s1", + ResourceID: 1, + SourceAddr: "10.0.0.1:5000", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now, + EndedAt: now.Add(100 * time.Millisecond), + }, + { + // Starts exactly sessionGapThreshold after s1 ends — should still merge + SessionID: "s2", + ResourceID: 1, + SourceAddr: "10.0.0.1:5001", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now.Add(100*time.Millisecond + sessionGapThreshold), + EndedAt: now.Add(100*time.Millisecond + sessionGapThreshold + 50*time.Millisecond), + }, + } + + result := consolidateSessions(sessions) + if len(result) != 1 { + t.Fatalf("expected 1 session (gap exactly at threshold merges), got %d", len(result)) + } + if result[0].ConnectionCount != 2 { + t.Errorf("expected ConnectionCount=2, got %d", result[0].ConnectionCount) + } +} + +func TestConsolidateSessions_JustOverGapThreshold(t *testing.T) { + now := time.Now() + sessions := []*AccessSession{ + { + SessionID: "s1", + ResourceID: 1, + SourceAddr: "10.0.0.1:5000", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now, + EndedAt: now.Add(100 * time.Millisecond), + }, + { + // Starts 1ms over the gap threshold after s1 ends — should split + SessionID: "s2", + ResourceID: 1, + SourceAddr: "10.0.0.1:5001", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now.Add(100*time.Millisecond + sessionGapThreshold + 1*time.Millisecond), + EndedAt: now.Add(100*time.Millisecond + sessionGapThreshold + 50*time.Millisecond), + }, + } + + result := consolidateSessions(sessions) + if len(result) != 2 { + t.Fatalf("expected 2 sessions (gap just over threshold splits), got %d", len(result)) + } +} + +func TestConsolidateSessions_UDPSessions(t *testing.T) { + now := time.Now() + sessions := []*AccessSession{ + { + SessionID: "u1", + ResourceID: 5, + SourceAddr: "10.0.0.1:6000", + DestAddr: "192.168.1.100:53", + Protocol: "udp", + StartedAt: now, + EndedAt: now.Add(50 * time.Millisecond), + BytesTx: 64, + BytesRx: 512, + }, + { + SessionID: "u2", + ResourceID: 5, + SourceAddr: "10.0.0.1:6001", + DestAddr: "192.168.1.100:53", + Protocol: "udp", + StartedAt: now.Add(100 * time.Millisecond), + EndedAt: now.Add(150 * time.Millisecond), + BytesTx: 64, + BytesRx: 256, + }, + { + SessionID: "u3", + ResourceID: 5, + SourceAddr: "10.0.0.1:6002", + DestAddr: "192.168.1.100:53", + Protocol: "udp", + StartedAt: now.Add(200 * time.Millisecond), + EndedAt: now.Add(250 * time.Millisecond), + BytesTx: 64, + BytesRx: 128, + }, + } + + result := consolidateSessions(sessions) + if len(result) != 1 { + t.Fatalf("expected 1 consolidated UDP session, got %d", len(result)) + } + + s := result[0] + if s.Protocol != "udp" { + t.Errorf("expected protocol=udp, got %q", s.Protocol) + } + if s.ConnectionCount != 3 { + t.Errorf("expected ConnectionCount=3, got %d", s.ConnectionCount) + } + if s.SourceAddr != "10.0.0.1" { + t.Errorf("expected source addr to be IP only, got %q", s.SourceAddr) + } + if s.BytesTx != 192 { + t.Errorf("expected BytesTx=192, got %d", s.BytesTx) + } + if s.BytesRx != 896 { + t.Errorf("expected BytesRx=896, got %d", s.BytesRx) + } +} + +func TestConsolidateSessions_MixedGroupsSomeConsolidatedSomeNot(t *testing.T) { + now := time.Now() + sessions := []*AccessSession{ + // Group 1: 3 connections to :443 from same IP — should consolidate + { + SessionID: "s1", + ResourceID: 1, + SourceAddr: "10.0.0.1:5000", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now, + EndedAt: now.Add(100 * time.Millisecond), + }, + { + SessionID: "s2", + ResourceID: 1, + SourceAddr: "10.0.0.1:5001", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now.Add(200 * time.Millisecond), + EndedAt: now.Add(300 * time.Millisecond), + }, + { + SessionID: "s3", + ResourceID: 1, + SourceAddr: "10.0.0.1:5002", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now.Add(400 * time.Millisecond), + EndedAt: now.Add(500 * time.Millisecond), + }, + // Group 2: 1 connection to :8080 from different IP — should pass through + { + SessionID: "s4", + ResourceID: 2, + SourceAddr: "10.0.0.2:6000", + DestAddr: "192.168.1.200:8080", + Protocol: "tcp", + StartedAt: now.Add(1 * time.Second), + EndedAt: now.Add(2 * time.Second), + }, + } + + result := consolidateSessions(sessions) + if len(result) != 2 { + t.Fatalf("expected 2 sessions total, got %d", len(result)) + } + + var consolidated, passthrough *AccessSession + for _, s := range result { + if s.ConnectionCount > 1 { + consolidated = s + } else { + passthrough = s + } + } + + if consolidated == nil { + t.Fatal("expected a consolidated session") + } + if consolidated.ConnectionCount != 3 { + t.Errorf("consolidated: expected ConnectionCount=3, got %d", consolidated.ConnectionCount) + } + + if passthrough == nil { + t.Fatal("expected a passthrough session") + } + if passthrough.SessionID != "s4" { + t.Errorf("passthrough: expected session s4, got %s", passthrough.SessionID) + } +} + +func TestConsolidateSessions_OverlappingConnections(t *testing.T) { + now := time.Now() + // Connections that overlap in time (not sequential) + sessions := []*AccessSession{ + { + SessionID: "s1", + ResourceID: 1, + SourceAddr: "10.0.0.1:5000", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now, + EndedAt: now.Add(5 * time.Second), + BytesTx: 100, + }, + { + SessionID: "s2", + ResourceID: 1, + SourceAddr: "10.0.0.1:5001", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now.Add(1 * time.Second), + EndedAt: now.Add(3 * time.Second), + BytesTx: 200, + }, + { + SessionID: "s3", + ResourceID: 1, + SourceAddr: "10.0.0.1:5002", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now.Add(2 * time.Second), + EndedAt: now.Add(6 * time.Second), + BytesTx: 300, + }, + } + + result := consolidateSessions(sessions) + if len(result) != 1 { + t.Fatalf("expected 1 consolidated session, got %d", len(result)) + } + + s := result[0] + if s.ConnectionCount != 3 { + t.Errorf("expected ConnectionCount=3, got %d", s.ConnectionCount) + } + if s.StartedAt != now { + t.Error("expected StartedAt to be earliest") + } + if s.EndedAt != now.Add(6*time.Second) { + t.Error("expected EndedAt to be the latest end time") + } + if s.BytesTx != 600 { + t.Errorf("expected BytesTx=600, got %d", s.BytesTx) + } +} + +func TestConsolidateSessions_DoesNotMutateOriginals(t *testing.T) { + now := time.Now() + s1 := &AccessSession{ + SessionID: "s1", + ResourceID: 1, + SourceAddr: "10.0.0.1:5000", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now, + EndedAt: now.Add(100 * time.Millisecond), + BytesTx: 100, + } + s2 := &AccessSession{ + SessionID: "s2", + ResourceID: 1, + SourceAddr: "10.0.0.1:5001", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now.Add(200 * time.Millisecond), + EndedAt: now.Add(300 * time.Millisecond), + BytesTx: 200, + } + + // Save original values + origS1Addr := s1.SourceAddr + origS1Bytes := s1.BytesTx + origS2Addr := s2.SourceAddr + origS2Bytes := s2.BytesTx + + _ = consolidateSessions([]*AccessSession{s1, s2}) + + if s1.SourceAddr != origS1Addr { + t.Errorf("s1.SourceAddr was mutated: %q -> %q", origS1Addr, s1.SourceAddr) + } + if s1.BytesTx != origS1Bytes { + t.Errorf("s1.BytesTx was mutated: %d -> %d", origS1Bytes, s1.BytesTx) + } + if s2.SourceAddr != origS2Addr { + t.Errorf("s2.SourceAddr was mutated: %q -> %q", origS2Addr, s2.SourceAddr) + } + if s2.BytesTx != origS2Bytes { + t.Errorf("s2.BytesTx was mutated: %d -> %d", origS2Bytes, s2.BytesTx) + } +} + +func TestConsolidateSessions_ThreeBurstsWithGaps(t *testing.T) { + now := time.Now() + + sessions := make([]*AccessSession, 0, 9) + + // Burst 1: 3 connections at t=0 + for i := 0; i < 3; i++ { + sessions = append(sessions, &AccessSession{ + SessionID: generateSessionID(), + ResourceID: 1, + SourceAddr: "10.0.0.1:" + string(rune('A'+i)), + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now.Add(time.Duration(i*100) * time.Millisecond), + EndedAt: now.Add(time.Duration(i*100+50) * time.Millisecond), + }) + } + + // Burst 2: 3 connections at t=20s (well past the 5s gap) + for i := 0; i < 3; i++ { + sessions = append(sessions, &AccessSession{ + SessionID: generateSessionID(), + ResourceID: 1, + SourceAddr: "10.0.0.1:" + string(rune('D'+i)), + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now.Add(20*time.Second + time.Duration(i*100)*time.Millisecond), + EndedAt: now.Add(20*time.Second + time.Duration(i*100+50)*time.Millisecond), + }) + } + + // Burst 3: 3 connections at t=40s + for i := 0; i < 3; i++ { + sessions = append(sessions, &AccessSession{ + SessionID: generateSessionID(), + ResourceID: 1, + SourceAddr: "10.0.0.1:" + string(rune('G'+i)), + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now.Add(40*time.Second + time.Duration(i*100)*time.Millisecond), + EndedAt: now.Add(40*time.Second + time.Duration(i*100+50)*time.Millisecond), + }) + } + + result := consolidateSessions(sessions) + if len(result) != 3 { + t.Fatalf("expected 3 consolidated sessions (3 bursts), got %d", len(result)) + } + + for _, s := range result { + if s.ConnectionCount != 3 { + t.Errorf("expected each burst to have ConnectionCount=3, got %d (started=%v)", s.ConnectionCount, s.StartedAt) + } + } +} + +func TestFinalizeMergedSourceAddr(t *testing.T) { + s := &AccessSession{SourceAddr: "10.0.0.1:5000"} + ports := map[string]struct{}{"10.0.0.1:5000": {}} + finalizeMergedSourceAddr(s, "10.0.0.1", ports) + if s.SourceAddr != "10.0.0.1:5000" { + t.Errorf("single port: expected addr preserved, got %q", s.SourceAddr) + } + + s2 := &AccessSession{SourceAddr: "10.0.0.1:5000"} + ports2 := map[string]struct{}{"10.0.0.1:5000": {}, "10.0.0.1:5001": {}} + finalizeMergedSourceAddr(s2, "10.0.0.1", ports2) + if s2.SourceAddr != "10.0.0.1" { + t.Errorf("multiple ports: expected IP only, got %q", s2.SourceAddr) + } +} + +func TestCloneSession(t *testing.T) { + original := &AccessSession{ + SessionID: "test", + ResourceID: 42, + SourceAddr: "1.2.3.4:100", + DestAddr: "5.6.7.8:443", + Protocol: "tcp", + BytesTx: 999, + } + + clone := cloneSession(original) + + if clone == original { + t.Error("clone should be a different pointer") + } + if clone.SessionID != original.SessionID { + t.Error("clone should have same SessionID") + } + + // Mutating clone should not affect original + clone.BytesTx = 0 + clone.SourceAddr = "changed" + if original.BytesTx != 999 { + t.Error("mutating clone affected original BytesTx") + } + if original.SourceAddr != "1.2.3.4:100" { + t.Error("mutating clone affected original SourceAddr") + } +} \ No newline at end of file From 1a4cdf7fc3b0d14d5d3c426c7b417ce764cd01b7 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 26 Mar 2026 17:23:19 -0700 Subject: [PATCH 032/161] Provisioning key working Former-commit-id: b43572dd8d4aa03a223ee7d987668f4354ab9163 --- main.go | 13 +++++ websocket/client.go | 5 ++ websocket/config.go | 126 ++++++++++++++++++++++++++++++++++++++++++++ websocket/types.go | 20 +++++-- 4 files changed, 159 insertions(+), 5 deletions(-) diff --git a/main.go b/main.go index 3646a27..94b3b48 100644 --- a/main.go +++ b/main.go @@ -159,6 +159,9 @@ var ( // Legacy PKCS12 support (deprecated) tlsPrivateKey string + + // Provisioning key – exchanged once for a permanent newt ID + secret + provisioningKey string ) func main() { @@ -264,6 +267,7 @@ func runNewtMain(ctx context.Context) { blueprintFile = os.Getenv("BLUEPRINT_FILE") noCloudEnv := os.Getenv("NO_CLOUD") noCloud = noCloudEnv == "true" + provisioningKey = os.Getenv("NEWT_PROVISIONING_KEY") if endpoint == "" { flag.StringVar(&endpoint, "endpoint", "", "Endpoint of your pangolin server") @@ -312,6 +316,9 @@ func runNewtMain(ctx context.Context) { } // 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") + } // Add new mTLS flags if tlsClientCert == "" { @@ -590,6 +597,12 @@ func runNewtMain(ctx context.Context) { 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 + } + endpoint = client.GetConfig().Endpoint // Update endpoint from config id = client.GetConfig().ID // Update ID from config // Update site labels for metrics with the resolved ID diff --git a/websocket/client.go b/websocket/client.go index 533771b..e645a6f 100644 --- a/websocket/client.go +++ b/websocket/client.go @@ -481,6 +481,11 @@ func (c *Client) connectWithRetry() { func (c *Client) establishConnection() error { ctx := context.Background() + // Exchange provisioning key for permanent credentials if needed. + if err := c.provisionIfNeeded(); err != nil { + return fmt.Errorf("failed to provision newt credentials: %w", err) + } + // Get token for authentication token, err := c.getToken() if err != nil { diff --git a/websocket/config.go b/websocket/config.go index 72c9164..8ae7ff5 100644 --- a/websocket/config.go +++ b/websocket/config.go @@ -1,11 +1,20 @@ package websocket import ( + "bytes" + "context" + "crypto/tls" "encoding/json" + "fmt" + "io" "log" + "net/http" + "net/url" "os" "path/filepath" "runtime" + "strings" + "time" "github.com/fosrl/newt/logger" ) @@ -83,6 +92,10 @@ func (c *Client) loadConfig() error { c.config.Endpoint = config.Endpoint c.baseURL = config.Endpoint } + // Always load the provisioning key from the file if not already set + if c.config.ProvisioningKey == "" { + c.config.ProvisioningKey = config.ProvisioningKey + } // Check if CLI args provided values that override file values if (!fileHadID && originalConfig.ID != "") || @@ -118,3 +131,116 @@ func (c *Client) saveConfig() error { } return err } + +// provisionIfNeeded checks whether a provisioning key is present and, if so, +// exchanges it for a newt ID and secret by calling the registration endpoint. +// On success the config is updated in-place and flagged for saving so that +// subsequent runs use the permanent credentials directly. +func (c *Client) provisionIfNeeded() error { + if c.config.ProvisioningKey == "" { + return nil + } + + // If we already have both credentials there is nothing to provision. + if c.config.ID != "" && c.config.Secret != "" { + logger.Debug("Credentials already present, skipping provisioning") + return nil + } + + logger.Info("Provisioning key found – exchanging for newt credentials...") + + baseURL, err := url.Parse(c.baseURL) + if err != nil { + return fmt.Errorf("failed to parse base URL for provisioning: %w", err) + } + baseEndpoint := strings.TrimRight(baseURL.String(), "/") + + reqBody := map[string]interface{}{ + "provisioningKey": c.config.ProvisioningKey, + } + jsonData, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("failed to marshal provisioning request: %w", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext( + ctx, + "POST", + baseEndpoint+"/api/v1/auth/newt/register", + bytes.NewBuffer(jsonData), + ) + if err != nil { + return fmt.Errorf("failed to create provisioning request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-CSRF-Token", "x-csrf-protection") + + // Mirror the TLS setup used by getToken so mTLS / self-signed CAs work. + var tlsCfg *tls.Config + if c.tlsConfig.ClientCertFile != "" || c.tlsConfig.ClientKeyFile != "" || + len(c.tlsConfig.CAFiles) > 0 || c.tlsConfig.PKCS12File != "" { + tlsCfg, err = c.setupTLS() + if err != nil { + return fmt.Errorf("failed to setup TLS for provisioning: %w", err) + } + } + if os.Getenv("SKIP_TLS_VERIFY") == "true" { + if tlsCfg == nil { + tlsCfg = &tls.Config{} + } + tlsCfg.InsecureSkipVerify = true + logger.Debug("TLS certificate verification disabled for provisioning via SKIP_TLS_VERIFY") + } + + httpClient := &http.Client{} + if tlsCfg != nil { + httpClient.Transport = &http.Transport{TLSClientConfig: tlsCfg} + } + + resp, err := httpClient.Do(req) + if err != nil { + return fmt.Errorf("provisioning request failed: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + logger.Debug("Provisioning response body: %s", string(body)) + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("provisioning endpoint returned status %d: %s", resp.StatusCode, string(body)) + } + + var provResp ProvisioningResponse + if err := json.Unmarshal(body, &provResp); err != nil { + return fmt.Errorf("failed to decode provisioning response: %w", err) + } + + if !provResp.Success { + return fmt.Errorf("provisioning failed: %s", provResp.Message) + } + + if provResp.Data.NewtID == "" || provResp.Data.Secret == "" { + return fmt.Errorf("provisioning response is missing newt ID or secret") + } + + logger.Info("Successfully provisioned – newt ID: %s", provResp.Data.NewtID) + + // Persist the returned credentials and clear the one-time provisioning key + // so subsequent runs authenticate normally. + c.config.ID = provResp.Data.NewtID + c.config.Secret = provResp.Data.Secret + c.config.ProvisioningKey = "" + c.configNeedsSave = true + + // Save immediately so that if the subsequent connection attempt fails the + // provisioning key is already gone from disk and the next retry uses the + // permanent credentials instead of trying to provision again. + if err := c.saveConfig(); err != nil { + logger.Error("Failed to save config after provisioning: %v", err) + } + + return nil +} \ No newline at end of file diff --git a/websocket/types.go b/websocket/types.go index 381f7a1..2b32dae 100644 --- a/websocket/types.go +++ b/websocket/types.go @@ -1,10 +1,11 @@ package websocket type Config struct { - ID string `json:"id"` - Secret string `json:"secret"` - Endpoint string `json:"endpoint"` - TlsClientCert string `json:"tlsClientCert"` + ID string `json:"id"` + Secret string `json:"secret"` + Endpoint string `json:"endpoint"` + TlsClientCert string `json:"tlsClientCert"` + ProvisioningKey string `json:"provisioningKey,omitempty"` } type TokenResponse struct { @@ -16,8 +17,17 @@ type TokenResponse struct { Message string `json:"message"` } +type ProvisioningResponse struct { + Data struct { + NewtID string `json:"newtId"` + Secret string `json:"secret"` + } `json:"data"` + Success bool `json:"success"` + Message string `json:"message"` +} + type WSMessage struct { Type string `json:"type"` Data interface{} `json:"data"` ConfigVersion int64 `json:"configVersion,omitempty"` -} +} \ No newline at end of file From 7f67714707a4eecf7ccf1b638b7f0ccd9a767033 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 26 Mar 2026 17:31:04 -0700 Subject: [PATCH 033/161] Add --config-file Former-commit-id: baca04ee58916e3df6a1f74ff77bf7a52dd45e89 --- config.json | 4 ++++ config.json.bak | 4 ++++ main.go | 8 ++++++++ websocket/client.go | 7 +++++++ websocket/config.go | 9 ++++++--- 5 files changed, 29 insertions(+), 3 deletions(-) create mode 100644 config.json create mode 100644 config.json.bak diff --git a/config.json b/config.json new file mode 100644 index 0000000..fac9795 --- /dev/null +++ b/config.json @@ -0,0 +1,4 @@ +{ + "endpoint": "http://you.fosrl.io", + "provisioningKey": "spk-xt1opb0fkoqb7qb.hi44jciamqcrdaja4lvz3kp52pl3lssamp6asuyx" +} \ No newline at end of file diff --git a/config.json.bak b/config.json.bak new file mode 100644 index 0000000..fac9795 --- /dev/null +++ b/config.json.bak @@ -0,0 +1,4 @@ +{ + "endpoint": "http://you.fosrl.io", + "provisioningKey": "spk-xt1opb0fkoqb7qb.hi44jciamqcrdaja4lvz3kp52pl3lssamp6asuyx" +} \ No newline at end of file diff --git a/main.go b/main.go index 94b3b48..0af8773 100644 --- a/main.go +++ b/main.go @@ -162,6 +162,9 @@ var ( // Provisioning key – exchanged once for a permanent newt ID + secret provisioningKey string + + // Path to config file (overrides CONFIG_FILE env var and default location) + configFile string ) func main() { @@ -268,6 +271,7 @@ func runNewtMain(ctx context.Context) { noCloudEnv := os.Getenv("NO_CLOUD") noCloud = noCloudEnv == "true" provisioningKey = os.Getenv("NEWT_PROVISIONING_KEY") + configFile = os.Getenv("CONFIG_FILE") if endpoint == "" { flag.StringVar(&endpoint, "endpoint", "", "Endpoint of your pangolin server") @@ -319,6 +323,9 @@ func runNewtMain(ctx context.Context) { if provisioningKey == "" { flag.StringVar(&provisioningKey, "provisioning-key", "", "One-time provisioning key used to obtain a newt ID and secret from the server") } + 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 == "" { @@ -593,6 +600,7 @@ func runNewtMain(ctx context.Context) { endpoint, 30*time.Second, opt, + websocket.WithConfigFile(configFile), ) if err != nil { logger.Fatal("Failed to create client: %v", err) diff --git a/websocket/client.go b/websocket/client.go index e645a6f..49cf414 100644 --- a/websocket/client.go +++ b/websocket/client.go @@ -42,6 +42,7 @@ type Client struct { onTokenUpdate func(token string) writeMux sync.Mutex clientType string // Type of client (e.g., "newt", "olm") + configFilePath string // Optional override for the config file path tlsConfig TLSConfig metricsCtxMu sync.RWMutex metricsCtx context.Context @@ -77,6 +78,12 @@ func WithBaseURL(url string) ClientOption { } // WithTLSConfig sets the TLS configuration for the client +func WithConfigFile(path string) ClientOption { + return func(c *Client) { + c.configFilePath = path + } +} + func WithTLSConfig(config TLSConfig) ClientOption { return func(c *Client) { c.tlsConfig = config diff --git a/websocket/config.go b/websocket/config.go index 8ae7ff5..4fb6513 100644 --- a/websocket/config.go +++ b/websocket/config.go @@ -19,7 +19,10 @@ import ( "github.com/fosrl/newt/logger" ) -func getConfigPath(clientType string) string { +func getConfigPath(clientType string, overridePath string) string { + if overridePath != "" { + return overridePath + } configFile := os.Getenv("CONFIG_FILE") if configFile == "" { var configDir string @@ -45,7 +48,7 @@ func getConfigPath(clientType string) string { func (c *Client) loadConfig() error { originalConfig := *c.config // Store original config to detect changes - configPath := getConfigPath(c.clientType) + configPath := getConfigPath(c.clientType, c.configFilePath) if c.config.ID != "" && c.config.Secret != "" && c.config.Endpoint != "" { logger.Debug("Config already provided, skipping loading from file") @@ -118,7 +121,7 @@ func (c *Client) saveConfig() error { return nil } - configPath := getConfigPath(c.clientType) + configPath := getConfigPath(c.clientType, c.configFilePath) data, err := json.MarshalIndent(c.config, "", " ") if err != nil { return err From 2cf60d0fdb31bb56d402f922e08fc844f24c93eb Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 26 Mar 2026 20:05:04 -0700 Subject: [PATCH 034/161] Allow blueprint interpolation for env vars Former-commit-id: fc4b375bf1fcf3f457c4d8730a55d8488d6cb87f --- common.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/common.go b/common.go index 4701411..34e3cd0 100644 --- a/common.go +++ b/common.go @@ -8,6 +8,7 @@ import ( "net" "os" "os/exec" + "regexp" "strings" "time" @@ -509,6 +510,29 @@ func executeUpdownScript(action, proto, target string) (string, error) { 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) error { if blueprintFile == "" { return nil @@ -518,6 +542,9 @@ func sendBlueprint(client *websocket.Client) error { 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 From ee1911709ff8ca5396c346b1b367162bd47d52ed Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 27 Mar 2026 11:55:34 -0700 Subject: [PATCH 035/161] Add chainId based dedup Former-commit-id: 1057013b50a7a40e1347f24139669f98c5c792a4 --- clients/clients.go | 27 ++++++++++++++++++++++++++- common.go | 3 +++ main.go | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/clients/clients.go b/clients/clients.go index 4c64dbd..6130dcb 100644 --- a/clients/clients.go +++ b/clients/clients.go @@ -2,6 +2,8 @@ package clients import ( "context" + "crypto/rand" + "encoding/hex" "encoding/json" "fmt" "net" @@ -34,6 +36,7 @@ type WgConfig struct { IpAddress string `json:"ipAddress"` Peers []Peer `json:"peers"` Targets []Target `json:"targets"` + ChainId string `json:"chainId"` } type Target struct { @@ -82,7 +85,8 @@ type WireGuardService struct { host string serverPubKey string token string - stopGetConfig func() + stopGetConfig func() + pendingConfigChainId string // Netstack fields tun tun.Device tnet *netstack2.Net @@ -107,6 +111,13 @@ type WireGuardService struct { wgTesterServer *wgtester.Server } +// 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 NewWireGuardService(interfaceName string, port uint16, mtu int, host string, newtId string, wsClient *websocket.Client, dns string, useNativeInterface bool) (*WireGuardService, error) { key, err := wgtypes.GeneratePrivateKey() if err != nil { @@ -442,9 +453,12 @@ func (s *WireGuardService) LoadRemoteConfig() error { s.stopGetConfig() s.stopGetConfig = nil } + chainId := generateChainId() + s.pendingConfigChainId = chainId s.stopGetConfig = s.client.SendMessageInterval("newt/wg/get-config", map[string]interface{}{ "publicKey": s.key.PublicKey().String(), "port": s.Port, + "chainId": chainId, }, 2*time.Second) logger.Debug("Requesting WireGuard configuration from remote server") @@ -469,6 +483,17 @@ func (s *WireGuardService) handleConfig(msg websocket.WSMessage) { logger.Info("Error unmarshaling target data: %v", err) return } + + // Deduplicate using chainId: discard responses that don't match the + // pending request, or that we have already processed. + if config.ChainId != "" { + if config.ChainId != s.pendingConfigChainId { + logger.Debug("Discarding duplicate/stale newt/wg/get-config response (chainId=%s, expected=%s)", config.ChainId, s.pendingConfigChainId) + return + } + s.pendingConfigChainId = "" // consume – further duplicates are rejected + } + s.config = config if s.stopGetConfig != nil { diff --git a/common.go b/common.go index 4701411..707eefa 100644 --- a/common.go +++ b/common.go @@ -287,9 +287,12 @@ func startPingCheck(tnet *netstack.Net, serverIP string, client *websocket.Clien } stopFunc = client.SendMessageInterval("newt/ping/request", map[string]interface{}{}, 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) diff --git a/main.go b/main.go index 3646a27..a79c70d 100644 --- a/main.go +++ b/main.go @@ -3,7 +3,9 @@ package main import ( "bytes" "context" + "crypto/rand" "crypto/tls" + "encoding/hex" "encoding/json" "errors" "flag" @@ -46,6 +48,7 @@ type WgData struct { TunnelIP string `json:"tunnelIP"` Targets TargetsByType `json:"targets"` HealthCheckTargets []healthcheck.Config `json:"healthCheckTargets"` + ChainId string `json:"chainId"` } type TargetsByType struct { @@ -128,6 +131,7 @@ var ( publicKey wgtypes.Key pingStopChan chan struct{} stopFunc func() + pendingRegisterChainId string healthFile string useNativeInterface bool authorizedKeysFile string @@ -161,6 +165,13 @@ var ( tlsPrivateKey string ) +// 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) if len(os.Args) > 1 { @@ -706,6 +717,24 @@ func runNewtMain(ctx context.Context) { 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 @@ -971,10 +1000,13 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( }, } + chainId := generateChainId() + pendingRegisterChainId = chainId stopFunc = client.SendMessageInterval(topicWGRegister, map[string]interface{}{ "publicKey": publicKey.String(), "pingResults": pingResults, "newtVersion": newtVersion, + "chainId": chainId, }, 2*time.Second) return @@ -1074,10 +1106,13 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( } // 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) @@ -1740,10 +1775,13 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( } // 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, }) sendBlueprint(client) From c191ff6cad893b735c707d2677d381c6358dfba8 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 29 Mar 2026 12:00:17 -0700 Subject: [PATCH 036/161] Add chain id to ping Former-commit-id: cdaf4f7898f057826ba191a59a5e139b299bfa69 --- common.go | 6 +++++- main.go | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/common.go b/common.go index 707eefa..c55909a 100644 --- a/common.go +++ b/common.go @@ -285,7 +285,11 @@ func startPingCheck(tnet *netstack.Net, serverIP string, client *websocket.Clien if tunnelID != "" { telemetry.IncReconnect(context.Background(), tunnelID, "client", telemetry.ReasonTimeout) } - stopFunc = client.SendMessageInterval("newt/ping/request", map[string]interface{}{}, 3*time.Second) + 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 diff --git a/main.go b/main.go index a79c70d..e051450 100644 --- a/main.go +++ b/main.go @@ -62,6 +62,7 @@ type TargetData struct { type ExitNodeData struct { ExitNodes []ExitNode `json:"exitNodes"` + ChainId string `json:"chainId"` } // ExitNode represents an exit node with an ID, endpoint, and weight. @@ -132,6 +133,7 @@ var ( pingStopChan chan struct{} stopFunc func() pendingRegisterChainId string + pendingPingChainId string healthFile string useNativeInterface bool authorizedKeysFile string @@ -919,8 +921,11 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( } // 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") @@ -949,6 +954,7 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( 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 @@ -968,6 +974,14 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( } 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 @@ -1762,8 +1776,11 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( 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") From 4ac3dea71b41b02d20628b0213d22cdd130eaddb Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 30 Mar 2026 17:18:22 -0700 Subject: [PATCH 037/161] Add name to provisioning Former-commit-id: 5208117c56e3e2ece7898cb958349d96b1dedaff --- main.go | 10 ++++++++++ websocket/config.go | 27 +++++++++++++++++++++++++++ websocket/types.go | 1 + 3 files changed, 38 insertions(+) diff --git a/main.go b/main.go index c573ee2..6ad1c2f 100644 --- a/main.go +++ b/main.go @@ -169,6 +169,9 @@ var ( // 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 ) @@ -284,6 +287,7 @@ func runNewtMain(ctx context.Context) { 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 == "" { @@ -336,6 +340,9 @@ func runNewtMain(ctx context.Context) { 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)") } @@ -623,6 +630,9 @@ func runNewtMain(ctx context.Context) { 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 diff --git a/websocket/config.go b/websocket/config.go index 4fb6513..d727a41 100644 --- a/websocket/config.go +++ b/websocket/config.go @@ -12,6 +12,7 @@ import ( "net/url" "os" "path/filepath" + "regexp" "runtime" "strings" "time" @@ -99,6 +100,10 @@ func (c *Client) loadConfig() error { if c.config.ProvisioningKey == "" { c.config.ProvisioningKey = config.ProvisioningKey } + // Always load the name from the file if not already set + if c.config.Name == "" { + c.config.Name = config.Name + } // Check if CLI args provided values that override file values if (!fileHadID && originalConfig.ID != "") || @@ -135,6 +140,21 @@ func (c *Client) saveConfig() error { return err } +// interpolateString replaces {{env.VAR}} tokens in s with the corresponding +// environment variable values. Tokens that do not match a supported scheme are +// left unchanged, mirroring the blueprint interpolation logic. +func interpolateString(s string) string { + re := regexp.MustCompile(`\{\{([^}]+)\}\}`) + return re.ReplaceAllStringFunc(s, func(match string) string { + inner := strings.TrimSpace(match[2 : len(match)-2]) + if strings.HasPrefix(inner, "env.") { + varName := strings.TrimPrefix(inner, "env.") + return os.Getenv(varName) + } + return match + }) +} + // provisionIfNeeded checks whether a provisioning key is present and, if so, // exchanges it for a newt ID and secret by calling the registration endpoint. // On success the config is updated in-place and flagged for saving so that @@ -158,9 +178,15 @@ func (c *Client) provisionIfNeeded() error { } baseEndpoint := strings.TrimRight(baseURL.String(), "/") + // Interpolate any {{env.VAR}} tokens in the name before sending. + name := interpolateString(c.config.Name) + reqBody := map[string]interface{}{ "provisioningKey": c.config.ProvisioningKey, } + if name != "" { + reqBody["name"] = name + } jsonData, err := json.Marshal(reqBody) if err != nil { return fmt.Errorf("failed to marshal provisioning request: %w", err) @@ -236,6 +262,7 @@ func (c *Client) provisionIfNeeded() error { c.config.ID = provResp.Data.NewtID c.config.Secret = provResp.Data.Secret c.config.ProvisioningKey = "" + c.config.Name = "" c.configNeedsSave = true // Save immediately so that if the subsequent connection attempt fails the diff --git a/websocket/types.go b/websocket/types.go index 2b32dae..195e06f 100644 --- a/websocket/types.go +++ b/websocket/types.go @@ -6,6 +6,7 @@ type Config struct { Endpoint string `json:"endpoint"` TlsClientCert string `json:"tlsClientCert"` ProvisioningKey string `json:"provisioningKey,omitempty"` + Name string `json:"name,omitempty"` } type TokenResponse struct { From 05be670f1c1153dafc36e84e91d641020b72620f Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 31 Mar 2026 17:06:07 -0700 Subject: [PATCH 038/161] Send health checks to the server on reconnect Former-commit-id: 8d82460a76ef47dd14d36d115f6e14871c5b67fe --- main.go | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/main.go b/main.go index 6ad1c2f..a5c4581 100644 --- a/main.go +++ b/main.go @@ -1820,6 +1820,30 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( } 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) + } 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 @@ -1832,8 +1856,6 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( "chainId": bcChainId, }) - sendBlueprint(client) - if err != nil { logger.Error("Failed to send registration message: %v", err) return err From e3105ad4cedf0b8e5a7bf63f02d08754a8e6229d Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 2 Apr 2026 21:39:59 -0400 Subject: [PATCH 039/161] Add provisioning blueprint file Former-commit-id: f4d071fe27f1c7a6c9b54e5123262b81d41ac7eb --- common.go | 6 +++--- main.go | 15 ++++++++++++--- websocket/client.go | 11 +++++++++++ websocket/config.go | 1 + 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/common.go b/common.go index e215813..4e1ed00 100644 --- a/common.go +++ b/common.go @@ -540,12 +540,12 @@ func interpolateBlueprint(data []byte) []byte { }) } -func sendBlueprint(client *websocket.Client) error { - if blueprintFile == "" { +func sendBlueprint(client *websocket.Client, file string) error { + if file == "" { return nil } // try to read the blueprint file - blueprintData, err := os.ReadFile(blueprintFile) + blueprintData, err := os.ReadFile(file) if err != nil { logger.Error("Failed to read blueprint file: %v", err) } else { diff --git a/main.go b/main.go index a5c4581..d5f2a96 100644 --- a/main.go +++ b/main.go @@ -155,8 +155,9 @@ var ( region string metricsAsyncBytes bool pprofEnabled bool - blueprintFile string - noCloud bool + blueprintFile string + provisioningBlueprintFile string + noCloud bool // New mTLS configuration variables tlsClientCert string @@ -284,6 +285,7 @@ func runNewtMain(ctx context.Context) { 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") @@ -393,6 +395,9 @@ func runNewtMain(ctx context.Context) { 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") } @@ -1821,7 +1826,11 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( 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) + 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. diff --git a/websocket/client.go b/websocket/client.go index 49cf414..6990bd2 100644 --- a/websocket/client.go +++ b/websocket/client.go @@ -53,6 +53,7 @@ type Client struct { processingMessage bool // Flag to track if a message is currently being processed processingMux sync.RWMutex // Protects processingMessage processingWg sync.WaitGroup // WaitGroup to wait for message processing to complete + justProvisioned bool // Set to true when provisionIfNeeded exchanges a key for permanent credentials } type ClientOption func(*Client) @@ -102,6 +103,16 @@ func (c *Client) OnTokenUpdate(callback func(token string)) { c.onTokenUpdate = callback } +// WasJustProvisioned reports whether the client exchanged a provisioning key +// for permanent credentials during the most recent connection attempt. It +// consumes the flag – subsequent calls return false until provisioning occurs +// again (which, in practice, never happens once credentials are persisted). +func (c *Client) WasJustProvisioned() bool { + v := c.justProvisioned + c.justProvisioned = false + return v +} + func (c *Client) metricsContext() context.Context { c.metricsCtxMu.RLock() defer c.metricsCtxMu.RUnlock() diff --git a/websocket/config.go b/websocket/config.go index d727a41..39f1bd2 100644 --- a/websocket/config.go +++ b/websocket/config.go @@ -264,6 +264,7 @@ func (c *Client) provisionIfNeeded() error { c.config.ProvisioningKey = "" c.config.Name = "" c.configNeedsSave = true + c.justProvisioned = true // Save immediately so that if the subsequent connection attempt fails the // provisioning key is already gone from disk and the next retry uses the From 736fb19d3b4194cfe282173e67f9e6c7378c7a2c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 12:27:19 +0000 Subject: [PATCH 040/161] chore(deps): bump github.com/gaissmai/bart Bumps the prod-patch-updates group with 1 update in the / directory: [github.com/gaissmai/bart](https://github.com/gaissmai/bart). Updates `github.com/gaissmai/bart` from 0.26.0 to 0.26.1 - [Release notes](https://github.com/gaissmai/bart/releases) - [Commits](https://github.com/gaissmai/bart/compare/v0.26.0...v0.26.1) --- updated-dependencies: - dependency-name: github.com/gaissmai/bart dependency-version: 0.26.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates ... Signed-off-by: dependabot[bot] Former-commit-id: f925c681d2c06a0ff84a0228475a6c46a266f22d --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index e494319..b60a090 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.25.0 require ( github.com/docker/docker v28.5.2+incompatible - github.com/gaissmai/bart v0.26.0 + github.com/gaissmai/bart v0.26.1 github.com/gorilla/websocket v1.5.3 github.com/prometheus/client_golang v1.23.2 github.com/vishvananda/netlink v1.3.1 diff --git a/go.sum b/go.sum index 0b75184..331dfb3 100644 --- a/go.sum +++ b/go.sum @@ -26,8 +26,8 @@ github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/gaissmai/bart v0.26.0 h1:xOZ57E9hJLBiQaSyeZa9wgWhGuzfGACgqp4BE77OkO0= -github.com/gaissmai/bart v0.26.0/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c= +github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo= +github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= From 86d2e5138667418b30615a80cfa71b43c81acdbb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" Date: Fri, 3 Apr 2026 12:28:34 +0000 Subject: [PATCH 041/161] chore(nix): fix hash for updated go dependencies Former-commit-id: 16864fc1d7c3904415c943f672a908bfc3bcc815 --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index ef1f52e..60d7d85 100644 --- a/flake.nix +++ b/flake.nix @@ -35,7 +35,7 @@ inherit version; src = pkgs.nix-gitignore.gitignoreSource [ ] ./.; - vendorHash = "sha256-0eK4C42Upqpp01pfjW9+t3NKzadwVlGwwuWXhdpgDz4="; + vendorHash = "sha256-YIcuj1S+ZWAzXZOMZbppTvsDcW1W1Sy8ynfMkzLMQpM="; nativeInstallCheckInputs = [ pkgs.versionCheckHook ]; From a8f755175098c651c9abade7815303d615550818 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 12:33:07 +0000 Subject: [PATCH 042/161] chore(deps): bump actions/stale from 10.1.1 to 10.2.0 Bumps [actions/stale](https://github.com/actions/stale) from 10.1.1 to 10.2.0. - [Release notes](https://github.com/actions/stale/releases) - [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/stale/compare/997185467fa4f803885201cee163a9f38240193d...b5d41d4e1d5dceea10e7104786b73624c18a190f) --- updated-dependencies: - dependency-name: actions/stale dependency-version: 10.2.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Former-commit-id: 5ced7d69090b5e688fb4a774fe0b97fbf201f759 --- .github/workflows/stale-bot.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale-bot.yml b/.github/workflows/stale-bot.yml index 4df7e93..2db8632 100644 --- a/.github/workflows/stale-bot.yml +++ b/.github/workflows/stale-bot.yml @@ -14,7 +14,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1 + - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 with: days-before-stale: 14 days-before-close: 14 From e89e5cd2d579180ac822e9edb82c267a682a9088 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 12:33:48 +0000 Subject: [PATCH 043/161] chore(deps): bump docker/setup-buildx-action from 3.12.0 to 4.0.0 Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.12.0 to 4.0.0. - [Release notes](https://github.com/docker/setup-buildx-action/releases) - [Commits](https://github.com/docker/setup-buildx-action/compare/8d2750c68a42422c14e847fe6c8ac0403b4cbd6f...4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd) --- updated-dependencies: - dependency-name: docker/setup-buildx-action dependency-version: 4.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Former-commit-id: c2187de482a230130ec66857541c6cdc264f22d7 --- .github/workflows/cicd.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index c00f4c2..b8faa7d 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -235,7 +235,7 @@ jobs: # uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 #- name: Set up Docker Buildx - # uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + # uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Log in to Docker Hub uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 @@ -259,7 +259,7 @@ jobs: echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 # Build ONLY amd64 and push arch-specific tag suffixes used later for manifest creation. - name: Build and push (amd64 -> *:amd64-TAG) @@ -384,7 +384,7 @@ jobs: echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 # Build ONLY arm64 and push arch-specific tag suffixes used later for manifest creation. - name: Build and push (arm64 -> *:arm64-TAG) @@ -502,7 +502,7 @@ jobs: uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Build and push (arm/v7 -> *:armv7-TAG) id: build_armv7 @@ -572,7 +572,7 @@ jobs: echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV" - name: Set up Docker Buildx (needed for imagetools) - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Create & push multi-arch index (GHCR :TAG) via imagetools shell: bash @@ -687,7 +687,7 @@ jobs: sudo apt-get install -y jq - name: Set up Docker Buildx (needed for imagetools) - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Resolve multi-arch digest refs (by TAG) shell: bash From c4711203f45de45317ac14967cb6459e40a4412f Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 3 Apr 2026 16:49:09 -0400 Subject: [PATCH 044/161] Remove files Former-commit-id: 2e02c9b7a93cb9c96ebbdbf097e75e55f7b3a20b --- config.json | 4 ---- config.json.bak | 4 ---- 2 files changed, 8 deletions(-) delete mode 100644 config.json delete mode 100644 config.json.bak diff --git a/config.json b/config.json deleted file mode 100644 index fac9795..0000000 --- a/config.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "endpoint": "http://you.fosrl.io", - "provisioningKey": "spk-xt1opb0fkoqb7qb.hi44jciamqcrdaja4lvz3kp52pl3lssamp6asuyx" -} \ No newline at end of file diff --git a/config.json.bak b/config.json.bak deleted file mode 100644 index fac9795..0000000 --- a/config.json.bak +++ /dev/null @@ -1,4 +0,0 @@ -{ - "endpoint": "http://you.fosrl.io", - "provisioningKey": "spk-xt1opb0fkoqb7qb.hi44jciamqcrdaja4lvz3kp52pl3lssamp6asuyx" -} \ No newline at end of file From 1749d7c0447c768345400aabd23edf3e06f7c6af Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 3 Apr 2026 17:36:48 -0400 Subject: [PATCH 045/161] Delete bad bp Former-commit-id: 184bfb12d6937eab74de4d14a3789ead7426c6c4 --- blueprint.yaml | 37 ------------------------------------- 1 file changed, 37 deletions(-) delete mode 100644 blueprint.yaml diff --git a/blueprint.yaml b/blueprint.yaml deleted file mode 100644 index 0465f00..0000000 --- a/blueprint.yaml +++ /dev/null @@ -1,37 +0,0 @@ -resources: - resource-nice-id: - name: this is my resource - protocol: http - full-domain: level1.test3.example.com - host-header: example.com - tls-server-name: example.com - auth: - pincode: 123456 - password: sadfasdfadsf - sso-enabled: true - sso-roles: - - Member - sso-users: - - owen@pangolin.net - whitelist-users: - - owen@pangolin.net - targets: - # - site: glossy-plains-viscacha-rat - - hostname: localhost - method: http - port: 8000 - healthcheck: - port: 8000 - hostname: localhost - # - site: glossy-plains-viscacha-rat - - hostname: localhost - method: http - port: 8001 - resource-nice-id2: - name: this is other resource - protocol: tcp - proxy-port: 3000 - targets: - # - site: glossy-plains-viscacha-rat - - hostname: localhost - port: 3000 \ No newline at end of file From e0177f7a4d2c49d8edd73ce38190234e3b13e29c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 10:10:11 +0000 Subject: [PATCH 046/161] chore(deps): bump actions/attest-build-provenance from 3.2.0 to 4.1.0 Bumps [actions/attest-build-provenance](https://github.com/actions/attest-build-provenance) from 3.2.0 to 4.1.0. - [Release notes](https://github.com/actions/attest-build-provenance/releases) - [Changelog](https://github.com/actions/attest-build-provenance/blob/main/RELEASE.md) - [Commits](https://github.com/actions/attest-build-provenance/compare/96278af6caaf10aea03fd8d33a09a777ca52d62f...a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32) --- updated-dependencies: - dependency-name: actions/attest-build-provenance dependency-version: 4.1.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Former-commit-id: 3f32c9e8ef86048af9326638dfe961bf9cf83b17 --- .github/workflows/cicd.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 03ba20e..cad773b 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -727,7 +727,7 @@ jobs: fi - name: Attest build provenance (GHCR) (digest) - uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0 + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: subject-name: ${{ env.GHCR_IMAGE }} subject-digest: ${{ env.GHCR_DIGEST }} @@ -737,7 +737,7 @@ jobs: - name: Attest build provenance (Docker Hub) continue-on-error: true if: ${{ env.DH_DIGEST != '' }} - uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0 + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: subject-name: index.docker.io/${{ github.repository_owner }}/${{ github.event.repository.name }} subject-digest: ${{ env.DH_DIGEST }} From 5f0fec22ad176f022d77d8ecda96018f75c04b01 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 10:10:17 +0000 Subject: [PATCH 047/161] chore(deps): bump sigstore/cosign-installer from 4.0.0 to 4.1.1 Bumps [sigstore/cosign-installer](https://github.com/sigstore/cosign-installer) from 4.0.0 to 4.1.1. - [Release notes](https://github.com/sigstore/cosign-installer/releases) - [Commits](https://github.com/sigstore/cosign-installer/compare/faadad0cce49287aee09b3a48701e75088a2c6ad...cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003) --- updated-dependencies: - dependency-name: sigstore/cosign-installer dependency-version: 4.1.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Former-commit-id: db6cabc6d758d96e731e375374dfe33194a45501 --- .github/workflows/cicd.yml | 2 +- .github/workflows/mirror.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 03ba20e..b0ddb3c 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -745,7 +745,7 @@ jobs: show-summary: true - name: Install cosign - uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 + uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 with: cosign-release: "v3.0.2" diff --git a/.github/workflows/mirror.yaml b/.github/workflows/mirror.yaml index 3f42f19..4d48003 100644 --- a/.github/workflows/mirror.yaml +++ b/.github/workflows/mirror.yaml @@ -23,7 +23,7 @@ jobs: skopeo --version - name: Install cosign - uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 + uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 - name: Input check run: | From deb2328f85fd2d6eaeb42f4bc2f484bfff9533a8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 10:10:21 +0000 Subject: [PATCH 048/161] chore(deps): bump docker/login-action from 4.0.0 to 4.1.0 Bumps [docker/login-action](https://github.com/docker/login-action) from 4.0.0 to 4.1.0. - [Release notes](https://github.com/docker/login-action/releases) - [Commits](https://github.com/docker/login-action/compare/b45d80f862d83dbcd57f89517bcf500b2ab88fb2...4907a6ddec9925e35a0a9e82d7399ccc52663121) --- updated-dependencies: - dependency-name: docker/login-action dependency-version: 4.1.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Former-commit-id: bc6661faa557b9685c970dbe280dabd8f50d9dfe --- .github/workflows/cicd.yml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 03ba20e..562afe6 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -238,14 +238,14 @@ jobs: # uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Log in to Docker Hub - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: docker.io username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Log in to GHCR - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -363,14 +363,14 @@ jobs: echo "Checked out $(git rev-parse --short HEAD) for tag ${TAG}" - name: Log in to Docker Hub - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: docker.io username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Log in to GHCR - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -478,14 +478,14 @@ jobs: echo "Checked out $(git rev-parse --short HEAD) for tag ${TAG}" - name: Log in to Docker Hub - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: docker.io username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Log in to GHCR - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -551,14 +551,14 @@ jobs: #PUBLISH_MINOR: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_minor || vars.PUBLISH_MINOR }} steps: - name: Log in to Docker Hub - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: docker.io username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Log in to GHCR - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -656,14 +656,14 @@ jobs: go-version-file: go.mod - name: Log in to Docker Hub - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: docker.io username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Log in to GHCR - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io username: ${{ github.actor }} From e62d5c463f0b748399a818b6ece6ae73a9358930 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 10:10:27 +0000 Subject: [PATCH 049/161] chore(deps): bump softprops/action-gh-release from 2.4.2 to 2.6.1 Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.4.2 to 2.6.1. - [Release notes](https://github.com/softprops/action-gh-release/releases) - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/5be0e66d93ac7ed76da52eca8bb058f665c3a5fe...153bb8e04406b158c6c84fc1615b65b24149a1fe) --- updated-dependencies: - dependency-name: softprops/action-gh-release dependency-version: 2.6.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Former-commit-id: fee7fbe20ab711a730a45a8a32b67b5f776e65e8 --- .github/workflows/cicd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 03ba20e..334e2e2 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -893,7 +893,7 @@ jobs: make -j 10 go-build-release VERSION="${TAG}" - name: Create GitHub Release (draft) - uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2 + uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 with: tag_name: ${{ env.TAG }} generate_release_notes: true From 9f68e9e2cf9022d1bafd3d938ab5ba8d05269ecb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 10:10:31 +0000 Subject: [PATCH 050/161] chore(deps): bump docker/setup-qemu-action from 3.7.0 to 4.0.0 Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3.7.0 to 4.0.0. - [Release notes](https://github.com/docker/setup-qemu-action/releases) - [Commits](https://github.com/docker/setup-qemu-action/compare/c7c53464625b32c7a7e944ae62b3e17d2b600130...ce360397dd3f832beb865e1373c09c0e9f86d70a) --- updated-dependencies: - dependency-name: docker/setup-qemu-action dependency-version: 4.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Former-commit-id: 56cc225bd3d82b8f0c0f000aa3d08d7406fe0960 --- .github/workflows/cicd.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 03ba20e..73bd209 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -232,7 +232,7 @@ jobs: echo "Checked out $(git rev-parse --short HEAD) for tag ${TAG}" #- name: Set up QEMU - # uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 + # uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 #- name: Set up Docker Buildx # uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 @@ -499,7 +499,7 @@ jobs: echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV" - name: Set up QEMU - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 + uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 From cc4f34b48ec14e2cd2c76a555027c8c8f28e6aaf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 10:10:38 +0000 Subject: [PATCH 051/161] chore(deps): bump the prod-minor-updates group with 13 updates Bumps the prod-minor-updates group with 13 updates: | Package | From | To | | --- | --- | --- | | [go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp](https://github.com/open-telemetry/opentelemetry-go-contrib) | `0.66.0` | `0.67.0` | | [go.opentelemetry.io/contrib/instrumentation/runtime](https://github.com/open-telemetry/opentelemetry-go-contrib) | `0.66.0` | `0.67.0` | | [go.opentelemetry.io/otel](https://github.com/open-telemetry/opentelemetry-go) | `1.41.0` | `1.42.0` | | [go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc](https://github.com/open-telemetry/opentelemetry-go) | `1.41.0` | `1.43.0` | | [go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc](https://github.com/open-telemetry/opentelemetry-go) | `1.41.0` | `1.43.0` | | [go.opentelemetry.io/otel/exporters/prometheus](https://github.com/open-telemetry/opentelemetry-go) | `0.63.0` | `0.65.0` | | [go.opentelemetry.io/otel/metric](https://github.com/open-telemetry/opentelemetry-go) | `1.41.0` | `1.43.0` | | [go.opentelemetry.io/otel/sdk](https://github.com/open-telemetry/opentelemetry-go) | `1.41.0` | `1.43.0` | | [go.opentelemetry.io/otel/sdk/metric](https://github.com/open-telemetry/opentelemetry-go) | `1.41.0` | `1.43.0` | | [golang.org/x/crypto](https://github.com/golang/crypto) | `0.48.0` | `0.49.0` | | [golang.org/x/net](https://github.com/golang/net) | `0.51.0` | `0.52.0` | | [golang.org/x/sys](https://github.com/golang/sys) | `0.41.0` | `0.42.0` | | [google.golang.org/grpc](https://github.com/grpc/grpc-go) | `1.79.3` | `1.80.0` | Updates `go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp` from 0.66.0 to 0.67.0 - [Release notes](https://github.com/open-telemetry/opentelemetry-go-contrib/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-go-contrib/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-go-contrib/compare/zpages/v0.66.0...zpages/v0.67.0) Updates `go.opentelemetry.io/contrib/instrumentation/runtime` from 0.66.0 to 0.67.0 - [Release notes](https://github.com/open-telemetry/opentelemetry-go-contrib/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-go-contrib/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-go-contrib/compare/zpages/v0.66.0...zpages/v0.67.0) Updates `go.opentelemetry.io/otel` from 1.41.0 to 1.42.0 - [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.41.0...v1.42.0) Updates `go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc` from 1.41.0 to 1.43.0 - [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.41.0...v1.43.0) Updates `go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc` from 1.41.0 to 1.43.0 - [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.41.0...v1.43.0) Updates `go.opentelemetry.io/otel/exporters/prometheus` from 0.63.0 to 0.65.0 - [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/exporters/prometheus/v0.63.0...exporters/prometheus/v0.65.0) Updates `go.opentelemetry.io/otel/metric` from 1.41.0 to 1.43.0 - [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.41.0...v1.43.0) Updates `go.opentelemetry.io/otel/sdk` from 1.41.0 to 1.43.0 - [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.41.0...v1.43.0) Updates `go.opentelemetry.io/otel/sdk/metric` from 1.41.0 to 1.43.0 - [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.41.0...v1.43.0) Updates `golang.org/x/crypto` from 0.48.0 to 0.49.0 - [Commits](https://github.com/golang/crypto/compare/v0.48.0...v0.49.0) Updates `golang.org/x/net` from 0.51.0 to 0.52.0 - [Commits](https://github.com/golang/net/compare/v0.51.0...v0.52.0) Updates `golang.org/x/sys` from 0.41.0 to 0.42.0 - [Commits](https://github.com/golang/sys/compare/v0.41.0...v0.42.0) Updates `google.golang.org/grpc` from 1.79.3 to 1.80.0 - [Release notes](https://github.com/grpc/grpc-go/releases) - [Commits](https://github.com/grpc/grpc-go/compare/v1.79.3...v1.80.0) --- updated-dependencies: - dependency-name: go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp dependency-version: 0.67.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: go.opentelemetry.io/contrib/instrumentation/runtime dependency-version: 0.67.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: go.opentelemetry.io/otel dependency-version: 1.42.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc dependency-version: 1.43.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc dependency-version: 1.43.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: go.opentelemetry.io/otel/exporters/prometheus dependency-version: 0.65.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: go.opentelemetry.io/otel/metric dependency-version: 1.43.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: go.opentelemetry.io/otel/sdk dependency-version: 1.43.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: go.opentelemetry.io/otel/sdk/metric dependency-version: 1.43.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: golang.org/x/crypto dependency-version: 0.49.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: golang.org/x/net dependency-version: 0.52.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: golang.org/x/sys dependency-version: 0.42.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: google.golang.org/grpc dependency-version: 1.80.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates ... Signed-off-by: dependabot[bot] Former-commit-id: 05fc12f66ee5fc5c7e29bf03d66cdefbadc2ef86 --- go.mod | 48 +++++++++++++-------------- go.sum | 100 ++++++++++++++++++++++++++++----------------------------- 2 files changed, 74 insertions(+), 74 deletions(-) diff --git a/go.mod b/go.mod index b60a090..8e9467e 100644 --- a/go.mod +++ b/go.mod @@ -8,23 +8,23 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/prometheus/client_golang v1.23.2 github.com/vishvananda/netlink v1.3.1 - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0 - go.opentelemetry.io/contrib/instrumentation/runtime v0.66.0 - go.opentelemetry.io/otel v1.41.0 - go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0 - go.opentelemetry.io/otel/exporters/prometheus v0.63.0 - go.opentelemetry.io/otel/metric v1.41.0 - go.opentelemetry.io/otel/sdk v1.41.0 - go.opentelemetry.io/otel/sdk/metric v1.41.0 - golang.org/x/crypto v0.48.0 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 + go.opentelemetry.io/contrib/instrumentation/runtime v0.67.0 + go.opentelemetry.io/otel v1.43.0 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 + go.opentelemetry.io/otel/exporters/prometheus v0.65.0 + go.opentelemetry.io/otel/metric v1.43.0 + go.opentelemetry.io/otel/sdk v1.43.0 + go.opentelemetry.io/otel/sdk/metric v1.43.0 + golang.org/x/crypto v0.49.0 golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 - golang.org/x/net v0.51.0 - golang.org/x/sys v0.41.0 + golang.org/x/net v0.52.0 + golang.org/x/sys v0.42.0 golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 golang.zx2c4.com/wireguard/windows v0.5.3 - google.golang.org/grpc v1.79.3 + google.golang.org/grpc v1.80.0 gopkg.in/yaml.v3 v3.0.1 gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c software.sslmate.com/src/go-pkcs12 v0.7.0 @@ -57,21 +57,21 @@ require ( github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/otlptranslator v1.0.0 // indirect - github.com/prometheus/procfs v0.19.2 // indirect + github.com/prometheus/procfs v0.20.1 // indirect github.com/vishvananda/netns v0.0.5 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect - go.opentelemetry.io/otel/trace v1.41.0 // indirect - go.opentelemetry.io/proto/otlp v1.9.0 // indirect - go.yaml.in/yaml/v2 v2.4.3 // indirect - golang.org/x/mod v0.32.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/text v0.34.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/text v0.35.0 // indirect golang.org/x/time v0.12.0 // indirect - golang.org/x/tools v0.41.0 // indirect + golang.org/x/tools v0.42.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/go.sum b/go.sum index 331dfb3..c8ca834 100644 --- a/go.sum +++ b/go.sum @@ -81,8 +81,8 @@ github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTU github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= -github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= -github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= @@ -95,56 +95,56 @@ github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zd github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0 h1:PnV4kVnw0zOmwwFkAzCN5O07fw1YOIQor120zrh0AVo= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0/go.mod h1:ofAwF4uinaf8SXdVzzbL4OsxJ3VfeEg3f/F6CeF49/Y= -go.opentelemetry.io/contrib/instrumentation/runtime v0.66.0 h1:JruBNmrPELWjR+PU3fsQBFQRYtsMLQ/zPfbvwDz9I/w= -go.opentelemetry.io/contrib/instrumentation/runtime v0.66.0/go.mod h1:vwNrfL6w1uAE3qX48KFii2Qoqf+NEDP5wNjus+RHz8Y= -go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= -go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0 h1:VO3BL6OZXRQ1yQc8W6EVfJzINeJ35BkiHx4MYfoQf44= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0/go.mod h1:qRDnJ2nv3CQXMK2HUd9K9VtvedsPAce3S+/4LZHjX/s= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 h1:ao6Oe+wSebTlQ1OEht7jlYTzQKE+pnx/iNywFvTbuuI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0/go.mod h1:u3T6vz0gh/NVzgDgiwkgLxpsSF6PaPmo2il0apGJbls= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0 h1:mq/Qcf28TWz719lE3/hMB4KkyDuLJIvgJnFGcd0kEUI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0/go.mod h1:yk5LXEYhsL2htyDNJbEq7fWzNEigeEdV5xBF/Y+kAv0= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= +go.opentelemetry.io/contrib/instrumentation/runtime v0.67.0 h1:fM78cKITJ2r08cl+nw5i+hI9zWAu3iak8o1Os/ca2Ck= +go.opentelemetry.io/contrib/instrumentation/runtime v0.67.0/go.mod h1:ybmlzIqGcQzwt5lAfi8TpSnHo/CI3yv1Czodmm+OJa8= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0/go.mod h1:2lmweYCiHYpEjQ/lSJBYhj9jP1zvCvQW4BqL9dnT7FQ= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4= -go.opentelemetry.io/otel/exporters/prometheus v0.63.0 h1:OLo1FNb0pBZykLqbKRZolKtGZd0Waqlr240YdMEnhhg= -go.opentelemetry.io/otel/exporters/prometheus v0.63.0/go.mod h1:8yeQAdhrK5xsWuFehO13Dk/Xb9FuhZoVpJfpoNCfJnw= -go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= -go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= -go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8= -go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90= -go.opentelemetry.io/otel/sdk/metric v1.41.0 h1:siZQIYBAUd1rlIWQT2uCxWJxcCO7q3TriaMlf08rXw8= -go.opentelemetry.io/otel/sdk/metric v1.41.0/go.mod h1:HNBuSvT7ROaGtGI50ArdRLUnvRTRGniSUZbxiWxSO8Y= -go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= -go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= -go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= -go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.opentelemetry.io/otel/exporters/prometheus v0.65.0 h1:jOveH/b4lU9HT7y+Gfamf18BqlOuz2PWEvs8yM7Q6XE= +go.opentelemetry.io/otel/exporters/prometheus v0.65.0/go.mod h1:i1P8pcumauPtUI4YNopea1dhzEMuEqWP1xoUZDylLHo= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= -go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0= golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A= @@ -153,14 +153,14 @@ golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdI golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ= golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0= -google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From ec96bdbf1d4066c4b1dcc9231fda48915fdd0ad8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" Date: Mon, 6 Apr 2026 10:12:08 +0000 Subject: [PATCH 052/161] chore(nix): fix hash for updated go dependencies Former-commit-id: 74183952fbf7032a8111fbc826933a4bec91b0b6 --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 60d7d85..b0e90a5 100644 --- a/flake.nix +++ b/flake.nix @@ -35,7 +35,7 @@ inherit version; src = pkgs.nix-gitignore.gitignoreSource [ ] ./.; - vendorHash = "sha256-YIcuj1S+ZWAzXZOMZbppTvsDcW1W1Sy8ynfMkzLMQpM="; + vendorHash = "sha256-JJNPd9uNQckz7LqpF7AsrBl2Xj8D8V9ixTUrbYg8M14="; nativeInstallCheckInputs = [ pkgs.versionCheckHook ]; From 753be0f74b98470842fb4ef591ae678412fe2c3e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 09:45:12 +0000 Subject: [PATCH 053/161] chore(deps): bump the prod-minor-updates group with 2 updates Bumps the prod-minor-updates group with 2 updates: [go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp](https://github.com/open-telemetry/opentelemetry-go-contrib) and [go.opentelemetry.io/contrib/instrumentation/runtime](https://github.com/open-telemetry/opentelemetry-go-contrib). Updates `go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp` from 0.67.0 to 0.68.0 - [Release notes](https://github.com/open-telemetry/opentelemetry-go-contrib/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-go-contrib/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-go-contrib/compare/zpages/v0.67.0...zpages/v0.68.0) Updates `go.opentelemetry.io/contrib/instrumentation/runtime` from 0.67.0 to 0.68.0 - [Release notes](https://github.com/open-telemetry/opentelemetry-go-contrib/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-go-contrib/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-go-contrib/compare/zpages/v0.67.0...zpages/v0.68.0) --- updated-dependencies: - dependency-name: go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp dependency-version: 0.68.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: go.opentelemetry.io/contrib/instrumentation/runtime dependency-version: 0.68.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates ... Signed-off-by: dependabot[bot] Former-commit-id: 8cf3942366c01f2bf63c8b7f0b729c73be916494 --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 8e9467e..dec88e0 100644 --- a/go.mod +++ b/go.mod @@ -8,8 +8,8 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/prometheus/client_golang v1.23.2 github.com/vishvananda/netlink v1.3.1 - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 - go.opentelemetry.io/contrib/instrumentation/runtime v0.67.0 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 + go.opentelemetry.io/contrib/instrumentation/runtime v0.68.0 go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 diff --git a/go.sum b/go.sum index c8ca834..73068f9 100644 --- a/go.sum +++ b/go.sum @@ -95,10 +95,10 @@ github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zd github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= -go.opentelemetry.io/contrib/instrumentation/runtime v0.67.0 h1:fM78cKITJ2r08cl+nw5i+hI9zWAu3iak8o1Os/ca2Ck= -go.opentelemetry.io/contrib/instrumentation/runtime v0.67.0/go.mod h1:ybmlzIqGcQzwt5lAfi8TpSnHo/CI3yv1Czodmm+OJa8= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= +go.opentelemetry.io/contrib/instrumentation/runtime v0.68.0 h1:jhVIQEprwUTV+KfzzliLidclhoTOoHTgdz96kAyR8mU= +go.opentelemetry.io/contrib/instrumentation/runtime v0.68.0/go.mod h1:4HsdbLUbernaTnA8CNaNE+1g026SciXb3juRYe3l8EY= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc= From 0dc50321c65a516ee23c0892fa006a4aaef2f1e6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" Date: Tue, 7 Apr 2026 09:47:01 +0000 Subject: [PATCH 054/161] chore(nix): fix hash for updated go dependencies Former-commit-id: f5f2ba38d7c5b4f222cf413aa38a7b0422f909dd --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index b0e90a5..8d6ce4f 100644 --- a/flake.nix +++ b/flake.nix @@ -35,7 +35,7 @@ inherit version; src = pkgs.nix-gitignore.gitignoreSource [ ] ./.; - vendorHash = "sha256-JJNPd9uNQckz7LqpF7AsrBl2Xj8D8V9ixTUrbYg8M14="; + vendorHash = "sha256-iXbt1KkSGcV+oj6TuSvkoOdch680IwcWkhT7DhFHz3U="; nativeInstallCheckInputs = [ pkgs.versionCheckHook ]; From 3a0dd20bd3a3368c7ea9394a0c53b5afb843ea5f Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 7 Apr 2026 11:34:18 -0400 Subject: [PATCH 055/161] Add CODEOWNERS Former-commit-id: 27e471942e19abc7d4abcacc4c1cf489dff49c68 --- .github/CODEOWNERS | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..c5f1403 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @oschwartz10612 @miloschwartz From 2ab9fb901a911a655be9fcc7e41e45e4619354c9 Mon Sep 17 00:00:00 2001 From: Laurence Date: Wed, 8 Apr 2026 14:13:13 +0100 Subject: [PATCH 056/161] fix: allow empty config file bootstrap before provisioning Treat an empty CONFIG_FILE as initial state instead of failing JSON parse, so provisioning can proceed and credentials can be saved. Ref: fosrl/pangolin#2812 Former-commit-id: d7c3c38d2417270074b4d1cc957779098ceaa8a9 --- websocket/config.go | 5 +++++ websocket/config_test.go | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 websocket/config_test.go diff --git a/websocket/config.go b/websocket/config.go index 39f1bd2..f24f65a 100644 --- a/websocket/config.go +++ b/websocket/config.go @@ -71,6 +71,11 @@ func (c *Client) loadConfig() error { } return err } + if len(bytes.TrimSpace(data)) == 0 { + logger.Info("Config file at %s is empty, will initialize it with provided values", configPath) + c.configNeedsSave = true + return nil + } var config Config if err := json.Unmarshal(data, &config); err != nil { diff --git a/websocket/config_test.go b/websocket/config_test.go new file mode 100644 index 0000000..b2d8a24 --- /dev/null +++ b/websocket/config_test.go @@ -0,0 +1,35 @@ +package websocket + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadConfig_EmptyFileMarksConfigForSave(t *testing.T) { + t.Setenv("CONFIG_FILE", "") + + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + if err := os.WriteFile(configPath, []byte(""), 0o644); err != nil { + t.Fatalf("failed to create empty config file: %v", err) + } + + client := &Client{ + config: &Config{ + Endpoint: "https://example.com", + ProvisioningKey: "spk-test", + }, + clientType: "newt", + configFilePath: configPath, + } + + if err := client.loadConfig(); err != nil { + t.Fatalf("loadConfig returned error for empty file: %v", err) + } + + if !client.configNeedsSave { + t.Fatal("expected empty config file to mark configNeedsSave") + } +} + From 5fb2fecc87d2800e6950dbfe0c9263c6382fa80b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 02:00:10 +0000 Subject: [PATCH 057/161] chore(deps): bump go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp Bumps [go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp](https://github.com/open-telemetry/opentelemetry-go) from 1.38.0 to 1.43.0. - [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.38.0...v1.43.0) --- updated-dependencies: - dependency-name: go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp dependency-version: 1.43.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] Former-commit-id: 6dd9c4b0d1f2df08f192097de1cd553310ab235c --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index dec88e0..79f94ec 100644 --- a/go.mod +++ b/go.mod @@ -61,7 +61,7 @@ require ( github.com/vishvananda/netns v0.0.5 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect diff --git a/go.sum b/go.sum index 73068f9..e38c6a4 100644 --- a/go.sum +++ b/go.sum @@ -107,8 +107,8 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bT go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= go.opentelemetry.io/otel/exporters/prometheus v0.65.0 h1:jOveH/b4lU9HT7y+Gfamf18BqlOuz2PWEvs8yM7Q6XE= go.opentelemetry.io/otel/exporters/prometheus v0.65.0/go.mod h1:i1P8pcumauPtUI4YNopea1dhzEMuEqWP1xoUZDylLHo= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= From 9442142c321554dc1542eed4c373eb5ac73b97c3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" Date: Thu, 9 Apr 2026 02:01:28 +0000 Subject: [PATCH 058/161] chore(nix): fix hash for updated go dependencies Former-commit-id: 0104fb9b2ddd7877154c88bf3b9a16767dcd53ec --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 8d6ce4f..5b2352a 100644 --- a/flake.nix +++ b/flake.nix @@ -35,7 +35,7 @@ inherit version; src = pkgs.nix-gitignore.gitignoreSource [ ] ./.; - vendorHash = "sha256-iXbt1KkSGcV+oj6TuSvkoOdch680IwcWkhT7DhFHz3U="; + vendorHash = "sha256-+zMSzNbqmWm/DXL2xMUd5uPP5tSIybsRokwJ2zd0pf0="; nativeInstallCheckInputs = [ pkgs.versionCheckHook ]; From 57831f9473b6ac79ca6a38ac9b3270e5673177b1 Mon Sep 17 00:00:00 2001 From: Laurence Date: Thu, 9 Apr 2026 15:45:55 +0100 Subject: [PATCH 059/161] fix(proxy): reclaim idle UDP flows and make timeout configurable Former-commit-id: 31f899588f0406d9e1b2b857952b1ee7b7331fe5 --- main.go | 16 ++++++++++++++++ proxy/manager.go | 24 ++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/main.go b/main.go index d5f2a96..7718c5d 100644 --- a/main.go +++ b/main.go @@ -129,6 +129,7 @@ var ( dockerEnforceNetworkValidationBool bool pingInterval time.Duration pingTimeout time.Duration + udpProxyIdleTimeout time.Duration publicKey wgtypes.Key pingStopChan chan struct{} stopFunc func() @@ -261,6 +262,7 @@ func runNewtMain(ctx context.Context) { 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") @@ -337,6 +339,9 @@ func runNewtMain(ctx context.Context) { 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 == "" { @@ -386,6 +391,16 @@ func runNewtMain(ctx context.Context) { 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)") } @@ -896,6 +911,7 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( // Create proxy manager pm = proxy.NewProxyManager(tnet) pm.SetAsyncBytes(metricsAsyncBytes) + pm.SetUDPIdleTimeout(udpProxyIdleTimeout) // Set tunnel_id for metrics (WireGuard peer public key) pm.SetTunnelID(wgData.PublicKey) diff --git a/proxy/manager.go b/proxy/manager.go index 5566589..d02956c 100644 --- a/proxy/manager.go +++ b/proxy/manager.go @@ -24,6 +24,7 @@ import ( const ( errUnsupportedProtoFmt = "unsupported protocol: %s" maxUDPPacketSize = 65507 + defaultUDPIdleTimeout = 90 * time.Second ) // Target represents a proxy target with its address and port @@ -47,6 +48,7 @@ type ProxyManager struct { tunnels map[string]*tunnelEntry asyncBytes bool flushStop chan struct{} + udpIdleTimeout time.Duration } // tunnelEntry holds per-tunnel attributes and (optional) async counters. @@ -132,6 +134,7 @@ func NewProxyManager(tnet *netstack.Net) *ProxyManager { listeners: make([]*gonet.TCPListener, 0), udpConns: make([]*gonet.UDPConn, 0), tunnels: make(map[string]*tunnelEntry), + udpIdleTimeout: defaultUDPIdleTimeout, } } @@ -209,6 +212,7 @@ func NewProxyManagerWithoutTNet() *ProxyManager { udpTargets: make(map[string]map[int]string), listeners: make([]*gonet.TCPListener, 0), udpConns: make([]*gonet.UDPConn, 0), + udpIdleTimeout: defaultUDPIdleTimeout, } } @@ -345,6 +349,17 @@ func (pm *ProxyManager) SetAsyncBytes(b bool) { go pm.flushLoop() } } + +// SetUDPIdleTimeout configures when idle UDP client flows are reclaimed. +func (pm *ProxyManager) SetUDPIdleTimeout(d time.Duration) { + pm.mutex.Lock() + defer pm.mutex.Unlock() + if d <= 0 { + pm.udpIdleTimeout = defaultUDPIdleTimeout + return + } + pm.udpIdleTimeout = d +} func (pm *ProxyManager) flushLoop() { flushInterval := 2 * time.Second if v := os.Getenv("OTEL_METRIC_EXPORT_INTERVAL"); v != "" { @@ -623,6 +638,9 @@ func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) { telemetry.IncProxyAccept(context.Background(), pm.currentTunnelID, "udp", "failure", classifyProxyError(err)) continue } + // Prevent idle UDP client goroutines from living forever and + // retaining large per-connection buffers. + _ = targetConn.SetReadDeadline(time.Now().Add(pm.udpIdleTimeout)) tunnelID := pm.currentTunnelID telemetry.IncProxyAccept(context.Background(), tunnelID, "udp", "success", "") telemetry.IncProxyConnectionEvent(context.Background(), tunnelID, "udp", telemetry.ProxyConnectionOpened) @@ -657,6 +675,10 @@ func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) { for { n, _, err := targetConn.ReadFromUDP(buffer) if err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + return + } // Connection closed is normal during cleanup if errors.Is(err, net.ErrClosed) || errors.Is(err, io.EOF) { return // defer will handle cleanup, result stays "success" @@ -699,6 +721,8 @@ func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) { delete(clientConns, clientKey) clientsMutex.Unlock() } else if pm.currentTunnelID != "" && written > 0 { + // Extend idle timeout whenever client traffic is observed. + _ = targetConn.SetReadDeadline(time.Now().Add(pm.udpIdleTimeout)) if pm.asyncBytes { if e := pm.getEntry(pm.currentTunnelID); e != nil { e.bytesInUDP.Add(uint64(written)) From 93c7cafc33d29f5a5eae8c7a1d5c5c9a228a8403 Mon Sep 17 00:00:00 2001 From: Laurence Date: Thu, 9 Apr 2026 15:59:03 +0100 Subject: [PATCH 060/161] perf(proxy): add sync.Pool for UDP buffers - Add udpBufferPool for reusable 65507-byte UDP packet buffers - Add getUDPBuffer() and putUDPBuffer() helper functions - Clear buffer contents before returning to pool to prevent data leakage - Apply pooling to both main handler buffer and per-client goroutine buffers - Reduces GC pressure from frequent large allocations during UDP proxying Made-with: Cursor Former-commit-id: 4d8d00241de07fadf05b052d2fe89759f1ef811d --- proxy/manager.go | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/proxy/manager.go b/proxy/manager.go index 5566589..36eb1d3 100644 --- a/proxy/manager.go +++ b/proxy/manager.go @@ -23,9 +23,30 @@ import ( const ( errUnsupportedProtoFmt = "unsupported protocol: %s" - maxUDPPacketSize = 65507 + maxUDPPacketSize = 65507 // Maximum UDP packet size ) +// udpBufferPool provides reusable buffers for UDP packet handling. +// This reduces GC pressure from frequent large allocations. +var udpBufferPool = sync.Pool{ + New: func() any { + buf := make([]byte, maxUDPPacketSize) + return &buf + }, +} + +// getUDPBuffer retrieves a buffer from the pool. +func getUDPBuffer() *[]byte { + return udpBufferPool.Get().(*[]byte) +} + +// putUDPBuffer clears and returns a buffer to the pool. +func putUDPBuffer(buf *[]byte) { + // Clear the buffer to prevent data leakage + clear(*buf) + udpBufferPool.Put(buf) +} + // Target represents a proxy target with its address and port type Target struct { Address string @@ -555,7 +576,9 @@ func (pm *ProxyManager) handleTCPProxy(listener net.Listener, targetAddr string) } func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) { - buffer := make([]byte, maxUDPPacketSize) // Max UDP packet size + bufPtr := getUDPBuffer() + defer putUDPBuffer(bufPtr) + buffer := *bufPtr clientConns := make(map[string]*net.UDPConn) var clientsMutex sync.RWMutex @@ -638,7 +661,10 @@ func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) { go func(clientKey string, targetConn *net.UDPConn, remoteAddr net.Addr, tunnelID string) { start := time.Now() result := "success" + bufPtr := getUDPBuffer() defer func() { + // Return buffer to pool first + putUDPBuffer(bufPtr) // Always clean up when this goroutine exits clientsMutex.Lock() if storedConn, exists := clientConns[clientKey]; exists && storedConn == targetConn { @@ -653,7 +679,7 @@ func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) { telemetry.IncProxyConnectionEvent(context.Background(), tunnelID, "udp", telemetry.ProxyConnectionClosed) }() - buffer := make([]byte, maxUDPPacketSize) + buffer := *bufPtr for { n, _, err := targetConn.ReadFromUDP(buffer) if err != nil { From 51c3f720faa63fd4ed84990bbbc1cc2228f4c676 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 9 Apr 2026 11:43:26 -0400 Subject: [PATCH 061/161] Basic http is working Former-commit-id: 47c646bc33a563a8fa82b9e7a7c5c80ae01f1aeb --- clients/clients.go | 1 + netstack2/handlers.go | 18 ++- netstack2/http_handler.go | 282 ++++++++++++++++++++++++++++++++++++++ netstack2/proxy.go | 20 ++- netstack2/tun.go | 3 + 5 files changed, 320 insertions(+), 4 deletions(-) create mode 100644 netstack2/http_handler.go diff --git a/clients/clients.go b/clients/clients.go index 78bc0c3..f1cf394 100644 --- a/clients/clients.go +++ b/clients/clients.go @@ -819,6 +819,7 @@ func (s *WireGuardService) ensureWireguardInterface(wgconfig WgConfig) error { EnableTCPProxy: true, EnableUDPProxy: true, EnableICMPProxy: true, + EnableHTTPProxy: true, }, ) if err != nil { diff --git a/netstack2/handlers.go b/netstack2/handlers.go index 07c235f..8baa5e2 100644 --- a/netstack2/handlers.go +++ b/netstack2/handlers.go @@ -137,14 +137,26 @@ func (h *TCPHandler) InstallTCPHandler() error { // handleTCPConn handles a TCP connection by proxying it to the actual target func (h *TCPHandler) handleTCPConn(netstackConn *gonet.TCPConn, id stack.TransportEndpointID) { - defer netstackConn.Close() - - // Extract source and target address from the connection ID + // Extract source and target address from the connection ID first so they + // are available for HTTP routing before any defer is set up. srcIP := id.RemoteAddress.String() srcPort := id.RemotePort dstIP := id.LocalAddress.String() dstPort := id.LocalPort + // Route to the HTTP handler when the destination port belongs to it. + // The HTTP handler takes full ownership of the connection lifecycle, so we + // must NOT install the defer close before handing the conn off. + if h.proxyHandler != nil && h.proxyHandler.httpHandler != nil { + if h.proxyHandler.httpHandler.HandlesPort(dstPort) { + logger.Info("+++++++++++++++++++++++TCP Forwarder: Routing %s:%d -> %s:%d to HTTP handler", srcIP, srcPort, dstIP, dstPort) + h.proxyHandler.httpHandler.HandleConn(netstackConn) + return + } + } + + defer netstackConn.Close() + logger.Info("TCP Forwarder: Handling connection %s:%d -> %s:%d", srcIP, srcPort, dstIP, dstPort) // Check if there's a destination rewrite for this connection (e.g., localhost targets) diff --git a/netstack2/http_handler.go b/netstack2/http_handler.go new file mode 100644 index 0000000..1502cf1 --- /dev/null +++ b/netstack2/http_handler.go @@ -0,0 +1,282 @@ +/* SPDX-License-Identifier: MIT + * + * Copyright (C) 2017-2025 WireGuard LLC. All Rights Reserved. + */ + +package netstack2 + +import ( + "crypto/tls" + "fmt" + "net" + "net/http" + "net/http/httputil" + "net/url" + "sync" + + "github.com/fosrl/newt/logger" + "gvisor.dev/gvisor/pkg/tcpip/stack" +) + +// --------------------------------------------------------------------------- +// Hardcoded test configuration +// --------------------------------------------------------------------------- + +// testHTTPServeHTTPS controls whether the proxy presents HTTP or HTTPS to +// incoming connections. Flip to true and supply valid cert/key paths to test +// TLS termination. +const testHTTPServeHTTPS = false + +// testHTTPCertFile / testHTTPKeyFile are paths to a self-signed certificate +// used when testHTTPServeHTTPS == true. +const testHTTPCertFile = "/tmp/test-cert.pem" +const testHTTPKeyFile = "/tmp/test-key.pem" + +// testHTTPListenPort is the destination port the handler intercepts from the +// netstack TCP forwarder (e.g. 80 for plain HTTP, 443 for HTTPS termination). +const testHTTPListenPort uint16 = 80 + +// testHTTPTargets is the hardcoded list of downstream services used for +// testing. DestAddr / DestPort describe where the real HTTP(S) server lives; +// UseHTTPS controls whether the outbound leg uses TLS. +var testHTTPTargets = []HTTPTarget{ + {DestAddr: "127.0.0.1", DestPort: 8080, UseHTTPS: false}, +} + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +// HTTPTarget describes a single downstream HTTP or HTTPS service. +type HTTPTarget struct { + DestAddr string // IP address or hostname of the downstream service + DestPort uint16 // TCP port of the downstream service + UseHTTPS bool // When true the outbound leg uses HTTPS +} + +// HTTPHandler intercepts TCP connections from the netstack forwarder and +// services them as HTTP or HTTPS, reverse-proxying each request to one of the +// configured downstream HTTPTarget services. +// +// It is intentionally separate from TCPHandler: there is no overlap between +// raw-TCP connections and HTTP-aware connections on the same destination port. +type HTTPHandler struct { + stack *stack.Stack + proxyHandler *ProxyHandler + + // Configuration (populated from hardcoded test values by NewHTTPHandler). + targets []HTTPTarget + listenPort uint16 // Port this handler claims; used for routing by TCPHandler + serveHTTPS bool // Present TLS to the incoming (client) side + certFile string // PEM certificate for the incoming TLS listener + keyFile string // PEM private key for the incoming TLS listener + + // Runtime state – initialised by Start(). + listener *chanListener + server *http.Server + // One pre-built reverse proxy per target entry. + proxies []*httputil.ReverseProxy +} + +// --------------------------------------------------------------------------- +// chanListener – net.Listener backed by a channel +// --------------------------------------------------------------------------- + +// chanListener implements net.Listener by receiving net.Conn values over a +// buffered channel. This lets the netstack TCP forwarder hand off connections +// directly to a running http.Server without any real OS socket. +type chanListener struct { + connCh chan net.Conn + closed chan struct{} + once sync.Once +} + +func newChanListener() *chanListener { + return &chanListener{ + connCh: make(chan net.Conn, 128), + closed: make(chan struct{}), + } +} + +// Accept blocks until a connection is available or the listener is closed. +func (l *chanListener) Accept() (net.Conn, error) { + select { + case conn, ok := <-l.connCh: + if !ok { + return nil, net.ErrClosed + } + return conn, nil + case <-l.closed: + return nil, net.ErrClosed + } +} + +// Close shuts down the listener; subsequent Accept calls return net.ErrClosed. +func (l *chanListener) Close() error { + l.once.Do(func() { close(l.closed) }) + return nil +} + +// Addr returns a placeholder address (the listener has no real OS socket). +func (l *chanListener) Addr() net.Addr { + return &net.TCPAddr{} +} + +// send delivers conn to the listener. Returns false if the listener is already +// closed, in which case the caller should close conn itself. +func (l *chanListener) send(conn net.Conn) bool { + select { + case l.connCh <- conn: + return true + case <-l.closed: + return false + } +} + +// --------------------------------------------------------------------------- +// HTTPHandler constructor and lifecycle +// --------------------------------------------------------------------------- + +// NewHTTPHandler creates an HTTPHandler wired to the given stack and +// ProxyHandler, using the hardcoded test configuration defined at the top of +// this file. +func NewHTTPHandler(s *stack.Stack, ph *ProxyHandler) *HTTPHandler { + return &HTTPHandler{ + stack: s, + proxyHandler: ph, + targets: testHTTPTargets, + listenPort: testHTTPListenPort, + serveHTTPS: testHTTPServeHTTPS, + certFile: testHTTPCertFile, + keyFile: testHTTPKeyFile, + } +} + +// Start builds the per-target reverse proxies and launches the HTTP(S) server +// that will service connections delivered via HandleConn. +func (h *HTTPHandler) Start() error { + // Build one ReverseProxy per target. + h.proxies = make([]*httputil.ReverseProxy, 0, len(h.targets)) + for i, t := range h.targets { + scheme := "http" + if t.UseHTTPS { + scheme = "https" + } + targetURL := &url.URL{ + Scheme: scheme, + Host: fmt.Sprintf("%s:%d", t.DestAddr, t.DestPort), + } + + proxy := httputil.NewSingleHostReverseProxy(targetURL) + + // For HTTPS downstream, allow self-signed certificates during testing. + if t.UseHTTPS { + proxy.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // intentional for test targets + }, + } + } + + idx := i // capture for closure + proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { + logger.Error("HTTP handler: upstream error (target %d, %s %s): %v", + idx, r.Method, r.URL.RequestURI(), err) + http.Error(w, "Bad Gateway", http.StatusBadGateway) + } + + h.proxies = append(h.proxies, proxy) + } + + h.listener = newChanListener() + + h.server = &http.Server{ + Handler: http.HandlerFunc(h.handleRequest), + } + + if h.serveHTTPS { + cert, err := tls.LoadX509KeyPair(h.certFile, h.keyFile) + if err != nil { + return fmt.Errorf("HTTP handler: failed to load TLS keypair (%s, %s): %w", + h.certFile, h.keyFile, err) + } + tlsCfg := &tls.Config{ + Certificates: []tls.Certificate{cert}, + } + tlsListener := tls.NewListener(h.listener, tlsCfg) + go func() { + if err := h.server.Serve(tlsListener); err != nil && err != http.ErrServerClosed { + logger.Error("HTTP handler: HTTPS server exited: %v", err) + } + }() + logger.Info("HTTP handler: listening (HTTPS) on port %d, %d downstream target(s)", + h.listenPort, len(h.targets)) + } else { + go func() { + if err := h.server.Serve(h.listener); err != nil && err != http.ErrServerClosed { + logger.Error("HTTP handler: HTTP server exited: %v", err) + } + }() + logger.Info("HTTP handler: listening (HTTP) on port %d, %d downstream target(s)", + h.listenPort, len(h.targets)) + } + + return nil +} + +// HandleConn accepts a TCP connection from the netstack forwarder and delivers +// it to the running HTTP(S) server. The HTTP handler takes full ownership of +// the connection's lifecycle; the caller must NOT close conn after this call. +func (h *HTTPHandler) HandleConn(conn net.Conn) { + if !h.listener.send(conn) { + // Listener already closed – clean up the orphaned connection. + conn.Close() + } +} + +// HandlesPort reports whether this handler is responsible for connections +// arriving on the given destination port. +func (h *HTTPHandler) HandlesPort(port uint16) bool { + return port == h.listenPort +} + +// Close shuts down the underlying HTTP server and the channel listener. +func (h *HTTPHandler) Close() error { + if h.server != nil { + if err := h.server.Close(); err != nil { + return err + } + } + if h.listener != nil { + h.listener.Close() + } + return nil +} + +// --------------------------------------------------------------------------- +// Request routing +// --------------------------------------------------------------------------- + +// handleRequest proxies an incoming HTTP request to the appropriate downstream +// target. Currently always routes to the first (and, in the hardcoded test +// setup, only) configured target. +func (h *HTTPHandler) handleRequest(w http.ResponseWriter, r *http.Request) { + if len(h.proxies) == 0 { + logger.Error("HTTP handler: no downstream targets configured") + http.Error(w, "no targets configured", http.StatusBadGateway) + return + } + + // TODO: add host/path-based routing when moving beyond hardcoded test config. + proxy := h.proxies[0] + target := h.targets[0] + + scheme := "http" + if target.UseHTTPS { + scheme = "https" + } + logger.Info("HTTP handler: %s %s -> %s://%s:%d", + r.Method, r.URL.RequestURI(), scheme, target.DestAddr, target.DestPort) + + proxy.ServeHTTP(w, r) +} \ No newline at end of file diff --git a/netstack2/proxy.go b/netstack2/proxy.go index e383fc0..1ad469e 100644 --- a/netstack2/proxy.go +++ b/netstack2/proxy.go @@ -114,6 +114,7 @@ type ProxyHandler struct { tcpHandler *TCPHandler udpHandler *UDPHandler icmpHandler *ICMPHandler + httpHandler *HTTPHandler subnetLookup *SubnetLookup natTable map[connKey]*natState reverseNatTable map[reverseConnKey]*natState // Reverse lookup map for O(1) reply packet NAT @@ -131,12 +132,13 @@ type ProxyHandlerOptions struct { EnableTCP bool EnableUDP bool EnableICMP bool + EnableHTTP bool MTU int } // NewProxyHandler creates a new proxy handler for promiscuous mode func NewProxyHandler(options ProxyHandlerOptions) (*ProxyHandler, error) { - if !options.EnableTCP && !options.EnableUDP && !options.EnableICMP { + if !options.EnableTCP && !options.EnableUDP && !options.EnableICMP && !options.EnableHTTP { return nil, nil // No proxy needed } @@ -189,6 +191,17 @@ func NewProxyHandler(options ProxyHandlerOptions) (*ProxyHandler, error) { logger.Debug("ProxyHandler: ICMP handler enabled") } + // Initialize HTTP handler if enabled. The HTTP handler piggybacks on the + // TCP forwarder: TCPHandler.handleTCPConn checks HandlesPort() and routes + // matching connections here instead of doing raw byte forwarding. + if options.EnableHTTP { + handler.httpHandler = NewHTTPHandler(handler.proxyStack, handler) + if err := handler.httpHandler.Start(); err != nil { + return nil, fmt.Errorf("failed to start HTTP handler: %v", err) + } + logger.Debug("ProxyHandler: HTTP handler enabled") + } + // // Example 1: Add a rule with no port restrictions (all ports allowed) // // This accepts all traffic FROM 10.0.0.0/24 TO 10.20.20.0/24 // sourceSubnet := netip.MustParsePrefix("10.0.0.0/24") @@ -794,6 +807,11 @@ func (p *ProxyHandler) Close() error { p.accessLogger.Close() } + // Shut down HTTP handler + if p.httpHandler != nil { + p.httpHandler.Close() + } + // Close ICMP replies channel if p.icmpReplies != nil { close(p.icmpReplies) diff --git a/netstack2/tun.go b/netstack2/tun.go index 3183c36..e879d3b 100644 --- a/netstack2/tun.go +++ b/netstack2/tun.go @@ -59,6 +59,7 @@ type NetTunOptions struct { EnableTCPProxy bool EnableUDPProxy bool EnableICMPProxy bool + EnableHTTPProxy bool } // CreateNetTUN creates a new TUN device with netstack without proxying @@ -67,6 +68,7 @@ func CreateNetTUN(localAddresses, dnsServers []netip.Addr, mtu int) (tun.Device, EnableTCPProxy: true, EnableUDPProxy: true, EnableICMPProxy: true, + EnableHTTPProxy: true, }) } @@ -93,6 +95,7 @@ func CreateNetTUNWithOptions(localAddresses, dnsServers []netip.Addr, mtu int, o EnableTCP: options.EnableTCPProxy, EnableUDP: options.EnableUDPProxy, EnableICMP: options.EnableICMPProxy, + EnableHTTP: options.EnableHTTPProxy, MTU: mtu, }) if err != nil { From aa50a339d2860841f0d4df8e05a5612032540b03 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 9 Apr 2026 16:04:11 -0400 Subject: [PATCH 062/161] Adjust to use data saved inside of the subnet rule Former-commit-id: 5848c8d4b4d4687e3bbd6b29ccdf1c6e2e519d16 --- clients/clients.go | 61 ++++++-- netstack2/handlers.go | 19 ++- netstack2/http_handler.go | 310 +++++++++++++++++++++---------------- netstack2/proxy.go | 45 +++--- netstack2/subnet_lookup.go | 26 ++-- netstack2/tun.go | 13 +- 6 files changed, 268 insertions(+), 206 deletions(-) diff --git a/clients/clients.go b/clients/clients.go index f1cf394..d57ab70 100644 --- a/clients/clients.go +++ b/clients/clients.go @@ -74,18 +74,18 @@ type PeerReading struct { } type WireGuardService struct { - interfaceName string - mtu int - client *websocket.Client - config WgConfig - key wgtypes.Key - newtId string - lastReadings map[string]PeerReading - mu sync.Mutex - Port uint16 - host string - serverPubKey string - token string + interfaceName string + mtu int + client *websocket.Client + config WgConfig + key wgtypes.Key + newtId string + lastReadings map[string]PeerReading + mu sync.Mutex + Port uint16 + host string + serverPubKey string + token string stopGetConfig func() pendingConfigChainId string // Netstack fields @@ -697,7 +697,14 @@ func (s *WireGuardService) syncTargets(desiredTargets []Target) error { }) } - s.tnet.AddProxySubnetRule(sourcePrefix, destPrefix, target.RewriteTo, portRanges, target.DisableIcmp, target.ResourceId) + s.tnet.AddProxySubnetRule(netstack2.SubnetRule{ + SourcePrefix: sourcePrefix, + DestPrefix: destPrefix, + RewriteTo: target.RewriteTo, + PortRanges: portRanges, + DisableIcmp: target.DisableIcmp, + ResourceId: target.ResourceId, + }) logger.Info("Added target %s -> %s during sync", target.SourcePrefix, target.DestPrefix) } } @@ -819,7 +826,6 @@ func (s *WireGuardService) ensureWireguardInterface(wgconfig WgConfig) error { EnableTCPProxy: true, EnableUDPProxy: true, EnableICMPProxy: true, - EnableHTTPProxy: true, }, ) if err != nil { @@ -956,7 +962,14 @@ func (s *WireGuardService) ensureTargets(targets []Target) error { if err != nil { return fmt.Errorf("invalid CIDR %s: %v", sp, err) } - s.tnet.AddProxySubnetRule(sourcePrefix, destPrefix, target.RewriteTo, portRanges, target.DisableIcmp, target.ResourceId) + s.tnet.AddProxySubnetRule(netstack2.SubnetRule{ + SourcePrefix: sourcePrefix, + DestPrefix: destPrefix, + RewriteTo: target.RewriteTo, + PortRanges: portRanges, + DisableIcmp: target.DisableIcmp, + ResourceId: target.ResourceId, + }) logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", sp, target.DestPrefix, target.RewriteTo, target.PortRange) } } @@ -1349,7 +1362,14 @@ func (s *WireGuardService) handleAddTarget(msg websocket.WSMessage) { logger.Info("Invalid CIDR %s: %v", sp, err) continue } - s.tnet.AddProxySubnetRule(sourcePrefix, destPrefix, target.RewriteTo, portRanges, target.DisableIcmp, target.ResourceId) + s.tnet.AddProxySubnetRule(netstack2.SubnetRule{ + SourcePrefix: sourcePrefix, + DestPrefix: destPrefix, + RewriteTo: target.RewriteTo, + PortRanges: portRanges, + DisableIcmp: target.DisableIcmp, + ResourceId: target.ResourceId, + }) logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", sp, target.DestPrefix, target.RewriteTo, target.PortRange) } } @@ -1467,7 +1487,14 @@ func (s *WireGuardService) handleUpdateTarget(msg websocket.WSMessage) { logger.Info("Invalid CIDR %s: %v", sp, err) continue } - s.tnet.AddProxySubnetRule(sourcePrefix, destPrefix, target.RewriteTo, portRanges, target.DisableIcmp, target.ResourceId) + s.tnet.AddProxySubnetRule(netstack2.SubnetRule{ + SourcePrefix: sourcePrefix, + DestPrefix: destPrefix, + RewriteTo: target.RewriteTo, + PortRanges: portRanges, + DisableIcmp: target.DisableIcmp, + ResourceId: target.ResourceId, + }) logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", sp, target.DestPrefix, target.RewriteTo, target.PortRange) } } diff --git a/netstack2/handlers.go b/netstack2/handlers.go index 8baa5e2..dabfee9 100644 --- a/netstack2/handlers.go +++ b/netstack2/handlers.go @@ -144,13 +144,18 @@ func (h *TCPHandler) handleTCPConn(netstackConn *gonet.TCPConn, id stack.Transpo dstIP := id.LocalAddress.String() dstPort := id.LocalPort - // Route to the HTTP handler when the destination port belongs to it. - // The HTTP handler takes full ownership of the connection lifecycle, so we - // must NOT install the defer close before handing the conn off. - if h.proxyHandler != nil && h.proxyHandler.httpHandler != nil { - if h.proxyHandler.httpHandler.HandlesPort(dstPort) { - logger.Info("+++++++++++++++++++++++TCP Forwarder: Routing %s:%d -> %s:%d to HTTP handler", srcIP, srcPort, dstIP, dstPort) - h.proxyHandler.httpHandler.HandleConn(netstackConn) + // For HTTP/HTTPS ports, look up the matching subnet rule. If the rule has + // Protocol configured, hand the connection off to the HTTP handler which + // takes full ownership of the lifecycle (the defer close must not be + // installed before this point). + if (dstPort == 80 || dstPort == 443) && h.proxyHandler != nil && h.proxyHandler.httpHandler != nil { + srcAddr, _ := netip.ParseAddr(srcIP) + dstAddr, _ := netip.ParseAddr(dstIP) + rule := h.proxyHandler.subnetLookup.Match(srcAddr, dstAddr, dstPort, tcp.ProtocolNumber) + if rule != nil && rule.Protocol != "" { + logger.Info("TCP Forwarder: Routing %s:%d -> %s:%d to HTTP handler (%s)", + srcIP, srcPort, dstIP, dstPort, rule.Protocol) + h.proxyHandler.httpHandler.HandleConn(netstackConn, rule) return } } diff --git a/netstack2/http_handler.go b/netstack2/http_handler.go index 1502cf1..4efa6a1 100644 --- a/netstack2/http_handler.go +++ b/netstack2/http_handler.go @@ -6,6 +6,7 @@ package netstack2 import ( + "context" "crypto/tls" "fmt" "net" @@ -19,63 +20,52 @@ import ( ) // --------------------------------------------------------------------------- -// Hardcoded test configuration +// HTTPTarget // --------------------------------------------------------------------------- -// testHTTPServeHTTPS controls whether the proxy presents HTTP or HTTPS to -// incoming connections. Flip to true and supply valid cert/key paths to test -// TLS termination. -const testHTTPServeHTTPS = false - -// testHTTPCertFile / testHTTPKeyFile are paths to a self-signed certificate -// used when testHTTPServeHTTPS == true. -const testHTTPCertFile = "/tmp/test-cert.pem" -const testHTTPKeyFile = "/tmp/test-key.pem" - -// testHTTPListenPort is the destination port the handler intercepts from the -// netstack TCP forwarder (e.g. 80 for plain HTTP, 443 for HTTPS termination). -const testHTTPListenPort uint16 = 80 - -// testHTTPTargets is the hardcoded list of downstream services used for -// testing. DestAddr / DestPort describe where the real HTTP(S) server lives; -// UseHTTPS controls whether the outbound leg uses TLS. -var testHTTPTargets = []HTTPTarget{ - {DestAddr: "127.0.0.1", DestPort: 8080, UseHTTPS: false}, -} - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -// HTTPTarget describes a single downstream HTTP or HTTPS service. +// HTTPTarget describes a single downstream HTTP or HTTPS service that the +// proxy should forward requests to. type HTTPTarget struct { DestAddr string // IP address or hostname of the downstream service DestPort uint16 // TCP port of the downstream service UseHTTPS bool // When true the outbound leg uses HTTPS } -// HTTPHandler intercepts TCP connections from the netstack forwarder and -// services them as HTTP or HTTPS, reverse-proxying each request to one of the -// configured downstream HTTPTarget services. +// --------------------------------------------------------------------------- +// HTTPHandler +// --------------------------------------------------------------------------- + +// HTTPHandler intercepts TCP connections from the netstack forwarder on ports +// 80 and 443 and services them as HTTP or HTTPS, reverse-proxying each request +// to downstream targets specified by the matching SubnetRule. // -// It is intentionally separate from TCPHandler: there is no overlap between -// raw-TCP connections and HTTP-aware connections on the same destination port. +// HTTP and raw TCP are fully separate: a connection is only routed here when +// its SubnetRule has Protocol set ("http" or "https"). All other connections +// on those ports fall through to the normal raw-TCP path. +// +// Incoming TLS termination (Protocol == "https") is performed per-connection +// using the certificate and key stored in the rule, so different subnet rules +// can present different certificates without sharing any state. +// +// Outbound connections to downstream targets honour HTTPTarget.UseHTTPS +// independently of the incoming protocol. type HTTPHandler struct { stack *stack.Stack proxyHandler *ProxyHandler - // Configuration (populated from hardcoded test values by NewHTTPHandler). - targets []HTTPTarget - listenPort uint16 // Port this handler claims; used for routing by TCPHandler - serveHTTPS bool // Present TLS to the incoming (client) side - certFile string // PEM certificate for the incoming TLS listener - keyFile string // PEM private key for the incoming TLS listener - - // Runtime state – initialised by Start(). listener *chanListener server *http.Server - // One pre-built reverse proxy per target entry. - proxies []*httputil.ReverseProxy + + // proxyCache holds pre-built *httputil.ReverseProxy values keyed by the + // canonical target URL string ("scheme://host:port"). Building a proxy is + // cheap, but reusing one preserves the underlying http.Transport connection + // pool, which matters for throughput. + proxyCache sync.Map // map[string]*httputil.ReverseProxy + + // tlsCache holds pre-parsed *tls.Config values keyed by the concatenation + // of the PEM certificate and key. Parsing a keypair is relatively expensive + // and the same cert is likely reused across many connections. + tlsCache sync.Map // map[string]*tls.Config } // --------------------------------------------------------------------------- @@ -123,7 +113,7 @@ func (l *chanListener) Addr() net.Addr { } // send delivers conn to the listener. Returns false if the listener is already -// closed, in which case the caller should close conn itself. +// closed, in which case the caller is responsible for closing conn. func (l *chanListener) send(conn net.Conn) bool { select { case l.connCh <- conn: @@ -134,113 +124,96 @@ func (l *chanListener) send(conn net.Conn) bool { } // --------------------------------------------------------------------------- -// HTTPHandler constructor and lifecycle +// httpConnCtx – conn wrapper that carries a SubnetRule through the listener // --------------------------------------------------------------------------- -// NewHTTPHandler creates an HTTPHandler wired to the given stack and -// ProxyHandler, using the hardcoded test configuration defined at the top of -// this file. +// httpConnCtx wraps a net.Conn so the matching SubnetRule can be passed +// through the chanListener into the http.Server's ConnContext callback, +// making it available to request handlers via the request context. +type httpConnCtx struct { + net.Conn + rule *SubnetRule +} + +// connCtxKey is the unexported context key used to store a *SubnetRule on the +// per-connection context created by http.Server.ConnContext. +type connCtxKey struct{} + +// --------------------------------------------------------------------------- +// Constructor and lifecycle +// --------------------------------------------------------------------------- + +// NewHTTPHandler creates an HTTPHandler attached to the given stack and +// ProxyHandler. Call Start to begin serving connections. func NewHTTPHandler(s *stack.Stack, ph *ProxyHandler) *HTTPHandler { return &HTTPHandler{ stack: s, proxyHandler: ph, - targets: testHTTPTargets, - listenPort: testHTTPListenPort, - serveHTTPS: testHTTPServeHTTPS, - certFile: testHTTPCertFile, - keyFile: testHTTPKeyFile, } } -// Start builds the per-target reverse proxies and launches the HTTP(S) server -// that will service connections delivered via HandleConn. +// Start launches the internal http.Server that services connections delivered +// via HandleConn. The server runs for the lifetime of the HTTPHandler; call +// Close to stop it. func (h *HTTPHandler) Start() error { - // Build one ReverseProxy per target. - h.proxies = make([]*httputil.ReverseProxy, 0, len(h.targets)) - for i, t := range h.targets { - scheme := "http" - if t.UseHTTPS { - scheme = "https" - } - targetURL := &url.URL{ - Scheme: scheme, - Host: fmt.Sprintf("%s:%d", t.DestAddr, t.DestPort), - } - - proxy := httputil.NewSingleHostReverseProxy(targetURL) - - // For HTTPS downstream, allow self-signed certificates during testing. - if t.UseHTTPS { - proxy.Transport = &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, //nolint:gosec // intentional for test targets - }, - } - } - - idx := i // capture for closure - proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { - logger.Error("HTTP handler: upstream error (target %d, %s %s): %v", - idx, r.Method, r.URL.RequestURI(), err) - http.Error(w, "Bad Gateway", http.StatusBadGateway) - } - - h.proxies = append(h.proxies, proxy) - } - h.listener = newChanListener() h.server = &http.Server{ Handler: http.HandlerFunc(h.handleRequest), + // ConnContext runs once per accepted connection and attaches the + // SubnetRule carried by httpConnCtx to the connection's context so + // that handleRequest can retrieve it without any global state. + ConnContext: func(ctx context.Context, c net.Conn) context.Context { + if cc, ok := c.(*httpConnCtx); ok { + return context.WithValue(ctx, connCtxKey{}, cc.rule) + } + return ctx + }, } - if h.serveHTTPS { - cert, err := tls.LoadX509KeyPair(h.certFile, h.keyFile) - if err != nil { - return fmt.Errorf("HTTP handler: failed to load TLS keypair (%s, %s): %w", - h.certFile, h.keyFile, err) + go func() { + if err := h.server.Serve(h.listener); err != nil && err != http.ErrServerClosed { + logger.Error("HTTP handler: server exited unexpectedly: %v", err) } - tlsCfg := &tls.Config{ - Certificates: []tls.Certificate{cert}, - } - tlsListener := tls.NewListener(h.listener, tlsCfg) - go func() { - if err := h.server.Serve(tlsListener); err != nil && err != http.ErrServerClosed { - logger.Error("HTTP handler: HTTPS server exited: %v", err) - } - }() - logger.Info("HTTP handler: listening (HTTPS) on port %d, %d downstream target(s)", - h.listenPort, len(h.targets)) - } else { - go func() { - if err := h.server.Serve(h.listener); err != nil && err != http.ErrServerClosed { - logger.Error("HTTP handler: HTTP server exited: %v", err) - } - }() - logger.Info("HTTP handler: listening (HTTP) on port %d, %d downstream target(s)", - h.listenPort, len(h.targets)) - } + }() + logger.Info("HTTP handler: ready — routing determined per SubnetRule on ports 80/443") return nil } -// HandleConn accepts a TCP connection from the netstack forwarder and delivers -// it to the running HTTP(S) server. The HTTP handler takes full ownership of -// the connection's lifecycle; the caller must NOT close conn after this call. -func (h *HTTPHandler) HandleConn(conn net.Conn) { - if !h.listener.send(conn) { - // Listener already closed – clean up the orphaned connection. - conn.Close() +// HandleConn accepts a TCP connection from the netstack forwarder together +// with the SubnetRule that matched it. The HTTP handler takes full ownership +// of the connection's lifecycle; the caller must NOT close conn after this call. +// +// When rule.Protocol is "https", TLS termination is performed on conn using +// the certificate and key stored in rule.TLSCert and rule.TLSKey before the +// connection is passed to the HTTP server. The HTTP server itself is always +// plain-HTTP; TLS is fully unwrapped at this layer. +func (h *HTTPHandler) HandleConn(conn net.Conn, rule *SubnetRule) { + var effectiveConn net.Conn = conn + + if rule.Protocol == "https" { + tlsCfg, err := h.getTLSConfig(rule) + if err != nil { + logger.Error("HTTP handler: cannot build TLS config for connection from %s: %v", + conn.RemoteAddr(), err) + conn.Close() + return + } + // tls.Server wraps the raw conn; the TLS handshake is deferred until + // the first Read, which the http.Server will trigger naturally. + effectiveConn = tls.Server(conn, tlsCfg) + } + + wrapped := &httpConnCtx{Conn: effectiveConn, rule: rule} + if !h.listener.send(wrapped) { + // Listener is already closed — clean up the orphaned connection. + effectiveConn.Close() } } -// HandlesPort reports whether this handler is responsible for connections -// arriving on the given destination port. -func (h *HTTPHandler) HandlesPort(port uint16) bool { - return port == h.listenPort -} - -// Close shuts down the underlying HTTP server and the channel listener. +// Close gracefully shuts down the HTTP server and the underlying channel +// listener, causing the goroutine started in Start to exit. func (h *HTTPHandler) Close() error { if h.server != nil { if err := h.server.Close(); err != nil { @@ -254,23 +227,86 @@ func (h *HTTPHandler) Close() error { } // --------------------------------------------------------------------------- -// Request routing +// Internal helpers // --------------------------------------------------------------------------- -// handleRequest proxies an incoming HTTP request to the appropriate downstream -// target. Currently always routes to the first (and, in the hardcoded test -// setup, only) configured target. +// getTLSConfig returns a *tls.Config for the cert/key pair in rule, using a +// cache to avoid re-parsing the same keypair on every connection. +// The cache key is the concatenation of the PEM cert and key strings, so +// different rules that happen to share the same material hit the same entry. +func (h *HTTPHandler) getTLSConfig(rule *SubnetRule) (*tls.Config, error) { + cacheKey := rule.TLSCert + "|" + rule.TLSKey + if v, ok := h.tlsCache.Load(cacheKey); ok { + return v.(*tls.Config), nil + } + + cert, err := tls.X509KeyPair([]byte(rule.TLSCert), []byte(rule.TLSKey)) + if err != nil { + return nil, fmt.Errorf("failed to parse TLS keypair: %w", err) + } + cfg := &tls.Config{ + Certificates: []tls.Certificate{cert}, + } + // LoadOrStore is safe under concurrent calls: if two goroutines race here + // both will produce a valid config; the loser's work is discarded. + actual, _ := h.tlsCache.LoadOrStore(cacheKey, cfg) + return actual.(*tls.Config), nil +} + +// getProxy returns a cached *httputil.ReverseProxy for the given target, +// creating one on first use. Reusing the proxy preserves its http.Transport +// connection pool, avoiding repeated TCP/TLS handshakes to the downstream. +func (h *HTTPHandler) getProxy(target HTTPTarget) *httputil.ReverseProxy { + scheme := "http" + if target.UseHTTPS { + scheme = "https" + } + cacheKey := fmt.Sprintf("%s://%s:%d", scheme, target.DestAddr, target.DestPort) + + if v, ok := h.proxyCache.Load(cacheKey); ok { + return v.(*httputil.ReverseProxy) + } + + targetURL := &url.URL{ + Scheme: scheme, + Host: fmt.Sprintf("%s:%d", target.DestAddr, target.DestPort), + } + proxy := httputil.NewSingleHostReverseProxy(targetURL) + + if target.UseHTTPS { + // Allow self-signed certificates on downstream HTTPS targets. + proxy.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // downstream self-signed certs are a supported configuration + }, + } + } + + proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { + logger.Error("HTTP handler: upstream error (%s %s -> %s): %v", + r.Method, r.URL.RequestURI(), cacheKey, err) + http.Error(w, "Bad Gateway", http.StatusBadGateway) + } + + actual, _ := h.proxyCache.LoadOrStore(cacheKey, proxy) + return actual.(*httputil.ReverseProxy) +} + +// handleRequest is the http.Handler entry point. It retrieves the SubnetRule +// attached to the connection by ConnContext, selects the first configured +// downstream target, and forwards the request via the cached ReverseProxy. +// +// TODO: add host/path-based routing across multiple HTTPTargets once the +// configuration model evolves beyond a single target per rule. func (h *HTTPHandler) handleRequest(w http.ResponseWriter, r *http.Request) { - if len(h.proxies) == 0 { - logger.Error("HTTP handler: no downstream targets configured") + rule, _ := r.Context().Value(connCtxKey{}).(*SubnetRule) + if rule == nil || len(rule.HTTPTargets) == 0 { + logger.Error("HTTP handler: no downstream targets for request %s %s", r.Method, r.URL.RequestURI()) http.Error(w, "no targets configured", http.StatusBadGateway) return } - // TODO: add host/path-based routing when moving beyond hardcoded test config. - proxy := h.proxies[0] - target := h.targets[0] - + target := rule.HTTPTargets[0] scheme := "http" if target.UseHTTPS { scheme = "https" @@ -278,5 +314,5 @@ func (h *HTTPHandler) handleRequest(w http.ResponseWriter, r *http.Request) { logger.Info("HTTP handler: %s %s -> %s://%s:%d", r.Method, r.URL.RequestURI(), scheme, target.DestAddr, target.DestPort) - proxy.ServeHTTP(w, r) + h.getProxy(target).ServeHTTP(w, r) } \ No newline at end of file diff --git a/netstack2/proxy.go b/netstack2/proxy.go index 1ad469e..f4c2352 100644 --- a/netstack2/proxy.go +++ b/netstack2/proxy.go @@ -53,6 +53,14 @@ type SubnetRule struct { RewriteTo string // Optional rewrite address for DNAT - can be IP/CIDR or domain name PortRanges []PortRange // empty slice means all ports allowed ResourceId int // Optional resource ID from the server for access logging + + // HTTP proxy configuration (optional). + // When Protocol is non-empty the TCP connection is handled by HTTPHandler + // instead of the raw TCP forwarder. + Protocol string // "", "http", or "https" — controls the incoming (client-facing) protocol + HTTPTargets []HTTPTarget // downstream services to proxy requests to + TLSCert string // PEM-encoded certificate for incoming HTTPS termination + TLSKey string // PEM-encoded private key for incoming HTTPS termination } // GetAllRules returns a copy of all subnet rules @@ -132,13 +140,12 @@ type ProxyHandlerOptions struct { EnableTCP bool EnableUDP bool EnableICMP bool - EnableHTTP bool MTU int } // NewProxyHandler creates a new proxy handler for promiscuous mode func NewProxyHandler(options ProxyHandlerOptions) (*ProxyHandler, error) { - if !options.EnableTCP && !options.EnableUDP && !options.EnableICMP && !options.EnableHTTP { + if !options.EnableTCP && !options.EnableUDP && !options.EnableICMP { return nil, nil // No proxy needed } @@ -166,12 +173,21 @@ func NewProxyHandler(options ProxyHandlerOptions) (*ProxyHandler, error) { }), } - // Initialize TCP handler if enabled + // Initialize TCP handler if enabled. The HTTP handler piggybacks on the + // TCP forwarder — TCPHandler.handleTCPConn checks the subnet rule for + // ports 80/443 and routes matching connections to the HTTP handler, so + // the HTTP handler is always initialised alongside TCP. if options.EnableTCP { handler.tcpHandler = NewTCPHandler(handler.proxyStack, handler) if err := handler.tcpHandler.InstallTCPHandler(); err != nil { return nil, fmt.Errorf("failed to install TCP handler: %v", err) } + + handler.httpHandler = NewHTTPHandler(handler.proxyStack, handler) + if err := handler.httpHandler.Start(); err != nil { + return nil, fmt.Errorf("failed to start HTTP handler: %v", err) + } + logger.Debug("ProxyHandler: HTTP handler enabled") } // Initialize UDP handler if enabled @@ -191,17 +207,6 @@ func NewProxyHandler(options ProxyHandlerOptions) (*ProxyHandler, error) { logger.Debug("ProxyHandler: ICMP handler enabled") } - // Initialize HTTP handler if enabled. The HTTP handler piggybacks on the - // TCP forwarder: TCPHandler.handleTCPConn checks HandlesPort() and routes - // matching connections here instead of doing raw byte forwarding. - if options.EnableHTTP { - handler.httpHandler = NewHTTPHandler(handler.proxyStack, handler) - if err := handler.httpHandler.Start(); err != nil { - return nil, fmt.Errorf("failed to start HTTP handler: %v", err) - } - logger.Debug("ProxyHandler: HTTP handler enabled") - } - // // Example 1: Add a rule with no port restrictions (all ports allowed) // // This accepts all traffic FROM 10.0.0.0/24 TO 10.20.20.0/24 // sourceSubnet := netip.MustParsePrefix("10.0.0.0/24") @@ -221,16 +226,14 @@ func NewProxyHandler(options ProxyHandlerOptions) (*ProxyHandler, error) { return handler, nil } -// AddSubnetRule adds a subnet with optional port restrictions to the proxy handler -// sourcePrefix: The IP prefix of the peer sending the data -// destPrefix: The IP prefix of the destination -// rewriteTo: Optional address to rewrite destination to - can be IP/CIDR or domain name -// If portRanges is nil or empty, all ports are allowed for this subnet -func (p *ProxyHandler) AddSubnetRule(sourcePrefix, destPrefix netip.Prefix, rewriteTo string, portRanges []PortRange, disableIcmp bool, resourceId int) { +// AddSubnetRule adds a subnet rule to the proxy handler. +// HTTP proxy behaviour is configured via rule.Protocol, rule.HTTPTargets, +// rule.TLSCert, and rule.TLSKey; leave Protocol empty for raw TCP/UDP. +func (p *ProxyHandler) AddSubnetRule(rule SubnetRule) { if p == nil || !p.enabled { return } - p.subnetLookup.AddSubnet(sourcePrefix, destPrefix, rewriteTo, portRanges, disableIcmp, resourceId) + p.subnetLookup.AddSubnet(rule) } // RemoveSubnetRule removes a subnet from the proxy handler diff --git a/netstack2/subnet_lookup.go b/netstack2/subnet_lookup.go index 317f85c..757908a 100644 --- a/netstack2/subnet_lookup.go +++ b/netstack2/subnet_lookup.go @@ -44,24 +44,18 @@ func prefixEqual(a, b netip.Prefix) bool { return a.Masked() == b.Masked() } -// AddSubnet adds a subnet rule with source and destination prefixes and optional port restrictions -// If portRanges is nil or empty, all ports are allowed for this subnet -// rewriteTo can be either an IP/CIDR (e.g., "192.168.1.1/32") or a domain name (e.g., "example.com") -func (sl *SubnetLookup) AddSubnet(sourcePrefix, destPrefix netip.Prefix, rewriteTo string, portRanges []PortRange, disableIcmp bool, resourceId int) { +// AddSubnet adds a subnet rule to the lookup table. +// If rule.PortRanges is nil or empty, all ports are allowed. +// rule.RewriteTo can be either an IP/CIDR (e.g., "192.168.1.1/32") or a domain name (e.g., "example.com"). +// HTTP proxy behaviour is driven by rule.Protocol, rule.HTTPTargets, rule.TLSCert, and rule.TLSKey. +func (sl *SubnetLookup) AddSubnet(rule SubnetRule) { sl.mu.Lock() defer sl.mu.Unlock() - rule := &SubnetRule{ - SourcePrefix: sourcePrefix, - DestPrefix: destPrefix, - DisableIcmp: disableIcmp, - RewriteTo: rewriteTo, - PortRanges: portRanges, - ResourceId: resourceId, - } + rulePtr := &rule // Canonicalize source prefix to handle host bits correctly - canonicalSourcePrefix := sourcePrefix.Masked() + canonicalSourcePrefix := rule.SourcePrefix.Masked() // Get or create destination trie for this source prefix destTriePtr, exists := sl.sourceTrie.Get(canonicalSourcePrefix) @@ -76,12 +70,12 @@ func (sl *SubnetLookup) AddSubnet(sourcePrefix, destPrefix netip.Prefix, rewrite // Canonicalize destination prefix to handle host bits correctly // BART masks prefixes internally, so we need to match that behavior in our bookkeeping - canonicalDestPrefix := destPrefix.Masked() + canonicalDestPrefix := rule.DestPrefix.Masked() // Add rule to destination trie // Original behavior: overwrite if same (sourcePrefix, destPrefix) exists // Store as single-element slice to match original overwrite behavior - destTriePtr.trie.Insert(canonicalDestPrefix, []*SubnetRule{rule}) + destTriePtr.trie.Insert(canonicalDestPrefix, []*SubnetRule{rulePtr}) // Update destTriePtr.rules - remove old rule with same canonical prefix if exists, then add new one // Use canonical comparison to handle cases like 10.0.0.5/24 vs 10.0.0.0/24 @@ -91,7 +85,7 @@ func (sl *SubnetLookup) AddSubnet(sourcePrefix, destPrefix netip.Prefix, rewrite newRules = append(newRules, r) } } - newRules = append(newRules, rule) + newRules = append(newRules, rulePtr) destTriePtr.rules = newRules } diff --git a/netstack2/tun.go b/netstack2/tun.go index e879d3b..5d2d6e1 100644 --- a/netstack2/tun.go +++ b/netstack2/tun.go @@ -59,7 +59,6 @@ type NetTunOptions struct { EnableTCPProxy bool EnableUDPProxy bool EnableICMPProxy bool - EnableHTTPProxy bool } // CreateNetTUN creates a new TUN device with netstack without proxying @@ -68,7 +67,6 @@ func CreateNetTUN(localAddresses, dnsServers []netip.Addr, mtu int) (tun.Device, EnableTCPProxy: true, EnableUDPProxy: true, EnableICMPProxy: true, - EnableHTTPProxy: true, }) } @@ -95,7 +93,6 @@ func CreateNetTUNWithOptions(localAddresses, dnsServers []netip.Addr, mtu int, o EnableTCP: options.EnableTCPProxy, EnableUDP: options.EnableUDPProxy, EnableICMP: options.EnableICMPProxy, - EnableHTTP: options.EnableHTTPProxy, MTU: mtu, }) if err != nil { @@ -354,13 +351,13 @@ func (net *Net) ListenUDP(laddr *net.UDPAddr) (*gonet.UDPConn, error) { return net.DialUDP(laddr, nil) } -// AddProxySubnetRule adds a subnet rule to the proxy handler -// If portRanges is nil or empty, all ports are allowed for this subnet -// rewriteTo can be either an IP/CIDR (e.g., "192.168.1.1/32") or a domain name (e.g., "example.com") -func (net *Net) AddProxySubnetRule(sourcePrefix, destPrefix netip.Prefix, rewriteTo string, portRanges []PortRange, disableIcmp bool, resourceId int) { +// AddProxySubnetRule adds a subnet rule to the proxy handler. +// HTTP proxy behaviour is configured via rule.Protocol, rule.HTTPTargets, +// rule.TLSCert, and rule.TLSKey; leave Protocol empty for raw TCP/UDP. +func (net *Net) AddProxySubnetRule(rule SubnetRule) { tun := (*netTun)(net) if tun.proxyHandler != nil { - tun.proxyHandler.AddSubnetRule(sourcePrefix, destPrefix, rewriteTo, portRanges, disableIcmp, resourceId) + tun.proxyHandler.AddSubnetRule(rule) } } From 90d486ca7f3bf54b7155c692652d9f3aac24c186 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 9 Apr 2026 16:13:19 -0400 Subject: [PATCH 063/161] Pass the new data down from the websocket Former-commit-id: 092535441ece30b3ffa30722e8079c58290bab51 --- clients/clients.go | 34 +++++++++++++++++++++++++++------- netstack2/http_handler.go | 8 ++++---- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/clients/clients.go b/clients/clients.go index d57ab70..e646053 100644 --- a/clients/clients.go +++ b/clients/clients.go @@ -40,13 +40,17 @@ type WgConfig struct { } type Target struct { - SourcePrefix string `json:"sourcePrefix"` - SourcePrefixes []string `json:"sourcePrefixes"` - DestPrefix string `json:"destPrefix"` - RewriteTo string `json:"rewriteTo,omitempty"` - DisableIcmp bool `json:"disableIcmp,omitempty"` - PortRange []PortRange `json:"portRange,omitempty"` - ResourceId int `json:"resourceId,omitempty"` + SourcePrefix string `json:"sourcePrefix"` + SourcePrefixes []string `json:"sourcePrefixes"` + DestPrefix string `json:"destPrefix"` + RewriteTo string `json:"rewriteTo,omitempty"` + DisableIcmp bool `json:"disableIcmp,omitempty"` + PortRange []PortRange `json:"portRange,omitempty"` + ResourceId int `json:"resourceId,omitempty"` + Protocol string `json:"protocol,omitempty"` // for now practicably either http or https + HTTPTargets []netstack2.HTTPTarget `json:"httpTargets,omitempty"` // for http protocol, list of downstream services to load balance across + TLSCert string `json:"tlsCert,omitempty"` // PEM-encoded certificate for incoming HTTPS termination + TLSKey string `json:"tlsKey,omitempty"` // PEM-encoded private key for incoming HTTPS termination } type PortRange struct { @@ -704,6 +708,10 @@ func (s *WireGuardService) syncTargets(desiredTargets []Target) error { PortRanges: portRanges, DisableIcmp: target.DisableIcmp, ResourceId: target.ResourceId, + Protocol: target.Protocol, + HTTPTargets: target.HTTPTargets, + TLSCert: target.TLSCert, + TLSKey: target.TLSKey, }) logger.Info("Added target %s -> %s during sync", target.SourcePrefix, target.DestPrefix) } @@ -969,6 +977,10 @@ func (s *WireGuardService) ensureTargets(targets []Target) error { PortRanges: portRanges, DisableIcmp: target.DisableIcmp, ResourceId: target.ResourceId, + Protocol: target.Protocol, + HTTPTargets: target.HTTPTargets, + TLSCert: target.TLSCert, + TLSKey: target.TLSKey, }) logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", sp, target.DestPrefix, target.RewriteTo, target.PortRange) } @@ -1369,6 +1381,10 @@ func (s *WireGuardService) handleAddTarget(msg websocket.WSMessage) { PortRanges: portRanges, DisableIcmp: target.DisableIcmp, ResourceId: target.ResourceId, + Protocol: target.Protocol, + HTTPTargets: target.HTTPTargets, + TLSCert: target.TLSCert, + TLSKey: target.TLSKey, }) logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", sp, target.DestPrefix, target.RewriteTo, target.PortRange) } @@ -1494,6 +1510,10 @@ func (s *WireGuardService) handleUpdateTarget(msg websocket.WSMessage) { PortRanges: portRanges, DisableIcmp: target.DisableIcmp, ResourceId: target.ResourceId, + Protocol: target.Protocol, + HTTPTargets: target.HTTPTargets, + TLSCert: target.TLSCert, + TLSKey: target.TLSKey, }) logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", sp, target.DestPrefix, target.RewriteTo, target.PortRange) } diff --git a/netstack2/http_handler.go b/netstack2/http_handler.go index 4efa6a1..c4d3a7c 100644 --- a/netstack2/http_handler.go +++ b/netstack2/http_handler.go @@ -26,9 +26,9 @@ import ( // HTTPTarget describes a single downstream HTTP or HTTPS service that the // proxy should forward requests to. type HTTPTarget struct { - DestAddr string // IP address or hostname of the downstream service - DestPort uint16 // TCP port of the downstream service - UseHTTPS bool // When true the outbound leg uses HTTPS + DestAddr string `json:"destAddr"` // IP address or hostname of the downstream service + DestPort uint16 `json:"destPort"` // TCP port of the downstream service + UseHTTPS bool `json:"useHttps"` // When true the outbound leg uses HTTPS } // --------------------------------------------------------------------------- @@ -315,4 +315,4 @@ func (h *HTTPHandler) handleRequest(w http.ResponseWriter, r *http.Request) { r.Method, r.URL.RequestURI(), scheme, target.DestAddr, target.DestPort) h.getProxy(target).ServeHTTP(w, r) -} \ No newline at end of file +} From f8d8eaab2ccc629426af39ce1b6caf7dc2f725de Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 9 Apr 2026 17:21:36 -0400 Subject: [PATCH 064/161] Switch to scheme Former-commit-id: 342af9e42df54547b339ab1b7615a2a60100e2d4 --- netstack2/http_handler.go | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/netstack2/http_handler.go b/netstack2/http_handler.go index c4d3a7c..c31a791 100644 --- a/netstack2/http_handler.go +++ b/netstack2/http_handler.go @@ -28,7 +28,7 @@ import ( type HTTPTarget struct { DestAddr string `json:"destAddr"` // IP address or hostname of the downstream service DestPort uint16 `json:"destPort"` // TCP port of the downstream service - UseHTTPS bool `json:"useHttps"` // When true the outbound leg uses HTTPS + Scheme string `json:"scheme"` // When true the outbound leg uses HTTPS } // --------------------------------------------------------------------------- @@ -257,10 +257,7 @@ func (h *HTTPHandler) getTLSConfig(rule *SubnetRule) (*tls.Config, error) { // creating one on first use. Reusing the proxy preserves its http.Transport // connection pool, avoiding repeated TCP/TLS handshakes to the downstream. func (h *HTTPHandler) getProxy(target HTTPTarget) *httputil.ReverseProxy { - scheme := "http" - if target.UseHTTPS { - scheme = "https" - } + scheme := target.Scheme cacheKey := fmt.Sprintf("%s://%s:%d", scheme, target.DestAddr, target.DestPort) if v, ok := h.proxyCache.Load(cacheKey); ok { @@ -273,7 +270,7 @@ func (h *HTTPHandler) getProxy(target HTTPTarget) *httputil.ReverseProxy { } proxy := httputil.NewSingleHostReverseProxy(targetURL) - if target.UseHTTPS { + if target.Scheme == "https" { // Allow self-signed certificates on downstream HTTPS targets. proxy.Transport = &http.Transport{ TLSClientConfig: &tls.Config{ @@ -307,10 +304,7 @@ func (h *HTTPHandler) handleRequest(w http.ResponseWriter, r *http.Request) { } target := rule.HTTPTargets[0] - scheme := "http" - if target.UseHTTPS { - scheme = "https" - } + scheme := target.Scheme logger.Info("HTTP handler: %s %s -> %s://%s:%d", r.Method, r.URL.RequestURI(), scheme, target.DestAddr, target.DestPort) From b3a23a719fbbe7168b7d3a126b255bb3a202830d Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 11 Apr 2026 21:56:28 -0700 Subject: [PATCH 065/161] Add logging Former-commit-id: 12776d65c172d09fc4ec19e70c8c12efb94584eb --- clients/clients.go | 7 ++ netstack2/http_handler.go | 45 ++++++++- netstack2/http_request_log.go | 175 ++++++++++++++++++++++++++++++++++ netstack2/proxy.go | 27 ++++++ netstack2/tun.go | 10 ++ 5 files changed, 261 insertions(+), 3 deletions(-) create mode 100644 netstack2/http_request_log.go diff --git a/clients/clients.go b/clients/clients.go index e646053..3862160 100644 --- a/clients/clients.go +++ b/clients/clients.go @@ -850,6 +850,13 @@ func (s *WireGuardService) ensureWireguardInterface(wgconfig WgConfig) error { }) }) + // Configure the HTTP request log sender to ship compressed request logs via websocket + s.tnet.SetHTTPRequestLogSender(func(data string) error { + return s.client.SendMessageNoLog("newt/request-log", map[string]interface{}{ + "compressed": data, + }) + }) + // Create WireGuard device using the shared bind s.device = device.NewDevice(s.tun, s.sharedBind, device.NewLogger( device.LogLevelSilent, // Use silent logging by default - could be made configurable diff --git a/netstack2/http_handler.go b/netstack2/http_handler.go index c31a791..8e04413 100644 --- a/netstack2/http_handler.go +++ b/netstack2/http_handler.go @@ -14,6 +14,7 @@ import ( "net/http/httputil" "net/url" "sync" + "time" "github.com/fosrl/newt/logger" "gvisor.dev/gvisor/pkg/tcpip/stack" @@ -50,8 +51,9 @@ type HTTPTarget struct { // Outbound connections to downstream targets honour HTTPTarget.UseHTTPS // independently of the incoming protocol. type HTTPHandler struct { - stack *stack.Stack - proxyHandler *ProxyHandler + stack *stack.Stack + proxyHandler *ProxyHandler + requestLogger *HTTPRequestLogger listener *chanListener server *http.Server @@ -152,6 +154,12 @@ func NewHTTPHandler(s *stack.Stack, ph *ProxyHandler) *HTTPHandler { } } +// SetRequestLogger attaches an HTTPRequestLogger so that every proxied request +// is recorded and periodically shipped to the server. +func (h *HTTPHandler) SetRequestLogger(rl *HTTPRequestLogger) { + h.requestLogger = rl +} + // Start launches the internal http.Server that services connections delivered // via HandleConn. The server runs for the lifetime of the HTTPHandler; call // Close to stop it. @@ -289,6 +297,19 @@ func (h *HTTPHandler) getProxy(target HTTPTarget) *httputil.ReverseProxy { return actual.(*httputil.ReverseProxy) } +// statusCapture wraps an http.ResponseWriter and records the HTTP status code +// written by the upstream handler. If WriteHeader is never called the status +// defaults to 200 (http.StatusOK), matching net/http semantics. +type statusCapture struct { + http.ResponseWriter + status int +} + +func (sc *statusCapture) WriteHeader(code int) { + sc.status = code + sc.ResponseWriter.WriteHeader(code) +} + // handleRequest is the http.Handler entry point. It retrieves the SubnetRule // attached to the connection by ConnContext, selects the first configured // downstream target, and forwards the request via the cached ReverseProxy. @@ -308,5 +329,23 @@ func (h *HTTPHandler) handleRequest(w http.ResponseWriter, r *http.Request) { logger.Info("HTTP handler: %s %s -> %s://%s:%d", r.Method, r.URL.RequestURI(), scheme, target.DestAddr, target.DestPort) - h.getProxy(target).ServeHTTP(w, r) + timestamp := time.Now() + sc := &statusCapture{ResponseWriter: w, status: http.StatusOK} + + h.getProxy(target).ServeHTTP(sc, r) + + if h.requestLogger != nil && rule.ResourceId != 0 { + h.requestLogger.LogRequest(HTTPRequestLog{ + ResourceID: rule.ResourceId, + Timestamp: timestamp, + Method: r.Method, + Scheme: rule.Protocol, + Host: r.Host, + Path: r.URL.Path, + RawQuery: r.URL.RawQuery, + UserAgent: r.UserAgent(), + SourceAddr: r.RemoteAddr, + TLS: rule.Protocol == "https", + }) + } } diff --git a/netstack2/http_request_log.go b/netstack2/http_request_log.go new file mode 100644 index 0000000..85ab5db --- /dev/null +++ b/netstack2/http_request_log.go @@ -0,0 +1,175 @@ +package netstack2 + +import ( + "bytes" + "compress/zlib" + "encoding/base64" + "encoding/json" + "sync" + "time" + + "github.com/fosrl/newt/logger" +) + +// HTTPRequestLog represents a single HTTP/HTTPS request proxied through the handler. +type HTTPRequestLog struct { + RequestID string `json:"requestId"` + ResourceID int `json:"resourceId"` + Timestamp time.Time `json:"timestamp"` + Method string `json:"method"` + Scheme string `json:"scheme"` + Host string `json:"host"` + Path string `json:"path"` + RawQuery string `json:"rawQuery,omitempty"` + UserAgent string `json:"userAgent,omitempty"` + SourceAddr string `json:"sourceAddr"` + TLS bool `json:"tls"` +} + +// HTTPRequestLogger buffers HTTP request logs and periodically flushes them +// to the server via a configurable SendFunc. +type HTTPRequestLogger struct { + mu sync.Mutex + pending []HTTPRequestLog + sendFn SendFunc + stopCh chan struct{} + flushDone chan struct{} +} + +// NewHTTPRequestLogger creates a new HTTPRequestLogger and starts its background flush loop. +func NewHTTPRequestLogger() *HTTPRequestLogger { + rl := &HTTPRequestLogger{ + pending: make([]HTTPRequestLog, 0), + stopCh: make(chan struct{}), + flushDone: make(chan struct{}), + } + go rl.backgroundLoop() + return rl +} + +// SetSendFunc sets the callback used to send compressed HTTP request log batches +// to the server. This can be called after construction once the websocket +// client is available. +func (rl *HTTPRequestLogger) SetSendFunc(fn SendFunc) { + rl.mu.Lock() + defer rl.mu.Unlock() + rl.sendFn = fn +} + +// LogRequest adds an HTTP request log entry to the buffer. If the buffer +// reaches maxBufferedSessions entries a flush is triggered immediately. +func (rl *HTTPRequestLogger) LogRequest(log HTTPRequestLog) { + if log.RequestID == "" { + log.RequestID = generateSessionID() + } + + rl.mu.Lock() + rl.pending = append(rl.pending, log) + shouldFlush := len(rl.pending) >= maxBufferedSessions + rl.mu.Unlock() + + if shouldFlush { + rl.flush() + } +} + +// backgroundLoop handles periodic flushing of buffered request logs. +func (rl *HTTPRequestLogger) backgroundLoop() { + defer close(rl.flushDone) + + ticker := time.NewTicker(flushInterval) + defer ticker.Stop() + + for { + select { + case <-rl.stopCh: + return + case <-ticker.C: + rl.flush() + } + } +} + +// flush drains the pending buffer, compresses with zlib, and sends via the SendFunc. +// On send failure the batch is re-queued, capped at maxBufferedSessions*5 entries +// to prevent unbounded memory growth when the server is unreachable. +func (rl *HTTPRequestLogger) flush() { + rl.mu.Lock() + if len(rl.pending) == 0 { + rl.mu.Unlock() + return + } + batch := rl.pending + rl.pending = make([]HTTPRequestLog, 0) + sendFn := rl.sendFn + rl.mu.Unlock() + + if sendFn == nil { + logger.Debug("HTTP request logger: no send function configured, discarding %d requests", len(batch)) + return + } + + compressed, err := compressRequestLogs(batch) + if err != nil { + logger.Error("HTTP request logger: failed to compress %d requests: %v", len(batch), err) + return + } + + if err := sendFn(compressed); err != nil { + logger.Error("HTTP request logger: failed to send %d requests: %v", len(batch), err) + // Re-queue the batch so we don't lose data + rl.mu.Lock() + rl.pending = append(batch, rl.pending...) + // Cap re-queued data to prevent unbounded growth if server is unreachable + if len(rl.pending) > maxBufferedSessions*5 { + dropped := len(rl.pending) - maxBufferedSessions*5 + rl.pending = rl.pending[:maxBufferedSessions*5] + logger.Warn("HTTP request logger: buffer overflow, dropped %d oldest requests", dropped) + } + rl.mu.Unlock() + return + } + + logger.Info("HTTP request logger: sent %d requests to server", len(batch)) +} + +// compressRequestLogs JSON-encodes the request logs, compresses with zlib, and +// returns a base64-encoded string suitable for embedding in a JSON message. +func compressRequestLogs(logs []HTTPRequestLog) (string, error) { + jsonData, err := json.Marshal(logs) + if err != nil { + return "", err + } + + var buf bytes.Buffer + w, err := zlib.NewWriterLevel(&buf, zlib.BestCompression) + if err != nil { + return "", err + } + if _, err := w.Write(jsonData); err != nil { + w.Close() + return "", err + } + if err := w.Close(); err != nil { + return "", err + } + + return base64.StdEncoding.EncodeToString(buf.Bytes()), nil +} + +// Close shuts down the background loop and performs one final flush to send +// any remaining buffered requests to the server. +func (rl *HTTPRequestLogger) Close() { + select { + case <-rl.stopCh: + // Already closed + return + default: + close(rl.stopCh) + } + + // Wait for the background loop to exit so we don't race on flush + <-rl.flushDone + + rl.flush() +} \ No newline at end of file diff --git a/netstack2/proxy.go b/netstack2/proxy.go index f4c2352..b08eea3 100644 --- a/netstack2/proxy.go +++ b/netstack2/proxy.go @@ -133,6 +133,7 @@ type ProxyHandler struct { icmpReplies chan []byte // Channel for ICMP reply packets to be sent back through the tunnel notifiable channel.Notification // Notification handler for triggering reads accessLogger *AccessLogger // Access logger for tracking sessions + httpRequestLogger *HTTPRequestLogger // HTTP request logger for proxied HTTP/HTTPS requests } // ProxyHandlerOptions configures the proxy handler @@ -187,6 +188,9 @@ func NewProxyHandler(options ProxyHandlerOptions) (*ProxyHandler, error) { if err := handler.httpHandler.Start(); err != nil { return nil, fmt.Errorf("failed to start HTTP handler: %v", err) } + + handler.httpRequestLogger = NewHTTPRequestLogger() + handler.httpHandler.SetRequestLogger(handler.httpRequestLogger) logger.Debug("ProxyHandler: HTTP handler enabled") } @@ -289,6 +293,24 @@ func (p *ProxyHandler) SetAccessLogSender(fn SendFunc) { p.accessLogger.SetSendFunc(fn) } +// GetHTTPRequestLogger returns the HTTP request logger. +func (p *ProxyHandler) GetHTTPRequestLogger() *HTTPRequestLogger { + if p == nil { + return nil + } + return p.httpRequestLogger +} + +// SetHTTPRequestLogSender configures the function used to send compressed HTTP +// request log batches to the server. This should be called once the websocket +// client is available. +func (p *ProxyHandler) SetHTTPRequestLogSender(fn SendFunc) { + if p == nil || !p.enabled || p.httpRequestLogger == nil { + return + } + p.httpRequestLogger.SetSendFunc(fn) +} + // LookupDestinationRewrite looks up the rewritten destination for a connection // This is used by TCP/UDP handlers to find the actual target address func (p *ProxyHandler) LookupDestinationRewrite(srcIP, dstIP string, dstPort uint16, proto uint8) (netip.Addr, bool) { @@ -810,6 +832,11 @@ func (p *ProxyHandler) Close() error { p.accessLogger.Close() } + // Shut down HTTP request logger + if p.httpRequestLogger != nil { + p.httpRequestLogger.Close() + } + // Shut down HTTP handler if p.httpHandler != nil { p.httpHandler.Close() diff --git a/netstack2/tun.go b/netstack2/tun.go index 5d2d6e1..fae90dd 100644 --- a/netstack2/tun.go +++ b/netstack2/tun.go @@ -394,6 +394,16 @@ func (net *Net) SetAccessLogSender(fn SendFunc) { } } +// SetHTTPRequestLogSender configures the function used to send compressed HTTP +// request log batches to the server. This should be called once the websocket +// client is available. +func (net *Net) SetHTTPRequestLogSender(fn SendFunc) { + tun := (*netTun)(net) + if tun.proxyHandler != nil { + tun.proxyHandler.SetHTTPRequestLogSender(fn) + } +} + type PingConn struct { laddr PingAddr raddr PingAddr From 4e12ef169dee7cee9c7ab74ff195feac3d52d22a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 10:36:35 +0000 Subject: [PATCH 066/161] chore(deps): bump softprops/action-gh-release from 2.6.1 to 3.0.0 Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.6.1 to 3.0.0. - [Release notes](https://github.com/softprops/action-gh-release/releases) - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/153bb8e04406b158c6c84fc1615b65b24149a1fe...b4309332981a82ec1c5618f44dd2e27cc8bfbfda) --- updated-dependencies: - dependency-name: softprops/action-gh-release dependency-version: 3.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Former-commit-id: 276dad89905da41e16ceaed281c4720764c78b5c --- .github/workflows/cicd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 2866455..69d5331 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -893,7 +893,7 @@ jobs: make -j 10 go-build-release VERSION="${TAG}" - name: Create GitHub Release (draft) - uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 with: tag_name: ${{ env.TAG }} generate_release_notes: true From 54a8390007f525dc580221d5c83a763d3b481353 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 10:36:39 +0000 Subject: [PATCH 067/161] chore(deps): bump aws-actions/configure-aws-credentials Bumps [aws-actions/configure-aws-credentials](https://github.com/aws-actions/configure-aws-credentials) from 6.0.0 to 6.1.0. - [Release notes](https://github.com/aws-actions/configure-aws-credentials/releases) - [Changelog](https://github.com/aws-actions/configure-aws-credentials/blob/main/CHANGELOG.md) - [Commits](https://github.com/aws-actions/configure-aws-credentials/compare/8df5847569e6427dd6c4fb1cf565c83acfa8afa7...ec61189d14ec14c8efccab744f656cffd0e33f37) --- updated-dependencies: - dependency-name: aws-actions/configure-aws-credentials dependency-version: 6.1.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Former-commit-id: 4205049c8185e1c6635448cb0d56036cc44a0377 --- .github/workflows/cicd.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 2866455..bd114c9 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -68,7 +68,7 @@ jobs: echo "image_created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" - name: Configure AWS credentials (OIDC) - uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6.0.0 + uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0 with: role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }} role-duration-seconds: 3600 @@ -920,7 +920,7 @@ jobs: permissions: write-all steps: - name: Configure AWS credentials (OIDC) - uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6.0.0 + uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0 with: role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }} role-duration-seconds: 3600 From e9ac43d12bec798025a43f03e3c76892832f7a6d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 10:36:44 +0000 Subject: [PATCH 068/161] chore(deps): bump docker/build-push-action from 7.0.0 to 7.1.0 Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 7.0.0 to 7.1.0. - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](https://github.com/docker/build-push-action/compare/d08e5c354a6adb9ed34480a06d141179aa583294...bcafcacb16a39f128d818304e6c9c0c18556b85f) --- updated-dependencies: - dependency-name: docker/build-push-action dependency-version: 7.1.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Former-commit-id: 5969119382e7e60b8eaa989eb7ec62f0c5582b1e --- .github/workflows/cicd.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 2866455..011f0aa 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -264,7 +264,7 @@ jobs: # Build ONLY amd64 and push arch-specific tag suffixes used later for manifest creation. - name: Build and push (amd64 -> *:amd64-TAG) id: build_amd - uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . push: true @@ -389,7 +389,7 @@ jobs: # Build ONLY arm64 and push arch-specific tag suffixes used later for manifest creation. - name: Build and push (arm64 -> *:arm64-TAG) id: build_arm - uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . push: true @@ -506,7 +506,7 @@ jobs: - name: Build and push (arm/v7 -> *:armv7-TAG) id: build_armv7 - uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . push: true From 0a043f2205276efddaf7f6701945aa772fec5e43 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 10:36:49 +0000 Subject: [PATCH 069/161] chore(deps): bump actions/setup-go from 6.2.0 to 6.4.0 Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6.2.0 to 6.4.0. - [Release notes](https://github.com/actions/setup-go/releases) - [Commits](https://github.com/actions/setup-go/compare/7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5...4a3601121dd01d1626a1e23e37211e3254c1c06c) --- updated-dependencies: - dependency-name: actions/setup-go dependency-version: 6.4.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Former-commit-id: 89b024b01043abcaefee4ce3522d86ef5bed07a5 --- .github/workflows/cicd.yml | 2 +- .github/workflows/test.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 2866455..54c369e 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -651,7 +651,7 @@ jobs: echo "Checked out $(git rev-parse --short HEAD) for tag ${TAG}" - name: Install Go - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: go.mod diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b1b232b..ea50179 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,7 +31,7 @@ jobs: uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Set up Go - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version: 1.25 From be17fc554f705ead161767523cb8ac01b430e6a8 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 13 Apr 2026 17:00:06 -0700 Subject: [PATCH 070/161] Add ldflags version to local Former-commit-id: 5c9d13bccae79b163ce1b3f8543cec688c8137c2 --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index c35bbbf..53c4bb2 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ VERSION ?= dev LDFLAGS = -X main.newtVersion=$(VERSION) local: - CGO_ENABLED=0 go build -o ./bin/newt + CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o ./bin/newt docker-build: docker build -t fosrl/newt:latest . From e9d2aed39addcfbf805e183032397d23e8b1ec83 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 14 Apr 2026 14:22:48 -0700 Subject: [PATCH 071/161] Update version Former-commit-id: 50be4f617eee7d7c72b8851e826a8083accd5d7f --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 5b2352a..bd760ea 100644 --- a/flake.nix +++ b/flake.nix @@ -25,7 +25,7 @@ inherit (pkgs) lib; # Update version when releasing - version = "1.8.0"; + version = "1.11.0"; in { default = self.packages.${system}.pangolin-newt; From 6ef16b8c3a1f7c498ce48285d02514951604d7f6 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 14 Apr 2026 14:22:52 -0700 Subject: [PATCH 072/161] Update nix version in cicd Former-commit-id: d133d69cb9e6c69794b758b0b4868f16fd10ca47 --- .github/workflows/cicd.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 2866455..d8c516a 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -110,6 +110,15 @@ jobs: exit 1 fi + - name: Update version in flake.nix + shell: bash + env: + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + sed -i "s/version = \"[0-9]*\.[0-9]*\.[0-9]*\(-rc\.[0-9]*\)\?\"/version = \"$VERSION\"/" flake.nix + echo "Updated flake.nix version to $VERSION" + - name: Create and push tag shell: bash env: @@ -127,6 +136,11 @@ jobs: echo "Tag $VERSION already exists" >&2 exit 1 fi + if ! git diff --quiet flake.nix; then + git add flake.nix + git commit -m "chore(nix): update version to $VERSION" + git push origin "$TARGET_BRANCH" + fi git tag -a "$VERSION" -m "Release $VERSION" git push origin "refs/tags/$VERSION" From b81c252586f3f1ee97baf20754ea5d7249383534 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 15 Apr 2026 21:01:04 -0700 Subject: [PATCH 073/161] Updating with new methods Former-commit-id: 9bb8eaeadbe4667a9d18309f7bb1245ad64dcd26 --- healthcheck/healthcheck.go | 182 ++++++++++++++++++++++++------------- 1 file changed, 121 insertions(+), 61 deletions(-) diff --git a/healthcheck/healthcheck.go b/healthcheck/healthcheck.go index f618803..0d1fa60 100644 --- a/healthcheck/healthcheck.go +++ b/healthcheck/healthcheck.go @@ -37,33 +37,39 @@ func (s Health) String() string { // Config holds the health check configuration for a target type Config struct { - ID int `json:"id"` - Enabled bool `json:"hcEnabled"` - Path string `json:"hcPath"` - Scheme string `json:"hcScheme"` - Mode string `json:"hcMode"` - Hostname string `json:"hcHostname"` - Port int `json:"hcPort"` - Interval int `json:"hcInterval"` // in seconds - UnhealthyInterval int `json:"hcUnhealthyInterval"` // in seconds - Timeout int `json:"hcTimeout"` // in seconds - Headers map[string]string `json:"hcHeaders"` - Method string `json:"hcMethod"` - Status int `json:"hcStatus"` // HTTP status code - TLSServerName string `json:"hcTlsServerName"` + ID int `json:"id"` + HcID int `json:"hcId"` // the id of the health check table not the target + Enabled bool `json:"hcEnabled"` + Path string `json:"hcPath"` + Scheme string `json:"hcScheme"` + Mode string `json:"hcMode"` + Hostname string `json:"hcHostname"` + Port int `json:"hcPort"` + Interval int `json:"hcInterval"` // in seconds + UnhealthyInterval int `json:"hcUnhealthyInterval"` // in seconds + Timeout int `json:"hcTimeout"` // in seconds + FollowRedirects bool `json:"hcFollowRedirects"` + Headers map[string]string `json:"hcHeaders"` + Method string `json:"hcMethod"` + Status int `json:"hcStatus"` // HTTP status code + TLSServerName string `json:"hcTlsServerName"` + HealthyThreshold int `json:"hcHealthyThreshold"` // consecutive successes required to become healthy + UnhealthyThreshold int `json:"hcUnhealthyThreshold"` // consecutive failures required to become unhealthy } // Target represents a health check target with its current status type Target struct { - Config Config `json:"config"` - Status Health `json:"status"` - LastCheck time.Time `json:"lastCheck"` - LastError string `json:"lastError,omitempty"` - CheckCount int `json:"checkCount"` - timer *time.Timer - ctx context.Context - cancel context.CancelFunc - client *http.Client + Config Config `json:"config"` + Status Health `json:"status"` + LastCheck time.Time `json:"lastCheck"` + LastError string `json:"lastError,omitempty"` + CheckCount int `json:"checkCount"` + timer *time.Timer + ctx context.Context + cancel context.CancelFunc + client *http.Client + consecutiveSuccesses int + consecutiveFailures int } // StatusChangeCallback is called when any target's status changes @@ -165,9 +171,16 @@ func (m *Monitor) addTargetUnsafe(config Config) error { if config.Timeout == 0 { config.Timeout = 5 } + if config.HealthyThreshold == 0 { + config.HealthyThreshold = 1 + } + if config.UnhealthyThreshold == 0 { + config.UnhealthyThreshold = 1 + } - logger.Debug("Target %d configuration: scheme=%s, method=%s, interval=%ds, timeout=%ds", - config.ID, config.Scheme, config.Method, config.Interval, config.Timeout) + logger.Debug("Target %d configuration: mode=%s, scheme=%s, method=%s, interval=%ds, timeout=%ds, healthyThreshold=%d, unhealthyThreshold=%d", + config.ID, config.Mode, config.Scheme, config.Method, config.Interval, config.Timeout, + config.HealthyThreshold, config.UnhealthyThreshold) // Parse headers if provided as string if len(config.Headers) == 0 && config.Path != "" { @@ -361,12 +374,69 @@ func (m *Monitor) monitorTarget(target *Target) { } } -// performHealthCheck performs a health check on a target +// performHealthCheck performs a health check on a target and applies threshold logic func (m *Monitor) performHealthCheck(target *Target) { target.CheckCount++ target.LastCheck = time.Now() - target.LastError = "" + var passed bool + var checkErr string + + switch strings.ToLower(target.Config.Mode) { + case "tcp": + passed, checkErr = m.performTCPCheck(target) + default: + // "http", "https", or anything else falls through to HTTP + passed, checkErr = m.performHTTPCheck(target) + } + + if passed { + target.consecutiveFailures = 0 + target.consecutiveSuccesses++ + + logger.Debug("Target %d: check passed (consecutive successes: %d / threshold: %d)", + target.Config.ID, target.consecutiveSuccesses, target.Config.HealthyThreshold) + + if target.consecutiveSuccesses >= target.Config.HealthyThreshold { + target.Status = StatusHealthy + target.LastError = "" + } + } else { + target.consecutiveSuccesses = 0 + target.consecutiveFailures++ + target.LastError = checkErr + + logger.Debug("Target %d: check failed (consecutive failures: %d / threshold: %d): %s", + target.Config.ID, target.consecutiveFailures, target.Config.UnhealthyThreshold, checkErr) + + if target.consecutiveFailures >= target.Config.UnhealthyThreshold { + target.Status = StatusUnhealthy + } + } +} + +// performTCPCheck dials the target's host:port over TCP and returns whether it succeeded +func (m *Monitor) performTCPCheck(target *Target) (bool, string) { + address := net.JoinHostPort(target.Config.Hostname, strconv.Itoa(target.Config.Port)) + timeout := time.Duration(target.Config.Timeout) * time.Second + + logger.Debug("Target %d: performing TCP health check to %s (timeout: %v)", + target.Config.ID, address, timeout) + + conn, err := net.DialTimeout("tcp", address, timeout) + if err != nil { + msg := fmt.Sprintf("TCP dial failed: %v", err) + logger.Warn("Target %d: %s", target.Config.ID, msg) + return false, msg + } + conn.Close() + + logger.Debug("Target %d: TCP health check passed", target.Config.ID) + return true, "" +} + +// performHTTPCheck performs an HTTP/HTTPS health check and returns whether it succeeded +func (m *Monitor) performHTTPCheck(target *Target) (bool, string) { // Build URL (use net.JoinHostPort to properly handle IPv6 addresses with ports) host := target.Config.Hostname if target.Config.Port > 0 { @@ -380,7 +450,7 @@ func (m *Monitor) performHealthCheck(target *Target) { url += target.Config.Path } - logger.Debug("Target %d: performing health check %d to %s", + logger.Debug("Target %d: performing HTTP health check %d to %s", target.Config.ID, target.CheckCount, url) if target.Config.Scheme == "https" { @@ -388,16 +458,15 @@ func (m *Monitor) performHealthCheck(target *Target) { target.Config.ID, m.enforceCert) } - // Create request + // Create request with timeout context ctx, cancel := context.WithTimeout(context.Background(), time.Duration(target.Config.Timeout)*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, target.Config.Method, url, nil) if err != nil { - target.Status = StatusUnhealthy - target.LastError = fmt.Sprintf("failed to create request: %v", err) - logger.Warn("Target %d: failed to create request: %v", target.Config.ID, err) - return + msg := fmt.Sprintf("failed to create request: %v", err) + logger.Warn("Target %d: %s", target.Config.ID, msg) + return false, msg } // Add headers @@ -413,43 +482,34 @@ func (m *Monitor) performHealthCheck(target *Target) { // Perform request resp, err := target.client.Do(req) if err != nil { - target.Status = StatusUnhealthy - target.LastError = fmt.Sprintf("request failed: %v", err) + msg := fmt.Sprintf("request failed: %v", err) logger.Warn("Target %d: health check failed: %v", target.Config.ID, err) - return + return false, msg } defer resp.Body.Close() // Check response status - var expectedStatus int if target.Config.Status > 0 { - expectedStatus = target.Config.Status - } else { - expectedStatus = 0 // Use range check for 200-299 + // Check for specific status code + logger.Debug("Target %d: checking status against expected code %d", target.Config.ID, target.Config.Status) + if resp.StatusCode == target.Config.Status { + logger.Debug("Target %d: health check passed (status: %d)", target.Config.ID, resp.StatusCode) + return true, "" + } + msg := fmt.Sprintf("unexpected status code: %d (expected: %d)", resp.StatusCode, target.Config.Status) + logger.Warn("Target %d: %s", target.Config.ID, msg) + return false, msg } - if expectedStatus > 0 { - logger.Debug("Target %d: checking health status against expected code %d", target.Config.ID, expectedStatus) - // Check for specific status code - if resp.StatusCode == expectedStatus { - target.Status = StatusHealthy - logger.Debug("Target %d: health check passed (status: %d, expected: %d)", target.Config.ID, resp.StatusCode, expectedStatus) - } else { - target.Status = StatusUnhealthy - target.LastError = fmt.Sprintf("unexpected status code: %d (expected: %d)", resp.StatusCode, expectedStatus) - logger.Warn("Target %d: health check failed with status code %d (expected: %d)", target.Config.ID, resp.StatusCode, expectedStatus) - } - } else { - // Check for 2xx range - if resp.StatusCode >= 200 && resp.StatusCode < 300 { - target.Status = StatusHealthy - logger.Debug("Target %d: health check passed (status: %d)", target.Config.ID, resp.StatusCode) - } else { - target.Status = StatusUnhealthy - target.LastError = fmt.Sprintf("unhealthy status code: %d", resp.StatusCode) - logger.Warn("Target %d: health check failed with status code %d", target.Config.ID, resp.StatusCode) - } + // Default: check for 2xx range + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + logger.Debug("Target %d: health check passed (status: %d)", target.Config.ID, resp.StatusCode) + return true, "" } + + msg := fmt.Sprintf("unhealthy status code: %d", resp.StatusCode) + logger.Warn("Target %d: health check failed with status code %d", target.Config.ID, resp.StatusCode) + return false, msg } // Stop stops monitoring all targets From 9725c266be9005fedcefd3826686678bdde15e48 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 15 Apr 2026 21:36:40 -0700 Subject: [PATCH 074/161] Use follow redirects bool Former-commit-id: e8961c5de5ec0837292a35bb45db0c636e3c5cb4 --- healthcheck/healthcheck.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/healthcheck/healthcheck.go b/healthcheck/healthcheck.go index 0d1fa60..be0c7ff 100644 --- a/healthcheck/healthcheck.go +++ b/healthcheck/healthcheck.go @@ -202,6 +202,14 @@ func (m *Monitor) addTargetUnsafe(config Config) error { ctx: ctx, cancel: cancel, client: &http.Client{ + CheckRedirect: func() func(*http.Request, []*http.Request) error { + if !config.FollowRedirects { + return func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + } + return nil + }(), Transport: &http.Transport{ TLSClientConfig: &tls.Config{ // Configure TLS settings based on certificate enforcement From 8a63f5cd12aef51fb05f25614575c63935afc226 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 16 Apr 2026 21:47:48 -0700 Subject: [PATCH 075/161] Find old bins and support freebsd Former-commit-id: 0f927a37abf3fbd7266741412fb983dbec77ff99 --- get-newt.sh | 206 +++++++++++++++++++++++++++++++++++----------------- 1 file changed, 139 insertions(+), 67 deletions(-) diff --git a/get-newt.sh b/get-newt.sh index d4ddd3f..77df9ed 100644 --- a/get-newt.sh +++ b/get-newt.sh @@ -30,41 +30,38 @@ print_error() { # Function to get latest version from GitHub API get_latest_version() { - local latest_info - + latest_info="" + if command -v curl >/dev/null 2>&1; then latest_info=$(curl -fsSL "$GITHUB_API_URL" 2>/dev/null) elif command -v wget >/dev/null 2>&1; then latest_info=$(wget -qO- "$GITHUB_API_URL" 2>/dev/null) else - print_error "Neither curl nor wget is available. Please install one of them." >&2 + print_error "Neither curl nor wget is available." exit 1 fi - + if [ -z "$latest_info" ]; then - print_error "Failed to fetch latest version information" >&2 + print_error "Failed to fetch latest version info" exit 1 fi - - # Extract version from JSON response (works without jq) - local version=$(echo "$latest_info" | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\([^"]*\)".*/\1/') - + + version=$(printf '%s' "$latest_info" | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\([^"]*\)".*/\1/') + if [ -z "$version" ]; then - print_error "Could not parse version from GitHub API response" >&2 + print_error "Could not parse version from GitHub API response" exit 1 fi - - # Remove 'v' prefix if present - version=$(echo "$version" | sed 's/^v//') - - echo "$version" + + version=$(printf '%s' "$version" | sed 's/^v//') + printf '%s' "$version" } # Detect OS and architecture detect_platform() { - local os arch - - # Detect OS + os="" + arch="" + case "$(uname -s)" in Linux*) os="linux" ;; Darwin*) os="darwin" ;; @@ -75,12 +72,11 @@ detect_platform() { exit 1 ;; esac - - # Detect architecture + case "$(uname -m)" in x86_64|amd64) arch="amd64" ;; arm64|aarch64) arch="arm64" ;; - armv7l|armv6l) + armv7l|armv6l) if [ "$os" = "linux" ]; then if [ "$(uname -m)" = "armv6l" ]; then arch="arm32v6" @@ -88,10 +84,10 @@ detect_platform() { arch="arm32" fi else - arch="arm64" # Default for non-Linux ARM + arch="arm64" fi ;; - riscv64) + riscv64) if [ "$os" = "linux" ]; then arch="riscv64" else @@ -104,23 +100,68 @@ detect_platform() { exit 1 ;; esac - - echo "${os}_${arch}" + + printf '%s_%s' "$os" "$arch" } -# Get installation directory +# Determine installation directory (default fallback) get_install_dir() { - if [ "$OS" = "windows" ]; then - echo "$HOME/bin" - else - # Prefer /usr/local/bin for system-wide installation - echo "/usr/local/bin" + case "$PLATFORM" in + *windows*) + echo "$HOME/bin" + ;; + *) + echo "/usr/local/bin" + ;; + esac +} + +# Parse --path argument from args +# Returns the value after --path, or empty string if not provided +parse_path_arg() { + while [ $# -gt 0 ]; do + case "$1" in + --path) + if [ -n "$2" ]; then + printf '%s' "$2" + return + fi + ;; + --path=*) + printf '%s' "${1#--path=}" + return + ;; + esac + shift + done +} + +# Detect an existing newt binary location. +# Tries unprivileged which first, then sudo which (for binaries only visible to root). +# Returns the full path of the binary, or empty string if not found. +detect_existing_binary() { + existing="" + + # Try unprivileged which first + existing=$(command -v newt 2>/dev/null || true) + if [ -n "$existing" ]; then + printf '%s' "$existing" + return + fi + + # Try sudo which — some installations land in paths only root can see in $PATH + if command -v sudo >/dev/null 2>&1; then + existing=$(sudo which newt 2>/dev/null || true) + if [ -n "$existing" ]; then + printf '%s' "$existing" + return + fi fi } # Check if we need sudo for installation needs_sudo() { - local install_dir="$1" + install_dir="$1" if [ -w "$install_dir" ] 2>/dev/null; then return 1 # No sudo needed else @@ -130,7 +171,7 @@ needs_sudo() { # Get the appropriate command prefix (sudo or empty) get_sudo_cmd() { - local install_dir="$1" + install_dir="$1" if needs_sudo "$install_dir"; then if command -v sudo >/dev/null 2>&1; then echo "sudo" @@ -146,40 +187,46 @@ get_sudo_cmd() { # Download and install newt install_newt() { - local platform="$1" - local install_dir="$2" - local sudo_cmd="$3" - local binary_name="newt_${platform}" - local exe_suffix="" + platform="$1" + install_dir="$2" + sudo_cmd="$3" + custom_path="$4" + binary_name="newt_${platform}" + final_name="newt" - # Add .exe suffix for Windows case "$platform" in *windows*) binary_name="${binary_name}.exe" - exe_suffix=".exe" + final_name="newt.exe" ;; esac - local download_url="${BASE_URL}/${binary_name}" - local temp_file="/tmp/newt${exe_suffix}" - local final_path="${install_dir}/newt${exe_suffix}" + download_url="${BASE_URL}/${binary_name}" + temp_file="/tmp/${final_name}" + + # If a custom path is provided, use it directly; otherwise use install_dir/final_name + if [ -n "$custom_path" ]; then + final_path="$custom_path" + install_dir=$(dirname "$final_path") + else + final_path="${install_dir}/${final_name}" + fi print_status "Downloading newt from ${download_url}" - # Download the binary if command -v curl >/dev/null 2>&1; then curl -fsSL "$download_url" -o "$temp_file" elif command -v wget >/dev/null 2>&1; then wget -q "$download_url" -O "$temp_file" else - print_error "Neither curl nor wget is available. Please install one of them." + print_error "Neither curl nor wget is available." exit 1 fi # Make executable before moving chmod +x "$temp_file" - # Create install directory if it doesn't exist + # Create install directory if it doesn't exist and move binary if [ -n "$sudo_cmd" ]; then $sudo_cmd mkdir -p "$install_dir" print_status "Using sudo to install to ${install_dir}" @@ -194,25 +241,25 @@ install_newt() { # Check if install directory is in PATH if ! echo "$PATH" | grep -q "$install_dir"; then print_warning "Install directory ${install_dir} is not in your PATH." - print_warning "Add it to your PATH by adding this line to your shell profile:" + print_warning "Add it with:" print_warning " export PATH=\"${install_dir}:\$PATH\"" fi } # Verify installation verify_installation() { - local install_dir="$1" - local exe_suffix="" - + install_dir="$1" + exe_suffix="" + case "$PLATFORM" in *windows*) exe_suffix=".exe" ;; esac - - local newt_path="${install_dir}/newt${exe_suffix}" - - if [ -f "$newt_path" ] && [ -x "$newt_path" ]; then + + newt_path="${install_dir}/newt${exe_suffix}" + + if [ -x "$newt_path" ]; then print_status "Installation successful!" - print_status "newt version: $("$newt_path" --version 2>/dev/null || echo "unknown")" + print_status "newt version: $("$newt_path" --version 2>/dev/null || printf 'unknown')" return 0 else print_error "Installation failed. Binary not found or not executable." @@ -222,22 +269,40 @@ verify_installation() { # Main installation process main() { - print_status "Installing latest version of newt..." + # --path explicitly overrides everything + CUSTOM_PATH=$(parse_path_arg "$@") - # Get latest version - print_status "Fetching latest version from GitHub..." + if [ -n "$CUSTOM_PATH" ]; then + print_status "Installing latest version of newt to ${CUSTOM_PATH} (--path override)..." + else + print_status "Installing latest version of newt..." + fi + + print_status "Fetching latest version..." VERSION=$(get_latest_version) print_status "Latest version: v${VERSION}" - # Set base URL with the fetched version BASE_URL="https://github.com/${REPO}/releases/download/${VERSION}" - # Detect platform PLATFORM=$(detect_platform) print_status "Detected platform: ${PLATFORM}" - # Get install directory - INSTALL_DIR=$(get_install_dir) + if [ -n "$CUSTOM_PATH" ]; then + # --path wins; derive INSTALL_DIR from it + INSTALL_DIR=$(dirname "$CUSTOM_PATH") + else + # Try to find an existing installation so we update the right place + EXISTING_BINARY=$(detect_existing_binary) + if [ -n "$EXISTING_BINARY" ]; then + print_status "Found existing newt binary at ${EXISTING_BINARY}" + CUSTOM_PATH="$EXISTING_BINARY" + INSTALL_DIR=$(dirname "$EXISTING_BINARY") + print_status "Will update existing installation at ${INSTALL_DIR}" + else + INSTALL_DIR=$(get_install_dir) + fi + fi + print_status "Install directory: ${INSTALL_DIR}" # Check if we need sudo @@ -246,13 +311,20 @@ main() { print_status "Root privileges required for installation to ${INSTALL_DIR}" fi - # Install newt - install_newt "$PLATFORM" "$INSTALL_DIR" "$SUDO_CMD" + install_newt "$PLATFORM" "$INSTALL_DIR" "$SUDO_CMD" "$CUSTOM_PATH" - # Verify installation - if verify_installation "$INSTALL_DIR"; then + if [ -n "$CUSTOM_PATH" ]; then + if [ -x "$CUSTOM_PATH" ]; then + print_status "Installation successful!" + print_status "newt version: $("$CUSTOM_PATH" --version 2>/dev/null || printf 'unknown')" + print_status "newt is ready to use!" + else + print_error "Installation failed. Binary not found or not executable at ${CUSTOM_PATH}." + exit 1 + fi + elif verify_installation "$INSTALL_DIR"; then print_status "newt is ready to use!" - print_status "Run 'newt --help' to get started" + print_status "Run 'newt --help' to get started." else exit 1 fi From 3aae8264a8fda28c1e181c34916d75b3fd6a4e8b Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 20 Apr 2026 15:04:59 -0700 Subject: [PATCH 076/161] Add x-forwarded-for Former-commit-id: 26de268466549b6fa96286926afedc30a60858c8 --- netstack2/http_handler.go | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/netstack2/http_handler.go b/netstack2/http_handler.go index 8e04413..4ff7ed9 100644 --- a/netstack2/http_handler.go +++ b/netstack2/http_handler.go @@ -276,17 +276,29 @@ func (h *HTTPHandler) getProxy(target HTTPTarget) *httputil.ReverseProxy { Scheme: scheme, Host: fmt.Sprintf("%s:%d", target.DestAddr, target.DestPort), } - proxy := httputil.NewSingleHostReverseProxy(targetURL) - + insecureTransport := (*http.Transport)(nil) if target.Scheme == "https" { // Allow self-signed certificates on downstream HTTPS targets. - proxy.Transport = &http.Transport{ + insecureTransport = &http.Transport{ TLSClientConfig: &tls.Config{ InsecureSkipVerify: true, //nolint:gosec // downstream self-signed certs are a supported configuration }, } } + proxy := &httputil.ReverseProxy{ + Rewrite: func(pr *httputil.ProxyRequest) { + pr.SetURL(targetURL) + // SetXForwarded sets X-Forwarded-For from the inbound request's + // RemoteAddr (the WireGuard/netstack client address), along with + // X-Forwarded-Host and X-Forwarded-Proto. Using Rewrite instead of + // Director means the proxy does not append its own automatic + // X-Forwarded-For entry, so the header is set exactly once. + pr.SetXForwarded() + }, + Transport: insecureTransport, + } + proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { logger.Error("HTTP handler: upstream error (%s %s -> %s): %v", r.Method, r.URL.RequestURI(), cacheKey, err) From b1db040bb90920543339d0a17178cefc2f294147 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 20 Apr 2026 15:05:07 -0700 Subject: [PATCH 077/161] Fix nil pointer Former-commit-id: 309f9caad237e88ac1823c474546381024c3022d --- websocket/client.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/websocket/client.go b/websocket/client.go index 6990bd2..67e23ec 100644 --- a/websocket/client.go +++ b/websocket/client.go @@ -707,6 +707,10 @@ func (c *Client) sendPing() { } c.writeMux.Lock() + if c.conn == nil { + c.writeMux.Unlock() + return + } err := c.conn.WriteJSON(pingMsg) if err == nil { telemetry.IncWSMessage(c.metricsContext(), "out", "ping") @@ -859,10 +863,12 @@ func (c *Client) readPumpWithDisconnectDetection(started time.Time) { func (c *Client) reconnect() { c.setConnected(false) telemetry.SetWSConnectionState(false) + c.writeMux.Lock() if c.conn != nil { c.conn.Close() c.conn = nil } + c.writeMux.Unlock() // Only reconnect if we're not shutting down select { From 7c3e0de5c9f4c59c8a7f81740069940cb11a89d4 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 20 Apr 2026 21:52:21 -0700 Subject: [PATCH 078/161] Remove hc id Former-commit-id: 8bfb4659c05f78af4fd82e58f7b470b4cac1c4ce --- healthcheck/healthcheck.go | 1 - 1 file changed, 1 deletion(-) diff --git a/healthcheck/healthcheck.go b/healthcheck/healthcheck.go index be0c7ff..a7f0b6a 100644 --- a/healthcheck/healthcheck.go +++ b/healthcheck/healthcheck.go @@ -38,7 +38,6 @@ func (s Health) String() string { // Config holds the health check configuration for a target type Config struct { ID int `json:"id"` - HcID int `json:"hcId"` // the id of the health check table not the target Enabled bool `json:"hcEnabled"` Path string `json:"hcPath"` Scheme string `json:"hcScheme"` From 015fe4b8db85b4b055ae12cfad11a98a86382650 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 22 Apr 2026 11:40:12 -0700 Subject: [PATCH 079/161] Revert nix version in cicd Former-commit-id: efd6743ce47059e3e1625f0a7727be53991de217 --- .github/workflows/cicd.yml | 9 --------- 1 file changed, 9 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index d8c516a..65c1756 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -110,15 +110,6 @@ jobs: exit 1 fi - - name: Update version in flake.nix - shell: bash - env: - VERSION: ${{ inputs.version }} - run: | - set -euo pipefail - sed -i "s/version = \"[0-9]*\.[0-9]*\.[0-9]*\(-rc\.[0-9]*\)\?\"/version = \"$VERSION\"/" flake.nix - echo "Updated flake.nix version to $VERSION" - - name: Create and push tag shell: bash env: From 232b3936de38e34197017b49557e97ac3bfaa72b Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 22 Apr 2026 20:12:51 -0700 Subject: [PATCH 080/161] Try to add redirect Former-commit-id: 294f99e0248ca3e62f3553d736a739a3328d7b03 --- netstack2/handlers.go | 16 ++++++++++++---- netstack2/http_handler.go | 13 +++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/netstack2/handlers.go b/netstack2/handlers.go index dabfee9..a63178c 100644 --- a/netstack2/handlers.go +++ b/netstack2/handlers.go @@ -152,10 +152,18 @@ func (h *TCPHandler) handleTCPConn(netstackConn *gonet.TCPConn, id stack.Transpo srcAddr, _ := netip.ParseAddr(srcIP) dstAddr, _ := netip.ParseAddr(dstIP) rule := h.proxyHandler.subnetLookup.Match(srcAddr, dstAddr, dstPort, tcp.ProtocolNumber) - if rule != nil && rule.Protocol != "" { - logger.Info("TCP Forwarder: Routing %s:%d -> %s:%d to HTTP handler (%s)", - srcIP, srcPort, dstIP, dstPort, rule.Protocol) - h.proxyHandler.httpHandler.HandleConn(netstackConn, rule) + if rule != nil { + if rule.Protocol != "" { + logger.Info("TCP Forwarder: Routing %s:%d -> %s:%d to HTTP handler (%s)", + srcIP, srcPort, dstIP, dstPort, rule.Protocol) + h.proxyHandler.httpHandler.HandleConn(netstackConn, rule) + } else { + // A matching HTTP rule exists but has no protocol configured — + // do not fall through to the raw TCP handler; drop the connection. + logger.Info("TCP Forwarder: Dropping %s:%d -> %s:%d (HTTP rule matched but no protocol set)", + srcIP, srcPort, dstIP, dstPort) + netstackConn.Close() + } return } } diff --git a/netstack2/http_handler.go b/netstack2/http_handler.go index 4ff7ed9..9d5efd8 100644 --- a/netstack2/http_handler.go +++ b/netstack2/http_handler.go @@ -336,6 +336,19 @@ func (h *HTTPHandler) handleRequest(w http.ResponseWriter, r *http.Request) { return } + // If the rule is plain HTTP but has a TLS certificate configured, redirect + // the client to the HTTPS equivalent of the requested URL. + if rule.Protocol == "http" && rule.TLSCert != "" && rule.TLSKey != "" { + host := r.Host + if host == "" { + host = r.URL.Host + } + httpsURL := "https://" + host + r.RequestURI + logger.Info("HTTP handler: redirecting %s %s -> %s (TLS cert present)", r.Method, r.URL.RequestURI(), httpsURL) + http.Redirect(w, r, httpsURL, http.StatusMovedPermanently) + return + } + target := rule.HTTPTargets[0] scheme := target.Scheme logger.Info("HTTP handler: %s %s -> %s://%s:%d", From d28b9bbca3064b9654021c1d5645a933a50f1d35 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 22 Apr 2026 21:36:16 -0700 Subject: [PATCH 081/161] Fix transport issue Former-commit-id: bfd61ca51169f2ad694fd082ee1c1ab070dde16f --- netstack2/http_handler.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/netstack2/http_handler.go b/netstack2/http_handler.go index 9d5efd8..df4e686 100644 --- a/netstack2/http_handler.go +++ b/netstack2/http_handler.go @@ -276,10 +276,10 @@ func (h *HTTPHandler) getProxy(target HTTPTarget) *httputil.ReverseProxy { Scheme: scheme, Host: fmt.Sprintf("%s:%d", target.DestAddr, target.DestPort), } - insecureTransport := (*http.Transport)(nil) + var transport http.RoundTripper = http.DefaultTransport if target.Scheme == "https" { // Allow self-signed certificates on downstream HTTPS targets. - insecureTransport = &http.Transport{ + transport = &http.Transport{ TLSClientConfig: &tls.Config{ InsecureSkipVerify: true, //nolint:gosec // downstream self-signed certs are a supported configuration }, @@ -296,7 +296,7 @@ func (h *HTTPHandler) getProxy(target HTTPTarget) *httputil.ReverseProxy { // X-Forwarded-For entry, so the header is set exactly once. pr.SetXForwarded() }, - Transport: insecureTransport, + Transport: transport, } proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { From 9545854cb5380a1fb4f28c13d216795a7b967274 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 24 Apr 2026 10:39:44 -0700 Subject: [PATCH 082/161] Hard code the ifconfig path Former-commit-id: 1a67ff30c2476f8c1c1a6f562704b762ffa9deb3 --- network/interface.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/network/interface.go b/network/interface.go index 70556be..089badd 100644 --- a/network/interface.go +++ b/network/interface.go @@ -120,7 +120,7 @@ func configureDarwin(interfaceName string, ip net.IP, ipNet *net.IPNet) error { prefix, _ := ipNet.Mask.Size() ipStr := fmt.Sprintf("%s/%d", ip.String(), prefix) - cmd := exec.Command("ifconfig", interfaceName, "inet", ipStr, ip.String(), "alias") + cmd := exec.Command("/sbin/ifconfig", interfaceName, "inet", ipStr, ip.String(), "alias") logger.Info("Running command: %v", cmd) out, err := cmd.CombinedOutput() @@ -129,7 +129,7 @@ func configureDarwin(interfaceName string, ip net.IP, ipNet *net.IPNet) error { } // Bring up the interface - cmd = exec.Command("ifconfig", interfaceName, "up") + cmd = exec.Command("/sbin/ifconfig", interfaceName, "up") logger.Info("Running command: %v", cmd) out, err = cmd.CombinedOutput() From 96327034af31038946f58f7073fbeae755977faf Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 27 Apr 2026 15:03:36 -0700 Subject: [PATCH 083/161] Fix crashing when removing hc Former-commit-id: 5c43db466a3b72dec8fa55c09fa4c5a41c695e60 --- healthcheck/healthcheck.go | 6 +++--- main.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/healthcheck/healthcheck.go b/healthcheck/healthcheck.go index a7f0b6a..0f3a039 100644 --- a/healthcheck/healthcheck.go +++ b/healthcheck/healthcheck.go @@ -250,7 +250,7 @@ func (m *Monitor) RemoveTarget(id int) error { // Notify callback of status change if m.callback != nil { - go m.callback(m.GetTargets()) + go m.callback(m.getAllTargetsUnsafe()) } logger.Info("Successfully removed target %d", id) @@ -283,7 +283,7 @@ func (m *Monitor) RemoveTargets(ids []int) error { // Notify callback of status change if any targets were removed if len(notFound) != len(ids) && m.callback != nil { - go m.callback(m.GetTargets()) + go m.callback(m.getAllTargetsUnsafe()) } if len(notFound) > 0 { @@ -583,7 +583,7 @@ func (m *Monitor) DisableTarget(id int) error { // Notify callback of status change if m.callback != nil { - go m.callback(m.GetTargets()) + go m.callback(m.getAllTargetsUnsafe()) } } else { logger.Debug("Target %d is already disabled", id) diff --git a/main.go b/main.go index 7718c5d..448f71d 100644 --- a/main.go +++ b/main.go @@ -542,7 +542,7 @@ func runNewtMain(ctx context.Context) { if telErr != nil { logger.Warn("Telemetry init failed: %v", telErr) } - if tel != nil { + if tel != nil && (metricsEnabled || pprofEnabled) { // Admin HTTP server (exposes /metrics when Prometheus exporter is enabled) logger.Debug("Starting metrics server on %s", tcfg.AdminAddr) mux := http.NewServeMux() From 3a5ce705b8d4c52267392074cc26f3aa37fe406e Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 27 Apr 2026 20:10:35 -0700 Subject: [PATCH 084/161] Quiet message Former-commit-id: 23caf57bf49623eca692c1cfd212b1727e2f9b69 --- netstack2/http_handler.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netstack2/http_handler.go b/netstack2/http_handler.go index df4e686..d894127 100644 --- a/netstack2/http_handler.go +++ b/netstack2/http_handler.go @@ -185,7 +185,7 @@ func (h *HTTPHandler) Start() error { } }() - logger.Info("HTTP handler: ready — routing determined per SubnetRule on ports 80/443") + logger.Debug("HTTP handler: ready — routing determined per SubnetRule on ports 80/443") return nil } From 2038383393c1bb6b7b82704e80c3a1ee091dd5de Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 28 Apr 2026 10:10:28 -0700 Subject: [PATCH 085/161] Follow redirects by default for backward compat Fixes #330 Former-commit-id: 7610aa40bf7ec9c886f9442aa1bf79a0e17f4352 --- healthcheck/healthcheck.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/healthcheck/healthcheck.go b/healthcheck/healthcheck.go index 0f3a039..d75797e 100644 --- a/healthcheck/healthcheck.go +++ b/healthcheck/healthcheck.go @@ -47,7 +47,7 @@ type Config struct { Interval int `json:"hcInterval"` // in seconds UnhealthyInterval int `json:"hcUnhealthyInterval"` // in seconds Timeout int `json:"hcTimeout"` // in seconds - FollowRedirects bool `json:"hcFollowRedirects"` + FollowRedirects *bool `json:"hcFollowRedirects"` Headers map[string]string `json:"hcHeaders"` Method string `json:"hcMethod"` Status int `json:"hcStatus"` // HTTP status code @@ -202,7 +202,9 @@ func (m *Monitor) addTargetUnsafe(config Config) error { cancel: cancel, client: &http.Client{ CheckRedirect: func() func(*http.Request, []*http.Request) error { - if !config.FollowRedirects { + // Default to following redirects if not explicitly configured + followRedirects := config.FollowRedirects == nil || *config.FollowRedirects + if !followRedirects { return func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse } From ad8013ff779065e04bb53daa9f4aa458fb54e16f Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 28 Apr 2026 14:29:55 -0700 Subject: [PATCH 086/161] Dont block tcp for http unless there are targets Former-commit-id: 66c72bbe2eb2b572c0abeebdf91f856be5fcd68f --- netstack2/handlers.go | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/netstack2/handlers.go b/netstack2/handlers.go index a63178c..e28a543 100644 --- a/netstack2/handlers.go +++ b/netstack2/handlers.go @@ -152,20 +152,14 @@ func (h *TCPHandler) handleTCPConn(netstackConn *gonet.TCPConn, id stack.Transpo srcAddr, _ := netip.ParseAddr(srcIP) dstAddr, _ := netip.ParseAddr(dstIP) rule := h.proxyHandler.subnetLookup.Match(srcAddr, dstAddr, dstPort, tcp.ProtocolNumber) - if rule != nil { - if rule.Protocol != "" { - logger.Info("TCP Forwarder: Routing %s:%d -> %s:%d to HTTP handler (%s)", - srcIP, srcPort, dstIP, dstPort, rule.Protocol) - h.proxyHandler.httpHandler.HandleConn(netstackConn, rule) - } else { - // A matching HTTP rule exists but has no protocol configured — - // do not fall through to the raw TCP handler; drop the connection. - logger.Info("TCP Forwarder: Dropping %s:%d -> %s:%d (HTTP rule matched but no protocol set)", - srcIP, srcPort, dstIP, dstPort) - netstackConn.Close() - } + if rule != nil && rule.Protocol != "" && len(rule.HTTPTargets) > 0 { + logger.Info("TCP Forwarder: Routing %s:%d -> %s:%d to HTTP handler (%s)", + srcIP, srcPort, dstIP, dstPort, rule.Protocol) + h.proxyHandler.httpHandler.HandleConn(netstackConn, rule) return } + // Otherwise fall through to raw TCP forwarding (e.g. CIDR resources + // that happen to use port 80/443 without HTTP configuration). } defer netstackConn.Close() From dd735555c4d7e22b330a4f9cb7bc91616936782e Mon Sep 17 00:00:00 2001 From: Laurence Date: Wed, 29 Apr 2026 07:12:35 +0100 Subject: [PATCH 087/161] Support websocket upgrades in private HTTP proxy Preserve optional ResponseWriter interfaces through statusCapture so httputil.ReverseProxy can hijack upgraded websocket connections. Add a regression test covering websocket traffic through the HTTP handler path. Former-commit-id: 8e19e475bf3f552db8aee0984cbbf6063a2aaa52 --- netstack2/http_handler.go | 22 +++++++- netstack2/http_handler_test.go | 97 ++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 netstack2/http_handler_test.go diff --git a/netstack2/http_handler.go b/netstack2/http_handler.go index d894127..5e44844 100644 --- a/netstack2/http_handler.go +++ b/netstack2/http_handler.go @@ -6,8 +6,10 @@ package netstack2 import ( + "bufio" "context" "crypto/tls" + "errors" "fmt" "net" "net/http" @@ -29,7 +31,7 @@ import ( type HTTPTarget struct { DestAddr string `json:"destAddr"` // IP address or hostname of the downstream service DestPort uint16 `json:"destPort"` // TCP port of the downstream service - Scheme string `json:"scheme"` // When true the outbound leg uses HTTPS + Scheme string `json:"scheme"` // When true the outbound leg uses HTTPS } // --------------------------------------------------------------------------- @@ -322,6 +324,24 @@ func (sc *statusCapture) WriteHeader(code int) { sc.ResponseWriter.WriteHeader(code) } +func (sc *statusCapture) Unwrap() http.ResponseWriter { + return sc.ResponseWriter +} + +func (sc *statusCapture) Flush() { + if flusher, ok := sc.ResponseWriter.(http.Flusher); ok { + flusher.Flush() + } +} + +func (sc *statusCapture) Hijack() (net.Conn, *bufio.ReadWriter, error) { + hijacker, ok := sc.ResponseWriter.(http.Hijacker) + if !ok { + return nil, nil, errors.New("underlying response writer does not support hijacking") + } + return hijacker.Hijack() +} + // handleRequest is the http.Handler entry point. It retrieves the SubnetRule // attached to the connection by ConnContext, selects the first configured // downstream target, and forwards the request via the cached ReverseProxy. diff --git a/netstack2/http_handler_test.go b/netstack2/http_handler_test.go new file mode 100644 index 0000000..a4cc3cd --- /dev/null +++ b/netstack2/http_handler_test.go @@ -0,0 +1,97 @@ +package netstack2 + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/gorilla/websocket" +) + +func TestHTTPHandlerProxiesWebSocketUpgrade(t *testing.T) { + upgrader := websocket.Upgrader{} + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Errorf("upgrade failed: %v", err) + return + } + defer conn.Close() + + messageType, payload, err := conn.ReadMessage() + if err != nil { + t.Errorf("read failed: %v", err) + return + } + if err := conn.WriteMessage(messageType, append([]byte("echo:"), payload...)); err != nil { + t.Errorf("write failed: %v", err) + } + })) + defer backend.Close() + + backendURL, err := url.Parse(backend.URL) + if err != nil { + t.Fatalf("parse backend URL: %v", err) + } + backendHost, backendPort, err := net.SplitHostPort(backendURL.Host) + if err != nil { + t.Fatalf("split backend host: %v", err) + } + port, err := net.LookupPort("tcp", backendPort) + if err != nil { + t.Fatalf("parse backend port: %v", err) + } + + handler := NewHTTPHandler(nil, nil) + rule := &SubnetRule{ + Protocol: "http", + HTTPTargets: []HTTPTarget{ + { + DestAddr: backendHost, + DestPort: uint16(port), + Scheme: backendURL.Scheme, + }, + }, + } + + frontend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), connCtxKey{}, rule) + handler.handleRequest(w, r.WithContext(ctx)) + })) + defer frontend.Close() + + frontendURL, err := url.Parse(frontend.URL) + if err != nil { + t.Fatalf("parse frontend URL: %v", err) + } + wsURL := url.URL{ + Scheme: "ws", + Host: frontendURL.Host, + Path: "/socket", + RawQuery: "token=test", + } + + conn, _, err := websocket.DefaultDialer.Dial(wsURL.String(), nil) + if err != nil { + t.Fatalf("dial websocket through proxy: %v", err) + } + defer conn.Close() + + if err := conn.WriteMessage(websocket.TextMessage, []byte("hello")); err != nil { + t.Fatalf("write websocket message: %v", err) + } + + messageType, payload, err := conn.ReadMessage() + if err != nil { + t.Fatalf("read websocket message: %v", err) + } + if messageType != websocket.TextMessage { + t.Fatalf("message type = %d, want %d", messageType, websocket.TextMessage) + } + if got, want := string(payload), "echo:hello"; got != want { + t.Fatalf("payload = %q, want %q", got, want) + } +} From 72c58c721bbd683c1363ef737d3d3f65b4c82043 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 29 Apr 2026 15:57:31 -0700 Subject: [PATCH 088/161] Add some test scripts for ws and move to testing/ Former-commit-id: b33c3b88497e17d64dcdc692e6531e18434ad793 --- udp_client.py => testing/udp_client.py | 0 udp_server.py => testing/udp_server.py | 0 testing/ws_client.py | 60 ++++++++++++++++++++++++++ testing/ws_server.py | 49 +++++++++++++++++++++ 4 files changed, 109 insertions(+) rename udp_client.py => testing/udp_client.py (100%) rename udp_server.py => testing/udp_server.py (100%) create mode 100644 testing/ws_client.py create mode 100644 testing/ws_server.py diff --git a/udp_client.py b/testing/udp_client.py similarity index 100% rename from udp_client.py rename to testing/udp_client.py diff --git a/udp_server.py b/testing/udp_server.py similarity index 100% rename from udp_server.py rename to testing/udp_server.py diff --git a/testing/ws_client.py b/testing/ws_client.py new file mode 100644 index 0000000..5aa5c72 --- /dev/null +++ b/testing/ws_client.py @@ -0,0 +1,60 @@ +import asyncio +import sys +import websockets + +# Argument parsing: Check if HOST and PORT are provided +if len(sys.argv) < 3 or len(sys.argv) > 4: + print("Usage: python ws_client.py [ws|wss]") + # Example: python ws_client.py 127.0.0.1 8765 + # Example: python ws_client.py 127.0.0.1 8765 wss + sys.exit(1) + +HOST = sys.argv[1] +try: + PORT = int(sys.argv[2]) +except ValueError: + print("Error: HOST_PORT must be an integer.") + sys.exit(1) + +if len(sys.argv) == 4: + SCHEME = sys.argv[3].lower() + if SCHEME not in ("ws", "wss"): + print("Error: scheme must be 'ws' or 'wss'.") + sys.exit(1) +else: + SCHEME = "ws" + +URI = f"{SCHEME}://{HOST}:{PORT}" + +# The message to send to the server +MESSAGE = "Hello WebSocket Server! How are you?" + + +async def main(): + print(f"Connecting to {URI}...") + + try: + async with websockets.connect(URI) as websocket: + print(f"Connected to server.") + print(f"Sending message: '{MESSAGE}'") + + await websocket.send(MESSAGE) + + response = await websocket.recv() + + print("-" * 30) + print(f"Received response from server:") + print(f"-> Data: '{response}'") + + except ConnectionRefusedError: + print(f"Error: Connection to {URI} was refused. Is the server running?") + except websockets.exceptions.InvalidMessage as e: + print(f"Error: Server did not respond with a valid WebSocket handshake: {e}") + except Exception as e: + print(f"Error during communication: {e}") + + print("-" * 30) + print("Client finished.") + + +asyncio.run(main()) diff --git a/testing/ws_server.py b/testing/ws_server.py new file mode 100644 index 0000000..2e2880d --- /dev/null +++ b/testing/ws_server.py @@ -0,0 +1,49 @@ +import asyncio +import sys +import websockets + +# Optionally take in a positional arg for the port +if len(sys.argv) > 1: + try: + PORT = int(sys.argv[1]) + except ValueError: + print("Invalid port number. Using default port 8765.") + PORT = 8765 +else: + PORT = 8765 + +# Define the server host +HOST = "0.0.0.0" + + +async def handle_client(websocket): + client_address = websocket.remote_address + print(f"Client connected: {client_address[0]}:{client_address[1]}") + + try: + async for message in websocket: + print("-" * 30) + print(f"Received message from {client_address[0]}:{client_address[1]}:") + print(f"-> Data: '{message}'") + + response = f"Hello client! Server received: '{message.upper()}'" + + await websocket.send(response) + print(f"Sent response back to client.") + + except websockets.exceptions.ConnectionClosedOK: + print(f"Client {client_address[0]}:{client_address[1]} disconnected cleanly.") + except websockets.exceptions.ConnectionClosedError as e: + print(f"Client {client_address[0]}:{client_address[1]} disconnected with error: {e}") + + +async def main(): + print(f"WebSocket Server listening on {HOST}:{PORT}") + async with websockets.serve(handle_client, HOST, PORT): + await asyncio.Future() # Run forever + + +try: + asyncio.run(main()) +except KeyboardInterrupt: + print("\nServer stopped.") From 07613ebe05ccc17cb1e00392d9ebcafeb63259fb Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 29 Apr 2026 21:11:07 -0700 Subject: [PATCH 089/161] Fix incorrect redirect logic Former-commit-id: a6533b3fa0b0af4d9080c813f1b91461bc38756d --- netstack2/http_handler.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/netstack2/http_handler.go b/netstack2/http_handler.go index 5e44844..6f08d76 100644 --- a/netstack2/http_handler.go +++ b/netstack2/http_handler.go @@ -356,9 +356,9 @@ func (h *HTTPHandler) handleRequest(w http.ResponseWriter, r *http.Request) { return } - // If the rule is plain HTTP but has a TLS certificate configured, redirect - // the client to the HTTPS equivalent of the requested URL. - if rule.Protocol == "http" && rule.TLSCert != "" && rule.TLSKey != "" { + // If the rule is HTTPS and a TLS certificate is configured, but the + // incoming request arrived over plain HTTP, redirect to HTTPS. + if rule.Protocol == "https" && rule.TLSCert != "" && rule.TLSKey != "" && r.TLS == nil { host := r.Host if host == "" { host = r.URL.Host From 367e4b03fea6d7f57437dd5bd7baeaf31bcb32a0 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 30 Apr 2026 15:55:52 -0700 Subject: [PATCH 090/161] Update status code Former-commit-id: 5090907307b6245ff7e01cdcf27b27dfbb0b717c --- netstack2/http_handler.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netstack2/http_handler.go b/netstack2/http_handler.go index 6f08d76..7ba2f63 100644 --- a/netstack2/http_handler.go +++ b/netstack2/http_handler.go @@ -365,7 +365,7 @@ func (h *HTTPHandler) handleRequest(w http.ResponseWriter, r *http.Request) { } httpsURL := "https://" + host + r.RequestURI logger.Info("HTTP handler: redirecting %s %s -> %s (TLS cert present)", r.Method, r.URL.RequestURI(), httpsURL) - http.Redirect(w, r, httpsURL, http.StatusMovedPermanently) + http.Redirect(w, r, httpsURL, http.StatusPermanentRedirect) return } From 46d4d6539a8d0b9fa45091d7d10e10ce5e77267d Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 5 May 2026 11:40:39 -0700 Subject: [PATCH 091/161] Try to fix failover not working Former-commit-id: 27f7ca6bb99c7c901e367878455b9df8c25be970 --- common.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/common.go b/common.go index 4e1ed00..95333d2 100644 --- a/common.go +++ b/common.go @@ -279,7 +279,7 @@ func startPingCheck(tnet *netstack.Net, serverIP string, client *websocket.Clien // More lenient threshold for declaring connection lost under load failureThreshold := 4 - if consecutiveFailures >= failureThreshold && currentInterval < maxInterval { + if consecutiveFailures >= failureThreshold { if !connectionLost { connectionLost = true logger.Warn("Connection to server lost after %d failures. Continuous reconnection attempts will be made.", consecutiveFailures) @@ -309,12 +309,14 @@ func startPingCheck(tnet *netstack.Net, serverIP string, client *websocket.Clien } } } - currentInterval = time.Duration(float64(currentInterval) * 1.3) // Slower increase - if currentInterval > maxInterval { - currentInterval = maxInterval + if currentInterval < maxInterval { + currentInterval = time.Duration(float64(currentInterval) * 1.3) // Slower increase + if currentInterval > maxInterval { + currentInterval = maxInterval + } + ticker.Reset(currentInterval) + logger.Debug("Increased ping check interval to %v due to consecutive failures", currentInterval) } - ticker.Reset(currentInterval) - logger.Debug("Increased ping check interval to %v due to consecutive failures", currentInterval) } } else { // Track recent latencies From b09f67c3775e22a30d576aa2954022ac4abe81d8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 11:49:20 +0000 Subject: [PATCH 092/161] chore(deps): bump the prod-minor-updates group across 1 directory with 4 updates Bumps the prod-minor-updates group with 3 updates in the / directory: [golang.org/x/crypto](https://github.com/golang/crypto), [golang.org/x/net](https://github.com/golang/net) and [google.golang.org/grpc](https://github.com/grpc/grpc-go). Updates `golang.org/x/crypto` from 0.49.0 to 0.50.0 - [Commits](https://github.com/golang/crypto/compare/v0.49.0...v0.50.0) Updates `golang.org/x/net` from 0.52.0 to 0.53.0 - [Commits](https://github.com/golang/net/compare/v0.52.0...v0.53.0) Updates `golang.org/x/sys` from 0.42.0 to 0.43.0 - [Commits](https://github.com/golang/sys/compare/v0.42.0...v0.43.0) Updates `google.golang.org/grpc` from 1.80.0 to 1.81.0 - [Release notes](https://github.com/grpc/grpc-go/releases) - [Commits](https://github.com/grpc/grpc-go/compare/v1.80.0...v1.81.0) --- updated-dependencies: - dependency-name: golang.org/x/crypto dependency-version: 0.50.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: golang.org/x/net dependency-version: 0.53.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: golang.org/x/sys dependency-version: 0.43.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: google.golang.org/grpc dependency-version: 1.81.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates ... Signed-off-by: dependabot[bot] Former-commit-id: 3aaebe64fbba0bc7cdc0c4f23539948f0a9a22d6 --- go.mod | 14 +++++++------- go.sum | 28 ++++++++++++++-------------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/go.mod b/go.mod index 79f94ec..d182bff 100644 --- a/go.mod +++ b/go.mod @@ -17,14 +17,14 @@ require ( go.opentelemetry.io/otel/metric v1.43.0 go.opentelemetry.io/otel/sdk v1.43.0 go.opentelemetry.io/otel/sdk/metric v1.43.0 - golang.org/x/crypto v0.49.0 + golang.org/x/crypto v0.50.0 golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 - golang.org/x/net v0.52.0 - golang.org/x/sys v0.42.0 + golang.org/x/net v0.53.0 + golang.org/x/sys v0.43.0 golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 golang.zx2c4.com/wireguard/windows v0.5.3 - google.golang.org/grpc v1.80.0 + google.golang.org/grpc v1.81.0 gopkg.in/yaml.v3 v3.0.1 gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c software.sslmate.com/src/go-pkcs12 v0.7.0 @@ -65,11 +65,11 @@ require ( go.opentelemetry.io/otel/trace v1.43.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect - golang.org/x/mod v0.33.0 // indirect + golang.org/x/mod v0.34.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/text v0.35.0 // indirect + golang.org/x/text v0.36.0 // indirect golang.org/x/time v0.12.0 // indirect - golang.org/x/tools v0.42.0 // indirect + golang.org/x/tools v0.43.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect diff --git a/go.sum b/go.sum index e38c6a4..a60e6db 100644 --- a/go.sum +++ b/go.sum @@ -125,26 +125,26 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0= golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A= @@ -159,8 +159,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1: google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= -google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw= +google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From f1bb6c663d0e8b47ab47be97a9f06eb76b345ae7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" Date: Mon, 4 May 2026 11:50:19 +0000 Subject: [PATCH 093/161] chore(nix): fix hash for updated go dependencies Former-commit-id: ced87b1d5e388c1820914d1cdc5081ce34cd8bb3 --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index bd760ea..148bbe7 100644 --- a/flake.nix +++ b/flake.nix @@ -35,7 +35,7 @@ inherit version; src = pkgs.nix-gitignore.gitignoreSource [ ] ./.; - vendorHash = "sha256-+zMSzNbqmWm/DXL2xMUd5uPP5tSIybsRokwJ2zd0pf0="; + vendorHash = "sha256-WfIK+Q8WQ372NzLw6DRapv1nYPduShi4KnVJBPk0Oz0="; nativeInstallCheckInputs = [ pkgs.versionCheckHook ]; From d16b856719d0a9bc55834b81ca4c6a457ca795cc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:28:55 +0000 Subject: [PATCH 094/161] chore(deps): bump aquasecurity/trivy-action from 0.35.0 to 0.36.0 Bumps [aquasecurity/trivy-action](https://github.com/aquasecurity/trivy-action) from 0.35.0 to 0.36.0. - [Release notes](https://github.com/aquasecurity/trivy-action/releases) - [Commits](https://github.com/aquasecurity/trivy-action/compare/57a97c7e7821a5776cebc9bb87c984fa69cba8f1...ed142fd0673e97e23eac54620cfb913e5ce36c25) --- updated-dependencies: - dependency-name: aquasecurity/trivy-action dependency-version: 0.36.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Former-commit-id: 9edaac9c114acec5f752f4d1ff4760b453f3d710 --- .github/workflows/cicd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 65c1756..cb6eecd 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -764,7 +764,7 @@ jobs: cosign public-key --key env://COSIGN_PRIVATE_KEY >/dev/null - name: Generate SBOM (SPDX JSON) from GHCR digest - uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: image-ref: ${{ env.GHCR_REF }} format: spdx-json From 34c5260e499d2d05ec7b53e2980fcd97d16c508b Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 7 May 2026 16:16:47 -0700 Subject: [PATCH 095/161] Fix not logging when rewriting nat Former-commit-id: 9ff32b8a8b37ea564761a30adb1c188c65a6111f --- netstack2/proxy.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/netstack2/proxy.go b/netstack2/proxy.go index b08eea3..95fab6a 100644 --- a/netstack2/proxy.go +++ b/netstack2/proxy.go @@ -572,6 +572,18 @@ func (p *ProxyHandler) HandleIncomingPacket(packet []byte) bool { // Store destination rewrite for handler lookups p.destRewriteTable[dKey] = newDst + + // Also store the resource ID under the rewritten destination key so that + // TCP/UDP handlers can find it after DNAT (they see the post-NAT dst IP). + if matchedRule.ResourceId != 0 { + rewrittenKey := destKey{ + srcIP: srcAddr.String(), + dstIP: newDst.String(), + dstPort: dstPort, + proto: uint8(protocol), + } + p.resourceTable[rewrittenKey] = matchedRule.ResourceId + } p.natMu.Unlock() logger.Debug("New NAT entry for connection: %s -> %s", dstAddr, newDst) } From a84bae3bc1deee47f522f627d2647df5e4ce1692 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 7 May 2026 16:23:59 -0700 Subject: [PATCH 096/161] Attempt to fix nix issue Former-commit-id: e8dc19a62bf2e6dbe88359ce5649c880070ded94 --- common.go | 1 + 1 file changed, 1 insertion(+) diff --git a/common.go b/common.go index 95333d2..bcbd968 100644 --- a/common.go +++ b/common.go @@ -208,6 +208,7 @@ func pingWithRetry(tnet *netstack.Net, dst string, timeout time.Duration) (stopC logger.Warn(msgHealthFileWriteFailed, err) } } + return } case <-pingStopChan: // Stop the goroutine when signaled From 5e7cd9c0b995ec3a1c08b68f9179ad9558da7f9b Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 7 May 2026 16:24:30 -0700 Subject: [PATCH 097/161] Bump version Former-commit-id: 74fd3f3aa33b6848ec067650209f784395bb7d3b --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index bd760ea..08f2864 100644 --- a/flake.nix +++ b/flake.nix @@ -25,7 +25,7 @@ inherit (pkgs) lib; # Update version when releasing - version = "1.11.0"; + version = "1.12.4"; in { default = self.packages.${system}.pangolin-newt; From 1f2d40c7931439961ec9b355ca271f470d98288a Mon Sep 17 00:00:00 2001 From: Daniel Snider Date: Thu, 7 May 2026 08:32:55 -0500 Subject: [PATCH 098/161] fix(ping): decouple data-plane recovery trigger from backoff ramp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trigger condition that decides whether to fire the data-plane recovery flow in startPingCheck was AND-ed with `currentInterval < maxInterval`. That clause was meant to throttle the *backoff ramp* (don't widen the interval past 6s), but it also gated the recovery trigger itself — a conflation that became invisibly load-bearing once commit 8161fa6 (March 2026) bumped the default pingInterval from 3s to 15s while leaving maxInterval at 6s. Under the new defaults `currentInterval` starts at 15s and `15 < 6` is permanently false, so the recovery branch never executed. Pings just kept failing and the failure counter climbed forever, with no "Connection to server lost" log line and no newt/ping/request emitted on the websocket. Real-world recovery only happened when the underlying network came back fast enough that a periodic ping naturally succeeded again — which doesn't happen if the WireGuard state on either end has rotated, so users were left stuck until they restarted newt. This is the proximate cause of the user reports in fosrl/newt#284 (and dups #310, fosrl/pangolin#1004). Logs in those issues all show ping-failure counters growing without ever emitting "Connection to server lost", which is exactly the fingerprint of this gate being false. The fix is to extract the trigger decision into shouldFireRecovery and remove currentInterval from it. Backoff is now computed in a separate `if` in the caller, still gated by `currentInterval < maxInterval` so the ramp is a no-op under default settings (which is the existing behaviour, just no longer entangled with the recovery trigger). Fixing the backoff ramp itself — making it useful when pingInterval >= maxInterval — is a follow-up: the priority is restoring recovery, not improving the dampening schedule. The new shouldFireRecovery helper is unit-tested. Its signature intentionally omits currentInterval, so a future refactor that re-introduces the interval-dependent gate would need to change the function signature, which makes the historical bug harder to reintroduce silently. Former-commit-id: 1e77b09e3b99731ce91ea0d721ce02e23c441664 --- common.go | 79 ++++++++++++++++++++++++++++++++------------------ common_test.go | 39 +++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 28 deletions(-) diff --git a/common.go b/common.go index 4e1ed00..01d6957 100644 --- a/common.go +++ b/common.go @@ -220,6 +220,25 @@ func pingWithRetry(tnet *netstack.Net, dst string, timeout time.Duration) (stopC return stopChan, fmt.Errorf("initial ping attempts failed, continuing in background") } +// shouldFireRecovery decides whether the data-plane recovery flow in +// startPingCheck should run on this tick. Recovery fires once when the +// consecutive-failure counter first crosses the threshold; the connectionLost +// flag prevents re-firing until a successful ping resets the state. +// +// This condition was previously inlined into startPingCheck and AND-ed with +// `currentInterval < maxInterval`, which silently broke recovery once +// pingInterval's default was bumped to 15s while maxInterval stayed at 6s +// (commit 8161fa6, March 2026): the gate became permanently false on default +// settings, so the recovery code never executed and ping failures climbed +// forever — the proximate cause of fosrl/newt#284, #310 and pangolin#1004. +// +// Recovery and backoff are independent concerns; the backoff ramp is now +// computed separately in the caller. Do not re-introduce currentInterval +// here. +func shouldFireRecovery(consecutiveFailures, failureThreshold int, connectionLost bool) bool { + return consecutiveFailures >= failureThreshold && !connectionLost +} + func startPingCheck(tnet *netstack.Net, serverIP string, client *websocket.Client, tunnelID string) chan struct{} { maxInterval := 6 * time.Second currentInterval := pingInterval @@ -279,37 +298,41 @@ func startPingCheck(tnet *netstack.Net, serverIP string, client *websocket.Clien // More lenient threshold for declaring connection lost under load failureThreshold := 4 - if consecutiveFailures >= failureThreshold && currentInterval < maxInterval { - if !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 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 send registration message: %v", err) - } - if healthFile != "" { - err = os.Remove(healthFile) - if err != nil { - logger.Error("Failed to remove health file: %v", err) - } + logger.Error("Failed to remove health file: %v", err) } } - currentInterval = time.Duration(float64(currentInterval) * 1.3) // Slower increase + } + // 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 } diff --git a/common_test.go b/common_test.go index a7e659a..67c02cf 100644 --- a/common_test.go +++ b/common_test.go @@ -210,3 +210,42 @@ 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. +func TestShouldFireRecovery(t *testing.T) { + const threshold = 4 + cases := []struct { + name string + failures int + connectionLost bool + want bool + }{ + {"below threshold, fresh", 3, false, false}, + {"below threshold, already lost", 3, true, false}, + {"at threshold, fresh — recovery must fire", threshold, false, true}, + {"at threshold, already lost — gate prevents re-fire", threshold, true, false}, + {"far above threshold, fresh", 100, false, true}, + {"far above threshold, already lost", 100, true, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := shouldFireRecovery(c.failures, threshold, c.connectionLost); got != c.want { + t.Errorf("shouldFireRecovery(failures=%d, threshold=%d, lost=%v) = %v, want %v", + c.failures, threshold, c.connectionLost, got, c.want) + } + }) + } +} From d5f48f782f6bb16a10735c32d0c29a17a61c4ea8 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 7 May 2026 17:25:13 -0700 Subject: [PATCH 099/161] Increase max attempts Former-commit-id: 901ec71baf1b10ebf24372b77fe48fe8e2fc6af0 --- websocket/client.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/websocket/client.go b/websocket/client.go index 67e23ec..3d35494 100644 --- a/websocket/client.go +++ b/websocket/client.go @@ -48,7 +48,7 @@ type Client struct { metricsCtx context.Context configNeedsSave bool // Flag to track if config needs to be saved serverVersion string - configVersion int64 // Latest config version received from server + configVersion int64 // Latest config version received from server configVersionMux sync.RWMutex processingMessage bool // Flag to track if a message is currently being processed processingMux sync.RWMutex // Protects processingMessage @@ -271,7 +271,7 @@ func (c *Client) SendMessageInterval(messageType string, data interface{}, inter stopChan := make(chan struct{}) go func() { count := 0 - maxAttempts := 10 + maxAttempts := 16 err := c.SendMessage(messageType, data) // Send immediately if err != nil { @@ -836,7 +836,7 @@ func (c *Client) readPumpWithDisconnectDetection(started time.Time) { logger.Error("WebSocket failed to parse message: %v", err) continue } - + c.setConfigVersion(msg.ConfigVersion) c.handlersMux.RLock() From 6b42cac02d8bbaa16953da7eb673115145beb81d Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 7 May 2026 17:27:01 -0700 Subject: [PATCH 100/161] Retry interval while we are disconnected Former-commit-id: 663e98af608c2d0df5519326a767291266f45975 --- websocket/client.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/websocket/client.go b/websocket/client.go index 3d35494..5068471 100644 --- a/websocket/client.go +++ b/websocket/client.go @@ -273,11 +273,15 @@ func (c *Client) SendMessageInterval(messageType string, data interface{}, inter count := 0 maxAttempts := 16 + c.reconnectMux.RLock() + connected := c.isConnected + c.reconnectMux.RUnlock() err := c.SendMessage(messageType, data) // Send immediately if err != nil { logger.Error("Failed to send initial message: %v", err) + } else if connected { + count++ } - count++ ticker := time.NewTicker(interval) defer ticker.Stop() @@ -288,11 +292,15 @@ func (c *Client) SendMessageInterval(messageType string, data interface{}, inter logger.Info("SendMessageInterval timed out after %d attempts for message type: %s", maxAttempts, messageType) return } + c.reconnectMux.RLock() + connected = c.isConnected + c.reconnectMux.RUnlock() err = c.SendMessage(messageType, data) if err != nil { logger.Error("Failed to send message: %v", err) + } else if connected { + count++ } - count++ case <-stopChan: return } From 85d51a300eb75a36387cfe463e58a13d5a72d55d Mon Sep 17 00:00:00 2001 From: Laurence Date: Fri, 8 May 2026 13:45:50 +0100 Subject: [PATCH 101/161] fix(http): Set host header based on in fix https://github.com/fosrl/pangolin/issues/2952 issue by setting the incoming host header to the outgoing one by the reverse proxy, this was the default behaviour when using single proxy but now since we use more features it now rewrites the host header Former-commit-id: 6aa94c0c2a4861378a468e0280d6e6cdc2db6ab3 --- netstack2/http_handler.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/netstack2/http_handler.go b/netstack2/http_handler.go index 7ba2f63..ed39d1e 100644 --- a/netstack2/http_handler.go +++ b/netstack2/http_handler.go @@ -291,6 +291,9 @@ func (h *HTTPHandler) getProxy(target HTTPTarget) *httputil.ReverseProxy { proxy := &httputil.ReverseProxy{ Rewrite: func(pr *httputil.ProxyRequest) { pr.SetURL(targetURL) + if host := pr.In.Host; host != "" { + pr.Out.Host = host + } // SetXForwarded sets X-Forwarded-For from the inbound request's // RemoteAddr (the WireGuard/netstack client address), along with // X-Forwarded-Host and X-Forwarded-Proto. Using Rewrite instead of From f871b5e3242d4967e8a6dc1f259d123c2baf0c42 Mon Sep 17 00:00:00 2001 From: Laurence Date: Fri, 8 May 2026 15:17:31 +0100 Subject: [PATCH 102/161] fix(http): populate Request.TLS for private HTTPS via httpConnCtx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit net/http only sets Request.TLS for *tls.Conn or conns implementing ConnectionState(). Our listener wrapped tls.Server in httpConnCtx with an embedded net.Conn, so TLS was never surfaced and r.TLS stayed nil. That triggered the HTTP→HTTPS permanent redirect on every request for HTTPS rules. Add ConnectionState() on httpConnCtx delegating to the underlying TLS conn. Add tests for TLS forwarding and plain TCP. Former-commit-id: 146e7835eb541c7f0dc6fba2a22b4ccfc37fdde8 --- netstack2/http_handler.go | 15 ++++++++++ netstack2/http_handler_tls_test.go | 48 ++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 netstack2/http_handler_tls_test.go diff --git a/netstack2/http_handler.go b/netstack2/http_handler.go index 7ba2f63..354781e 100644 --- a/netstack2/http_handler.go +++ b/netstack2/http_handler.go @@ -139,6 +139,21 @@ type httpConnCtx struct { rule *SubnetRule } +// ConnectionState allows net/http.Server to populate Request.TLS when the +// underlying connection is TLS (e.g. *tls.Conn from tls.Server). Without this, +// the connection is not *tls.Conn and does not expose ConnectionState through +// the net.Conn interface field, so tlsState stays nil and the HTTPS redirect +// in handleRequest runs on every request. +func (c *httpConnCtx) ConnectionState() tls.ConnectionState { + type tlsConn interface { + ConnectionState() tls.ConnectionState + } + if tc, ok := c.Conn.(tlsConn); ok { + return tc.ConnectionState() + } + return tls.ConnectionState{} +} + // connCtxKey is the unexported context key used to store a *SubnetRule on the // per-connection context created by http.Server.ConnContext. type connCtxKey struct{} diff --git a/netstack2/http_handler_tls_test.go b/netstack2/http_handler_tls_test.go new file mode 100644 index 0000000..0f2ffdc --- /dev/null +++ b/netstack2/http_handler_tls_test.go @@ -0,0 +1,48 @@ +package netstack2 + +import ( + "crypto/tls" + "net" + "testing" +) + +// tlsConnStub is a minimal net.Conn that also exposes TLS state, matching +// *tls.Conn's ConnectionState used by net/http.Server. +type tlsConnStub struct { + net.Conn + state tls.ConnectionState +} + +func (t *tlsConnStub) ConnectionState() tls.ConnectionState { + return t.state +} + +func TestHTTPConnCtxForwardsConnectionState(t *testing.T) { + c1, c2 := net.Pipe() + defer c1.Close() + defer c2.Close() + + inner := &tlsConnStub{ + Conn: c1, + state: tls.ConnectionState{Version: tls.VersionTLS12, HandshakeComplete: true}, + } + wrapped := &httpConnCtx{Conn: inner, rule: nil} + + got := wrapped.ConnectionState() + if got.Version != tls.VersionTLS12 || !got.HandshakeComplete { + t.Fatalf("ConnectionState = %+v, want TLS 1.2 and HandshakeComplete", got) + } +} + +func TestHTTPConnCtxConnectionStatePlainTCP(t *testing.T) { + c1, c2 := net.Pipe() + defer c1.Close() + defer c2.Close() + + wrapped := &httpConnCtx{Conn: c1, rule: nil} + got := wrapped.ConnectionState() + if got.Version != 0 { + t.Fatalf("expected zero ConnectionState for plain conn, got %+v", got) + } + _ = c2 +} From 328b1aced3719af297e90119bbc9648bd5cdc3cd Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 8 May 2026 11:03:00 -0700 Subject: [PATCH 103/161] Fix the redirect Former-commit-id: 86155072dee15ddf64bf36b414c3c28f6948324b --- netstack2/http_handler.go | 70 +++++++++++++++++++++------------------ 1 file changed, 37 insertions(+), 33 deletions(-) diff --git a/netstack2/http_handler.go b/netstack2/http_handler.go index 0056df1..ece82e9 100644 --- a/netstack2/http_handler.go +++ b/netstack2/http_handler.go @@ -131,33 +131,23 @@ func (l *chanListener) send(conn net.Conn) bool { // httpConnCtx – conn wrapper that carries a SubnetRule through the listener // --------------------------------------------------------------------------- -// httpConnCtx wraps a net.Conn so the matching SubnetRule can be passed -// through the chanListener into the http.Server's ConnContext callback, -// making it available to request handlers via the request context. +// httpConnCtx wraps a net.Conn so the matching SubnetRule and TLS state can +// be passed through the chanListener into the http.Server's ConnContext +// callback, making them available to request handlers via the request context. type httpConnCtx struct { net.Conn - rule *SubnetRule -} - -// ConnectionState allows net/http.Server to populate Request.TLS when the -// underlying connection is TLS (e.g. *tls.Conn from tls.Server). Without this, -// the connection is not *tls.Conn and does not expose ConnectionState through -// the net.Conn interface field, so tlsState stays nil and the HTTPS redirect -// in handleRequest runs on every request. -func (c *httpConnCtx) ConnectionState() tls.ConnectionState { - type tlsConn interface { - ConnectionState() tls.ConnectionState - } - if tc, ok := c.Conn.(tlsConn); ok { - return tc.ConnectionState() - } - return tls.ConnectionState{} + rule *SubnetRule + isTLS bool // true when the conn was wrapped with tls.Server } // connCtxKey is the unexported context key used to store a *SubnetRule on the // per-connection context created by http.Server.ConnContext. type connCtxKey struct{} +// connTLSKey is the unexported context key used to store the isTLS flag on +// the per-connection context created by http.Server.ConnContext. +type connTLSKey struct{} + // --------------------------------------------------------------------------- // Constructor and lifecycle // --------------------------------------------------------------------------- @@ -190,7 +180,8 @@ func (h *HTTPHandler) Start() error { // that handleRequest can retrieve it without any global state. ConnContext: func(ctx context.Context, c net.Conn) context.Context { if cc, ok := c.(*httpConnCtx); ok { - return context.WithValue(ctx, connCtxKey{}, cc.rule) + ctx = context.WithValue(ctx, connCtxKey{}, cc.rule) + ctx = context.WithValue(ctx, connTLSKey{}, cc.isTLS) } return ctx }, @@ -218,19 +209,28 @@ func (h *HTTPHandler) HandleConn(conn net.Conn, rule *SubnetRule) { var effectiveConn net.Conn = conn if rule.Protocol == "https" { - tlsCfg, err := h.getTLSConfig(rule) - if err != nil { - logger.Error("HTTP handler: cannot build TLS config for connection from %s: %v", - conn.RemoteAddr(), err) - conn.Close() - return + // Only perform TLS termination for connections arriving on port 443. + // Connections on port 80 are passed through as plain HTTP so that + // handleRequest can issue the HTTP→HTTPS redirect. + doTLS := false + if tcpAddr, ok := conn.LocalAddr().(*net.TCPAddr); ok { + doTLS = tcpAddr.Port == 443 + } + if doTLS { + tlsCfg, err := h.getTLSConfig(rule) + if err != nil { + logger.Error("HTTP handler: cannot build TLS config for connection from %s: %v", + conn.RemoteAddr(), err) + conn.Close() + return + } + // tls.Server wraps the raw conn; the TLS handshake is deferred until + // the first Read, which the http.Server will trigger naturally. + effectiveConn = tls.Server(conn, tlsCfg) } - // tls.Server wraps the raw conn; the TLS handshake is deferred until - // the first Read, which the http.Server will trigger naturally. - effectiveConn = tls.Server(conn, tlsCfg) } - wrapped := &httpConnCtx{Conn: effectiveConn, rule: rule} + wrapped := &httpConnCtx{Conn: effectiveConn, rule: rule, isTLS: effectiveConn != conn} if !h.listener.send(wrapped) { // Listener is already closed — clean up the orphaned connection. effectiveConn.Close() @@ -374,9 +374,13 @@ func (h *HTTPHandler) handleRequest(w http.ResponseWriter, r *http.Request) { return } - // If the rule is HTTPS and a TLS certificate is configured, but the - // incoming request arrived over plain HTTP, redirect to HTTPS. - if rule.Protocol == "https" && rule.TLSCert != "" && rule.TLSKey != "" && r.TLS == nil { + // If the rule is HTTPS but the incoming request arrived over plain HTTP + // (port 80), redirect to HTTPS. We use the isTLS flag stored on the + // connection context rather than r.TLS, because Go's http.Server calls + // ConnectionState() before the TLS handshake completes, so r.TLS.Version + // is 0 even for genuine TLS connections at that point. + isTLS, _ := r.Context().Value(connTLSKey{}).(bool) + if rule.Protocol == "https" && !isTLS { host := r.Host if host == "" { host = r.URL.Host From 5541f92d0736caa9583a3e7c247c8104d058d499 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 8 May 2026 11:05:24 -0700 Subject: [PATCH 104/161] Bump version Former-commit-id: a1218ab67aa715472f7a8204d2cf1080051f45e2 --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index b388cc9..78d0291 100644 --- a/flake.nix +++ b/flake.nix @@ -25,7 +25,7 @@ inherit (pkgs) lib; # Update version when releasing - version = "1.12.4"; + version = "1.12.5"; in { default = self.packages.${system}.pangolin-newt; From 8246eb1e3628abae4c5f75d82b653e2a13aae98e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Sch=C3=A4fer?= Date: Sun, 10 May 2026 10:50:38 +0200 Subject: [PATCH 105/161] refactor: update Docker client imports and adjust container handling logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Marc Schäfer Former-commit-id: 0e2d7057b6f61cfba92aed4b82ae41823fd3db50 --- docker/docker.go | 54 ++++++++++++++++++++++++++-------------------- go.mod | 21 ++++++------------ go.sum | 56 ++++++++++++++++-------------------------------- 3 files changed, 56 insertions(+), 75 deletions(-) diff --git a/docker/docker.go b/docker/docker.go index 281c594..8033c67 100644 --- a/docker/docker.go +++ b/docker/docker.go @@ -9,10 +9,9 @@ import ( "strings" "time" - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/events" - "github.com/docker/docker/api/types/filters" - "github.com/docker/docker/client" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/api/types/events" + "github.com/moby/moby/client" "github.com/fosrl/newt/logger" ) @@ -170,7 +169,7 @@ func ListContainers(socketPath string, enforceNetworkValidation bool) ([]Contain } // Used to filter down containers returned to Pangolin - containerFilters := filters.NewArgs() + containerFilters := make(client.Filters) // Used to determine if we will send IP addresses or hostnames to Pangolin useContainerIpAddresses := true @@ -215,21 +214,28 @@ func ListContainers(socketPath string, enforceNetworkValidation bool) ([]Contain } // List containers - containers, err := cli.ContainerList(ctx, container.ListOptions{All: true, Filters: containerFilters}) + containerListResult, err := cli.ContainerList(ctx, client.ContainerListOptions{All: true, Filters: containerFilters}) if err != nil { return nil, fmt.Errorf("failed to list containers: %v", err) } + addrToString := func(addr interface{ IsValid() bool; String() string }) string { + if addr.IsValid() { + return addr.String() + } + return "" + } + var dockerContainers []Container - for _, c := range containers { + for _, c := range containerListResult.Items { // Short ID like docker ps shortId := c.ID[:12] // Inspect container to get hostname hostname := "" - containerInfo, err := cli.ContainerInspect(ctx, c.ID) - if err == nil && containerInfo.Config != nil { - hostname = containerInfo.Config.Hostname + containerInfo, err := cli.ContainerInspect(ctx, c.ID, client.ContainerInspectOptions{}) + if err == nil && containerInfo.Container.Config != nil { + hostname = containerInfo.Container.Config.Hostname } // Skip host container if set @@ -253,8 +259,8 @@ func ListContainers(socketPath string, enforceNetworkValidation bool) ([]Contain if port.PublicPort != 0 { dockerPort.PublicPort = int(port.PublicPort) } - if port.IP != "" { - dockerPort.IP = port.IP + if port.IP.IsValid() { + dockerPort.IP = port.IP.String() } ports = append(ports, dockerPort) } @@ -268,19 +274,19 @@ func ListContainers(socketPath string, enforceNetworkValidation bool) ([]Contain dockerNetwork := Network{ NetworkID: endpoint.NetworkID, EndpointID: endpoint.EndpointID, - Gateway: endpoint.Gateway, + Gateway: addrToString(endpoint.Gateway), IPPrefixLen: endpoint.IPPrefixLen, - IPv6Gateway: endpoint.IPv6Gateway, - GlobalIPv6Address: endpoint.GlobalIPv6Address, + IPv6Gateway: addrToString(endpoint.IPv6Gateway), + GlobalIPv6Address: addrToString(endpoint.GlobalIPv6Address), GlobalIPv6PrefixLen: endpoint.GlobalIPv6PrefixLen, - MacAddress: endpoint.MacAddress, + MacAddress: endpoint.MacAddress.String(), Aliases: endpoint.Aliases, DNSNames: endpoint.DNSNames, } // Use IPs over hostnames/containers as we're on the bridge network if useContainerIpAddresses { - dockerNetwork.IPAddress = endpoint.IPAddress + dockerNetwork.IPAddress = addrToString(endpoint.IPAddress) } networks[networkName] = dockerNetwork @@ -291,7 +297,7 @@ func ListContainers(socketPath string, enforceNetworkValidation bool) ([]Contain ID: shortId, Name: name, Image: c.Image, - State: c.State, + State: string(c.State), Status: c.Status, Ports: ports, Labels: c.Labels, @@ -315,12 +321,12 @@ func getHostContainer(dockerContext context.Context, dockerClient *client.Client } // Get host container from the docker socket - hostContainer, err := dockerClient.ContainerInspect(dockerContext, hostContainerName) + hostContainer, err := dockerClient.ContainerInspect(dockerContext, hostContainerName, client.ContainerInspectOptions{}) if err != nil { return nil, fmt.Errorf("failed to find host container") } - return &hostContainer, nil + return &hostContainer.Container, nil } // EventCallback defines the function signature for handling Docker events @@ -371,7 +377,7 @@ func (em *EventMonitor) Start() error { logger.Debug("Starting Docker event monitoring") // Filter for container events we care about - eventFilters := filters.NewArgs() + eventFilters := make(client.Filters) eventFilters.Add("type", "container") // eventFilters.Add("event", "create") eventFilters.Add("event", "start") @@ -382,9 +388,10 @@ func (em *EventMonitor) Start() error { // eventFilters.Add("event", "unpause") // Start listening for events - eventCh, errCh := em.client.Events(em.ctx, events.ListOptions{ + eventsResult := em.client.Events(em.ctx, client.EventsListOptions{ Filters: eventFilters, }) + eventCh, errCh := eventsResult.Messages, eventsResult.Err go func() { defer func() { @@ -408,9 +415,10 @@ func (em *EventMonitor) Start() error { time.Sleep(5 * time.Second) if em.ctx.Err() == nil { logger.Info("Attempting to reconnect to Docker event stream") - eventCh, errCh = em.client.Events(em.ctx, events.ListOptions{ + eventsResult = em.client.Events(em.ctx, client.EventsListOptions{ Filters: eventFilters, }) + eventCh, errCh = eventsResult.Messages, eventsResult.Err } } return diff --git a/go.mod b/go.mod index d182bff..e6d39d7 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,10 @@ module github.com/fosrl/newt go 1.25.0 require ( - github.com/docker/docker v28.5.2+incompatible github.com/gaissmai/bart v0.26.1 github.com/gorilla/websocket v1.5.3 + github.com/moby/moby/api v1.54.2 + github.com/moby/moby/client v0.4.1 github.com/prometheus/client_golang v1.23.2 github.com/vishvananda/netlink v1.3.1 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 @@ -31,15 +32,15 @@ require ( ) require ( - github.com/Microsoft/go-winio v0.6.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/containerd/errdefs v0.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/docker/go-connections v0.6.0 // indirect - github.com/docker/go-units v0.4.0 // indirect + github.com/docker/go-connections v0.7.0 // indirect + github.com/docker/go-units v0.5.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -47,13 +48,9 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect - github.com/moby/sys/atomicwriter v0.1.0 // indirect - github.com/moby/term v0.5.2 // indirect - github.com/morikuni/aec v1.0.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect - github.com/opencontainers/image-spec v1.1.0 // indirect - github.com/pkg/errors v0.9.1 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/otlptranslator v1.0.0 // indirect @@ -61,15 +58,11 @@ require ( github.com/vishvananda/netns v0.0.5 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect - golang.org/x/mod v0.34.0 // indirect - golang.org/x/sync v0.20.0 // indirect golang.org/x/text v0.36.0 // indirect golang.org/x/time v0.12.0 // indirect - golang.org/x/tools v0.43.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect diff --git a/go.sum b/go.sum index a60e6db..102924e 100644 --- a/go.sum +++ b/go.sum @@ -1,29 +1,23 @@ -github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= -github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/Microsoft/go-winio v0.6.0 h1:slsWYD/zyx7lCXoZVlvQrj0hPTM1HI4+v1sIda2yDvg= -github.com/Microsoft/go-winio v0.6.0/go.mod h1:cTAf44im0RAYeL23bpB+fzCyDH2MJiz2BO69KH/soAE= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/containerd/errdefs v0.3.0 h1:FSZgGOeK4yuT/+DnF07/Olde/q4KBoMsaamhXxIMDp4= -github.com/containerd/errdefs v0.3.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= -github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= -github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= -github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= -github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= -github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw= -github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo= @@ -55,22 +49,16 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= -github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= -github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= -github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= -github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= -github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= -github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= -github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= -github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg= +github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY= +github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= -github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= -github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= @@ -85,8 +73,6 @@ github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEy github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= @@ -107,8 +93,6 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bT go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= go.opentelemetry.io/otel/exporters/prometheus v0.65.0 h1:jOveH/b4lU9HT7y+Gfamf18BqlOuz2PWEvs8yM7Q6XE= go.opentelemetry.io/otel/exporters/prometheus v0.65.0/go.mod h1:i1P8pcumauPtUI4YNopea1dhzEMuEqWP1xoUZDylLHo= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= @@ -129,12 +113,8 @@ golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0= golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= -golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= -golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= @@ -143,8 +123,6 @@ golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= -golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= -golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A= @@ -168,9 +146,11 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gotest.tools/v3 v3.4.0 h1:ZazjZUfuVeZGLAmlKKuyv3IKP5orXcwtOwDQH6YVr6o= -gotest.tools/v3 v3.4.0/go.mod h1:CtbdzLSsqVhDgMtKsx03ird5YTGB3ar27v0u/yKBW5g= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgIMBW8ZZhT4gLnUyDIhzI= gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= software.sslmate.com/src/go-pkcs12 v0.7.0 h1:Db8W44cB54TWD7stUFFSWxdfpdn6fZVcDl0w3R4RVM0= software.sslmate.com/src/go-pkcs12 v0.7.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= From 7ff795b6bb801bd1f1eac766a0dcb9eb50925708 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 May 2026 14:36:15 +0000 Subject: [PATCH 106/161] chore(deps): bump aws-actions/configure-aws-credentials Bumps [aws-actions/configure-aws-credentials](https://github.com/aws-actions/configure-aws-credentials) from 6.1.0 to 6.1.1. - [Release notes](https://github.com/aws-actions/configure-aws-credentials/releases) - [Changelog](https://github.com/aws-actions/configure-aws-credentials/blob/main/CHANGELOG.md) - [Commits](https://github.com/aws-actions/configure-aws-credentials/compare/ec61189d14ec14c8efccab744f656cffd0e33f37...d979d5b3a71173a29b74b5b88418bfda9437d885) --- updated-dependencies: - dependency-name: aws-actions/configure-aws-credentials dependency-version: 6.1.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Former-commit-id: 820978ae6f39acebde75b7f027f68598f460dbf1 --- .github/workflows/cicd.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index e216efd..4f8195e 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -68,7 +68,7 @@ jobs: echo "image_created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" - name: Configure AWS credentials (OIDC) - uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0 + uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1 with: role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }} role-duration-seconds: 3600 @@ -925,7 +925,7 @@ jobs: permissions: write-all steps: - name: Configure AWS credentials (OIDC) - uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0 + uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1 with: role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }} role-duration-seconds: 3600 From 74acbad133a14dc11d5bd487b1d15cedbb06abd3 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 11 May 2026 10:34:30 -0700 Subject: [PATCH 107/161] Update log message Former-commit-id: d28c2aaa87fab3ed8c914f43319683ea36e42797 --- get-newt.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/get-newt.sh b/get-newt.sh index 77df9ed..5390871 100644 --- a/get-newt.sh +++ b/get-newt.sh @@ -273,7 +273,7 @@ main() { CUSTOM_PATH=$(parse_path_arg "$@") if [ -n "$CUSTOM_PATH" ]; then - print_status "Installing latest version of newt to ${CUSTOM_PATH} (--path override)..." + print_status "Installing latest version of newt to ${CUSTOM_PATH}..." else print_status "Installing latest version of newt..." fi @@ -331,4 +331,4 @@ main() { } # Run main function -main "$@" \ No newline at end of file +main "$@" From c9ac8fc28fd716f002b20de837a40c242c93a836 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 11 May 2026 18:04:51 -0700 Subject: [PATCH 108/161] Remove test Former-commit-id: efa1ecf61e674ddfec3d40f828b6e79ec511f451 --- netstack2/http_handler_tls_test.go | 48 ------------------------------ 1 file changed, 48 deletions(-) delete mode 100644 netstack2/http_handler_tls_test.go diff --git a/netstack2/http_handler_tls_test.go b/netstack2/http_handler_tls_test.go deleted file mode 100644 index 0f2ffdc..0000000 --- a/netstack2/http_handler_tls_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package netstack2 - -import ( - "crypto/tls" - "net" - "testing" -) - -// tlsConnStub is a minimal net.Conn that also exposes TLS state, matching -// *tls.Conn's ConnectionState used by net/http.Server. -type tlsConnStub struct { - net.Conn - state tls.ConnectionState -} - -func (t *tlsConnStub) ConnectionState() tls.ConnectionState { - return t.state -} - -func TestHTTPConnCtxForwardsConnectionState(t *testing.T) { - c1, c2 := net.Pipe() - defer c1.Close() - defer c2.Close() - - inner := &tlsConnStub{ - Conn: c1, - state: tls.ConnectionState{Version: tls.VersionTLS12, HandshakeComplete: true}, - } - wrapped := &httpConnCtx{Conn: inner, rule: nil} - - got := wrapped.ConnectionState() - if got.Version != tls.VersionTLS12 || !got.HandshakeComplete { - t.Fatalf("ConnectionState = %+v, want TLS 1.2 and HandshakeComplete", got) - } -} - -func TestHTTPConnCtxConnectionStatePlainTCP(t *testing.T) { - c1, c2 := net.Pipe() - defer c1.Close() - defer c2.Close() - - wrapped := &httpConnCtx{Conn: c1, rule: nil} - got := wrapped.ConnectionState() - if got.Version != 0 { - t.Fatalf("expected zero ConnectionState for plain conn, got %+v", got) - } - _ = c2 -} From 832b06d8d57578ddf1051b6590685cd6568ab7df Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 11 May 2026 18:03:40 -0700 Subject: [PATCH 109/161] Add browser gateway Former-commit-id: 67b81c1f406900e9578e7e5eeca61892cfe36b99 --- browsergateway/main.go | 305 ++++++++++++++++++++++++++++++++++ browsergateway/rdcleanpath.go | 88 ++++++++++ browsergateway/ssh.go | 225 +++++++++++++++++++++++++ browsergateway/ssh_native.go | 107 ++++++++++++ go.mod | 2 + go.sum | 4 + 6 files changed, 731 insertions(+) create mode 100644 browsergateway/main.go create mode 100644 browsergateway/rdcleanpath.go create mode 100644 browsergateway/ssh.go create mode 100644 browsergateway/ssh_native.go diff --git a/browsergateway/main.go b/browsergateway/main.go new file mode 100644 index 0000000..0317167 --- /dev/null +++ b/browsergateway/main.go @@ -0,0 +1,305 @@ +package browsergateway + +import ( + "context" + "crypto/subtle" + "crypto/tls" + "encoding/binary" + "errors" + "fmt" + "io" + "log" + "net" + "net/http" + "time" + + "github.com/coder/websocket" +) + +// Forwarding buffer size. RDP graphics traffic is bursty and TLS records cap +// at ~16 KiB, so 64 KiB lets a couple of records pile up per syscall/frame +// without wasting memory per session. +const forwardBufSize = 64 * 1024 + +// Config holds the configuration for a Gateway. +type Config struct { + // AuthToken is the shared secret required by RDP clients in the RDCleanPath + // ProxyAuth field, and by SSH clients as the authToken query parameter. + AuthToken string + // NativeSSH, when non-nil, configures a local PTY/shell SSH mode instead + // of proxying to an external SSH server. + NativeSSH *NativeSSHConfig +} + +// Gateway is a browser-based RDP/SSH WebSocket proxy. +// Create one with New and mount it via RegisterHandlers or the individual +// HandleRDP / HandleSSH http.HandlerFunc methods. +type Gateway struct { + authToken string + nativeSSH *NativeSSHConfig +} + +// New creates a new Gateway from the provided Config. +func New(cfg Config) *Gateway { + return &Gateway{ + authToken: cfg.AuthToken, + nativeSSH: cfg.NativeSSH, + } +} + +// RegisterHandlers registers the /jet/rdp and /jet/ssh routes on mux. +func (g *Gateway) RegisterHandlers(mux *http.ServeMux) { + mux.HandleFunc("/rdp", g.HandleRDP) + mux.HandleFunc("/ssh", g.HandleSSH) +} + +// HandleRDP is an http.HandlerFunc for RDP-over-WebSocket connections. +func (g *Gateway) HandleRDP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + InsecureSkipVerify: true, // any-origin: minimal dev proxy with no auth + Subprotocols: []string{"binary"}, + }) + if err != nil { + log.Printf("websocket upgrade failed: %v", err) + return + } + // Disable per-message read size cap (default is 32 KiB which would break + // large RDP graphics messages). + ws.SetReadLimit(-1) + defer ws.CloseNow() //nolint:errcheck + + if err := g.serveSession(ctx, ws); err != nil { + log.Printf("session error: %v", err) + } +} + +func (g *Gateway) serveSession(ctx context.Context, ws *websocket.Conn) error { + // Expose the WebSocket as a streaming net.Conn. Binary messages are + // concatenated into a byte stream and writes become single binary frames. + // This is a thin wrapper with no per-message goroutine, unlike Gorilla. + stream := websocket.NetConn(ctx, ws, websocket.MessageBinary) + defer stream.Close() //nolint:errcheck + + // -- Read the initial RDCleanPath request from the client -- + pdu, err := readCleanPath(stream) + if err != nil { + return fmt.Errorf("read RDCleanPath: %w", err) + } + + if pdu.Destination == "" { + return errors.New("RDCleanPath missing destination") + } + if len(pdu.X224) == 0 { + return errors.New("RDCleanPath missing X224 connection PDU") + } + + // Constant-time comparison to avoid leaking the expected token via timing. + if subtle.ConstantTimeCompare([]byte(pdu.ProxyAuth), []byte(g.authToken)) != 1 { + return errors.New("RDCleanPath ProxyAuth token mismatch") + } + + target := pdu.Destination + // Default port for RDP if not specified. + if _, _, splitErr := net.SplitHostPort(target); splitErr != nil { + target = net.JoinHostPort(target, "3389") + } + + log.Printf("Connecting to RDP server %s", target) + + // -- Open TCP connection to the destination RDP server -- + serverTCP, err := net.DialTimeout("tcp", target, 15*time.Second) + if err != nil { + return fmt.Errorf("dial %s: %w", target, err) + } + defer serverTCP.Close() + if tcp, ok := serverTCP.(*net.TCPConn); ok { + // NoDelay is Go's default; set explicitly. RDP wants low latency for + // input echo, and the bulk path is naturally chunked by TLS records. + _ = tcp.SetNoDelay(true) + _ = tcp.SetKeepAlive(true) + _ = tcp.SetKeepAlivePeriod(30 * time.Second) + _ = tcp.SetReadBuffer(forwardBufSize) + _ = tcp.SetWriteBuffer(forwardBufSize) + } + serverAddr := serverTCP.RemoteAddr().String() + + // Forward the optional pre-connection blob, then the X.224 connection request. + if pdu.PreconnectionBlob != "" { + if _, err := serverTCP.Write([]byte(pdu.PreconnectionBlob)); err != nil { + return fmt.Errorf("send PCB: %w", err) + } + } + if _, err := serverTCP.Write(pdu.X224); err != nil { + return fmt.Errorf("send X224: %w", err) + } + + // -- Read the X.224 connection confirm from the server -- + x224Rsp, err := readX224(serverTCP) + if err != nil { + return fmt.Errorf("read X224 response: %w", err) + } + logX224Negotiation(x224Rsp) + + // -- Upgrade the server connection to TLS (skip verification) -- + // + // Windows RDP hosts are picky: only set SNI when the target is a hostname + // (Go would skip SNI for IP literals anyway, but be explicit), and accept + // the full range of TLS versions / ciphers since some servers only + // negotiate TLS 1.0 or legacy suites. + host, _, _ := net.SplitHostPort(target) + tlsCfg := &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // proxy intentionally skips verification + MinVersion: tls.VersionTLS10, + // Cap at TLS 1.2: Windows RDP servers commonly send a TLS "internal_error" + // alert when CredSSP/NLA is layered on top of a TLS 1.3 session. + MaxVersion: tls.VersionTLS12, + } + if net.ParseIP(host) == nil { + tlsCfg.ServerName = host + } + tlsConn := tls.Client(serverTCP, tlsCfg) + if err := tlsConn.Handshake(); err != nil { + return fmt.Errorf("TLS handshake with server: %w", err) + } + log.Printf("Server TLS handshake OK (version=0x%04x cipher=0x%04x)", + tlsConn.ConnectionState().Version, tlsConn.ConnectionState().CipherSuite) + + // Collect the raw DER server certificate chain to return to the client. + state := tlsConn.ConnectionState() + if len(state.PeerCertificates) == 0 { + return errors.New("server did not present any certificates") + } + chain := make([][]byte, 0, len(state.PeerCertificates)) + for _, c := range state.PeerCertificates { + chain = append(chain, c.Raw) + } + + // -- Send the RDCleanPath response back to the client -- + rsp, err := encodeRDCleanPathResponse(serverAddr, x224Rsp, chain) + if err != nil { + return fmt.Errorf("encode RDCleanPath response: %w", err) + } + if _, err := stream.Write(rsp); err != nil { + return fmt.Errorf("write RDCleanPath response: %w", err) + } + + log.Printf("RDCleanPath handshake complete, forwarding traffic to %s", serverAddr) + + // -- Two-way blind forwarding of the (now TLS-encrypted) RDP stream -- + return forward(stream, tlsConn) +} + +// readCleanPath buffers bytes from the stream until a full RDCleanPath PDU has +// been received, then decodes it. +func readCleanPath(r io.Reader) (*rdCleanPathPdu, error) { + buf := make([]byte, 0, 1024) + tmp := make([]byte, 1024) + for { + total := detectRDCleanPathLength(buf) + switch { + case total == -2: + return nil, errors.New("invalid RDCleanPath PDU") + case total > 0 && len(buf) >= total: + return decodeRDCleanPathRequest(buf[:total]) + } + n, err := r.Read(tmp) + if n > 0 { + buf = append(buf, tmp[:n]...) + } + if err != nil { + return nil, err + } + } +} + +// readX224 reads exactly one TPKT-framed X.224 PDU from the server. +// +// The TPKT header is 4 bytes: version (0x03), reserved (0x00), and a u16 +// big-endian total length that includes the header itself. +func readX224(r io.Reader) ([]byte, error) { + hdr := make([]byte, 4) + if _, err := io.ReadFull(r, hdr); err != nil { + return nil, err + } + if hdr[0] != 0x03 { + return nil, fmt.Errorf("unexpected TPKT version 0x%02x", hdr[0]) + } + total := int(binary.BigEndian.Uint16(hdr[2:4])) + if total < 4 || total > 4096 { + return nil, fmt.Errorf("unreasonable TPKT length %d", total) + } + out := make([]byte, total) + copy(out, hdr) + if _, err := io.ReadFull(r, out[4:]); err != nil { + return nil, err + } + return out, nil +} + +// logX224Negotiation prints which RDP security protocol the server selected +// (or the failure code), to help diagnose handshake issues such as the server +// requiring NLA/CredSSP. +// +// X.224 Connection Confirm layout (RFC 1006 / [MS-RDPBCGR]): +// +// bytes 0..3 TPKT header (03 00 LL LL) +// byte 4 X.224 length indicator +// byte 5 X.224 code (0xD0 = CC) +// bytes 6..10 DST-REF, SRC-REF, class +// byte 11 optional RDP Negotiation type (0x02 = response, 0x03 = failure) +// byte 12 flags +// bytes 13..14 length (little-endian, =8) +// bytes 15..18 selected protocol / failure code (u32 little-endian) +func logX224Negotiation(pdu []byte) { + if len(pdu) < 19 { + log.Printf("X.224 response too short (%d bytes) to contain RDP negotiation", len(pdu)) + return + } + switch pdu[11] { + case 0x02: + proto := uint32(pdu[15]) | uint32(pdu[16])<<8 | uint32(pdu[17])<<16 | uint32(pdu[18])<<24 + name := "unknown" + switch proto { + case 0: + name = "RDP (standard)" + case 1: + name = "SSL/TLS" + case 2: + name = "HYBRID (CredSSP/NLA)" + case 8: + name = "HYBRID_EX" + } + log.Printf("Server selected RDP protocol 0x%x (%s)", proto, name) + case 0x03: + code := uint32(pdu[15]) | uint32(pdu[16])<<8 | uint32(pdu[17])<<16 | uint32(pdu[18])<<24 + log.Printf("Server returned RDP negotiation failure code 0x%x", code) + default: + log.Printf("X.224 response has no RDP negotiation block (type=0x%02x)", pdu[11]) + } +} + +// forward shuttles bytes between the two streams until either side closes. +func forward(a, b io.ReadWriteCloser) error { + errc := make(chan error, 2) + go func() { + buf := make([]byte, forwardBufSize) + _, err := io.CopyBuffer(a, b, buf) + _ = a.Close() + _ = b.Close() + errc <- err + }() + go func() { + buf := make([]byte, forwardBufSize) + _, err := io.CopyBuffer(b, a, buf) + _ = a.Close() + _ = b.Close() + errc <- err + }() + // Wait for one side to finish, then return. + err := <-errc + if errors.Is(err, io.EOF) || err == nil { + return nil + } + return err +} diff --git a/browsergateway/rdcleanpath.go b/browsergateway/rdcleanpath.go new file mode 100644 index 0000000..28dd486 --- /dev/null +++ b/browsergateway/rdcleanpath.go @@ -0,0 +1,88 @@ +package browsergateway + +import ( + "encoding/asn1" + "fmt" +) + +// RDCleanPath PDU version (BASE_VERSION + 1 = 3389 + 1). +const rdCleanPathVersion = int64(3390) + +// rdCleanPathPdu is a Go translation of the ASN.1 SEQUENCE defined in the +// ironrdp-rdcleanpath crate. All optional fields use EXPLICIT context-specific +// tagging, matching the Rust `der::Sequence` derivation with +// `tag_mode = "EXPLICIT"`. +// +// We only need a subset of fields for the basic proxy flow, but the struct +// declares every tag we may encounter so that decoding does not fail on an +// unexpected element. +type rdCleanPathPdu struct { + Version int64 `asn1:"explicit,tag:0"` + Destination string `asn1:"explicit,tag:2,optional,utf8"` + ProxyAuth string `asn1:"explicit,tag:3,optional,utf8"` + ServerAuth string `asn1:"explicit,tag:4,optional,utf8"` + PreconnectionBlob string `asn1:"explicit,tag:5,optional,utf8"` + X224 []byte `asn1:"explicit,tag:6,optional"` + ServerCertChain [][]byte `asn1:"explicit,tag:7,optional"` + ServerAddr string `asn1:"explicit,tag:9,optional,utf8"` +} + +// decodeRDCleanPathRequest parses a client-to-proxy RDCleanPath PDU. +func decodeRDCleanPathRequest(buf []byte) (*rdCleanPathPdu, error) { + var pdu rdCleanPathPdu + rest, err := asn1.Unmarshal(buf, &pdu) + if err != nil { + return nil, fmt.Errorf("asn1 unmarshal: %w", err) + } + if len(rest) != 0 { + return nil, fmt.Errorf("trailing data after RDCleanPath PDU: %d bytes", len(rest)) + } + if pdu.Version != rdCleanPathVersion { + return nil, fmt.Errorf("unexpected RDCleanPath version: %d", pdu.Version) + } + return &pdu, nil +} + +// encodeRDCleanPathResponse builds a proxy-to-client RDCleanPath response PDU +// containing the server address, X.224 connection confirm and server TLS chain. +func encodeRDCleanPathResponse(serverAddr string, x224Rsp []byte, certChain [][]byte) ([]byte, error) { + pdu := rdCleanPathPdu{ + Version: rdCleanPathVersion, + X224: x224Rsp, + ServerCertChain: certChain, + ServerAddr: serverAddr, + } + return asn1.Marshal(pdu) +} + +// detectRDCleanPathLength returns the total DER length of an RDCleanPath PDU +// if enough bytes are available, otherwise -1. +// +// The PDU is a DER SEQUENCE, which begins with the universal SEQUENCE tag +// (0x30) followed by a length octet/octets. We parse just enough to know the +// total length so we can buffer accordingly. +func detectRDCleanPathLength(buf []byte) int { + if len(buf) < 2 { + return -1 + } + if buf[0] != 0x30 { + // Not a SEQUENCE: cannot be RDCleanPath. + return -2 + } + l := buf[1] + if l < 0x80 { + return 2 + int(l) + } + n := int(l & 0x7f) + if n == 0 || n > 4 { + return -2 + } + if len(buf) < 2+n { + return -1 + } + total := 0 + for i := 0; i < n; i++ { + total = (total << 8) | int(buf[2+i]) + } + return 2 + n + total +} diff --git a/browsergateway/ssh.go b/browsergateway/ssh.go new file mode 100644 index 0000000..290eb5a --- /dev/null +++ b/browsergateway/ssh.go @@ -0,0 +1,225 @@ +package browsergateway + +import ( + "context" + "crypto/subtle" + "encoding/json" + "fmt" + "log" + "net" + "net/http" + "time" + + "github.com/coder/websocket" + "golang.org/x/crypto/ssh" +) + +// sshClientMsg is a JSON message sent from the browser to the proxy. +type sshClientMsg struct { + // type: "auth" | "data" | "resize" + Type string `json:"type"` + Password string `json:"password,omitempty"` // used when type="auth" + Data string `json:"data,omitempty"` // used when type="data" + Cols uint32 `json:"cols,omitempty"` // used when type="resize" + Rows uint32 `json:"rows,omitempty"` // used when type="resize" +} + +// sshServerMsg is a JSON message sent from the proxy back to the browser. +type sshServerMsg struct { + // type: "data" | "error" + Type string `json:"type"` + Data string `json:"data,omitempty"` + Error string `json:"error,omitempty"` +} + +// HandleSSH is an http.HandlerFunc for SSH-over-WebSocket connections. +func (g *Gateway) HandleSSH(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // -- Validate auth token from query parameter before upgrading -- + token := r.URL.Query().Get("authToken") + if subtle.ConstantTimeCompare([]byte(token), []byte(g.authToken)) != 1 { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + // In proxy mode we also need host + username from query params. + var target, username string + if g.nativeSSH == nil { + host := r.URL.Query().Get("host") + port := r.URL.Query().Get("port") + username = r.URL.Query().Get("username") + if host == "" || username == "" { + http.Error(w, "missing host or username", http.StatusBadRequest) + return + } + if port == "" { + port = "22" + } + target = net.JoinHostPort(host, port) + } + + ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + InsecureSkipVerify: true, + Subprotocols: []string{"ssh"}, + }) + if err != nil { + log.Printf("SSH websocket upgrade failed: %v", err) + return + } + ws.SetReadLimit(-1) + defer ws.CloseNow() //nolint:errcheck + + if g.nativeSSH != nil { + if err := serveNativeSSHSession(ctx, ws, *g.nativeSSH); err != nil { + log.Printf("SSH native session error: %v", err) + } + } else { + if err := serveSSHSession(ctx, ws, target, username, g.authToken); err != nil { + log.Printf("SSH session error: %v", err) + } + } +} + +func serveSSHSession(ctx context.Context, ws *websocket.Conn, target, username, _ string) error { + // -- Wait for the auth message from the client to get the password -- + _, authBytes, err := ws.Read(ctx) + if err != nil { + return fmt.Errorf("read auth message: %w", err) + } + var authMsg sshClientMsg + if err := json.Unmarshal(authBytes, &authMsg); err != nil || authMsg.Type != "auth" { + return fmt.Errorf("expected auth message, got: %s", authBytes) + } + password := authMsg.Password + + // -- Dial the SSH server -- + log.Printf("SSH: connecting to %s as %s", target, username) + sshCfg := &ssh.ClientConfig{ + User: username, + Auth: []ssh.AuthMethod{ + ssh.Password(password), + }, + // HostKeyCallback is intentionally InsecureIgnoreHostKey for this dev + // proxy. In production, verify against a known-hosts store. + HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec + Timeout: 15 * time.Second, + } + + sshClient, err := ssh.Dial("tcp", target, sshCfg) + if err != nil { + sendSSHError(ctx, ws, fmt.Sprintf("SSH dial failed: %v", err)) + return fmt.Errorf("ssh dial %s: %w", target, err) + } + defer sshClient.Close() + + // -- Open an interactive session -- + sess, err := sshClient.NewSession() + if err != nil { + sendSSHError(ctx, ws, fmt.Sprintf("Failed to open SSH session: %v", err)) + return fmt.Errorf("ssh new session: %w", err) + } + defer sess.Close() + + // Request a PTY. + if err := sess.RequestPty("xterm-256color", 24, 80, ssh.TerminalModes{ + ssh.ECHO: 1, + ssh.TTY_OP_ISPEED: 38400, + ssh.TTY_OP_OSPEED: 38400, + }); err != nil { + sendSSHError(ctx, ws, fmt.Sprintf("Failed to request PTY: %v", err)) + return fmt.Errorf("ssh request pty: %w", err) + } + + stdinPipe, err := sess.StdinPipe() + if err != nil { + return fmt.Errorf("ssh stdin pipe: %w", err) + } + stdoutPipe, err := sess.StdoutPipe() + if err != nil { + return fmt.Errorf("ssh stdout pipe: %w", err) + } + stderrPipe, err := sess.StderrPipe() + if err != nil { + return fmt.Errorf("ssh stderr pipe: %w", err) + } + + if err := sess.Shell(); err != nil { + sendSSHError(ctx, ws, fmt.Sprintf("Failed to start shell: %v", err)) + return fmt.Errorf("ssh shell: %w", err) + } + + log.Printf("SSH: session established with %s", target) + + // -- Pump SSH stdout/stderr → WebSocket -- + sessCtx, cancelSess := context.WithCancel(ctx) + defer cancelSess() + + go func() { + buf := make([]byte, 4096) + for { + n, readErr := stdoutPipe.Read(buf) + if n > 0 { + msg := sshServerMsg{Type: "data", Data: string(buf[:n])} + b, _ := json.Marshal(msg) + if writeErr := ws.Write(sessCtx, websocket.MessageText, b); writeErr != nil { + return + } + } + if readErr != nil { + cancelSess() + return + } + } + }() + + go func() { + buf := make([]byte, 4096) + for { + n, readErr := stderrPipe.Read(buf) + if n > 0 { + msg := sshServerMsg{Type: "data", Data: string(buf[:n])} + b, _ := json.Marshal(msg) + if writeErr := ws.Write(sessCtx, websocket.MessageText, b); writeErr != nil { + return + } + } + if readErr != nil { + return + } + } + }() + + // -- Pump WebSocket input → SSH stdin / resize -- + for { + _, msgBytes, readErr := ws.Read(sessCtx) + if readErr != nil { + break + } + + var msg sshClientMsg + if err := json.Unmarshal(msgBytes, &msg); err != nil { + continue + } + + switch msg.Type { + case "data": + if _, err := stdinPipe.Write([]byte(msg.Data)); err != nil { + return fmt.Errorf("write ssh stdin: %w", err) + } + case "resize": + if msg.Cols > 0 && msg.Rows > 0 { + _ = sess.WindowChange(int(msg.Rows), int(msg.Cols)) + } + } + } + + return nil +} + +// sendSSHError sends an error message to the browser and logs it. +func sendSSHError(ctx context.Context, ws *websocket.Conn, msg string) { + log.Printf("SSH error: %s", msg) + b, _ := json.Marshal(sshServerMsg{Type: "error", Error: msg}) + _ = ws.Write(ctx, websocket.MessageText, b) +} diff --git a/browsergateway/ssh_native.go b/browsergateway/ssh_native.go new file mode 100644 index 0000000..b65eb83 --- /dev/null +++ b/browsergateway/ssh_native.go @@ -0,0 +1,107 @@ +package browsergateway + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + "os/exec" + + "github.com/coder/websocket" + "github.com/creack/pty" +) + +// NativeSSHConfig holds configuration for the native PTY/shell mode. +type NativeSSHConfig struct { + // Shell is the executable to spawn (e.g. /bin/bash). Defaults to /bin/sh. + Shell string +} + +// serveNativeSSHSession handles a WebSocket SSH session by spawning a local +// PTY+shell instead of proxying to an external SSH server. The auth token has +// already been validated at the WebSocket upgrade level, so this function only +// reads (and discards) the initial "auth" frame for protocol compatibility with +// the browser client before starting the shell. +func serveNativeSSHSession(ctx context.Context, ws *websocket.Conn, cfg NativeSSHConfig) error { + // Read and discard the auth frame (token already validated at HTTP layer). + _, authBytes, err := ws.Read(ctx) + if err != nil { + return fmt.Errorf("read auth message: %w", err) + } + var authMsg sshClientMsg + if err := json.Unmarshal(authBytes, &authMsg); err != nil || authMsg.Type != "auth" { + return fmt.Errorf("expected auth message, got: %s", authBytes) + } + + shell := cfg.Shell + if shell == "" { + shell = "/bin/sh" + } + + log.Printf("SSH native: spawning %s", shell) + + cmd := exec.CommandContext(ctx, shell) + cmd.Env = append(os.Environ(), "TERM=xterm-256color") + + // Start the command with a PTY attached. + ptmx, err := pty.Start(cmd) + if err != nil { + sendSSHError(ctx, ws, fmt.Sprintf("Failed to spawn shell: %v", err)) + return fmt.Errorf("pty start: %w", err) + } + defer func() { + _ = ptmx.Close() + _ = cmd.Wait() + }() + + // Cancel context to unblock the WebSocket read loop when the shell exits. + sessCtx, cancelSess := context.WithCancel(ctx) + defer cancelSess() + + // Pump PTY output → WebSocket. + go func() { + defer cancelSess() + buf := make([]byte, 4096) + for { + n, readErr := ptmx.Read(buf) + if n > 0 { + msg := sshServerMsg{Type: "data", Data: string(buf[:n])} + b, _ := json.Marshal(msg) + if writeErr := ws.Write(sessCtx, websocket.MessageText, b); writeErr != nil { + return + } + } + if readErr != nil { + return + } + } + }() + + // Pump WebSocket input → PTY stdin / resize. + for { + _, msgBytes, readErr := ws.Read(sessCtx) + if readErr != nil { + break + } + var msg sshClientMsg + if err := json.Unmarshal(msgBytes, &msg); err != nil { + continue + } + switch msg.Type { + case "data": + if _, writeErr := ptmx.Write([]byte(msg.Data)); writeErr != nil { + return fmt.Errorf("write pty: %w", writeErr) + } + case "resize": + if msg.Cols > 0 && msg.Rows > 0 { + _ = pty.Setsize(ptmx, &pty.Winsize{ + Cols: uint16(msg.Cols), + Rows: uint16(msg.Rows), + }) + } + } + } + + return nil +} diff --git a/go.mod b/go.mod index d182bff..1508e71 100644 --- a/go.mod +++ b/go.mod @@ -35,8 +35,10 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/coder/websocket v1.8.14 // indirect github.com/containerd/errdefs v0.3.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/creack/pty v1.1.24 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.4.0 // indirect diff --git a/go.sum b/go.sum index a60e6db..c79a283 100644 --- a/go.sum +++ b/go.sum @@ -8,12 +8,16 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/containerd/errdefs v0.3.0 h1:FSZgGOeK4yuT/+DnF07/Olde/q4KBoMsaamhXxIMDp4= github.com/containerd/errdefs v0.3.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= From 7ace4978a41897a66375cf2e1823c0685bb68e23 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 11 May 2026 21:13:55 -0700 Subject: [PATCH 110/161] Add vnc Former-commit-id: 825a7f460f9a789c2d6995b343ba0c6c21e901bd --- browsergateway/main.go | 1 + browsergateway/vnc.go | 96 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 browsergateway/vnc.go diff --git a/browsergateway/main.go b/browsergateway/main.go index 0317167..2c580ab 100644 --- a/browsergateway/main.go +++ b/browsergateway/main.go @@ -51,6 +51,7 @@ func New(cfg Config) *Gateway { func (g *Gateway) RegisterHandlers(mux *http.ServeMux) { mux.HandleFunc("/rdp", g.HandleRDP) mux.HandleFunc("/ssh", g.HandleSSH) + mux.HandleFunc("/vnc", g.handleVNC) } // HandleRDP is an http.HandlerFunc for RDP-over-WebSocket connections. diff --git a/browsergateway/vnc.go b/browsergateway/vnc.go new file mode 100644 index 0000000..57a93a7 --- /dev/null +++ b/browsergateway/vnc.go @@ -0,0 +1,96 @@ +package browsergateway + +import ( + "context" + "crypto/subtle" + "io" + "log" + "net" + "net/http" + "time" + + "github.com/coder/websocket" +) + +const ( + vncDialTimeout = 10 * time.Second + vncKeepAlive = 30 * time.Second + vncForwardBufSize = 32 * 1024 +) + +// handleVNC proxies a noVNC WebSocket connection to a raw TCP VNC backend. +// It follows the same auth-token-in-query-param pattern as handleSSH. +// +// Query parameters: +// +// authToken – shared secret matching the -auth-token flag +// host – VNC backend hostname or IP +// port – VNC backend port (default: 5900) +func (g *Gateway) handleVNC(w http.ResponseWriter, r *http.Request) { + if subtle.ConstantTimeCompare([]byte(r.URL.Query().Get("authToken")), []byte(g.authToken)) != 1 { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + host := r.URL.Query().Get("host") + port := r.URL.Query().Get("port") + if host == "" { + http.Error(w, "missing host", http.StatusBadRequest) + return + } + if port == "" { + port = "5900" + } + target := net.JoinHostPort(host, port) + + // Accept the WebSocket. noVNC negotiates the "binary" subprotocol; + // fall back gracefully when the client sends "base64" as well. + ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + InsecureSkipVerify: true, + Subprotocols: []string{"binary", "base64"}, + }) + if err != nil { + log.Printf("vnc: websocket upgrade failed: %v", err) + return + } + ws.SetReadLimit(-1) + defer ws.CloseNow() //nolint:errcheck + + ctx := r.Context() + if err := serveVNC(ctx, ws, target); err != nil { + log.Printf("vnc: session error (%s): %v", target, err) + } +} + +func serveVNC(ctx context.Context, ws *websocket.Conn, target string) error { + // Dial the VNC backend TCP server. + dialer := &net.Dialer{ + Timeout: vncDialTimeout, + KeepAlive: vncKeepAlive, + } + conn, err := dialer.DialContext(ctx, "tcp", target) + if err != nil { + return err + } + defer conn.Close() //nolint:errcheck + + // Expose the WebSocket as a plain net.Conn byte stream (binary frames). + stream := websocket.NetConn(ctx, ws, websocket.MessageBinary) + defer stream.Close() //nolint:errcheck + + // Proxy bidirectionally: VNC backend <-> browser. + errc := make(chan error, 2) + go func() { + buf := make([]byte, vncForwardBufSize) + _, err := io.CopyBuffer(conn, stream, buf) + errc <- err + }() + go func() { + buf := make([]byte, vncForwardBufSize) + _, err := io.CopyBuffer(stream, conn, buf) + errc <- err + }() + + // Return when either direction closes. + err = <-errc + return err +} From b72c8e643d0ca9d7242909d7dd0fe614421b0e2e Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 11 May 2026 21:49:55 -0700 Subject: [PATCH 111/161] Split out rdp Former-commit-id: bd53edf8e4fa788b1474bf5a555161deb75b4617 --- browsergateway/browsergateway.go | 43 ++++++++++++++++++++++++++++++ browsergateway/{main.go => rdp.go} | 38 -------------------------- 2 files changed, 43 insertions(+), 38 deletions(-) create mode 100644 browsergateway/browsergateway.go rename browsergateway/{main.go => rdp.go} (86%) diff --git a/browsergateway/browsergateway.go b/browsergateway/browsergateway.go new file mode 100644 index 0000000..2f3a2e5 --- /dev/null +++ b/browsergateway/browsergateway.go @@ -0,0 +1,43 @@ +package browsergateway + +import ( + "net/http" +) + +// Forwarding buffer size. RDP graphics traffic is bursty and TLS records cap +// at ~16 KiB, so 64 KiB lets a couple of records pile up per syscall/frame +// without wasting memory per session. +const forwardBufSize = 64 * 1024 + +// Config holds the configuration for a Gateway. +type Config struct { + // AuthToken is the shared secret required by RDP clients in the RDCleanPath + // ProxyAuth field, and by SSH clients as the authToken query parameter. + AuthToken string + // NativeSSH, when non-nil, configures a local PTY/shell SSH mode instead + // of proxying to an external SSH server. + NativeSSH *NativeSSHConfig +} + +// Gateway is a browser-based RDP/SSH/VNC WebSocket proxy. +// Create one with New and mount it via RegisterHandlers or the individual +// HandleRDP / HandleSSH / HandleVNC http.HandlerFunc methods. +type Gateway struct { + authToken string + nativeSSH *NativeSSHConfig +} + +// New creates a new Gateway from the provided Config. +func New(cfg Config) *Gateway { + return &Gateway{ + authToken: cfg.AuthToken, + nativeSSH: cfg.NativeSSH, + } +} + +// RegisterHandlers registers the /rdp, /ssh, and /vnc routes on mux. +func (g *Gateway) RegisterHandlers(mux *http.ServeMux) { + mux.HandleFunc("/rdp", g.HandleRDP) + mux.HandleFunc("/ssh", g.HandleSSH) + mux.HandleFunc("/vnc", g.handleVNC) +} diff --git a/browsergateway/main.go b/browsergateway/rdp.go similarity index 86% rename from browsergateway/main.go rename to browsergateway/rdp.go index 2c580ab..dbd58e0 100644 --- a/browsergateway/main.go +++ b/browsergateway/rdp.go @@ -16,44 +16,6 @@ import ( "github.com/coder/websocket" ) -// Forwarding buffer size. RDP graphics traffic is bursty and TLS records cap -// at ~16 KiB, so 64 KiB lets a couple of records pile up per syscall/frame -// without wasting memory per session. -const forwardBufSize = 64 * 1024 - -// Config holds the configuration for a Gateway. -type Config struct { - // AuthToken is the shared secret required by RDP clients in the RDCleanPath - // ProxyAuth field, and by SSH clients as the authToken query parameter. - AuthToken string - // NativeSSH, when non-nil, configures a local PTY/shell SSH mode instead - // of proxying to an external SSH server. - NativeSSH *NativeSSHConfig -} - -// Gateway is a browser-based RDP/SSH WebSocket proxy. -// Create one with New and mount it via RegisterHandlers or the individual -// HandleRDP / HandleSSH http.HandlerFunc methods. -type Gateway struct { - authToken string - nativeSSH *NativeSSHConfig -} - -// New creates a new Gateway from the provided Config. -func New(cfg Config) *Gateway { - return &Gateway{ - authToken: cfg.AuthToken, - nativeSSH: cfg.NativeSSH, - } -} - -// RegisterHandlers registers the /jet/rdp and /jet/ssh routes on mux. -func (g *Gateway) RegisterHandlers(mux *http.ServeMux) { - mux.HandleFunc("/rdp", g.HandleRDP) - mux.HandleFunc("/ssh", g.HandleSSH) - mux.HandleFunc("/vnc", g.handleVNC) -} - // HandleRDP is an http.HandlerFunc for RDP-over-WebSocket connections. func (g *Gateway) HandleRDP(w http.ResponseWriter, r *http.Request) { ctx := r.Context() From c55071c60aae4ff93c3aa532d1a4ea120c95965d Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 13 May 2026 16:24:26 -0700 Subject: [PATCH 112/161] Basic browser gateway target support Former-commit-id: 559b7021fece50378719f2ed85de6eb7022cf048 --- browsergateway/browsergateway.go | 77 +++++++++++++++++++++++++++ browsergateway/rdp.go | 8 +++ browsergateway/ssh.go | 6 +++ browsergateway/vnc.go | 6 +++ main.go | 91 +++++++++++++++++++++++++------- 5 files changed, 170 insertions(+), 18 deletions(-) diff --git a/browsergateway/browsergateway.go b/browsergateway/browsergateway.go index 2f3a2e5..56faffc 100644 --- a/browsergateway/browsergateway.go +++ b/browsergateway/browsergateway.go @@ -1,7 +1,10 @@ package browsergateway import ( + "errors" + "net" "net/http" + "sync" ) // Forwarding buffer size. RDP graphics traffic is bursty and TLS records cap @@ -9,6 +12,24 @@ import ( // without wasting memory per session. const forwardBufSize = 64 * 1024 +// ListenPort is the port the browser gateway HTTP server listens on inside the +// WireGuard netstack. This is a fixed value shared between newt and pangolin. +const ListenPort = 8082 + +// HardcodedAuthToken is a temporary shared secret used during development. +// TODO: replace with a per-session token negotiated with pangolin. +const HardcodedAuthToken = "pangolin-browser-gateway-dev" + +// Target represents an allowed proxy destination for the browser gateway. +// Only connections whose (Type, Destination, DestinationPort) match a +// registered Target will be forwarded; all others are rejected. +type Target struct { + ID int + Type string // "rdp" | "ssh" | "vnc" + Destination string + DestinationPort int +} + // Config holds the configuration for a Gateway. type Config struct { // AuthToken is the shared secret required by RDP clients in the RDCleanPath @@ -25,6 +46,11 @@ type Config struct { type Gateway struct { authToken string nativeSSH *NativeSSHConfig + + mu sync.RWMutex + targets map[int]Target // keyed by Target.ID + + server *http.Server } // New creates a new Gateway from the provided Config. @@ -32,9 +58,60 @@ func New(cfg Config) *Gateway { return &Gateway{ authToken: cfg.AuthToken, nativeSSH: cfg.NativeSSH, + targets: make(map[int]Target), } } +// SetTargets replaces the entire allowed-destination list atomically. +func (g *Gateway) SetTargets(targets []Target) { + g.mu.Lock() + defer g.mu.Unlock() + g.targets = make(map[int]Target, len(targets)) + for _, t := range targets { + g.targets[t.ID] = t + } +} + +// AddTarget adds or updates a single allowed destination. +func (g *Gateway) AddTarget(t Target) { + g.mu.Lock() + defer g.mu.Unlock() + g.targets[t.ID] = t +} + +// RemoveTarget removes an allowed destination by its ID. +func (g *Gateway) RemoveTarget(id int) { + g.mu.Lock() + defer g.mu.Unlock() + delete(g.targets, id) +} + +// isAllowed reports whether a connection to (targetType, host, port) is +// permitted by the current target list. +func (g *Gateway) isAllowed(targetType, host string, port int) bool { + g.mu.RLock() + defer g.mu.RUnlock() + for _, t := range g.targets { + if t.Type == targetType && t.Destination == host && t.DestinationPort == port { + return true + } + } + return false +} + +// Start serves the browser gateway HTTP server on the provided listener. +// It returns nil when the listener is closed (normal shutdown). +func (g *Gateway) Start(ln net.Listener) error { + mux := http.NewServeMux() + g.RegisterHandlers(mux) + g.server = &http.Server{Handler: mux} + err := g.server.Serve(ln) + if errors.Is(err, net.ErrClosed) || errors.Is(err, http.ErrServerClosed) { + return nil + } + return err +} + // RegisterHandlers registers the /rdp, /ssh, and /vnc routes on mux. func (g *Gateway) RegisterHandlers(mux *http.ServeMux) { mux.HandleFunc("/rdp", g.HandleRDP) diff --git a/browsergateway/rdp.go b/browsergateway/rdp.go index dbd58e0..78046b3 100644 --- a/browsergateway/rdp.go +++ b/browsergateway/rdp.go @@ -11,6 +11,7 @@ import ( "log" "net" "net/http" + "strconv" "time" "github.com/coder/websocket" @@ -68,6 +69,13 @@ func (g *Gateway) serveSession(ctx context.Context, ws *websocket.Conn) error { target = net.JoinHostPort(target, "3389") } + // Validate destination against the registered target allowlist. + rdpHost, rdpPortStr, _ := net.SplitHostPort(target) + rdpPort, _ := strconv.Atoi(rdpPortStr) + if !g.isAllowed("rdp", rdpHost, rdpPort) { + return fmt.Errorf("RDP destination %s is not in the allowed target list", target) + } + log.Printf("Connecting to RDP server %s", target) // -- Open TCP connection to the destination RDP server -- diff --git a/browsergateway/ssh.go b/browsergateway/ssh.go index 290eb5a..0596f99 100644 --- a/browsergateway/ssh.go +++ b/browsergateway/ssh.go @@ -8,6 +8,7 @@ import ( "log" "net" "net/http" + "strconv" "time" "github.com/coder/websocket" @@ -56,6 +57,11 @@ func (g *Gateway) HandleSSH(w http.ResponseWriter, r *http.Request) { if port == "" { port = "22" } + sshPort, _ := strconv.Atoi(port) + if !g.isAllowed("ssh", host, sshPort) { + http.Error(w, "destination not allowed", http.StatusForbidden) + return + } target = net.JoinHostPort(host, port) } diff --git a/browsergateway/vnc.go b/browsergateway/vnc.go index 57a93a7..55ec439 100644 --- a/browsergateway/vnc.go +++ b/browsergateway/vnc.go @@ -7,6 +7,7 @@ import ( "log" "net" "net/http" + "strconv" "time" "github.com/coder/websocket" @@ -40,6 +41,11 @@ func (g *Gateway) handleVNC(w http.ResponseWriter, r *http.Request) { if port == "" { port = "5900" } + vncPort, _ := strconv.Atoi(port) + if !g.isAllowed("vnc", host, vncPort) { + http.Error(w, "destination not allowed", http.StatusForbidden) + return + } target := net.JoinHostPort(host, port) // Accept the WebSocket. noVNC negotiates the "binary" subprotocol; diff --git a/main.go b/main.go index 448f71d..c86a35c 100644 --- a/main.go +++ b/main.go @@ -22,6 +22,7 @@ import ( "time" "github.com/fosrl/newt/authdaemon" + "github.com/fosrl/newt/browsergateway" "github.com/fosrl/newt/docker" "github.com/fosrl/newt/healthcheck" "github.com/fosrl/newt/logger" @@ -40,15 +41,23 @@ import ( "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"` +} + 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"` - ChainId string `json:"chainId"` + Endpoint string `json:"endpoint"` + RelayPort uint16 `json:"relayPort"` + PublicKey string `json:"publicKey"` + ServerIP string `json:"serverIP"` + TunnelIP string `json:"tunnelIP"` + Targets TargetsByType `json:"targets"` + HealthCheckTargets []healthcheck.Config `json:"healthCheckTargets"` + BrowserGatewayTargets []BrowserGatewayTarget `json:"browserGatewayTargets"` + ChainId string `json:"chainId"` } type TargetsByType struct { @@ -134,6 +143,8 @@ var ( pingStopChan chan struct{} stopFunc func() pendingRegisterChainId string + browserGateway *browsergateway.Gateway + browserGatewayStop func() pendingPingChainId string healthFile string useNativeInterface bool @@ -150,15 +161,15 @@ var ( newtVersion = "version_replaceme" // Observability/metrics flags - metricsEnabled bool - otlpEnabled bool - adminAddr string - region string - metricsAsyncBytes bool - pprofEnabled bool - blueprintFile string - provisioningBlueprintFile string - noCloud bool + metricsEnabled bool + otlpEnabled bool + adminAddr string + region string + metricsAsyncBytes bool + pprofEnabled bool + blueprintFile string + provisioningBlueprintFile string + noCloud bool // New mTLS configuration variables tlsClientCert string @@ -741,6 +752,13 @@ func runNewtMain(ctx context.Context) { 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() @@ -947,6 +965,43 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( 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, + }) + } + + browserGateway = browsergateway.New(browsergateway.Config{ + AuthToken: browsergateway.HardcodedAuthToken, + }) + browserGateway.SetTargets(bgTargets) + + ln, bgErr := tnet.ListenTCP(&net.TCPAddr{Port: browsergateway.ListenPort}) + if bgErr != nil { + logger.Error("Failed to start browser gateway listener: %v", bgErr) + } else { + browserGatewayStop = func() { _ = ln.Close() } + go func() { + logger.Info("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) { @@ -1841,7 +1896,7 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( } 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") From 79e21b7917cdf6cdf906c0b7ade7a0737b627df3 Mon Sep 17 00:00:00 2001 From: rinseaid Date: Wed, 13 May 2026 22:38:35 -0400 Subject: [PATCH 113/161] Fix X-Forwarded-Proto always set to "http" for TLS connections httpConnCtx wraps *tls.Conn behind net.Conn, so Go's http.Server cannot detect TLS via type assertion and r.TLS is always nil. SetXForwarded() then always writes X-Forwarded-Proto: http. Override using the isTLS context flag already set by ConnContext. Former-commit-id: 817824bd6fee04b188e6c657de39e17b4d06f19f --- netstack2/http_handler.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/netstack2/http_handler.go b/netstack2/http_handler.go index ece82e9..ba0495f 100644 --- a/netstack2/http_handler.go +++ b/netstack2/http_handler.go @@ -315,6 +315,13 @@ func (h *HTTPHandler) getProxy(target HTTPTarget) *httputil.ReverseProxy { // Director means the proxy does not append its own automatic // X-Forwarded-For entry, so the header is set exactly once. pr.SetXForwarded() + + // SetXForwarded derives X-Forwarded-Proto from pr.In.TLS, + // which is nil because httpConnCtx wraps *tls.Conn behind + // net.Conn. Override using the context flag set by ConnContext. + if isTLS, _ := pr.In.Context().Value(connTLSKey{}).(bool); isTLS { + pr.Out.Header.Set("X-Forwarded-Proto", "https") + } }, Transport: transport, } From a855047139dc61ec54770ad2d2825587ec81292f Mon Sep 17 00:00:00 2001 From: rinseaid Date: Wed, 13 May 2026 22:40:22 -0400 Subject: [PATCH 114/161] Add workflow to build patched image to ghcr.io/rinseaid/newt:patched Former-commit-id: b6e2d61a18a8d85f4e7f56bc56cdc44d94ada062 --- .github/workflows/build-patched.yml | 34 +++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/build-patched.yml diff --git a/.github/workflows/build-patched.yml b/.github/workflows/build-patched.yml new file mode 100644 index 0000000..bd6d330 --- /dev/null +++ b/.github/workflows/build-patched.yml @@ -0,0 +1,34 @@ +name: Build patched image + +on: + push: + branches: [fix/x-forwarded-proto-tls] + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-qemu-action@v3 + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/build-push-action@v6 + with: + context: . + push: true + platforms: linux/amd64,linux/arm64 + build-args: VERSION=patched + tags: ghcr.io/rinseaid/newt:patched From b0f8589443409d93b6f7618d5d957c4a78c67b27 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 13 May 2026 20:53:27 -0700 Subject: [PATCH 115/161] Delete .github/workflows/build-patched.yml Former-commit-id: 55ca18a1dbc4443c005031c4645d63794fb5e092 --- .github/workflows/build-patched.yml | 34 ----------------------------- 1 file changed, 34 deletions(-) delete mode 100644 .github/workflows/build-patched.yml diff --git a/.github/workflows/build-patched.yml b/.github/workflows/build-patched.yml deleted file mode 100644 index bd6d330..0000000 --- a/.github/workflows/build-patched.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Build patched image - -on: - push: - branches: [fix/x-forwarded-proto-tls] - workflow_dispatch: - -permissions: - contents: read - packages: write - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: docker/setup-qemu-action@v3 - - - uses: docker/setup-buildx-action@v3 - - - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - uses: docker/build-push-action@v6 - with: - context: . - push: true - platforms: linux/amd64,linux/arm64 - build-args: VERSION=patched - tags: ghcr.io/rinseaid/newt:patched From d5c3dedca690ae348154cb8a36159a31f78a47c4 Mon Sep 17 00:00:00 2001 From: Chris Wiggins Date: Fri, 15 May 2026 11:48:50 -0600 Subject: [PATCH 116/161] feat(ci): run a go test before we build Former-commit-id: 54a13b268e34cad5496aa67a4966680c67edb245 --- .github/workflows/test.yml | 14 ++++++++++++++ Makefile | 7 +++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ea50179..45b3674 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,6 +10,20 @@ on: - dev jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: 1.25 + + - name: Run Go tests + run: make test + build: runs-on: ubuntu-latest strategy: diff --git a/Makefile b/Makefile index 53c4bb2..759656f 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all local docker-build docker-build-release +.PHONY: all local test docker-build docker-build-release all: local @@ -8,6 +8,9 @@ LDFLAGS = -X main.newtVersion=$(VERSION) local: CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o ./bin/newt +test: + go test ./... + docker-build: docker build -t fosrl/newt:latest . @@ -70,4 +73,4 @@ go-build-release-freebsd-amd64: CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/newt_freebsd_amd64 go-build-release-freebsd-arm64: - CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o bin/newt_freebsd_arm64 \ No newline at end of file + CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o bin/newt_freebsd_arm64 From 385e9c0a8368fd905900ea0961a38425089c0f12 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 15 May 2026 13:46:39 -0700 Subject: [PATCH 117/161] Support per target auth token Former-commit-id: 710408ac670bb3f5ded6ba612f2643c406323f18 --- browsergateway/browsergateway.go | 38 ++++++++++++++++++-------------- browsergateway/rdp.go | 13 ++++------- browsergateway/ssh.go | 15 +++++++------ browsergateway/vnc.go | 10 +++------ main.go | 6 ++--- 5 files changed, 40 insertions(+), 42 deletions(-) diff --git a/browsergateway/browsergateway.go b/browsergateway/browsergateway.go index 56faffc..4f99a29 100644 --- a/browsergateway/browsergateway.go +++ b/browsergateway/browsergateway.go @@ -1,9 +1,11 @@ package browsergateway import ( + "crypto/subtle" "errors" "net" "net/http" + "strings" "sync" ) @@ -14,26 +16,25 @@ const forwardBufSize = 64 * 1024 // ListenPort is the port the browser gateway HTTP server listens on inside the // WireGuard netstack. This is a fixed value shared between newt and pangolin. -const ListenPort = 8082 - -// HardcodedAuthToken is a temporary shared secret used during development. -// TODO: replace with a per-session token negotiated with pangolin. -const HardcodedAuthToken = "pangolin-browser-gateway-dev" +// Targets do not overlap with this port because they start at 40000. +const ListenPort = 39999 // Target represents an allowed proxy destination for the browser gateway. -// Only connections whose (Type, Destination, DestinationPort) match a +// Only connections whose (Type, Destination, DestinationPort, AuthToken) match a // registered Target will be forwarded; all others are rejected. type Target struct { ID int Type string // "rdp" | "ssh" | "vnc" Destination string DestinationPort int + AuthToken string // per-target secret; must match the token supplied by the client } // Config holds the configuration for a Gateway. type Config struct { - // AuthToken is the shared secret required by RDP clients in the RDCleanPath - // ProxyAuth field, and by SSH clients as the authToken query parameter. + // AuthToken is used only for NativeSSH mode (which has no external target + // to match against). For all proxy targets (RDP/SSH/VNC), auth tokens are + // stored per-Target and validated by isAllowed. AuthToken string // NativeSSH, when non-nil, configures a local PTY/shell SSH mode instead // of proxying to an external SSH server. @@ -86,14 +87,15 @@ func (g *Gateway) RemoveTarget(id int) { delete(g.targets, id) } -// isAllowed reports whether a connection to (targetType, host, port) is -// permitted by the current target list. -func (g *Gateway) isAllowed(targetType, host string, port int) bool { +// isAllowed reports whether a connection to (targetType, host, port) with the +// given authToken is permitted. The token is compared against the per-target +// AuthToken using constant-time comparison to prevent timing attacks. +func (g *Gateway) isAllowed(targetType, host string, port int, authToken string) bool { g.mu.RLock() defer g.mu.RUnlock() for _, t := range g.targets { if t.Type == targetType && t.Destination == host && t.DestinationPort == port { - return true + return subtle.ConstantTimeCompare([]byte(authToken), []byte(t.AuthToken)) == 1 } } return false @@ -106,7 +108,11 @@ func (g *Gateway) Start(ln net.Listener) error { g.RegisterHandlers(mux) g.server = &http.Server{Handler: mux} err := g.server.Serve(ln) - if errors.Is(err, net.ErrClosed) || errors.Is(err, http.ErrServerClosed) { + if err == nil || + errors.Is(err, net.ErrClosed) || + errors.Is(err, http.ErrServerClosed) || + strings.Contains(err.Error(), "use of closed") || + strings.Contains(err.Error(), "invalid state") { return nil } return err @@ -114,7 +120,7 @@ func (g *Gateway) Start(ln net.Listener) error { // RegisterHandlers registers the /rdp, /ssh, and /vnc routes on mux. func (g *Gateway) RegisterHandlers(mux *http.ServeMux) { - mux.HandleFunc("/rdp", g.HandleRDP) - mux.HandleFunc("/ssh", g.HandleSSH) - mux.HandleFunc("/vnc", g.handleVNC) + mux.HandleFunc("/gateway/rdp", g.HandleRDP) + mux.HandleFunc("/gateway/ssh", g.HandleSSH) + mux.HandleFunc("/gateway/vnc", g.handleVNC) } diff --git a/browsergateway/rdp.go b/browsergateway/rdp.go index 78046b3..1df5a13 100644 --- a/browsergateway/rdp.go +++ b/browsergateway/rdp.go @@ -2,7 +2,6 @@ package browsergateway import ( "context" - "crypto/subtle" "crypto/tls" "encoding/binary" "errors" @@ -58,22 +57,18 @@ func (g *Gateway) serveSession(ctx context.Context, ws *websocket.Conn) error { return errors.New("RDCleanPath missing X224 connection PDU") } - // Constant-time comparison to avoid leaking the expected token via timing. - if subtle.ConstantTimeCompare([]byte(pdu.ProxyAuth), []byte(g.authToken)) != 1 { - return errors.New("RDCleanPath ProxyAuth token mismatch") - } - target := pdu.Destination // Default port for RDP if not specified. if _, _, splitErr := net.SplitHostPort(target); splitErr != nil { target = net.JoinHostPort(target, "3389") } - // Validate destination against the registered target allowlist. + // Validate destination against the registered target allowlist, + // including per-target auth token. rdpHost, rdpPortStr, _ := net.SplitHostPort(target) rdpPort, _ := strconv.Atoi(rdpPortStr) - if !g.isAllowed("rdp", rdpHost, rdpPort) { - return fmt.Errorf("RDP destination %s is not in the allowed target list", target) + if !g.isAllowed("rdp", rdpHost, rdpPort, pdu.ProxyAuth) { + return fmt.Errorf("RDP destination %s is not in the allowed target list or auth token mismatch", target) } log.Printf("Connecting to RDP server %s", target) diff --git a/browsergateway/ssh.go b/browsergateway/ssh.go index 0596f99..f8e83d4 100644 --- a/browsergateway/ssh.go +++ b/browsergateway/ssh.go @@ -37,12 +37,7 @@ type sshServerMsg struct { func (g *Gateway) HandleSSH(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - // -- Validate auth token from query parameter before upgrading -- token := r.URL.Query().Get("authToken") - if subtle.ConstantTimeCompare([]byte(token), []byte(g.authToken)) != 1 { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } // In proxy mode we also need host + username from query params. var target, username string @@ -58,11 +53,17 @@ func (g *Gateway) HandleSSH(w http.ResponseWriter, r *http.Request) { port = "22" } sshPort, _ := strconv.Atoi(port) - if !g.isAllowed("ssh", host, sshPort) { - http.Error(w, "destination not allowed", http.StatusForbidden) + if !g.isAllowed("ssh", host, sshPort, token) { + http.Error(w, "destination not allowed or auth token mismatch", http.StatusForbidden) return } target = net.JoinHostPort(host, port) + } else { + // Native SSH mode: validate against the global gateway token. + if subtle.ConstantTimeCompare([]byte(token), []byte(g.authToken)) != 1 { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } } ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{ diff --git a/browsergateway/vnc.go b/browsergateway/vnc.go index 55ec439..5d917a4 100644 --- a/browsergateway/vnc.go +++ b/browsergateway/vnc.go @@ -2,7 +2,6 @@ package browsergateway import ( "context" - "crypto/subtle" "io" "log" "net" @@ -28,10 +27,6 @@ const ( // host – VNC backend hostname or IP // port – VNC backend port (default: 5900) func (g *Gateway) handleVNC(w http.ResponseWriter, r *http.Request) { - if subtle.ConstantTimeCompare([]byte(r.URL.Query().Get("authToken")), []byte(g.authToken)) != 1 { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } host := r.URL.Query().Get("host") port := r.URL.Query().Get("port") if host == "" { @@ -42,8 +37,9 @@ func (g *Gateway) handleVNC(w http.ResponseWriter, r *http.Request) { port = "5900" } vncPort, _ := strconv.Atoi(port) - if !g.isAllowed("vnc", host, vncPort) { - http.Error(w, "destination not allowed", http.StatusForbidden) + authToken := r.URL.Query().Get("authToken") + if !g.isAllowed("vnc", host, vncPort, authToken) { + http.Error(w, "destination not allowed or auth token mismatch", http.StatusForbidden) return } target := net.JoinHostPort(host, port) diff --git a/main.go b/main.go index c86a35c..566c105 100644 --- a/main.go +++ b/main.go @@ -46,6 +46,7 @@ type BrowserGatewayTarget struct { Type string `json:"type"` Destination string `json:"destination"` DestinationPort int `json:"destinationPort"` + AuthToken string `json:"authToken"` } type WgData struct { @@ -981,12 +982,11 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( Type: t.Type, Destination: t.Destination, DestinationPort: t.DestinationPort, + AuthToken: t.AuthToken, }) } - browserGateway = browsergateway.New(browsergateway.Config{ - AuthToken: browsergateway.HardcodedAuthToken, - }) + browserGateway = browsergateway.New(browsergateway.Config{}) browserGateway.SetTargets(bgTargets) ln, bgErr := tnet.ListenTCP(&net.TCPAddr{Port: browsergateway.ListenPort}) From 40710e5197e42116e3528710502b44e4406f6a92 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 15 May 2026 14:59:37 -0700 Subject: [PATCH 118/161] Support add and remove for gateway Former-commit-id: 28cddf7066b1ed5af3667868da173d3945ca33dd --- main.go | 88 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/main.go b/main.go index 566c105..1727f2e 100644 --- a/main.go +++ b/main.go @@ -1872,6 +1872,94 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( } }) + // Register handler for adding browser gateway targets dynamically + client.RegisterHandler("newt/browsergateway/add", func(msg websocket.WSMessage) { + logger.Debug("Received browser gateway add message") + + type BrowserGatewayAddData struct { + Targets []BrowserGatewayTarget `json:"targets"` + } + + var addData BrowserGatewayAddData + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Error("Error marshaling browser gateway add data: %v", err) + return + } + if err := json.Unmarshal(jsonData, &addData); err != nil { + logger.Error("Error unmarshaling browser gateway add data: %v", err) + return + } + + if len(addData.Targets) == 0 { + return + } + + // If the gateway doesn't exist yet but we have a tunnel, start it + if browserGateway == nil && tnet != nil { + browserGateway = browsergateway.New(browsergateway.Config{}) + 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.Info("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) From e438a249dd1e4dfa0ae4c4ddfcad14b70914acf4 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 15 May 2026 16:07:05 -0700 Subject: [PATCH 119/161] Support ssh private key Former-commit-id: 1f8be1d826aa6653d617fd6d2d427d6ccbe7b1e4 --- browsergateway/ssh.go | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/browsergateway/ssh.go b/browsergateway/ssh.go index f8e83d4..dc5bb13 100644 --- a/browsergateway/ssh.go +++ b/browsergateway/ssh.go @@ -18,11 +18,12 @@ import ( // sshClientMsg is a JSON message sent from the browser to the proxy. type sshClientMsg struct { // type: "auth" | "data" | "resize" - Type string `json:"type"` - Password string `json:"password,omitempty"` // used when type="auth" - Data string `json:"data,omitempty"` // used when type="data" - Cols uint32 `json:"cols,omitempty"` // used when type="resize" - Rows uint32 `json:"rows,omitempty"` // used when type="resize" + Type string `json:"type"` + Password string `json:"password,omitempty"` // used when type="auth" + PrivateKey string `json:"privateKey,omitempty"` // used when type="auth" + Data string `json:"data,omitempty"` // used when type="data" + Cols uint32 `json:"cols,omitempty"` // used when type="resize" + Rows uint32 `json:"rows,omitempty"` // used when type="resize" } // sshServerMsg is a JSON message sent from the proxy back to the browser. @@ -99,14 +100,29 @@ func serveSSHSession(ctx context.Context, ws *websocket.Conn, target, username, return fmt.Errorf("expected auth message, got: %s", authBytes) } password := authMsg.Password + privateKey := authMsg.PrivateKey - // -- Dial the SSH server -- + // Build the list of auth methods. Private key takes priority when provided. + var authMethods []ssh.AuthMethod + if privateKey != "" { + signer, err := ssh.ParsePrivateKey([]byte(privateKey)) + if err != nil { + sendSSHError(ctx, ws, fmt.Sprintf("Failed to parse private key: %v", err)) + return fmt.Errorf("parse private key: %w", err) + } + authMethods = append(authMethods, ssh.PublicKeys(signer)) + } + if password != "" { + authMethods = append(authMethods, ssh.Password(password)) + } + if len(authMethods) == 0 { + sendSSHError(ctx, ws, "No authentication credentials provided") + return fmt.Errorf("no auth credentials") + } log.Printf("SSH: connecting to %s as %s", target, username) sshCfg := &ssh.ClientConfig{ User: username, - Auth: []ssh.AuthMethod{ - ssh.Password(password), - }, + Auth: authMethods, // HostKeyCallback is intentionally InsecureIgnoreHostKey for this dev // proxy. In production, verify against a known-hosts store. HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec From 16cb83fd28ee1e722e9e4ddc30a5933830d9847c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Sch=C3=A4fer?= Date: Sat, 16 May 2026 16:31:27 +0200 Subject: [PATCH 120/161] fix(security): update cosign to v3.0.6 and installer to 4.1.2 Former-commit-id: 8736f89291076ef66ce9477ef0ccfbea43001319 --- .github/workflows/cicd.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index e216efd..746f1b1 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -750,9 +750,9 @@ jobs: show-summary: true - name: Install cosign - uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 with: - cosign-release: "v3.0.2" + cosign-release: v3.0.6 - name: Sanity check cosign private key env: From f41e15fcb3106f331923af9b7e6242a1ff940ad6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 16 May 2026 21:57:19 +0000 Subject: [PATCH 121/161] chore(deps): bump sigstore/cosign-installer from 4.1.1 to 4.1.2 Bumps [sigstore/cosign-installer](https://github.com/sigstore/cosign-installer) from 4.1.1 to 4.1.2. - [Release notes](https://github.com/sigstore/cosign-installer/releases) - [Commits](https://github.com/sigstore/cosign-installer/compare/v4.1.1...6f9f17788090df1f26f669e9d70d6ae9567deba6) --- updated-dependencies: - dependency-name: sigstore/cosign-installer dependency-version: 4.1.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Former-commit-id: 24f5d55cced0162f1daa09818a3eb32e979b0402 --- .github/workflows/mirror.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/mirror.yaml b/.github/workflows/mirror.yaml index 4d48003..fb34418 100644 --- a/.github/workflows/mirror.yaml +++ b/.github/workflows/mirror.yaml @@ -23,7 +23,7 @@ jobs: skopeo --version - name: Install cosign - uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 - name: Input check run: | From 8d30a584b508a9926a48238a9a573e63b16a8fdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Sch=C3=A4fer?= Date: Thu, 21 May 2026 02:42:59 +0200 Subject: [PATCH 122/161] ci(test): use Go version from go.mod Former-commit-id: 88d946c9b443d0d97524d9c9a2ad8ccdcbe6a8c3 --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 45b3674..16c6d38 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,7 +19,7 @@ jobs: - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: 1.25 + go-version-file: go.mod - name: Run Go tests run: make test @@ -47,7 +47,7 @@ jobs: - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: 1.25 + go-version-file: go.mod - name: Build targets via `make` run: make ${{ matrix.target }} From cb5d87587c518e5938266a76cc316d41484a77f8 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 21 May 2026 18:19:39 -0700 Subject: [PATCH 123/161] Basic ssh server for private resources created Former-commit-id: e0d65d81258b14bbe8e0f93e1bf213391d3ce937 --- browsergateway/ssh_native.go | 33 +--- main.go | 9 + nativessh/pty.go | 52 ++++++ nativessh/server.go | 321 +++++++++++++++++++++++++++++++++++ 4 files changed, 390 insertions(+), 25 deletions(-) create mode 100644 nativessh/pty.go create mode 100644 nativessh/server.go diff --git a/browsergateway/ssh_native.go b/browsergateway/ssh_native.go index b65eb83..91509b0 100644 --- a/browsergateway/ssh_native.go +++ b/browsergateway/ssh_native.go @@ -5,11 +5,9 @@ import ( "encoding/json" "fmt" "log" - "os" - "os/exec" "github.com/coder/websocket" - "github.com/creack/pty" + "github.com/fosrl/newt/nativessh" ) // NativeSSHConfig holds configuration for the native PTY/shell mode. @@ -34,26 +32,14 @@ func serveNativeSSHSession(ctx context.Context, ws *websocket.Conn, cfg NativeSS return fmt.Errorf("expected auth message, got: %s", authBytes) } - shell := cfg.Shell - if shell == "" { - shell = "/bin/sh" - } + log.Printf("SSH native: spawning shell") - log.Printf("SSH native: spawning %s", shell) - - cmd := exec.CommandContext(ctx, shell) - cmd.Env = append(os.Environ(), "TERM=xterm-256color") - - // Start the command with a PTY attached. - ptmx, err := pty.Start(cmd) + sess, err := nativessh.NewPTYSession(cfg.Shell) if err != nil { sendSSHError(ctx, ws, fmt.Sprintf("Failed to spawn shell: %v", err)) - return fmt.Errorf("pty start: %w", err) + return fmt.Errorf("pty session: %w", err) } - defer func() { - _ = ptmx.Close() - _ = cmd.Wait() - }() + defer sess.Close() // Cancel context to unblock the WebSocket read loop when the shell exits. sessCtx, cancelSess := context.WithCancel(ctx) @@ -64,7 +50,7 @@ func serveNativeSSHSession(ctx context.Context, ws *websocket.Conn, cfg NativeSS defer cancelSess() buf := make([]byte, 4096) for { - n, readErr := ptmx.Read(buf) + n, readErr := sess.Read(buf) if n > 0 { msg := sshServerMsg{Type: "data", Data: string(buf[:n])} b, _ := json.Marshal(msg) @@ -90,15 +76,12 @@ func serveNativeSSHSession(ctx context.Context, ws *websocket.Conn, cfg NativeSS } switch msg.Type { case "data": - if _, writeErr := ptmx.Write([]byte(msg.Data)); writeErr != nil { + if _, writeErr := sess.Write([]byte(msg.Data)); writeErr != nil { return fmt.Errorf("write pty: %w", writeErr) } case "resize": if msg.Cols > 0 && msg.Rows > 0 { - _ = pty.Setsize(ptmx, &pty.Winsize{ - Cols: uint16(msg.Cols), - Rows: uint16(msg.Rows), - }) + _ = sess.Resize(uint16(msg.Cols), uint16(msg.Rows)) } } } diff --git a/main.go b/main.go index 1727f2e..2ccfbda 100644 --- a/main.go +++ b/main.go @@ -26,6 +26,7 @@ import ( "github.com/fosrl/newt/docker" "github.com/fosrl/newt/healthcheck" "github.com/fosrl/newt/logger" + "github.com/fosrl/newt/nativessh" "github.com/fosrl/newt/proxy" "github.com/fosrl/newt/updates" "github.com/fosrl/newt/util" @@ -534,6 +535,14 @@ func runNewtMain(ctx context.Context) { logger.Fatal("Failed to start auth daemon: %v", err) } } + + // Start native SSH server for testing (listens on :2222). + go func() { + srv := nativessh.NewServer(nativessh.ServerConfig{}) + if err := srv.ListenAndServe(); err != nil { + logger.Error("Native SSH server error: %v", err) + } + }() logger.GetLogger().SetLevel(loggerLevel) // Initialize telemetry after flags are parsed (so flags override env) diff --git a/nativessh/pty.go b/nativessh/pty.go new file mode 100644 index 0000000..f35531a --- /dev/null +++ b/nativessh/pty.go @@ -0,0 +1,52 @@ +package nativessh + +import ( + "fmt" + "os" + "os/exec" + + "github.com/creack/pty" +) + +// PTYSession is a running shell process attached to a PTY. +// It implements io.ReadWriteCloser so it can be bridged to any transport. +type PTYSession struct { + ptmx *os.File + cmd *exec.Cmd +} + +// NewPTYSession spawns shell in a PTY. If shell is empty, /bin/sh is used. +func NewPTYSession(shell string) (*PTYSession, error) { + if shell == "" { + shell = "/bin/sh" + } + cmd := exec.Command(shell) + cmd.Env = append(os.Environ(), "TERM=xterm-256color") + ptmx, err := pty.Start(cmd) + if err != nil { + return nil, fmt.Errorf("pty start: %w", err) + } + return &PTYSession{ptmx: ptmx, cmd: cmd}, nil +} + +// Read reads output from the PTY. +func (p *PTYSession) Read(b []byte) (int, error) { + return p.ptmx.Read(b) +} + +// Write writes input to the PTY. +func (p *PTYSession) Write(b []byte) (int, error) { + return p.ptmx.Write(b) +} + +// Resize changes the PTY window size. +func (p *PTYSession) Resize(cols, rows uint16) error { + return pty.Setsize(p.ptmx, &pty.Winsize{Cols: cols, Rows: rows}) +} + +// Close closes the PTY and waits for the child process to exit. +func (p *PTYSession) Close() error { + err := p.ptmx.Close() + _ = p.cmd.Wait() + return err +} diff --git a/nativessh/server.go b/nativessh/server.go new file mode 100644 index 0000000..8cedf94 --- /dev/null +++ b/nativessh/server.go @@ -0,0 +1,321 @@ +package nativessh + +import ( + "bufio" + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "fmt" + "io" + "log" + "net" + "os" + "strings" + + "golang.org/x/crypto/ssh" +) + +const ( + // DefaultCAKeyPath is the path to the SSH CA public key used to validate + // client certificates. + DefaultCAKeyPath = "/tmp/newt/ssh_ca.pub" + // DefaultPrincipalsPath is the path to a file listing allowed SSH + // certificate principals, one per line. + DefaultPrincipalsPath = "/tmp/newt/ssh_principals" + // DefaultHostKeyPath is where the server's Ed25519 host key is persisted. + // A new key is generated and saved here on first run. + DefaultHostKeyPath = "/tmp/newt/ssh_host_key" +) + +// ServerConfig holds configuration for the native SSH server. +type ServerConfig struct { + // ListenAddr is the TCP address to listen on. Defaults to ":2222". + ListenAddr string + // CAKeyPath is the path to the CA public key file (authorized_keys format). + // Defaults to DefaultCAKeyPath. + CAKeyPath string + // PrincipalsPath is the path to a file of allowed principals, one per line. + // Defaults to DefaultPrincipalsPath. + PrincipalsPath string + // HostKeyPath is where the Ed25519 host private key is stored (PEM). + // Defaults to DefaultHostKeyPath. Generated on first run if absent. + HostKeyPath string + // Shell is the shell executable to spawn. Defaults to /bin/sh. + Shell string +} + +// Server is a simple SSH server that authenticates clients via SSH certificate +// auth only. Certificates must be signed by the configured CA and the +// connecting username must appear in both the certificate's principal list and +// the local principals file. +type Server struct { + cfg ServerConfig +} + +// NewServer creates a new Server. Zero-value fields in cfg are replaced with +// defaults. +func NewServer(cfg ServerConfig) *Server { + if cfg.ListenAddr == "" { + cfg.ListenAddr = ":2222" + } + if cfg.CAKeyPath == "" { + cfg.CAKeyPath = DefaultCAKeyPath + } + if cfg.PrincipalsPath == "" { + cfg.PrincipalsPath = DefaultPrincipalsPath + } + if cfg.HostKeyPath == "" { + cfg.HostKeyPath = DefaultHostKeyPath + } + if cfg.Shell == "" { + cfg.Shell = "/bin/sh" + } + return &Server{cfg: cfg} +} + +// ListenAndServe starts the SSH server and blocks until the listener is closed. +func (s *Server) ListenAndServe() error { + caKey, err := loadCAPublicKey(s.cfg.CAKeyPath) + if err != nil { + return fmt.Errorf("load CA public key from %s: %w", s.cfg.CAKeyPath, err) + } + + principals, err := loadPrincipals(s.cfg.PrincipalsPath) + if err != nil { + return fmt.Errorf("load principals from %s: %w", s.cfg.PrincipalsPath, err) + } + + hostSigner, err := generateOrLoadHostKey(s.cfg.HostKeyPath) + if err != nil { + return fmt.Errorf("host key: %w", err) + } + + sshCfg := &ssh.ServerConfig{ + PublicKeyCallback: makeCertAuthCallback(caKey, principals), + } + sshCfg.AddHostKey(hostSigner) + + ln, err := net.Listen("tcp", s.cfg.ListenAddr) + if err != nil { + return fmt.Errorf("listen %s: %w", s.cfg.ListenAddr, err) + } + defer ln.Close() + log.Printf("nativessh: server listening on %s", s.cfg.ListenAddr) + + for { + conn, err := ln.Accept() + if err != nil { + return fmt.Errorf("accept: %w", err) + } + go s.handleConn(conn, sshCfg) + } +} + +func (s *Server) handleConn(conn net.Conn, cfg *ssh.ServerConfig) { + defer conn.Close() + sshConn, chans, reqs, err := ssh.NewServerConn(conn, cfg) + if err != nil { + log.Printf("nativessh: handshake failed from %s: %v", conn.RemoteAddr(), err) + return + } + defer sshConn.Close() + log.Printf("nativessh: connection from %s user=%s", conn.RemoteAddr(), sshConn.User()) + + go ssh.DiscardRequests(reqs) + + for newChan := range chans { + if newChan.ChannelType() != "session" { + _ = newChan.Reject(ssh.UnknownChannelType, "unknown channel type") + continue + } + ch, requests, err := newChan.Accept() + if err != nil { + log.Printf("nativessh: channel accept error: %v", err) + return + } + go s.handleSession(ch, requests) + } +} + +// handleSession drives a single SSH session channel. It waits for a pty-req +// followed by a shell request and then bridges the PTY to the channel. +func (s *Server) handleSession(ch ssh.Channel, requests <-chan *ssh.Request) { + defer ch.Close() + + var ( + sess *PTYSession + started bool + ) + + for req := range requests { + switch req.Type { + case "pty-req": + var err error + if sess == nil { + sess, err = NewPTYSession(s.cfg.Shell) + if err != nil { + log.Printf("nativessh: PTY start error: %v", err) + if req.WantReply { + _ = req.Reply(false, nil) + } + return + } + } + cols, rows := parsePTYReq(req.Payload) + _ = sess.Resize(cols, rows) + if req.WantReply { + _ = req.Reply(true, nil) + } + + case "shell": + if req.WantReply { + _ = req.Reply(true, nil) + } + if started || sess == nil { + continue + } + started = true + // PTY output → SSH channel. + go func() { + _, _ = io.Copy(ch, sess) + _ = ch.CloseWrite() + sess.Close() //nolint:errcheck + }() + // SSH channel input → PTY stdin. + go func() { + _, _ = io.Copy(sess, ch) + }() + + case "window-change": + if sess != nil { + cols, rows := parseWindowChange(req.Payload) + _ = sess.Resize(cols, rows) + } + if req.WantReply { + _ = req.Reply(true, nil) + } + + default: + if req.WantReply { + _ = req.Reply(false, nil) + } + } + } + + if sess != nil && !started { + sess.Close() //nolint:errcheck + } +} + +// makeCertAuthCallback returns an ssh.PublicKeyCallback that accepts only +// SSH user certificates that are: +// 1. Signed by caKey. +// 2. Listing the connecting username in ValidPrincipals (standard cert auth). +// 3. Whose connecting username is also in the local allowedPrincipals set. +func makeCertAuthCallback(caKey ssh.PublicKey, allowedPrincipals map[string]struct{}) func(ssh.ConnMetadata, ssh.PublicKey) (*ssh.Permissions, error) { + checker := &ssh.CertChecker{ + IsUserAuthority: func(auth ssh.PublicKey) bool { + return ssh.FingerprintSHA256(auth) == ssh.FingerprintSHA256(caKey) + }, + } + return func(meta ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { + perms, err := checker.Authenticate(meta, key) + if err != nil { + return nil, err + } + if _, ok := allowedPrincipals[meta.User()]; !ok { + return nil, fmt.Errorf("user %q not in allowed principals list", meta.User()) + } + return perms, nil + } +} + +// generateOrLoadHostKey loads an Ed25519 host key from path, or generates and +// saves a new one if the file does not exist. +func generateOrLoadHostKey(path string) (ssh.Signer, error) { + data, err := os.ReadFile(path) + if err == nil { + return ssh.ParsePrivateKey(data) + } + if !os.IsNotExist(err) { + return nil, fmt.Errorf("read host key %s: %w", path, err) + } + + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("generate host key: %w", err) + } + pemBlock, err := ssh.MarshalPrivateKey(priv, "") + if err != nil { + return nil, fmt.Errorf("marshal host key: %w", err) + } + pemData := pem.EncodeToMemory(pemBlock) + if writeErr := os.WriteFile(path, pemData, 0600); writeErr != nil { + log.Printf("nativessh: warning: could not persist host key to %s: %v", path, writeErr) + } + log.Printf("nativessh: generated new Ed25519 host key (saved to %s)", path) + return ssh.NewSignerFromKey(priv) +} + +func loadCAPublicKey(path string) (ssh.PublicKey, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + key, _, _, _, err := ssh.ParseAuthorizedKey(data) + if err != nil { + return nil, err + } + return key, nil +} + +func loadPrincipals(path string) (map[string]struct{}, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + principals := make(map[string]struct{}) + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line != "" && !strings.HasPrefix(line, "#") { + principals[line] = struct{}{} + } + } + return principals, scanner.Err() +} + +// ptyRequestMsg mirrors the SSH wire format for pty-req (RFC 4254 §6.2). +type ptyRequestMsg struct { + Term string + Columns uint32 + Rows uint32 + Width uint32 + Height uint32 + Modelist string +} + +func parsePTYReq(payload []byte) (cols, rows uint16) { + var req ptyRequestMsg + if err := ssh.Unmarshal(payload, &req); err != nil { + return 80, 24 + } + return uint16(req.Columns), uint16(req.Rows) +} + +// windowChangeMsg mirrors the SSH wire format for window-change (RFC 4254 §6.7). +type windowChangeMsg struct { + Columns uint32 + Rows uint32 + Width uint32 + Height uint32 +} + +func parseWindowChange(payload []byte) (cols, rows uint16) { + var msg windowChangeMsg + if err := ssh.Unmarshal(payload, &msg); err != nil { + return 80, 24 + } + return uint16(msg.Columns), uint16(msg.Rows) +} From 7636549e9a442f227c4f6400641d6a14dbc5ea6b Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 21 May 2026 18:23:11 -0700 Subject: [PATCH 124/161] Pty to find its own shell Former-commit-id: 133311f1c4b0f8694cd40b81bea103bbaf40e46e --- browsergateway/ssh_native.go | 2 +- nativessh/pty.go | 18 ++++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/browsergateway/ssh_native.go b/browsergateway/ssh_native.go index 91509b0..a2ac8e9 100644 --- a/browsergateway/ssh_native.go +++ b/browsergateway/ssh_native.go @@ -34,7 +34,7 @@ func serveNativeSSHSession(ctx context.Context, ws *websocket.Conn, cfg NativeSS log.Printf("SSH native: spawning shell") - sess, err := nativessh.NewPTYSession(cfg.Shell) + sess, err := nativessh.NewPTYSession() if err != nil { sendSSHError(ctx, ws, fmt.Sprintf("Failed to spawn shell: %v", err)) return fmt.Errorf("pty session: %w", err) diff --git a/nativessh/pty.go b/nativessh/pty.go index f35531a..ee03295 100644 --- a/nativessh/pty.go +++ b/nativessh/pty.go @@ -15,11 +15,21 @@ type PTYSession struct { cmd *exec.Cmd } -// NewPTYSession spawns shell in a PTY. If shell is empty, /bin/sh is used. -func NewPTYSession(shell string) (*PTYSession, error) { - if shell == "" { - shell = "/bin/sh" +// findShell returns the path to the best available interactive shell by +// checking preferred shells in order, falling back to /bin/sh. +func findShell() string { + preferred := []string{"zsh", "bash", "fish", "ksh", "sh"} + for _, name := range preferred { + if path, err := exec.LookPath(name); err == nil { + return path + } } + return "/bin/sh" +} + +// NewPTYSession spawns the best available shell in a PTY. +func NewPTYSession() (*PTYSession, error) { + shell := findShell() cmd := exec.Command(shell) cmd.Env = append(os.Environ(), "TERM=xterm-256color") ptmx, err := pty.Start(cmd) From cc7b2a5ade3dcb2a1c6cd5624af53b1a274581f0 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 21 May 2026 20:16:46 -0700 Subject: [PATCH 125/161] Keep host key in memory Former-commit-id: 388795ecf4d691268bd9ed2f234de64ce7dc01cd --- nativessh/server.go | 43 ++++++------------------------------------- 1 file changed, 6 insertions(+), 37 deletions(-) diff --git a/nativessh/server.go b/nativessh/server.go index 8cedf94..0e5cbe9 100644 --- a/nativessh/server.go +++ b/nativessh/server.go @@ -4,7 +4,6 @@ import ( "bufio" "crypto/ed25519" "crypto/rand" - "encoding/pem" "fmt" "io" "log" @@ -22,9 +21,6 @@ const ( // DefaultPrincipalsPath is the path to a file listing allowed SSH // certificate principals, one per line. DefaultPrincipalsPath = "/tmp/newt/ssh_principals" - // DefaultHostKeyPath is where the server's Ed25519 host key is persisted. - // A new key is generated and saved here on first run. - DefaultHostKeyPath = "/tmp/newt/ssh_host_key" ) // ServerConfig holds configuration for the native SSH server. @@ -37,11 +33,6 @@ type ServerConfig struct { // PrincipalsPath is the path to a file of allowed principals, one per line. // Defaults to DefaultPrincipalsPath. PrincipalsPath string - // HostKeyPath is where the Ed25519 host private key is stored (PEM). - // Defaults to DefaultHostKeyPath. Generated on first run if absent. - HostKeyPath string - // Shell is the shell executable to spawn. Defaults to /bin/sh. - Shell string } // Server is a simple SSH server that authenticates clients via SSH certificate @@ -64,12 +55,6 @@ func NewServer(cfg ServerConfig) *Server { if cfg.PrincipalsPath == "" { cfg.PrincipalsPath = DefaultPrincipalsPath } - if cfg.HostKeyPath == "" { - cfg.HostKeyPath = DefaultHostKeyPath - } - if cfg.Shell == "" { - cfg.Shell = "/bin/sh" - } return &Server{cfg: cfg} } @@ -85,7 +70,7 @@ func (s *Server) ListenAndServe() error { return fmt.Errorf("load principals from %s: %w", s.cfg.PrincipalsPath, err) } - hostSigner, err := generateOrLoadHostKey(s.cfg.HostKeyPath) + hostSigner, err := generateHostKey() if err != nil { return fmt.Errorf("host key: %w", err) } @@ -152,7 +137,7 @@ func (s *Server) handleSession(ch ssh.Channel, requests <-chan *ssh.Request) { case "pty-req": var err error if sess == nil { - sess, err = NewPTYSession(s.cfg.Shell) + sess, err = NewPTYSession() if err != nil { log.Printf("nativessh: PTY start error: %v", err) if req.WantReply { @@ -230,30 +215,14 @@ func makeCertAuthCallback(caKey ssh.PublicKey, allowedPrincipals map[string]stru } } -// generateOrLoadHostKey loads an Ed25519 host key from path, or generates and -// saves a new one if the file does not exist. -func generateOrLoadHostKey(path string) (ssh.Signer, error) { - data, err := os.ReadFile(path) - if err == nil { - return ssh.ParsePrivateKey(data) - } - if !os.IsNotExist(err) { - return nil, fmt.Errorf("read host key %s: %w", path, err) - } - +// generateHostKey generates a fresh ephemeral Ed25519 host key in memory. +// A new key is created on every server start; nothing is written to disk. +func generateHostKey() (ssh.Signer, error) { _, priv, err := ed25519.GenerateKey(rand.Reader) if err != nil { return nil, fmt.Errorf("generate host key: %w", err) } - pemBlock, err := ssh.MarshalPrivateKey(priv, "") - if err != nil { - return nil, fmt.Errorf("marshal host key: %w", err) - } - pemData := pem.EncodeToMemory(pemBlock) - if writeErr := os.WriteFile(path, pemData, 0600); writeErr != nil { - log.Printf("nativessh: warning: could not persist host key to %s: %v", path, writeErr) - } - log.Printf("nativessh: generated new Ed25519 host key (saved to %s)", path) + log.Printf("nativessh: generated ephemeral Ed25519 host key") return ssh.NewSignerFromKey(priv) } From 097450ae1593704085911586da49a4eba9389211 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 21 May 2026 20:30:17 -0700 Subject: [PATCH 126/161] Bind the ssh server to the newt ip Former-commit-id: 9640ada8b7a823e605e45f319457340c3981b8d0 --- clients/clients.go | 44 +++++++++++++++++++++++++++++++++++++++++++ main.go | 8 -------- nativessh/server.go | 46 +++++++++++++++++++++++++++++---------------- 3 files changed, 74 insertions(+), 24 deletions(-) diff --git a/clients/clients.go b/clients/clients.go index 3862160..6c4d968 100644 --- a/clients/clients.go +++ b/clients/clients.go @@ -19,6 +19,7 @@ import ( newtDevice "github.com/fosrl/newt/device" "github.com/fosrl/newt/holepunch" "github.com/fosrl/newt/logger" + "github.com/fosrl/newt/nativessh" "github.com/fosrl/newt/netstack2" "github.com/fosrl/newt/network" "github.com/fosrl/newt/util" @@ -108,6 +109,8 @@ type WireGuardService struct { sharedBind *bind.SharedBind holePunchManager *holepunch.Manager useNativeInterface bool + // SSH server running on the clients' netstack + sshServer *sshServerHandle // Direct UDP relay from main tunnel to clients' WireGuard directRelayStop chan struct{} directRelayWg sync.WaitGroup @@ -211,6 +214,12 @@ func (s *WireGuardService) Close() { s.stopGetConfig = nil } + // Stop SSH server before tearing down the netstack + if s.sshServer != nil { + s.sshServer.stop() + s.sshServer = nil + } + // Flush access logs before tearing down the tunnel if s.tnet != nil { if ph := s.tnet.GetProxyHandler(); ph != nil { @@ -890,6 +899,13 @@ func (s *WireGuardService) ensureWireguardInterface(wgconfig WgConfig) error { logger.Error("Failed to start WireGuard tester server: %v", err) } + // Start the SSH server on the clients' netstack (port 22). + if h, sshErr := startSSHOnNetstack(s.tnet); sshErr != nil { + logger.Warn("nativessh: not starting SSH server on clients netstack: %v", sshErr) + } else { + s.sshServer = h + } + // Note: we already unlocked above, so don't use defer unlock return nil } @@ -1563,3 +1579,31 @@ func (s *WireGuardService) filterReadOnlyFields(config string) string { return strings.Join(filteredLines, "\n") } + +// sshServerHandle holds the listener so the SSH server can be stopped by +// closing it. +type sshServerHandle struct { + ln net.Listener +} + +func (h *sshServerHandle) stop() { + _ = h.ln.Close() +} + +// startSSHOnNetstack creates a TCP listener on port 22 of the clients' netstack +// and starts serving SSH connections on it in the background. The returned +// handle can be used to stop the server by closing the listener. +func startSSHOnNetstack(tnet *netstack2.Net) (*sshServerHandle, error) { + srv := nativessh.NewServer(nativessh.ServerConfig{}) + ln, err := tnet.ListenTCP(&net.TCPAddr{Port: 22}) + if err != nil { + return nil, fmt.Errorf("listen on netstack port 22: %w", err) + } + h := &sshServerHandle{ln: ln} + go func() { + if err := srv.Serve(ln); err != nil { + logger.Debug("nativessh: clients netstack server stopped: %v", err) + } + }() + return h, nil +} diff --git a/main.go b/main.go index 2ccfbda..cfeacf2 100644 --- a/main.go +++ b/main.go @@ -26,7 +26,6 @@ import ( "github.com/fosrl/newt/docker" "github.com/fosrl/newt/healthcheck" "github.com/fosrl/newt/logger" - "github.com/fosrl/newt/nativessh" "github.com/fosrl/newt/proxy" "github.com/fosrl/newt/updates" "github.com/fosrl/newt/util" @@ -536,13 +535,6 @@ func runNewtMain(ctx context.Context) { } } - // Start native SSH server for testing (listens on :2222). - go func() { - srv := nativessh.NewServer(nativessh.ServerConfig{}) - if err := srv.ListenAndServe(); err != nil { - logger.Error("Native SSH server error: %v", err) - } - }() logger.GetLogger().SetLevel(loggerLevel) // Initialize telemetry after flags are parsed (so flags override env) diff --git a/nativessh/server.go b/nativessh/server.go index 0e5cbe9..d5c8b1f 100644 --- a/nativessh/server.go +++ b/nativessh/server.go @@ -58,42 +58,56 @@ func NewServer(cfg ServerConfig) *Server { return &Server{cfg: cfg} } -// ListenAndServe starts the SSH server and blocks until the listener is closed. -func (s *Server) ListenAndServe() error { +// buildSSHConfig loads keys/principals and builds the ssh.ServerConfig. +func (s *Server) buildSSHConfig() (*ssh.ServerConfig, error) { caKey, err := loadCAPublicKey(s.cfg.CAKeyPath) if err != nil { - return fmt.Errorf("load CA public key from %s: %w", s.cfg.CAKeyPath, err) + return nil, fmt.Errorf("load CA public key from %s: %w", s.cfg.CAKeyPath, err) } principals, err := loadPrincipals(s.cfg.PrincipalsPath) if err != nil { - return fmt.Errorf("load principals from %s: %w", s.cfg.PrincipalsPath, err) + return nil, fmt.Errorf("load principals from %s: %w", s.cfg.PrincipalsPath, err) } hostSigner, err := generateHostKey() if err != nil { - return fmt.Errorf("host key: %w", err) + return nil, fmt.Errorf("host key: %w", err) } - sshCfg := &ssh.ServerConfig{ + cfg := &ssh.ServerConfig{ PublicKeyCallback: makeCertAuthCallback(caKey, principals), } - sshCfg.AddHostKey(hostSigner) + cfg.AddHostKey(hostSigner) + return cfg, nil +} +// Serve accepts connections on ln and handles them. It returns when ln is +// closed or a non-temporary Accept error occurs. +func (s *Server) Serve(ln net.Listener) error { + sshCfg, err := s.buildSSHConfig() + if err != nil { + return err + } + log.Printf("nativessh: server listening on %s", ln.Addr()) + for { + conn, err := ln.Accept() + if err != nil { + return err + } + go s.handleConn(conn, sshCfg) + } +} + +// ListenAndServe starts the SSH server on the host network and blocks until +// the listener is closed. +func (s *Server) ListenAndServe() error { ln, err := net.Listen("tcp", s.cfg.ListenAddr) if err != nil { return fmt.Errorf("listen %s: %w", s.cfg.ListenAddr, err) } defer ln.Close() - log.Printf("nativessh: server listening on %s", s.cfg.ListenAddr) - - for { - conn, err := ln.Accept() - if err != nil { - return fmt.Errorf("accept: %w", err) - } - go s.handleConn(conn, sshCfg) - } + return s.Serve(ln) } func (s *Server) handleConn(conn net.Conn, cfg *ssh.ServerConfig) { From 677146e20e320b7ef17753be2fdfd86681909486 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 21 May 2026 20:38:38 -0700 Subject: [PATCH 127/161] Fix exit not closing session Former-commit-id: f217ddf6a7b41baec00af570628e7f226ff848cd --- nativessh/pty.go | 33 ++++++++++++++++++++++++++++++--- nativessh/server.go | 10 ++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/nativessh/pty.go b/nativessh/pty.go index ee03295..cb5fa88 100644 --- a/nativessh/pty.go +++ b/nativessh/pty.go @@ -1,9 +1,11 @@ package nativessh import ( + "errors" "fmt" "os" "os/exec" + "sync" "github.com/creack/pty" ) @@ -11,8 +13,10 @@ import ( // PTYSession is a running shell process attached to a PTY. // It implements io.ReadWriteCloser so it can be bridged to any transport. type PTYSession struct { - ptmx *os.File - cmd *exec.Cmd + ptmx *os.File + cmd *exec.Cmd + waitOnce sync.Once + exitCode int } // findShell returns the path to the best available interactive shell by @@ -54,9 +58,32 @@ func (p *PTYSession) Resize(cols, rows uint16) error { return pty.Setsize(p.ptmx, &pty.Winsize{Cols: cols, Rows: rows}) } +// wait waits for the child process to exit exactly once and records its exit +// code. Safe to call concurrently or multiple times. +func (p *PTYSession) wait() { + p.waitOnce.Do(func() { + err := p.cmd.Wait() + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + p.exitCode = exitErr.ExitCode() + return + } + p.exitCode = 1 + } + }) +} + +// ExitCode waits for the shell process to exit and returns its exit code. +// It is safe to call before or after Close. +func (p *PTYSession) ExitCode() int { + p.wait() + return p.exitCode +} + // Close closes the PTY and waits for the child process to exit. func (p *PTYSession) Close() error { err := p.ptmx.Close() - _ = p.cmd.Wait() + p.wait() return err } diff --git a/nativessh/server.go b/nativessh/server.go index d5c8b1f..db91d08 100644 --- a/nativessh/server.go +++ b/nativessh/server.go @@ -177,8 +177,18 @@ func (s *Server) handleSession(ch ssh.Channel, requests <-chan *ssh.Request) { // PTY output → SSH channel. go func() { _, _ = io.Copy(ch, sess) + // Notify the client of the shell's exit status so it can + // disconnect cleanly instead of requiring a manual disconnect. + exitCode := sess.ExitCode() + exitStatusPayload := ssh.Marshal(struct{ Status uint32 }{uint32(exitCode)}) + _, _ = ch.SendRequest("exit-status", false, exitStatusPayload) _ = ch.CloseWrite() sess.Close() //nolint:errcheck + // Close the channel so the ssh library closes the requests + // channel, which unblocks the for-range loop in handleSession + // and allows the deferred ch.Close() to run. Without this, + // handleSession blocks forever waiting for requests to drain. + _ = ch.Close() }() // SSH channel input → PTY stdin. go func() { From 647a6d2635e103ea0a2903e9975d528a7eb36fa3 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 21 May 2026 21:09:51 -0700 Subject: [PATCH 128/161] Add credentials store and use the auth daemon info Former-commit-id: 631eab53d1f1d663d5bb458e13403a5742e5e1f8 --- clients.go | 5 +- clients/clients.go | 16 ++++- main.go | 27 +++++++- nativessh/server.go | 153 ++++++++++++++++++++++---------------------- 4 files changed, 118 insertions(+), 83 deletions(-) diff --git a/clients.go b/clients.go index d650eeb..4162eef 100644 --- a/clients.go +++ b/clients.go @@ -7,6 +7,7 @@ import ( 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" ) @@ -14,7 +15,7 @@ import ( var wgService *clients.WireGuardService var ready bool -func setupClients(client *websocket.Client) { +func setupClients(client *websocket.Client, credStore *nativessh.CredentialStore) { var host = endpoint if strings.HasPrefix(host, "http://") { host = strings.TrimPrefix(host, "http://") @@ -42,6 +43,8 @@ func setupClients(client *websocket.Client) { logger.Fatal("Failed to create WireGuard service: %v", err) } + wgService.SetCredentialStore(credStore) + client.OnTokenUpdate(func(token string) { wgService.SetToken(token) }) diff --git a/clients/clients.go b/clients/clients.go index 6c4d968..325a828 100644 --- a/clients/clients.go +++ b/clients/clients.go @@ -111,6 +111,7 @@ type WireGuardService struct { useNativeInterface bool // SSH server running on the clients' netstack sshServer *sshServerHandle + credStore *nativessh.CredentialStore // Direct UDP relay from main tunnel to clients' WireGuard directRelayStop chan struct{} directRelayWg sync.WaitGroup @@ -196,6 +197,13 @@ func NewWireGuardService(interfaceName string, port uint16, mtu int, host string return service, nil } +// SetCredentialStore sets the in-memory SSH credential store used by the +// native SSH server. It must be called before the netstack is configured +// (i.e. before the first newt/wg/receive-config message is processed). +func (s *WireGuardService) SetCredentialStore(store *nativessh.CredentialStore) { + s.credStore = store +} + // ReportRTT allows reporting native RTTs to telemetry, rate-limited externally. func (s *WireGuardService) ReportRTT(seconds float64) { if s.serverPubKey == "" { @@ -900,7 +908,7 @@ func (s *WireGuardService) ensureWireguardInterface(wgconfig WgConfig) error { } // Start the SSH server on the clients' netstack (port 22). - if h, sshErr := startSSHOnNetstack(s.tnet); sshErr != nil { + if h, sshErr := startSSHOnNetstack(s.tnet, s.credStore); sshErr != nil { logger.Warn("nativessh: not starting SSH server on clients netstack: %v", sshErr) } else { s.sshServer = h @@ -1593,8 +1601,10 @@ func (h *sshServerHandle) stop() { // startSSHOnNetstack creates a TCP listener on port 22 of the clients' netstack // and starts serving SSH connections on it in the background. The returned // handle can be used to stop the server by closing the listener. -func startSSHOnNetstack(tnet *netstack2.Net) (*sshServerHandle, error) { - srv := nativessh.NewServer(nativessh.ServerConfig{}) +func startSSHOnNetstack(tnet *netstack2.Net, creds *nativessh.CredentialStore) (*sshServerHandle, error) { + srv := nativessh.NewServer(nativessh.ServerConfig{ + Credentials: creds, + }) ln, err := tnet.ListenTCP(&net.TCPAddr{Port: 22}) if err != nil { return nil, fmt.Errorf("listen on netstack port 22: %w", err) diff --git a/main.go b/main.go index cfeacf2..cc03bc2 100644 --- a/main.go +++ b/main.go @@ -26,6 +26,7 @@ import ( "github.com/fosrl/newt/docker" "github.com/fosrl/newt/healthcheck" "github.com/fosrl/newt/logger" + "github.com/fosrl/newt/nativessh" "github.com/fosrl/newt/proxy" "github.com/fosrl/newt/updates" "github.com/fosrl/newt/util" @@ -714,8 +715,12 @@ func runNewtMain(ctx context.Context) { var wgData WgData var dockerEventMonitor *docker.EventMonitor + // In-memory SSH credentials shared with the native SSH server started in + // the clients netstack once the WireGuard interface is ready. + sshCredStore := nativessh.NewCredentialStore() + if !disableClients { - setupClients(client) + setupClients(client, sshCredStore) } // Initialize health check monitor with status change callback @@ -1740,6 +1745,26 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( return } + var useNativeSSH = true + + if useNativeSSH { + // 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) + + // Acknowledge the PAM connection to the cloud. + if err := client.SendMessage("ws/round-trip/complete", map[string]interface{}{ + "messageId": certData.MessageId, + "complete": true, + }); err != nil { + logger.Error("nativessh: failed to send round-trip complete: %v", err) + } + return + } + // Check if we're running the auth daemon internally if authDaemonServer != nil && !certData.ExternalAuthDaemon { // if the auth daemon is running internally and the external auth daemon is not enabled // Call ProcessConnection directly when running internally diff --git a/nativessh/server.go b/nativessh/server.go index db91d08..46eb804 100644 --- a/nativessh/server.go +++ b/nativessh/server.go @@ -1,38 +1,80 @@ package nativessh import ( - "bufio" "crypto/ed25519" "crypto/rand" "fmt" "io" "log" "net" - "os" "strings" + "sync" "golang.org/x/crypto/ssh" ) -const ( - // DefaultCAKeyPath is the path to the SSH CA public key used to validate - // client certificates. - DefaultCAKeyPath = "/tmp/newt/ssh_ca.pub" - // DefaultPrincipalsPath is the path to a file listing allowed SSH - // certificate principals, one per line. - DefaultPrincipalsPath = "/tmp/newt/ssh_principals" -) +// CredentialStore holds in-memory SSH credentials that can be updated at runtime. +// It is safe for concurrent use. +type CredentialStore struct { + mu sync.RWMutex + caKey ssh.PublicKey + principals map[string]map[string]struct{} // username -> set of allowed principals +} + +// NewCredentialStore returns an empty, ready-to-use CredentialStore. +func NewCredentialStore() *CredentialStore { + return &CredentialStore{ + principals: make(map[string]map[string]struct{}), + } +} + +// SetCAKey parses and stores the CA public key from authorized_keys-format data. +func (s *CredentialStore) SetCAKey(authorizedKeyData string) error { + key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(authorizedKeyData)) + if err != nil { + return fmt.Errorf("parse CA key: %w", err) + } + s.mu.Lock() + s.caKey = key + s.mu.Unlock() + return nil +} + +// AddPrincipals records username and niceId as allowed principals for username. +// Both values are stored; either can appear in the certificate's ValidPrincipals +// field to satisfy the standard cert-auth principal check. +func (s *CredentialStore) AddPrincipals(username, niceId string) { + username = strings.TrimSpace(username) + niceId = strings.TrimSpace(niceId) + if username == "" { + return + } + s.mu.Lock() + defer s.mu.Unlock() + if s.principals[username] == nil { + s.principals[username] = make(map[string]struct{}) + } + s.principals[username][username] = struct{}{} + if niceId != "" { + s.principals[username][niceId] = struct{}{} + } +} + +// get returns the CA key and the principal set for username under a read lock. +func (s *CredentialStore) get(username string) (ssh.PublicKey, map[string]struct{}) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.caKey, s.principals[username] +} // ServerConfig holds configuration for the native SSH server. type ServerConfig struct { // ListenAddr is the TCP address to listen on. Defaults to ":2222". ListenAddr string - // CAKeyPath is the path to the CA public key file (authorized_keys format). - // Defaults to DefaultCAKeyPath. - CAKeyPath string - // PrincipalsPath is the path to a file of allowed principals, one per line. - // Defaults to DefaultPrincipalsPath. - PrincipalsPath string + // Credentials provides in-memory CA key and per-user principals. + // Updates to the store are reflected immediately for new connections. + // If nil or the store has no CA key set, all connections are rejected. + Credentials *CredentialStore } // Server is a simple SSH server that authenticates clients via SSH certificate @@ -43,40 +85,22 @@ type Server struct { cfg ServerConfig } -// NewServer creates a new Server. Zero-value fields in cfg are replaced with -// defaults. +// NewServer creates a new Server. The ListenAddr defaults to ":2222" when empty. func NewServer(cfg ServerConfig) *Server { if cfg.ListenAddr == "" { cfg.ListenAddr = ":2222" } - if cfg.CAKeyPath == "" { - cfg.CAKeyPath = DefaultCAKeyPath - } - if cfg.PrincipalsPath == "" { - cfg.PrincipalsPath = DefaultPrincipalsPath - } return &Server{cfg: cfg} } -// buildSSHConfig loads keys/principals and builds the ssh.ServerConfig. +// buildSSHConfig builds the ssh.ServerConfig backed by the in-memory CredentialStore. func (s *Server) buildSSHConfig() (*ssh.ServerConfig, error) { - caKey, err := loadCAPublicKey(s.cfg.CAKeyPath) - if err != nil { - return nil, fmt.Errorf("load CA public key from %s: %w", s.cfg.CAKeyPath, err) - } - - principals, err := loadPrincipals(s.cfg.PrincipalsPath) - if err != nil { - return nil, fmt.Errorf("load principals from %s: %w", s.cfg.PrincipalsPath, err) - } - hostSigner, err := generateHostKey() if err != nil { return nil, fmt.Errorf("host key: %w", err) } - cfg := &ssh.ServerConfig{ - PublicKeyCallback: makeCertAuthCallback(caKey, principals), + PublicKeyCallback: makeCredentialStoreCallback(s.cfg.Credentials), } cfg.AddHostKey(hostSigner) return cfg, nil @@ -216,23 +240,25 @@ func (s *Server) handleSession(ch ssh.Channel, requests <-chan *ssh.Request) { } } -// makeCertAuthCallback returns an ssh.PublicKeyCallback that accepts only -// SSH user certificates that are: -// 1. Signed by caKey. -// 2. Listing the connecting username in ValidPrincipals (standard cert auth). -// 3. Whose connecting username is also in the local allowedPrincipals set. -func makeCertAuthCallback(caKey ssh.PublicKey, allowedPrincipals map[string]struct{}) func(ssh.ConnMetadata, ssh.PublicKey) (*ssh.Permissions, error) { - checker := &ssh.CertChecker{ - IsUserAuthority: func(auth ssh.PublicKey) bool { - return ssh.FingerprintSHA256(auth) == ssh.FingerprintSHA256(caKey) - }, - } +// makeCredentialStoreCallback returns an ssh.PublicKeyCallback that reads +// the CA key and per-user principals from store on every auth attempt, so +// credentials updated via AddPrincipals/SetCAKey are applied immediately. +func makeCredentialStoreCallback(store *CredentialStore) func(ssh.ConnMetadata, ssh.PublicKey) (*ssh.Permissions, error) { return func(meta ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { + caKey, userPrincipals := store.get(meta.User()) + if caKey == nil { + return nil, fmt.Errorf("no CA key configured") + } + checker := &ssh.CertChecker{ + IsUserAuthority: func(auth ssh.PublicKey) bool { + return ssh.FingerprintSHA256(auth) == ssh.FingerprintSHA256(caKey) + }, + } perms, err := checker.Authenticate(meta, key) if err != nil { return nil, err } - if _, ok := allowedPrincipals[meta.User()]; !ok { + if len(userPrincipals) == 0 { return nil, fmt.Errorf("user %q not in allowed principals list", meta.User()) } return perms, nil @@ -250,35 +276,6 @@ func generateHostKey() (ssh.Signer, error) { return ssh.NewSignerFromKey(priv) } -func loadCAPublicKey(path string) (ssh.PublicKey, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, err - } - key, _, _, _, err := ssh.ParseAuthorizedKey(data) - if err != nil { - return nil, err - } - return key, nil -} - -func loadPrincipals(path string) (map[string]struct{}, error) { - f, err := os.Open(path) - if err != nil { - return nil, err - } - defer f.Close() - principals := make(map[string]struct{}) - scanner := bufio.NewScanner(f) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line != "" && !strings.HasPrefix(line, "#") { - principals[line] = struct{}{} - } - } - return principals, scanner.Err() -} - // ptyRequestMsg mirrors the SSH wire format for pty-req (RFC 4254 §6.2). type ptyRequestMsg struct { Term string From b9536597d297b918d48bc639ded2efe06792a784 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 22 May 2026 11:19:57 -0700 Subject: [PATCH 129/161] Remove nativeSsh config with shell Former-commit-id: e36ae4b746d3c9e84a2db071af58fd98e59eb5d6 --- browsergateway/browsergateway.go | 5 ----- browsergateway/ssh.go | 8 +++++--- browsergateway/ssh_native.go | 8 +------- 3 files changed, 6 insertions(+), 15 deletions(-) diff --git a/browsergateway/browsergateway.go b/browsergateway/browsergateway.go index 4f99a29..2d0c6a8 100644 --- a/browsergateway/browsergateway.go +++ b/browsergateway/browsergateway.go @@ -36,9 +36,6 @@ type Config struct { // to match against). For all proxy targets (RDP/SSH/VNC), auth tokens are // stored per-Target and validated by isAllowed. AuthToken string - // NativeSSH, when non-nil, configures a local PTY/shell SSH mode instead - // of proxying to an external SSH server. - NativeSSH *NativeSSHConfig } // Gateway is a browser-based RDP/SSH/VNC WebSocket proxy. @@ -46,7 +43,6 @@ type Config struct { // HandleRDP / HandleSSH / HandleVNC http.HandlerFunc methods. type Gateway struct { authToken string - nativeSSH *NativeSSHConfig mu sync.RWMutex targets map[int]Target // keyed by Target.ID @@ -58,7 +54,6 @@ type Gateway struct { func New(cfg Config) *Gateway { return &Gateway{ authToken: cfg.AuthToken, - nativeSSH: cfg.NativeSSH, targets: make(map[int]Target), } } diff --git a/browsergateway/ssh.go b/browsergateway/ssh.go index dc5bb13..bc76460 100644 --- a/browsergateway/ssh.go +++ b/browsergateway/ssh.go @@ -40,9 +40,11 @@ func (g *Gateway) HandleSSH(w http.ResponseWriter, r *http.Request) { token := r.URL.Query().Get("authToken") + var nativeSSH = false + // In proxy mode we also need host + username from query params. var target, username string - if g.nativeSSH == nil { + if !nativeSSH { host := r.URL.Query().Get("host") port := r.URL.Query().Get("port") username = r.URL.Query().Get("username") @@ -78,8 +80,8 @@ func (g *Gateway) HandleSSH(w http.ResponseWriter, r *http.Request) { ws.SetReadLimit(-1) defer ws.CloseNow() //nolint:errcheck - if g.nativeSSH != nil { - if err := serveNativeSSHSession(ctx, ws, *g.nativeSSH); err != nil { + if nativeSSH { + if err := serveNativeSSHSession(ctx, ws); err != nil { log.Printf("SSH native session error: %v", err) } } else { diff --git a/browsergateway/ssh_native.go b/browsergateway/ssh_native.go index a2ac8e9..3745aa4 100644 --- a/browsergateway/ssh_native.go +++ b/browsergateway/ssh_native.go @@ -10,18 +10,12 @@ import ( "github.com/fosrl/newt/nativessh" ) -// NativeSSHConfig holds configuration for the native PTY/shell mode. -type NativeSSHConfig struct { - // Shell is the executable to spawn (e.g. /bin/bash). Defaults to /bin/sh. - Shell string -} - // serveNativeSSHSession handles a WebSocket SSH session by spawning a local // PTY+shell instead of proxying to an external SSH server. The auth token has // already been validated at the WebSocket upgrade level, so this function only // reads (and discards) the initial "auth" frame for protocol compatibility with // the browser client before starting the shell. -func serveNativeSSHSession(ctx context.Context, ws *websocket.Conn, cfg NativeSSHConfig) error { +func serveNativeSSHSession(ctx context.Context, ws *websocket.Conn) error { // Read and discard the auth frame (token already validated at HTTP layer). _, authBytes, err := ws.Read(ctx) if err != nil { From 6bf6b47d1869054c0bf7f8c4512d31e9cc2d9ba2 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 22 May 2026 11:20:21 -0700 Subject: [PATCH 130/161] Add pam to native ssh server Former-commit-id: e6267cc1fc1f7d074acd34495a8a9831c7ad57a1 --- go.mod | 1 + go.sum | 2 ++ main.go | 49 +++++++++++----------------- nativessh/auth.go | 50 +++++++++++++++++++++++++++++ nativessh/pam_linux.go | 34 ++++++++++++++++++++ nativessh/pam_other.go | 11 +++++++ nativessh/server.go | 72 ++++++++++++++++++++++++++++++------------ 7 files changed, 169 insertions(+), 50 deletions(-) create mode 100644 nativessh/auth.go create mode 100644 nativessh/pam_linux.go create mode 100644 nativessh/pam_other.go diff --git a/go.mod b/go.mod index 1508e71..32b24c5 100644 --- a/go.mod +++ b/go.mod @@ -52,6 +52,7 @@ require ( github.com/moby/sys/atomicwriter v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect github.com/morikuni/aec v1.0.0 // indirect + github.com/msteinert/pam/v2 v2.1.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.0 // indirect diff --git a/go.sum b/go.sum index c79a283..77e8085 100644 --- a/go.sum +++ b/go.sum @@ -67,6 +67,8 @@ github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/msteinert/pam/v2 v2.1.0 h1:er5F9TKV5nGFuTt12ubtqPHEUdeBwReP7vd3wovidGY= +github.com/msteinert/pam/v2 v2.1.0/go.mod h1:KT28NNIcDFf3PcBmNI2mIGO4zZJ+9RSs/At2PB3IDVc= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= diff --git a/main.go b/main.go index cc03bc2..75a3f61 100644 --- a/main.go +++ b/main.go @@ -1715,14 +1715,14 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( // 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"` - CACert string `json:"caCert"` - Username string `json:"username"` - NiceID string `json:"niceId"` - Metadata struct { + MessageId int `json:"messageId"` + AgentPort int `json:"agentPort"` + AgentHost string `json:"agentHost"` + 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"` @@ -1745,28 +1745,8 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( return } - var useNativeSSH = true - - if useNativeSSH { - // 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) - - // Acknowledge the PAM connection to the cloud. - if err := client.SendMessage("ws/round-trip/complete", map[string]interface{}{ - "messageId": certData.MessageId, - "complete": true, - }); err != nil { - logger.Error("nativessh: failed to send round-trip complete: %v", err) - } - return - } - // Check if we're running the auth daemon internally - if authDaemonServer != nil && !certData.ExternalAuthDaemon { // if the auth daemon is running internally and the external auth daemon is not enabled + if authDaemonServer != nil && certData.AuthDaemonMode == "site" { // if the auth daemon is running internally and the external auth daemon is not enabled // Call ProcessConnection directly when running internally logger.Debug("Calling internal auth daemon ProcessConnection for user %s", certData.Username) @@ -1789,7 +1769,7 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( }) logger.Info("Successfully processed connection via internal auth daemon for user %s", certData.Username) - } else { + } else if certData.AuthDaemonMode == "remote" { // External auth daemon mode - make HTTP request // Check if auth daemon key is configured if authDaemonKey == "" { @@ -1886,6 +1866,15 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( } logger.Info("Successfully registered SSH certificate with external auth daemon for user %s", certData.Username) + } else if certData.AuthDaemonMode == "native" { + // 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("Unknown auth daemon mode: %s", certData.AuthDaemonMode) } // Send success response back to cloud diff --git a/nativessh/auth.go b/nativessh/auth.go new file mode 100644 index 0000000..54d0902 --- /dev/null +++ b/nativessh/auth.go @@ -0,0 +1,50 @@ +package nativessh + +import ( + "bufio" + "os" + "os/user" + "path/filepath" + "strings" + + "golang.org/x/crypto/ssh" +) + +// checkAuthorizedKeys reports whether key matches any entry in the system +// user's ~/.ssh/authorized_keys file. Returns false (not an error) when the +// user or file does not exist. +func checkAuthorizedKeys(username string, key ssh.PublicKey) bool { + u, err := user.Lookup(username) + if err != nil { + return false + } + f, err := os.Open(filepath.Join(u.HomeDir, ".ssh", "authorized_keys")) + if err != nil { + return false + } + defer f.Close() + + want := ssh.FingerprintSHA256(key) + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + parsed, _, _, _, err := ssh.ParseAuthorizedKey([]byte(line)) + if err != nil { + continue + } + if ssh.FingerprintSHA256(parsed) == want { + return true + } + } + return false +} + +// systemUserExists reports whether a user account with the given name exists +// on the host OS. +func systemUserExists(username string) bool { + _, err := user.Lookup(username) + return err == nil +} diff --git a/nativessh/pam_linux.go b/nativessh/pam_linux.go new file mode 100644 index 0000000..c41a046 --- /dev/null +++ b/nativessh/pam_linux.go @@ -0,0 +1,34 @@ +//go:build linux + +package nativessh + +import ( + "fmt" + + "github.com/msteinert/pam/v2" +) + +// verifySystemPassword authenticates username/password via PAM using the +// "sshd" service stack. It returns nil on success and an error on failure. +// The caller must not reveal the error detail to the client. +func verifySystemPassword(username, password string) error { + tx, err := pam.StartFunc("sshd", username, func(s pam.Style, msg string) (string, error) { + switch s { + case pam.PromptEchoOff, pam.PromptEchoOn: + return password, nil + default: + return "", nil + } + }) + if err != nil { + return fmt.Errorf("PAM start: %w", err) + } + + if err := tx.Authenticate(0); err != nil { + return fmt.Errorf("PAM authenticate: %w", err) + } + if err := tx.AcctMgmt(0); err != nil { + return fmt.Errorf("PAM acct_mgmt: %w", err) + } + return nil +} diff --git a/nativessh/pam_other.go b/nativessh/pam_other.go new file mode 100644 index 0000000..267400a --- /dev/null +++ b/nativessh/pam_other.go @@ -0,0 +1,11 @@ +//go:build !linux + +package nativessh + +import "errors" + +// verifySystemPassword is not supported on non-Linux platforms; it always +// returns an error so that password authentication is never accepted. +func verifySystemPassword(username, password string) error { + return errors.New("password authentication not supported on this platform") +} diff --git a/nativessh/server.go b/nativessh/server.go index 46eb804..f4aa2c9 100644 --- a/nativessh/server.go +++ b/nativessh/server.go @@ -93,14 +93,17 @@ func NewServer(cfg ServerConfig) *Server { return &Server{cfg: cfg} } -// buildSSHConfig builds the ssh.ServerConfig backed by the in-memory CredentialStore. +// buildSSHConfig builds the ssh.ServerConfig with multi-method authentication: +// 1. Public key: host ~/.ssh/authorized_keys, then CA certificate. +// 2. Password: system PAM stack (Linux only). func (s *Server) buildSSHConfig() (*ssh.ServerConfig, error) { hostSigner, err := generateHostKey() if err != nil { return nil, fmt.Errorf("host key: %w", err) } cfg := &ssh.ServerConfig{ - PublicKeyCallback: makeCredentialStoreCallback(s.cfg.Credentials), + PublicKeyCallback: makePublicKeyCallback(s.cfg.Credentials), + PasswordCallback: makePasswordCallback(), } cfg.AddHostKey(hostSigner) return cfg, nil @@ -240,28 +243,57 @@ func (s *Server) handleSession(ch ssh.Channel, requests <-chan *ssh.Request) { } } -// makeCredentialStoreCallback returns an ssh.PublicKeyCallback that reads -// the CA key and per-user principals from store on every auth attempt, so -// credentials updated via AddPrincipals/SetCAKey are applied immediately. -func makeCredentialStoreCallback(store *CredentialStore) func(ssh.ConnMetadata, ssh.PublicKey) (*ssh.Permissions, error) { +// makePublicKeyCallback returns a PublicKeyCallback that tries, in order: +// 1. Host authorized_keys – matches any key in the OS user's +// ~/.ssh/authorized_keys file. +// 2. CA certificate – validates an SSH certificate signed by the +// configured CA and checks that the user appears in the principals map. +// +// store may be nil or empty; those paths are simply skipped. +func makePublicKeyCallback(store *CredentialStore) func(ssh.ConnMetadata, ssh.PublicKey) (*ssh.Permissions, error) { return func(meta ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { - caKey, userPrincipals := store.get(meta.User()) - if caKey == nil { - return nil, fmt.Errorf("no CA key configured") + // 1. Host authorized_keys. + if checkAuthorizedKeys(meta.User(), key) { + log.Printf("nativessh: authorized_keys auth for user %q", meta.User()) + return &ssh.Permissions{}, nil } - checker := &ssh.CertChecker{ - IsUserAuthority: func(auth ssh.PublicKey) bool { - return ssh.FingerprintSHA256(auth) == ssh.FingerprintSHA256(caKey) - }, + + // 2. CA certificate. + if store != nil { + caKey, userPrincipals := store.get(meta.User()) + if caKey != nil { + checker := &ssh.CertChecker{ + IsUserAuthority: func(auth ssh.PublicKey) bool { + return ssh.FingerprintSHA256(auth) == ssh.FingerprintSHA256(caKey) + }, + } + perms, err := checker.Authenticate(meta, key) + if err == nil { + if len(userPrincipals) == 0 { + return nil, fmt.Errorf("user %q not in allowed principals list", meta.User()) + } + log.Printf("nativessh: CA cert auth for user %q", meta.User()) + return perms, nil + } + } } - perms, err := checker.Authenticate(meta, key) - if err != nil { - return nil, err + + return nil, fmt.Errorf("public key not authorized for user %q", meta.User()) + } +} + +// makePasswordCallback returns a PasswordCallback that validates the supplied +// password via the host OS PAM stack. On non-Linux platforms this always +// fails (see pam_other.go). +func makePasswordCallback() func(ssh.ConnMetadata, []byte) (*ssh.Permissions, error) { + return func(meta ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) { + if err := verifySystemPassword(meta.User(), string(password)); err != nil { + // Return a generic message to the client; log the real reason. + log.Printf("nativessh: password auth failed for user %q: %v", meta.User(), err) + return nil, fmt.Errorf("permission denied") } - if len(userPrincipals) == 0 { - return nil, fmt.Errorf("user %q not in allowed principals list", meta.User()) - } - return perms, nil + log.Printf("nativessh: password auth for user %q", meta.User()) + return &ssh.Permissions{}, nil } } From 865697204b900f1bb645196ab802dfb974c77f9b Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 22 May 2026 11:30:21 -0700 Subject: [PATCH 131/161] Bring the naitve ssh pam to the browser gateway Former-commit-id: 1ff26b7acdbd0ab4c76247a24bde0edca611092e --- browsergateway/ssh.go | 9 +++-- browsergateway/ssh_native.go | 28 +++++++++------ nativessh/auth.go | 34 +++++++++++++++--- nativessh/pam_linux.go | 4 +-- nativessh/pam_other.go | 4 +-- nativessh/pty_unix.go | 69 ++++++++++++++++++++++++++++++++++++ nativessh/server.go | 4 +-- 7 files changed, 130 insertions(+), 22 deletions(-) create mode 100644 nativessh/pty_unix.go diff --git a/browsergateway/ssh.go b/browsergateway/ssh.go index bc76460..eaea313 100644 --- a/browsergateway/ssh.go +++ b/browsergateway/ssh.go @@ -62,11 +62,16 @@ func (g *Gateway) HandleSSH(w http.ResponseWriter, r *http.Request) { } target = net.JoinHostPort(host, port) } else { - // Native SSH mode: validate against the global gateway token. + // Native SSH mode: validate the gateway token then read the target username. if subtle.ConstantTimeCompare([]byte(token), []byte(g.authToken)) != 1 { http.Error(w, "unauthorized", http.StatusUnauthorized) return } + username = r.URL.Query().Get("username") + if username == "" { + http.Error(w, "missing username", http.StatusBadRequest) + return + } } ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{ @@ -81,7 +86,7 @@ func (g *Gateway) HandleSSH(w http.ResponseWriter, r *http.Request) { defer ws.CloseNow() //nolint:errcheck if nativeSSH { - if err := serveNativeSSHSession(ctx, ws); err != nil { + if err := serveNativeSSHSession(ctx, ws, username); err != nil { log.Printf("SSH native session error: %v", err) } } else { diff --git a/browsergateway/ssh_native.go b/browsergateway/ssh_native.go index 3745aa4..d81e762 100644 --- a/browsergateway/ssh_native.go +++ b/browsergateway/ssh_native.go @@ -10,13 +10,15 @@ import ( "github.com/fosrl/newt/nativessh" ) -// serveNativeSSHSession handles a WebSocket SSH session by spawning a local -// PTY+shell instead of proxying to an external SSH server. The auth token has -// already been validated at the WebSocket upgrade level, so this function only -// reads (and discards) the initial "auth" frame for protocol compatibility with -// the browser client before starting the shell. -func serveNativeSSHSession(ctx context.Context, ws *websocket.Conn) error { - // Read and discard the auth frame (token already validated at HTTP layer). +// serveNativeSSHSession handles a WebSocket SSH session by authenticating the +// user against the host OS (authorized_keys then PAM password) and then +// spawning a PTY+shell running as that user. +// +// The auth frame from the browser must be a JSON sshClientMsg with type="auth" +// carrying the same password/privateKey fields used by the proxy SSH path. +// The target username is passed in from the HTTP layer (query param). +func serveNativeSSHSession(ctx context.Context, ws *websocket.Conn, username string) error { + // Read the auth frame. _, authBytes, err := ws.Read(ctx) if err != nil { return fmt.Errorf("read auth message: %w", err) @@ -26,12 +28,18 @@ func serveNativeSSHSession(ctx context.Context, ws *websocket.Conn) error { return fmt.Errorf("expected auth message, got: %s", authBytes) } - log.Printf("SSH native: spawning shell") + // Authenticate using host authorized_keys or PAM password. + if err := nativessh.Authenticate(username, authMsg.Password, authMsg.PrivateKey); err != nil { + sendSSHError(ctx, ws, "Authentication failed") + return fmt.Errorf("auth for user %q: %w", username, err) + } - sess, err := nativessh.NewPTYSession() + log.Printf("SSH native: spawning shell as user %q", username) + + sess, err := nativessh.NewPTYSessionAs(username) if err != nil { sendSSHError(ctx, ws, fmt.Sprintf("Failed to spawn shell: %v", err)) - return fmt.Errorf("pty session: %w", err) + return fmt.Errorf("pty session as %q: %w", username, err) } defer sess.Close() diff --git a/nativessh/auth.go b/nativessh/auth.go index 54d0902..a060ef7 100644 --- a/nativessh/auth.go +++ b/nativessh/auth.go @@ -2,6 +2,7 @@ package nativessh import ( "bufio" + "fmt" "os" "os/user" "path/filepath" @@ -10,10 +11,10 @@ import ( "golang.org/x/crypto/ssh" ) -// checkAuthorizedKeys reports whether key matches any entry in the system +// CheckAuthorizedKeys reports whether key matches any entry in the system // user's ~/.ssh/authorized_keys file. Returns false (not an error) when the // user or file does not exist. -func checkAuthorizedKeys(username string, key ssh.PublicKey) bool { +func CheckAuthorizedKeys(username string, key ssh.PublicKey) bool { u, err := user.Lookup(username) if err != nil { return false @@ -42,9 +43,34 @@ func checkAuthorizedKeys(username string, key ssh.PublicKey) bool { return false } -// systemUserExists reports whether a user account with the given name exists +// SystemUserExists reports whether a user account with the given name exists // on the host OS. -func systemUserExists(username string) bool { +func SystemUserExists(username string) bool { _, err := user.Lookup(username) return err == nil } + +// Authenticate authenticates a user for a browser-based native SSH session. +// It tries, in order: +// 1. Private key — parses privateKeyPEM and checks it against the user's +// ~/.ssh/authorized_keys. +// 2. Password — verifies password via the host OS PAM stack (Linux only). +// +// Returns nil on the first method that succeeds, or an error if all fail. +func Authenticate(username, password, privateKeyPEM string) error { + if !SystemUserExists(username) { + return fmt.Errorf("user %q does not exist", username) + } + if privateKeyPEM != "" { + signer, err := ssh.ParsePrivateKey([]byte(privateKeyPEM)) + if err == nil && CheckAuthorizedKeys(username, signer.PublicKey()) { + return nil + } + } + if password != "" { + if err := VerifySystemPassword(username, password); err == nil { + return nil + } + } + return fmt.Errorf("authentication failed for user %q", username) +} diff --git a/nativessh/pam_linux.go b/nativessh/pam_linux.go index c41a046..b6f359d 100644 --- a/nativessh/pam_linux.go +++ b/nativessh/pam_linux.go @@ -8,10 +8,10 @@ import ( "github.com/msteinert/pam/v2" ) -// verifySystemPassword authenticates username/password via PAM using the +// VerifySystemPassword authenticates username/password via PAM using the // "sshd" service stack. It returns nil on success and an error on failure. // The caller must not reveal the error detail to the client. -func verifySystemPassword(username, password string) error { +func VerifySystemPassword(username, password string) error { tx, err := pam.StartFunc("sshd", username, func(s pam.Style, msg string) (string, error) { switch s { case pam.PromptEchoOff, pam.PromptEchoOn: diff --git a/nativessh/pam_other.go b/nativessh/pam_other.go index 267400a..624c847 100644 --- a/nativessh/pam_other.go +++ b/nativessh/pam_other.go @@ -4,8 +4,8 @@ package nativessh import "errors" -// verifySystemPassword is not supported on non-Linux platforms; it always +// VerifySystemPassword is not supported on non-Linux platforms; it always // returns an error so that password authentication is never accepted. -func verifySystemPassword(username, password string) error { +func VerifySystemPassword(username, password string) error { return errors.New("password authentication not supported on this platform") } diff --git a/nativessh/pty_unix.go b/nativessh/pty_unix.go new file mode 100644 index 0000000..7e89c15 --- /dev/null +++ b/nativessh/pty_unix.go @@ -0,0 +1,69 @@ +//go:build !windows + +package nativessh + +import ( + "fmt" + "os/exec" + "os/user" + "strconv" + "syscall" + + "github.com/creack/pty" +) + +// NewPTYSessionAs spawns an interactive shell in a PTY running as the given +// system user. The calling process must have sufficient privileges (typically +// root / CAP_SETUID) to switch to a different UID/GID. +func NewPTYSessionAs(username string) (*PTYSession, error) { + u, err := user.Lookup(username) + if err != nil { + return nil, fmt.Errorf("user lookup %q: %w", username, err) + } + uid, err := strconv.ParseUint(u.Uid, 10, 32) + if err != nil { + return nil, fmt.Errorf("parse uid: %w", err) + } + gid, err := strconv.ParseUint(u.Gid, 10, 32) + if err != nil { + return nil, fmt.Errorf("parse gid: %w", err) + } + + // Collect supplementary group IDs. + groupIDs, err := u.GroupIds() + if err != nil { + groupIDs = []string{} + } + var groups []uint32 + for _, g := range groupIDs { + gval, err := strconv.ParseUint(g, 10, 32) + if err == nil { + groups = append(groups, uint32(gval)) + } + } + + shell := findShell() + cmd := exec.Command(shell, "--login") + cmd.Env = []string{ + "TERM=xterm-256color", + "HOME=" + u.HomeDir, + "USER=" + username, + "LOGNAME=" + username, + "SHELL=" + shell, + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + } + cmd.Dir = u.HomeDir + cmd.SysProcAttr = &syscall.SysProcAttr{ + Credential: &syscall.Credential{ + Uid: uint32(uid), + Gid: uint32(gid), + Groups: groups, + }, + } + + ptmx, err := pty.Start(cmd) + if err != nil { + return nil, fmt.Errorf("pty start: %w", err) + } + return &PTYSession{ptmx: ptmx, cmd: cmd}, nil +} diff --git a/nativessh/server.go b/nativessh/server.go index f4aa2c9..e6109b1 100644 --- a/nativessh/server.go +++ b/nativessh/server.go @@ -253,7 +253,7 @@ func (s *Server) handleSession(ch ssh.Channel, requests <-chan *ssh.Request) { func makePublicKeyCallback(store *CredentialStore) func(ssh.ConnMetadata, ssh.PublicKey) (*ssh.Permissions, error) { return func(meta ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { // 1. Host authorized_keys. - if checkAuthorizedKeys(meta.User(), key) { + if CheckAuthorizedKeys(meta.User(), key) { log.Printf("nativessh: authorized_keys auth for user %q", meta.User()) return &ssh.Permissions{}, nil } @@ -287,7 +287,7 @@ func makePublicKeyCallback(store *CredentialStore) func(ssh.ConnMetadata, ssh.Pu // fails (see pam_other.go). func makePasswordCallback() func(ssh.ConnMetadata, []byte) (*ssh.Permissions, error) { return func(meta ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) { - if err := verifySystemPassword(meta.User(), string(password)); err != nil { + if err := VerifySystemPassword(meta.User(), string(password)); err != nil { // Return a generic message to the client; log the real reason. log.Printf("nativessh: password auth failed for user %q: %v", meta.User(), err) return nil, fmt.Errorf("permission denied") From 2a3ec1953f91a36534b757f7465b1158cef3e141 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 22 May 2026 13:39:14 -0700 Subject: [PATCH 132/161] Add --disable-ssh flag to replace --auth-daemon Former-commit-id: 60d67ee9a1e65481e690b8948666fa1bfaa652fe --- clients/clients.go | 11 ++++++---- main.go | 51 +++++++++++++++++++++++++++++++--------------- 2 files changed, 42 insertions(+), 20 deletions(-) diff --git a/clients/clients.go b/clients/clients.go index 325a828..9c6e680 100644 --- a/clients/clients.go +++ b/clients/clients.go @@ -908,10 +908,13 @@ func (s *WireGuardService) ensureWireguardInterface(wgconfig WgConfig) error { } // Start the SSH server on the clients' netstack (port 22). - if h, sshErr := startSSHOnNetstack(s.tnet, s.credStore); sshErr != nil { - logger.Warn("nativessh: not starting SSH server on clients netstack: %v", sshErr) - } else { - s.sshServer = h + // A nil credStore means SSH is disabled (--disable-ssh), so skip starting the server. + if s.credStore != nil { + if h, sshErr := startSSHOnNetstack(s.tnet, s.credStore); sshErr != nil { + logger.Warn("nativessh: not starting SSH server on clients netstack: %v", sshErr) + } else { + s.sshServer = h + } } // Note: we already unlocked above, so don't use defer unlock diff --git a/main.go b/main.go index 75a3f61..d7cf7ad 100644 --- a/main.go +++ b/main.go @@ -134,6 +134,7 @@ var ( port uint16 portStr string disableClients bool + disableSSH bool updownScript string dockerSocket string dockerEnforceNetworkValidation string @@ -157,7 +158,6 @@ var ( authDaemonKey string authDaemonPrincipalsFile string authDaemonCACertPath string - authDaemonEnabled bool authDaemonGenerateRandomPassword bool // Build/version (can be overridden via -ldflags "-X main.newtVersion=...") newtVersion = "version_replaceme" @@ -255,7 +255,6 @@ func runNewtMain(ctx context.Context) { authDaemonKey = os.Getenv("AD_KEY") authDaemonPrincipalsFile = os.Getenv("AD_PRINCIPALS_FILE") authDaemonCACertPath = os.Getenv("AD_CA_CERT_PATH") - authDaemonEnabledEnv := os.Getenv("AUTH_DAEMON_ENABLED") authDaemonGenerateRandomPasswordEnv := os.Getenv("AD_GENERATE_RANDOM_PASSWORD") // Metrics/observability env mirrors @@ -268,6 +267,8 @@ func runNewtMain(ctx context.Context) { disableClientsEnv := os.Getenv("DISABLE_CLIENTS") disableClients = disableClientsEnv == "true" + disableSSHEnv := os.Getenv("DISABLE_SSH") + disableSSH = disableSSHEnv == "true" useNativeInterfaceEnv := os.Getenv("USE_NATIVE_INTERFACE") useNativeInterface = useNativeInterfaceEnv == "true" enforceHealthcheckCertEnv := os.Getenv("ENFORCE_HC_CERT") @@ -340,6 +341,9 @@ func runNewtMain(ctx context.Context) { 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)") } @@ -374,6 +378,8 @@ func runNewtMain(ctx context.Context) { 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 @@ -485,13 +491,7 @@ func runNewtMain(ctx context.Context) { if authDaemonCACertPath == "" { flag.StringVar(&authDaemonCACertPath, "ad-ca-cert-path", "/etc/ssh/ca.pem", "Path to the CA certificate file for auth daemon") } - if authDaemonEnabledEnv == "" { - flag.BoolVar(&authDaemonEnabled, "auth-daemon", false, "Enable auth daemon mode (runs alongside normal newt operation)") - } else { - if v, err := strconv.ParseBool(authDaemonEnabledEnv); err == nil { - authDaemonEnabled = v - } - } + if authDaemonGenerateRandomPasswordEnv == "" { flag.BoolVar(&authDaemonGenerateRandomPassword, "ad-generate-random-password", false, "Generate a random password for authenticated users") } else { @@ -530,7 +530,7 @@ func runNewtMain(ctx context.Context) { loggerLevel := util.ParseLogLevel(logLevel) // Start auth daemon if enabled - if authDaemonEnabled { + if !disableSSH { if err := startAuthDaemon(ctx); err != nil { logger.Fatal("Failed to start auth daemon: %v", err) } @@ -717,7 +717,10 @@ func runNewtMain(ctx context.Context) { // In-memory SSH credentials shared with the native SSH server started in // the clients netstack once the WireGuard interface is ready. - sshCredStore := nativessh.NewCredentialStore() + var sshCredStore *nativessh.CredentialStore + if !disableSSH { + sshCredStore = nativessh.NewCredentialStore() + } if !disableClients { setupClients(client, sshCredStore) @@ -1867,12 +1870,28 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( logger.Info("Successfully registered SSH certificate with external auth daemon for user %s", certData.Username) } else if certData.AuthDaemonMode == "native" { - // 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) + if disableSSH { + logger.Warn("Received native SSH connection request but SSH is disabled via --disable-ssh") + } else { + 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, + }, + }) + + // 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) } - sshCredStore.AddPrincipals(certData.Username, certData.NiceID) - logger.Info("nativessh: updated credentials for user %s (niceId=%s)", certData.Username, certData.NiceID) } else { logger.Error("Unknown auth daemon mode: %s", certData.AuthDaemonMode) } From 71ab4da7f52545621cb723f0cbd5427fee98e433 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 22 May 2026 13:49:54 -0700 Subject: [PATCH 133/161] Handle the different modes Former-commit-id: 5d84fa91414b43c16d028c697a7e69dcab888676 --- authdaemon/connection.go | 2 +- authdaemon/routes.go | 4 +- main.go | 87 +++++++++++++++++++++++++++------------- 3 files changed, 62 insertions(+), 31 deletions(-) diff --git a/authdaemon/connection.go b/authdaemon/connection.go index 3fb44c5..82ddae6 100644 --- a/authdaemon/connection.go +++ b/authdaemon/connection.go @@ -19,7 +19,7 @@ func (s *Server) ProcessConnection(req ConnectionRequest) { if err := ensureUser(req.Username, req.Metadata, s.cfg.GenerateRandomPassword); err != nil { logger.Warn("auth-daemon: ensure user: %v", err) } - if cfg.PrincipalsFilePath != "" { + if cfg.PrincipalsFilePath != "" && req.NiceId != "" { if err := writePrincipals(cfg.PrincipalsFilePath, req.Username, req.NiceId); err != nil { logger.Warn("auth-daemon: write principals: %v", err) } diff --git a/authdaemon/routes.go b/authdaemon/routes.go index 8457c60..3d1be3e 100644 --- a/authdaemon/routes.go +++ b/authdaemon/routes.go @@ -14,9 +14,9 @@ func (s *Server) registerRoutes() { // ConnectionMetadata is the metadata object in POST /connection. type ConnectionMetadata struct { SudoMode string `json:"sudoMode"` // "none" | "full" | "commands" - SudoCommands []string `json:"sudoCommands"` // used when sudoMode is "commands" + SudoCommands []string `json:"sudoCommands"` // used when sudoMode is "commands" Homedir bool `json:"homedir"` - Groups []string `json:"groups"` // system groups to add the user to + Groups []string `json:"groups"` // system groups to add the user to } // ConnectionRequest is the JSON body for POST /connection. diff --git a/main.go b/main.go index d7cf7ad..d21628d 100644 --- a/main.go +++ b/main.go @@ -1748,31 +1748,45 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( return } - // Check if we're running the auth daemon internally - if authDaemonServer != nil && certData.AuthDaemonMode == "site" { // if the auth daemon is running internally and the external auth daemon is not enabled + // Use a switch statement for AuthDaemonMode + switch certData.AuthDaemonMode { + case "site": // Call ProcessConnection directly when running internally logger.Debug("Calling internal auth daemon ProcessConnection for user %s", certData.Username) - 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, - }) + 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", + }) + 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) - } else if certData.AuthDaemonMode == "remote" { + case "remote": // External auth daemon mode - make HTTP request // Check if auth daemon key is configured if authDaemonKey == "" { @@ -1869,15 +1883,14 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( } logger.Info("Successfully registered SSH certificate with external auth daemon for user %s", certData.Username) - } else if certData.AuthDaemonMode == "native" { - if disableSSH { - logger.Warn("Received native SSH connection request but SSH is disabled via --disable-ssh") - } else { + 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: certData.CACert, - NiceId: certData.NiceID, + CaCert: "", // dont write the cert to the host + NiceId: "", // dont write the cert to the host Username: certData.Username, - Metadata: authdaemon.ConnectionMetadata{ + Metadata: authdaemon.ConnectionMetadata{ // but push the user SudoMode: certData.Metadata.SudoMode, SudoCommands: certData.Metadata.SudoCommands, Homedir: certData.Metadata.Homedir, @@ -1891,9 +1904,27 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( } 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 } - } else { + 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 From 3264aa097549a93cd7a35ee955ba7600bba3c3e7 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 25 May 2026 21:40:43 -0700 Subject: [PATCH 134/161] update issue template Former-commit-id: 6ccfe96e69ad0d027035da5ad245cfed849ec8a9 --- .github/ISSUE_TEMPLATE/1.bug_report.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/1.bug_report.yml b/.github/ISSUE_TEMPLATE/1.bug_report.yml index 41dbe7b..c945608 100644 --- a/.github/ISSUE_TEMPLATE/1.bug_report.yml +++ b/.github/ISSUE_TEMPLATE/1.bug_report.yml @@ -14,12 +14,13 @@ body: label: Environment description: Please fill out the relevant details below for your environment. value: | - - OS Type & Version: (e.g., Ubuntu 22.04) + - OS Type & Version: - Pangolin Version: + - Edition (Community or Enterprise): - Gerbil Version: - Traefik Version: - Newt Version: - - Olm Version: (if applicable) + - Client Version: validations: required: true From c058500932ace118d989528e3d04b3d0ac3ecda4 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 21 May 2026 11:29:57 -0700 Subject: [PATCH 135/161] Auto update newt Former-commit-id: 42cb8e790851ea6fa2df73a846523751e0d96276 --- Dockerfile | 4 + main.go | 26 +++++ updates/reexec_unix.go | 14 +++ updates/reexec_windows.go | 28 +++++ updates/selfupdate.go | 218 ++++++++++++++++++++++++++++++++++++++ websocket/client.go | 16 +++ 6 files changed, 306 insertions(+) create mode 100644 updates/reexec_unix.go create mode 100644 updates/reexec_windows.go create mode 100644 updates/selfupdate.go diff --git a/Dockerfile b/Dockerfile index ea870c2..6887923 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,6 +27,10 @@ RUN apk --no-cache add ca-certificates tzdata iputils COPY --from=builder /newt /usr/local/bin/ COPY entrypoint.sh / +# Marks this as an official Fossorial container image. +# Auto-update is disabled in official images — update by pulling a new image tag. +ENV NEWT_OFFICIAL_CONTAINER=true + # Admin/metrics endpoint (Prometheus scrape) EXPOSE 2112 diff --git a/main.go b/main.go index 448f71d..034ab82 100644 --- a/main.go +++ b/main.go @@ -176,6 +176,9 @@ var ( // Path to config file (overrides CONFIG_FILE env var and default location) configFile string + + // Auto-update flag + autoUpdate bool ) // generateChainId generates a random chain ID for deduplicating round-trip messages. @@ -489,6 +492,7 @@ func runNewtMain(ctx context.Context) { // do a --version check version := flag.Bool("version", false, "Print the version") + flag.BoolVar(&autoUpdate, "auto-update", false, "Check for a newer version on the server and self-update if one is available") flag.Parse() @@ -656,9 +660,31 @@ func runNewtMain(ctx context.Context) { 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) + // Auto-update: check for a new version, download and replace if available. + if autoUpdate { + 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) + } + if err := updates.CheckAndSelfUpdate(updates.SelfUpdateConfig{ + Endpoint: endpoint, + NewtID: id, + Secret: secret, + CurrentVersion: newtVersion, + TLSConfig: tlsCfg, + }); err != nil { + logger.Fatal("Auto-update failed: %v", err) + } + // CheckAndSelfUpdate re-execs on success, so we only reach here if + // newt is already up to date. + return + } + // output env var values if set logger.Debug("Endpoint: %v", endpoint) logger.Debug("Log Level: %v", logLevel) diff --git a/updates/reexec_unix.go b/updates/reexec_unix.go new file mode 100644 index 0000000..b439d10 --- /dev/null +++ b/updates/reexec_unix.go @@ -0,0 +1,14 @@ +//go:build !windows + +package updates + +import ( + "os" + "syscall" +) + +// reexec replaces the current process image with the binary at exePath, +// forwarding all original arguments and environment variables. +func reexec(exePath string) error { + return syscall.Exec(exePath, os.Args, os.Environ()) +} diff --git a/updates/reexec_windows.go b/updates/reexec_windows.go new file mode 100644 index 0000000..6174037 --- /dev/null +++ b/updates/reexec_windows.go @@ -0,0 +1,28 @@ +//go:build windows + +package updates + +import ( + "fmt" + "os" + "os/exec" +) + +// reexec on Windows cannot use syscall.Exec (there is no exec syscall that +// replaces the process image). Instead we start a new process and exit the +// current one. +func reexec(exePath string) error { + cmd := exec.Command(exePath, os.Args[1:]...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = os.Environ() + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start updated binary: %w", err) + } + + // Exit the current process so the new binary takes over. + os.Exit(0) + return nil // unreachable +} diff --git a/updates/selfupdate.go b/updates/selfupdate.go new file mode 100644 index 0000000..751f358 --- /dev/null +++ b/updates/selfupdate.go @@ -0,0 +1,218 @@ +package updates + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "runtime" + "strings" + "time" +) + +// SelfUpdateConfig holds the configuration required to perform a self-update. +type SelfUpdateConfig struct { + // Endpoint is the base URL of the pangolin server (e.g. "https://pangolin.example.com") + Endpoint string + // NewtID is the newt client identifier used for authentication. + NewtID string + // Secret is the newt client secret used for authentication. + Secret string + // CurrentVersion is the version of the currently running binary. + CurrentVersion string + // TLSConfig is an optional TLS configuration for the HTTP client (may be nil). + TLSConfig *tls.Config +} + +// versionResponse mirrors the JSON returned by POST /api/v1/auth/newt/version +type versionResponse struct { + Data struct { + LatestVersion string `json:"latestVersion"` + CurrentIsLatest bool `json:"currentIsLatest"` + DownloadUrl string `json:"downloadUrl"` + } `json:"data"` + Success bool `json:"success"` + Message string `json:"message"` +} + +// isOfficialContainer returns true when the process is running inside an +// official Fossorial-built container image. The image sets +// NEWT_OFFICIAL_CONTAINER=true at build time; users running newt in their own +// containers (or bare-metal) will not have this variable set. +func isOfficialContainer() bool { + return os.Getenv("NEWT_OFFICIAL_CONTAINER") == "true" +} + +// platform returns the OS+arch string used in the newt release binary names, +// e.g. "linux_amd64", "darwin_arm64", "windows_amd64". +func platform() string { + goarch := runtime.GOARCH + goos := runtime.GOOS + + // Map Go arch names to the names used by newt releases + archMap := map[string]string{ + "amd64": "amd64", + "arm64": "arm64", + "arm": "arm32", + "riscv64": "riscv64", + } + + arch, ok := archMap[goarch] + if !ok { + arch = goarch + } + + return fmt.Sprintf("%s_%s", goos, arch) +} + +// CheckAndSelfUpdate contacts the pangolin server, checks whether a newer +// version of newt is available, downloads it if so, replaces the running +// binary on disk, and re-executes from the new binary. +// +// It returns an error when the check or update fails. On a successful update +// the function does not return – the process is replaced by the new binary via +// syscall.Exec. +func CheckAndSelfUpdate(cfg SelfUpdateConfig) error { + if isOfficialContainer() { + return fmt.Errorf("auto-update is not supported in official Fossorial container images; pull a new image tag instead") + } + + if cfg.CurrentVersion == "version_replaceme" { + return fmt.Errorf("cannot auto-update a development build (version_replaceme)") + } + + baseEndpoint := strings.TrimRight(cfg.Endpoint, "/") + + // Build the HTTP client. + httpClient := &http.Client{Timeout: 30 * time.Second} + if cfg.TLSConfig != nil { + httpClient.Transport = &http.Transport{TLSClientConfig: cfg.TLSConfig} + } + + // Check the current binary path before we do anything else so we can fail + // fast if the executable path cannot be determined. + exePath, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to determine current executable path: %w", err) + } + exePath, err = filepath.EvalSymlinks(exePath) + if err != nil { + return fmt.Errorf("failed to resolve symlinks for executable path: %w", err) + } + + // --- Step 1: Ask the server for the latest version --- + reqBody, err := json.Marshal(map[string]string{ + "newtId": cfg.NewtID, + "secret": cfg.Secret, + "platform": platform(), + }) + if err != nil { + return fmt.Errorf("failed to marshal version request: %w", err) + } + + versionURL, err := url.JoinPath(baseEndpoint, "/api/v1/auth/newt/version") + if err != nil { + return fmt.Errorf("failed to build version URL: %w", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "POST", versionURL, bytes.NewBuffer(reqBody)) + if err != nil { + return fmt.Errorf("failed to create version request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-CSRF-Token", "x-csrf-protection") + + resp, err := httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to request version info: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("server returned status %d: %s", resp.StatusCode, string(body)) + } + + var verResp versionResponse + if err := json.NewDecoder(resp.Body).Decode(&verResp); err != nil { + return fmt.Errorf("failed to parse version response: %w", err) + } + + if !verResp.Success { + return fmt.Errorf("server error: %s", verResp.Message) + } + + if verResp.Data.CurrentIsLatest { + fmt.Printf("newt is already up to date (%s)\n", cfg.CurrentVersion) + return nil + } + + fmt.Printf("Update available: %s → %s\n", cfg.CurrentVersion, verResp.Data.LatestVersion) + fmt.Printf("Downloading from: %s\n", verResp.Data.DownloadUrl) + + // --- Step 2: Download the new binary --- + dlCtx, dlCancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer dlCancel() + + dlReq, err := http.NewRequestWithContext(dlCtx, "GET", verResp.Data.DownloadUrl, nil) + if err != nil { + return fmt.Errorf("failed to create download request: %w", err) + } + + dlResp, err := httpClient.Do(dlReq) + if err != nil { + return fmt.Errorf("failed to download new binary: %w", err) + } + defer dlResp.Body.Close() + + if dlResp.StatusCode != http.StatusOK { + return fmt.Errorf("download failed with status %d", dlResp.StatusCode) + } + + // Write to a temp file in the same directory as the current binary so that + // an atomic rename works even across filesystem boundaries. + exeDir := filepath.Dir(exePath) + tmpFile, err := os.CreateTemp(exeDir, ".newt-update-*") + if err != nil { + return fmt.Errorf("failed to create temp file for download: %w", err) + } + tmpPath := tmpFile.Name() + + // Ensure the temp file is cleaned up on any error path. + defer func() { + _ = os.Remove(tmpPath) + }() + + if _, err := io.Copy(tmpFile, dlResp.Body); err != nil { + _ = tmpFile.Close() + return fmt.Errorf("failed to write downloaded binary: %w", err) + } + if err := tmpFile.Close(); err != nil { + return fmt.Errorf("failed to close temp file: %w", err) + } + + // Make the new binary executable. + if err := os.Chmod(tmpPath, 0755); err != nil { + return fmt.Errorf("failed to set executable permission: %w", err) + } + + // --- Step 3: Replace the running binary --- + // On Unix an atomic rename works even while the file is running. + if err := os.Rename(tmpPath, exePath); err != nil { + return fmt.Errorf("failed to replace binary (you may need to run as root): %w", err) + } + + fmt.Printf("Binary updated to %s at %s\n", verResp.Data.LatestVersion, exePath) + + // --- Step 4: Re-exec --- + return reexec(exePath) +} diff --git a/websocket/client.go b/websocket/client.go index 5068471..22187ac 100644 --- a/websocket/client.go +++ b/websocket/client.go @@ -931,3 +931,19 @@ func loadClientCertificate(p12Path string) (*tls.Config, error) { RootCAs: rootCAs, }, nil } + +// BuildTLSConfig creates a *tls.Config from the provided certificate files. +// Pass empty strings / nil slice for fields that are not used. +// Returns nil, nil when no TLS credentials are provided. +func BuildTLSConfig(certFile, keyFile string, caFiles []string, pkcs12File string) (*tls.Config, error) { + c := &Client{ + tlsConfig: TLSConfig{ + ClientCertFile: certFile, + ClientKeyFile: keyFile, + CAFiles: caFiles, + PKCS12File: pkcs12File, + }, + config: &Config{}, + } + return c.setupTLS() +} From e1b6fa90083ca017ec4ddd92c67f878888146f73 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 21 May 2026 14:33:29 -0700 Subject: [PATCH 136/161] Add permissions check, shasum check, & build info Former-commit-id: 357f718e00112be07fd1e4116910028b2ae7db35 --- Makefile | 20 +++++++------- main.go | 24 +++++++++-------- updates/selfupdate.go | 63 ++++++++++++++++++++++++++++++++++++++----- 3 files changed, 79 insertions(+), 28 deletions(-) diff --git a/Makefile b/Makefile index 53c4bb2..6382b21 100644 --- a/Makefile +++ b/Makefile @@ -43,31 +43,31 @@ go-build-release: \ go-build-release-freebsd-arm64 go-build-release-linux-arm64: - CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o bin/newt_linux_arm64 + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=linux_arm64" -o bin/newt_linux_arm64 go-build-release-linux-arm32-v7: - CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags "$(LDFLAGS)" -o bin/newt_linux_arm32 + CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=linux_arm32" -o bin/newt_linux_arm32 go-build-release-linux-arm32-v6: - CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags "$(LDFLAGS)" -o bin/newt_linux_arm32v6 + CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=linux_arm32v6" -o bin/newt_linux_arm32v6 go-build-release-linux-amd64: - CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/newt_linux_amd64 + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=linux_amd64" -o bin/newt_linux_amd64 go-build-release-linux-riscv64: - CGO_ENABLED=0 GOOS=linux GOARCH=riscv64 go build -ldflags "$(LDFLAGS)" -o bin/newt_linux_riscv64 + CGO_ENABLED=0 GOOS=linux GOARCH=riscv64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=linux_riscv64" -o bin/newt_linux_riscv64 go-build-release-darwin-arm64: - CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o bin/newt_darwin_arm64 + CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=darwin_arm64" -o bin/newt_darwin_arm64 go-build-release-darwin-amd64: - CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/newt_darwin_amd64 + CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=darwin_amd64" -o bin/newt_darwin_amd64 go-build-release-windows-amd64: - CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/newt_windows_amd64.exe + CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=windows_amd64" -o bin/newt_windows_amd64.exe go-build-release-freebsd-amd64: - CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/newt_freebsd_amd64 + CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=freebsd_amd64" -o bin/newt_freebsd_amd64 go-build-release-freebsd-arm64: - CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o bin/newt_freebsd_arm64 \ No newline at end of file + CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=freebsd_arm64" -o bin/newt_freebsd_arm64 \ No newline at end of file diff --git a/main.go b/main.go index 034ab82..b612fb9 100644 --- a/main.go +++ b/main.go @@ -147,18 +147,19 @@ var ( authDaemonEnabled bool authDaemonGenerateRandomPassword bool // Build/version (can be overridden via -ldflags "-X main.newtVersion=...") - newtVersion = "version_replaceme" + 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 + metricsEnabled bool + otlpEnabled bool + adminAddr string + region string + metricsAsyncBytes bool + pprofEnabled bool + blueprintFile string + provisioningBlueprintFile string + noCloud bool // New mTLS configuration variables tlsClientCert string @@ -676,6 +677,7 @@ func runNewtMain(ctx context.Context) { NewtID: id, Secret: secret, CurrentVersion: newtVersion, + Platform: newtPlatform, TLSConfig: tlsCfg, }); err != nil { logger.Fatal("Auto-update failed: %v", err) @@ -1867,7 +1869,7 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( } 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") diff --git a/updates/selfupdate.go b/updates/selfupdate.go index 751f358..b322c75 100644 --- a/updates/selfupdate.go +++ b/updates/selfupdate.go @@ -3,7 +3,9 @@ package updates import ( "bytes" "context" + "crypto/sha256" "crypto/tls" + "encoding/hex" "encoding/json" "fmt" "io" @@ -26,6 +28,10 @@ type SelfUpdateConfig struct { Secret string // CurrentVersion is the version of the currently running binary. CurrentVersion string + // Platform is the OS+arch string embedded at build time via ldflags + // (e.g. "linux_amd64", "darwin_arm64"). When non-empty it is used + // directly; when empty the value is derived from runtime.GOOS/GOARCH. + Platform string // TLSConfig is an optional TLS configuration for the HTTP client (may be nil). TLSConfig *tls.Config } @@ -33,9 +39,10 @@ type SelfUpdateConfig struct { // versionResponse mirrors the JSON returned by POST /api/v1/auth/newt/version type versionResponse struct { Data struct { - LatestVersion string `json:"latestVersion"` + LatestVersion string `json:"latestVersion"` CurrentIsLatest bool `json:"currentIsLatest"` - DownloadUrl string `json:"downloadUrl"` + DownloadUrl string `json:"downloadUrl"` + Sha256 string `json:"sha256"` } `json:"data"` Success bool `json:"success"` Message string `json:"message"` @@ -51,6 +58,7 @@ func isOfficialContainer() bool { // platform returns the OS+arch string used in the newt release binary names, // e.g. "linux_amd64", "darwin_arm64", "windows_amd64". +// It is used as a fallback when no platform was embedded at build time. func platform() string { goarch := runtime.GOARCH goos := runtime.GOOS @@ -71,6 +79,26 @@ func platform() string { return fmt.Sprintf("%s_%s", goos, arch) } +// verifySHA256 checks that the file at path has the expected SHA-256 hex digest. +func verifySHA256(path, expected string) error { + f, err := os.Open(path) + if err != nil { + return fmt.Errorf("failed to open file for hashing: %w", err) + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return fmt.Errorf("failed to hash file: %w", err) + } + + got := hex.EncodeToString(h.Sum(nil)) + if !strings.EqualFold(got, expected) { + return fmt.Errorf("sha256 mismatch: expected %s, got %s", expected, got) + } + return nil +} + // CheckAndSelfUpdate contacts the pangolin server, checks whether a newer // version of newt is available, downloads it if so, replaces the running // binary on disk, and re-executes from the new binary. @@ -107,10 +135,14 @@ func CheckAndSelfUpdate(cfg SelfUpdateConfig) error { } // --- Step 1: Ask the server for the latest version --- + plat := cfg.Platform + if plat == "" { + plat = platform() + } reqBody, err := json.Marshal(map[string]string{ "newtId": cfg.NewtID, "secret": cfg.Secret, - "platform": platform(), + "platform": plat, }) if err != nil { return fmt.Errorf("failed to marshal version request: %w", err) @@ -159,6 +191,16 @@ func CheckAndSelfUpdate(cfg SelfUpdateConfig) error { fmt.Printf("Update available: %s → %s\n", cfg.CurrentVersion, verResp.Data.LatestVersion) fmt.Printf("Downloading from: %s\n", verResp.Data.DownloadUrl) + // --- Pre-download: verify we can write to the binary's directory --- + // Do this before downloading so a permission failure doesn't waste bandwidth. + exeDir := filepath.Dir(exePath) + writeTestFile, err := os.CreateTemp(exeDir, ".newt-write-test-*") + if err != nil { + return fmt.Errorf("cannot write to %s (you may need to run as root or with elevated permissions): %w", exeDir, err) + } + writeTestFile.Close() + _ = os.Remove(writeTestFile.Name()) + // --- Step 2: Download the new binary --- dlCtx, dlCancel := context.WithTimeout(context.Background(), 5*time.Minute) defer dlCancel() @@ -180,11 +222,7 @@ func CheckAndSelfUpdate(cfg SelfUpdateConfig) error { // Write to a temp file in the same directory as the current binary so that // an atomic rename works even across filesystem boundaries. - exeDir := filepath.Dir(exePath) tmpFile, err := os.CreateTemp(exeDir, ".newt-update-*") - if err != nil { - return fmt.Errorf("failed to create temp file for download: %w", err) - } tmpPath := tmpFile.Name() // Ensure the temp file is cleaned up on any error path. @@ -200,6 +238,17 @@ func CheckAndSelfUpdate(cfg SelfUpdateConfig) error { return fmt.Errorf("failed to close temp file: %w", err) } + // --- Verify SHA256 checksum if the server provided one --- + if verResp.Data.Sha256 != "" { + fmt.Println("Verifying SHA256 checksum...") + if err := verifySHA256(tmpPath, verResp.Data.Sha256); err != nil { + return fmt.Errorf("binary integrity check failed: %w", err) + } + fmt.Println("SHA256 checksum verified.") + } else { + fmt.Println("Warning: no SHA256 checksum provided by server, skipping verification.") + } + // Make the new binary executable. if err := os.Chmod(tmpPath, 0755); err != nil { return fmt.Errorf("failed to set executable permission: %w", err) From 24c4fbc0b080b0b9bcb6aba184b8144281d5925b Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 21 May 2026 17:04:38 -0700 Subject: [PATCH 137/161] Keep checking if we need to update Former-commit-id: 535fd717be3d3c35292e08ca6955bbc6c47e5b4b --- Makefile | 2 +- main.go | 38 +++++++++++++++++++++++--------------- updates/selfupdate.go | 37 ++++++++++++++++++++++++++++--------- 3 files changed, 52 insertions(+), 25 deletions(-) diff --git a/Makefile b/Makefile index 6382b21..ce9989b 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ VERSION ?= dev LDFLAGS = -X main.newtVersion=$(VERSION) local: - CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o ./bin/newt + CGO_ENABLED=0 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=$(shell go env GOOS)_$(shell go env GOARCH)" -o ./bin/newt docker-build: docker build -t fosrl/newt:latest . diff --git a/main.go b/main.go index b612fb9..0c8e7dd 100644 --- a/main.go +++ b/main.go @@ -177,9 +177,6 @@ var ( // Path to config file (overrides CONFIG_FILE env var and default location) configFile string - - // Auto-update flag - autoUpdate bool ) // generateChainId generates a random chain ID for deduplicating round-trip messages. @@ -493,7 +490,6 @@ func runNewtMain(ctx context.Context) { // do a --version check version := flag.Bool("version", false, "Print the version") - flag.BoolVar(&autoUpdate, "auto-update", false, "Check for a newer version on the server and self-update if one is available") flag.Parse() @@ -665,13 +661,13 @@ func runNewtMain(ctx context.Context) { // Update site labels for metrics with the resolved ID telemetry.UpdateSiteInfo(id, region) - // Auto-update: check for a new version, download and replace if available. - if autoUpdate { - 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) - } + 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, @@ -680,12 +676,24 @@ func runNewtMain(ctx context.Context) { Platform: newtPlatform, TLSConfig: tlsCfg, }); err != nil { - logger.Fatal("Auto-update failed: %v", err) + logger.Error("Auto-update check failed: %v", err) } - // CheckAndSelfUpdate re-execs on success, so we only reach here if - // newt is already up to date. - return } + 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 + ticker := time.NewTicker(6 * time.Hour) + defer ticker.Stop() + for { + select { + case <-ticker.C: + doUpdate() + case <-ctx.Done(): + return + } + } + }() // output env var values if set logger.Debug("Endpoint: %v", endpoint) diff --git a/updates/selfupdate.go b/updates/selfupdate.go index b322c75..f80dc2d 100644 --- a/updates/selfupdate.go +++ b/updates/selfupdate.go @@ -16,11 +16,13 @@ import ( "runtime" "strings" "time" + + "github.com/fosrl/newt/logger" ) // SelfUpdateConfig holds the configuration required to perform a self-update. type SelfUpdateConfig struct { - // Endpoint is the base URL of the pangolin server (e.g. "https://pangolin.example.com") + // Endpoint is the base URL of the pangolin server (e.g. "https://app.pangolin.net") Endpoint string // NewtID is the newt client identifier used for authentication. NewtID string @@ -107,11 +109,15 @@ func verifySHA256(path, expected string) error { // the function does not return – the process is replaced by the new binary via // syscall.Exec. func CheckAndSelfUpdate(cfg SelfUpdateConfig) error { + logger.Debug("++++++++++++++++++++++++++++++++++++++++++++checkAndSelfUpdate: starting update check (currentVersion=%s)", cfg.CurrentVersion) + if isOfficialContainer() { + logger.Debug("checkAndSelfUpdate: running inside official container, skipping auto-update") return fmt.Errorf("auto-update is not supported in official Fossorial container images; pull a new image tag instead") } if cfg.CurrentVersion == "version_replaceme" { + logger.Debug("checkAndSelfUpdate: development build detected, skipping auto-update") return fmt.Errorf("cannot auto-update a development build (version_replaceme)") } @@ -133,12 +139,14 @@ func CheckAndSelfUpdate(cfg SelfUpdateConfig) error { if err != nil { return fmt.Errorf("failed to resolve symlinks for executable path: %w", err) } + logger.Debug("checkAndSelfUpdate: current executable path: %s", exePath) // --- Step 1: Ask the server for the latest version --- plat := cfg.Platform if plat == "" { plat = platform() } + logger.Debug("checkAndSelfUpdate: querying server for latest version (platform=%s, endpoint=%s)", plat, baseEndpoint) reqBody, err := json.Marshal(map[string]string{ "newtId": cfg.NewtID, "secret": cfg.Secret, @@ -169,6 +177,16 @@ func CheckAndSelfUpdate(cfg SelfUpdateConfig) error { } defer resp.Body.Close() + if resp.StatusCode == http.StatusNoContent { // updates are disabled + logger.Debug("checkAndSelfUpdate: server indicated updates are disabled (204 No Content)") + return nil + } + + if resp.StatusCode == http.StatusNotFound { // older server without version endpoint + logger.Debug("checkAndSelfUpdate: server does not support version endpoint (404 Not Found), skipping") + return nil + } + if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return fmt.Errorf("server returned status %d: %s", resp.StatusCode, string(body)) @@ -184,16 +202,16 @@ func CheckAndSelfUpdate(cfg SelfUpdateConfig) error { } if verResp.Data.CurrentIsLatest { - fmt.Printf("newt is already up to date (%s)\n", cfg.CurrentVersion) + logger.Debug("checkAndSelfUpdate: already up to date (%s)", cfg.CurrentVersion) return nil } - fmt.Printf("Update available: %s → %s\n", cfg.CurrentVersion, verResp.Data.LatestVersion) - fmt.Printf("Downloading from: %s\n", verResp.Data.DownloadUrl) + logger.Debug("checkAndSelfUpdate: update available %s → %s", cfg.CurrentVersion, verResp.Data.LatestVersion) // --- Pre-download: verify we can write to the binary's directory --- // Do this before downloading so a permission failure doesn't waste bandwidth. exeDir := filepath.Dir(exePath) + logger.Debug("checkAndSelfUpdate: verifying write access to %s", exeDir) writeTestFile, err := os.CreateTemp(exeDir, ".newt-write-test-*") if err != nil { return fmt.Errorf("cannot write to %s (you may need to run as root or with elevated permissions): %w", exeDir, err) @@ -202,6 +220,7 @@ func CheckAndSelfUpdate(cfg SelfUpdateConfig) error { _ = os.Remove(writeTestFile.Name()) // --- Step 2: Download the new binary --- + logger.Debug("checkAndSelfUpdate: beginning download of new binary") dlCtx, dlCancel := context.WithTimeout(context.Background(), 5*time.Minute) defer dlCancel() @@ -240,13 +259,13 @@ func CheckAndSelfUpdate(cfg SelfUpdateConfig) error { // --- Verify SHA256 checksum if the server provided one --- if verResp.Data.Sha256 != "" { - fmt.Println("Verifying SHA256 checksum...") + logger.Debug("checkAndSelfUpdate: verifying SHA256 checksum") if err := verifySHA256(tmpPath, verResp.Data.Sha256); err != nil { return fmt.Errorf("binary integrity check failed: %w", err) } - fmt.Println("SHA256 checksum verified.") + logger.Debug("checkAndSelfUpdate: SHA256 checksum verified") } else { - fmt.Println("Warning: no SHA256 checksum provided by server, skipping verification.") + logger.Debug("checkAndSelfUpdate: no SHA256 checksum provided by server, skipping verification") } // Make the new binary executable. @@ -256,12 +275,12 @@ func CheckAndSelfUpdate(cfg SelfUpdateConfig) error { // --- Step 3: Replace the running binary --- // On Unix an atomic rename works even while the file is running. + logger.Debug("checkAndSelfUpdate: replacing binary at %s", exePath) if err := os.Rename(tmpPath, exePath); err != nil { return fmt.Errorf("failed to replace binary (you may need to run as root): %w", err) } - fmt.Printf("Binary updated to %s at %s\n", verResp.Data.LatestVersion, exePath) - // --- Step 4: Re-exec --- + logger.Debug("checkAndSelfUpdate: re-executing new binary") return reexec(exePath) } From d2bd8cb97d49444ad1fc7ff40c3bd46e3ac5d716 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 25 May 2026 17:01:48 -0700 Subject: [PATCH 138/161] Add advantech router app updates Former-commit-id: ec89eb7d0fd3418986a8bcfed52dfbe33c451532 --- Dockerfile | 2 +- updates/advantech.go | 48 +++++++++++++++++++++++++++++++++++++++++++ updates/selfupdate.go | 15 +++++++++++--- 3 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 updates/advantech.go diff --git a/Dockerfile b/Dockerfile index 6887923..10309d8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,7 +29,7 @@ COPY entrypoint.sh / # Marks this as an official Fossorial container image. # Auto-update is disabled in official images — update by pulling a new image tag. -ENV NEWT_OFFICIAL_CONTAINER=true +ENV NEWT_SYSTEM_SUBSTRATE="CONTAINER" # Admin/metrics endpoint (Prometheus scrape) EXPOSE 2112 diff --git a/updates/advantech.go b/updates/advantech.go new file mode 100644 index 0000000..18cacdf --- /dev/null +++ b/updates/advantech.go @@ -0,0 +1,48 @@ +//go:build !windows + +package updates + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/fosrl/newt/logger" +) + +const advantechVersionFile = "/opt/newt/etc/version" + +// postUpdateAdvantech performs Advantech Router App-specific post-update steps: +// - Writes the new version string to /opt/newt/etc/version so that the +// router firmware's package management reflects the installed version. +// - Updates the PID file at pidFile (if non-empty) with the current process +// PID, in case the re-exec lands with a new PID on platforms where +// syscall.Exec behaviour differs. +func postUpdateAdvantech(newVersion, pidFile string) error { + // --- Write version file --- + versionDir := filepath.Dir(advantechVersionFile) + if err := os.MkdirAll(versionDir, 0755); err != nil { + return fmt.Errorf("advantech: failed to create version directory %s: %w", versionDir, err) + } + + if err := os.WriteFile(advantechVersionFile, []byte(newVersion+"\n"), 0644); err != nil { + return fmt.Errorf("advantech: failed to write version file %s: %w", advantechVersionFile, err) + } + logger.Debug("postUpdateAdvantech: wrote version %s to %s", newVersion, advantechVersionFile) + + // --- Update PID file --- + // syscall.Exec replaces the process image in-place so the PID is preserved. + // We update the PID file here anyway so that any race between the old and + // new binary is covered (e.g. on platforms that fork before exec). + if pidFile != "" { + pid := fmt.Sprintf("%d\n", os.Getpid()) + if err := os.WriteFile(pidFile, []byte(pid), 0644); err != nil { + // Non-fatal: log and continue so the update still proceeds. + logger.Debug("postUpdateAdvantech: warning: failed to update PID file %s: %v", pidFile, err) + } else { + logger.Debug("postUpdateAdvantech: updated PID file %s with PID %d", pidFile, os.Getpid()) + } + } + + return nil +} diff --git a/updates/selfupdate.go b/updates/selfupdate.go index f80dc2d..c828236 100644 --- a/updates/selfupdate.go +++ b/updates/selfupdate.go @@ -52,10 +52,10 @@ type versionResponse struct { // isOfficialContainer returns true when the process is running inside an // official Fossorial-built container image. The image sets -// NEWT_OFFICIAL_CONTAINER=true at build time; users running newt in their own +// NEWT_SYSTEM_SUBSTRATE="CONTAINER" at build time; users running newt in their own // containers (or bare-metal) will not have this variable set. func isOfficialContainer() bool { - return os.Getenv("NEWT_OFFICIAL_CONTAINER") == "true" + return os.Getenv("NEWT_SYSTEM_SUBSTRATE") == "CONTAINER" } // platform returns the OS+arch string used in the newt release binary names, @@ -109,7 +109,7 @@ func verifySHA256(path, expected string) error { // the function does not return – the process is replaced by the new binary via // syscall.Exec. func CheckAndSelfUpdate(cfg SelfUpdateConfig) error { - logger.Debug("++++++++++++++++++++++++++++++++++++++++++++checkAndSelfUpdate: starting update check (currentVersion=%s)", cfg.CurrentVersion) + logger.Debug("checkAndSelfUpdate: starting update check (currentVersion=%s)", cfg.CurrentVersion) if isOfficialContainer() { logger.Debug("checkAndSelfUpdate: running inside official container, skipping auto-update") @@ -280,6 +280,15 @@ func CheckAndSelfUpdate(cfg SelfUpdateConfig) error { return fmt.Errorf("failed to replace binary (you may need to run as root): %w", err) } + systemSubstrate := os.Getenv("NEWT_SYSTEM_SUBSTRATE") + logger.Debug("checkAndSelfUpdate: system substrate: %s", systemSubstrate) + if systemSubstrate == "ADVANTECH_ROUTER_APP" { + pidFile := os.Getenv("NEWT_PID_FILE") + if err := postUpdateAdvantech(verResp.Data.LatestVersion, pidFile); err != nil { + logger.Debug("checkAndSelfUpdate: advantech post-update steps failed: %v", err) + } + } + // --- Step 4: Re-exec --- logger.Debug("checkAndSelfUpdate: re-executing new binary") return reexec(exePath) From 7c22fe274ae71f5d0ea07fa9c94e45fee00ff157 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 25 May 2026 17:56:01 -0700 Subject: [PATCH 139/161] Add advantech package Former-commit-id: 8955213aefa1bb404d72a15c8cb1451b16391470 --- packages/advantech/.gitignore | 3 + packages/advantech/Makefile | 53 +++++ packages/advantech/merge/etc/defaults | 61 +++++ packages/advantech/merge/etc/description | 4 + packages/advantech/merge/etc/init | 124 ++++++++++ packages/advantech/merge/etc/install | 15 ++ packages/advantech/merge/etc/name | 1 + packages/advantech/merge/etc/summary | 1 + packages/advantech/merge/etc/uninstall | 9 + packages/advantech/merge/etc/version | 1 + packages/advantech/merge/www/index.cgi | 282 +++++++++++++++++++++++ packages/openwrt/.gitkeep | 0 packages/teltonika/.gitkeep | 0 13 files changed, 554 insertions(+) create mode 100644 packages/advantech/.gitignore create mode 100644 packages/advantech/Makefile create mode 100644 packages/advantech/merge/etc/defaults create mode 100644 packages/advantech/merge/etc/description create mode 100644 packages/advantech/merge/etc/init create mode 100644 packages/advantech/merge/etc/install create mode 100644 packages/advantech/merge/etc/name create mode 100644 packages/advantech/merge/etc/summary create mode 100644 packages/advantech/merge/etc/uninstall create mode 100644 packages/advantech/merge/etc/version create mode 100644 packages/advantech/merge/www/index.cgi create mode 100644 packages/openwrt/.gitkeep create mode 100644 packages/teltonika/.gitkeep diff --git a/packages/advantech/.gitignore b/packages/advantech/.gitignore new file mode 100644 index 0000000..0377a32 --- /dev/null +++ b/packages/advantech/.gitignore @@ -0,0 +1,3 @@ +.build +*.tgz +bin \ No newline at end of file diff --git a/packages/advantech/Makefile b/packages/advantech/Makefile new file mode 100644 index 0000000..2250025 --- /dev/null +++ b/packages/advantech/Makefile @@ -0,0 +1,53 @@ +MODNAME := newt +VERSION := 1.12.5 +RELEASE_URL := https://github.com/fosrl/newt/releases/download/$(VERSION) +PLATFORM ?= v4 + +# Map Advantech platform to newt release binary name +arch_v4 := arm64 +arch_v4i := arm64 +arch_v3 := arm32 +arch_v2i := arm32 + +NEWT_ARCH := $(arch_$(PLATFORM)) +ifeq ($(NEWT_ARCH),) +$(error Unknown platform '$(PLATFORM)'. Supported: v4, v4i, v3, v2i) +endif + +BINARY := newt_linux_$(NEWT_ARCH) +BINDIR := bin +OUTFILE := $(MODNAME).$(PLATFORM).tgz +STAGEDIR := .build/$(MODNAME) + +.PHONY: all clean + +all: $(OUTFILE) + +# Cache the downloaded binary in bin/ (re-download only if missing) +$(BINDIR)/$(BINARY): + mkdir -p $(BINDIR) + @echo "Downloading $(BINARY) $(VERSION) for platform $(PLATFORM)..." + wget -q -O $@ "$(RELEASE_URL)/$(BINARY)" 2>/dev/null || \ + curl -fsSL -o $@ "$(RELEASE_URL)/$(BINARY)" + chmod +x $@ + @echo "Binary ready: $@" + +# Build the package +$(OUTFILE): $(BINDIR)/$(BINARY) $(shell find merge -type f 2>/dev/null) + @rm -rf $(STAGEDIR) + @mkdir -p $(STAGEDIR)/bin + @cp $(BINDIR)/$(BINARY) $(STAGEDIR)/bin/newt + @chmod +x $(STAGEDIR)/bin/newt + @cp -r merge/. $(STAGEDIR)/ + @chmod +x \ + $(STAGEDIR)/etc/init \ + $(STAGEDIR)/etc/install \ + $(STAGEDIR)/etc/uninstall \ + $(STAGEDIR)/www/index.cgi + tar -c --owner=0 --group=0 --mtime="2001-01-01 UTC" \ + -C .build $(MODNAME) | gzip -n > $@ + @echo "Created: $@" + +clean: + rm -rf .build $(MODNAME).*.tgz + @echo "Cleaned." diff --git a/packages/advantech/merge/etc/defaults b/packages/advantech/merge/etc/defaults new file mode 100644 index 0000000..e566be4 --- /dev/null +++ b/packages/advantech/merge/etc/defaults @@ -0,0 +1,61 @@ +MOD_PANGOLIN_SITE_ENABLED=0 +MOD_PANGOLIN_SITE_ENDPOINT= +MOD_PANGOLIN_SITE_ID= +MOD_PANGOLIN_SITE_SECRET= + +# Core networking +MOD_PANGOLIN_SITE_MTU= +MOD_PANGOLIN_SITE_DNS= +MOD_PANGOLIN_SITE_INTERFACE= +MOD_PANGOLIN_SITE_PORT= +MOD_PANGOLIN_SITE_UPDOWN_SCRIPT= +MOD_PANGOLIN_SITE_PREFER_ENDPOINT= + +# Logging +MOD_PANGOLIN_SITE_LOG_LEVEL= + +# Behavior toggles (true/false) +MOD_PANGOLIN_SITE_DISABLE_CLIENTS= +MOD_PANGOLIN_SITE_USE_NATIVE_INTERFACE= +MOD_PANGOLIN_SITE_ENFORCE_HC_CERT= +MOD_PANGOLIN_SITE_NO_CLOUD= + +# Timers +MOD_PANGOLIN_SITE_PING_INTERVAL= +MOD_PANGOLIN_SITE_PING_TIMEOUT= +MOD_PANGOLIN_SITE_UDP_PROXY_IDLE_TIMEOUT= + +# Docker integration +MOD_PANGOLIN_SITE_DOCKER_SOCKET= +MOD_PANGOLIN_SITE_DOCKER_ENFORCE_NETWORK_VALIDATION= + +# Health / files +MOD_PANGOLIN_SITE_HEALTH_FILE= +MOD_PANGOLIN_SITE_BLUEPRINT_FILE= +MOD_PANGOLIN_SITE_PROVISIONING_BLUEPRINT_FILE= +MOD_PANGOLIN_SITE_CONFIG_FILE= + +# Provisioning +MOD_PANGOLIN_SITE_PROVISIONING_KEY= +MOD_PANGOLIN_SITE_NAME= + +# Auth daemon +MOD_PANGOLIN_SITE_AUTH_DAEMON_ENABLED= +MOD_PANGOLIN_SITE_AD_GENERATE_RANDOM_PASSWORD= +MOD_PANGOLIN_SITE_AD_KEY= +MOD_PANGOLIN_SITE_AD_PRINCIPALS_FILE= +MOD_PANGOLIN_SITE_AD_CA_CERT_PATH= + +# Metrics / observability +MOD_PANGOLIN_SITE_METRICS_PROMETHEUS_ENABLED= +MOD_PANGOLIN_SITE_METRICS_OTLP_ENABLED= +MOD_PANGOLIN_SITE_ADMIN_ADDR= +MOD_PANGOLIN_SITE_REGION= +MOD_PANGOLIN_SITE_METRICS_ASYNC_BYTES= +MOD_PANGOLIN_SITE_PPROF_ENABLED= + +# mTLS +MOD_PANGOLIN_SITE_TLS_CLIENT_CERT= +MOD_PANGOLIN_SITE_TLS_CLIENT_KEY= +MOD_PANGOLIN_SITE_TLS_CLIENT_CAS= +MOD_PANGOLIN_SITE_TLS_CLIENT_CERT_PKCS12= diff --git a/packages/advantech/merge/etc/description b/packages/advantech/merge/etc/description new file mode 100644 index 0000000..04f09a4 --- /dev/null +++ b/packages/advantech/merge/etc/description @@ -0,0 +1,4 @@ +Pangolin Site (newt) is a WireGuard-based tunnel client that connects this router to a +Pangolin server, enabling secure remote access to local resources without opening +firewall ports. Configure the server endpoint, ID, and secret via the web +interface to establish the tunnel automatically on startup. diff --git a/packages/advantech/merge/etc/init b/packages/advantech/merge/etc/init new file mode 100644 index 0000000..cf90ae0 --- /dev/null +++ b/packages/advantech/merge/etc/init @@ -0,0 +1,124 @@ +#!/bin/sh + +MODNAME=newt +PIDFILE=/tmp/newt.pid +LOGFILE=/tmp/newt.log + +set_setting_raw() { + key="$1" + value="$2" + [ -f "/opt/$MODNAME/etc/settings" ] || cp "/opt/$MODNAME/etc/defaults" "/opt/$MODNAME/etc/settings" 2>/dev/null + awk -v k="$key" -v v="$value" ' + BEGIN { done=0 } + index($0, k "=") == 1 { print k "=" v; done=1; next } + { print } + END { if (!done) print k "=" v } + ' "/opt/$MODNAME/etc/settings" > "/tmp/${MODNAME}.settings.tmp" && mv "/tmp/${MODNAME}.settings.tmp" "/opt/$MODNAME/etc/settings" +} + +/usr/bin/logger -t $MODNAME "DEBUG: $0 $@" + +case "$1" in + start) + . /opt/$MODNAME/etc/settings + if [ "$MOD_PANGOLIN_SITE_ENABLED" != "1" ]; then + echo "$MODNAME is disabled in settings, skipping start" + exit 0 + fi + if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null; then + echo "$MODNAME is already running (PID: $(cat "$PIDFILE"))" + exit 0 + fi + + # Starting newt implies enable at boot. + set_setting_raw "MOD_PANGOLIN_SITE_ENABLED" "1" + + # Map module settings to Newt environment variables. + [ -n "$MOD_PANGOLIN_SITE_ENDPOINT" ] && export PANGOLIN_ENDPOINT="$MOD_PANGOLIN_SITE_ENDPOINT" + [ -n "$MOD_PANGOLIN_SITE_ID" ] && export NEWT_ID="$MOD_PANGOLIN_SITE_ID" + [ -n "$MOD_PANGOLIN_SITE_SECRET" ] && export NEWT_SECRET="$MOD_PANGOLIN_SITE_SECRET" + [ -n "$MOD_PANGOLIN_SITE_MTU" ] && export MTU="$MOD_PANGOLIN_SITE_MTU" + [ -n "$MOD_PANGOLIN_SITE_DNS" ] && export DNS="$MOD_PANGOLIN_SITE_DNS" + [ -n "$MOD_PANGOLIN_SITE_LOG_LEVEL" ] && export LOG_LEVEL="$MOD_PANGOLIN_SITE_LOG_LEVEL" + [ -n "$MOD_PANGOLIN_SITE_UPDOWN_SCRIPT" ] && export UPDOWN_SCRIPT="$MOD_PANGOLIN_SITE_UPDOWN_SCRIPT" + [ -n "$MOD_PANGOLIN_SITE_INTERFACE" ] && export INTERFACE="$MOD_PANGOLIN_SITE_INTERFACE" + [ -n "$MOD_PANGOLIN_SITE_PORT" ] && export PORT="$MOD_PANGOLIN_SITE_PORT" + [ -n "$MOD_PANGOLIN_SITE_DISABLE_CLIENTS" ] && export DISABLE_CLIENTS="$MOD_PANGOLIN_SITE_DISABLE_CLIENTS" + [ -n "$MOD_PANGOLIN_SITE_USE_NATIVE_INTERFACE" ] && export USE_NATIVE_INTERFACE="$MOD_PANGOLIN_SITE_USE_NATIVE_INTERFACE" + [ -n "$MOD_PANGOLIN_SITE_ENFORCE_HC_CERT" ] && export ENFORCE_HC_CERT="$MOD_PANGOLIN_SITE_ENFORCE_HC_CERT" + [ -n "$MOD_PANGOLIN_SITE_DOCKER_SOCKET" ] && export DOCKER_SOCKET="$MOD_PANGOLIN_SITE_DOCKER_SOCKET" + [ -n "$MOD_PANGOLIN_SITE_PING_INTERVAL" ] && export PING_INTERVAL="$MOD_PANGOLIN_SITE_PING_INTERVAL" + [ -n "$MOD_PANGOLIN_SITE_PING_TIMEOUT" ] && export PING_TIMEOUT="$MOD_PANGOLIN_SITE_PING_TIMEOUT" + [ -n "$MOD_PANGOLIN_SITE_UDP_PROXY_IDLE_TIMEOUT" ] && export NEWT_UDP_PROXY_IDLE_TIMEOUT="$MOD_PANGOLIN_SITE_UDP_PROXY_IDLE_TIMEOUT" + [ -n "$MOD_PANGOLIN_SITE_DOCKER_ENFORCE_NETWORK_VALIDATION" ] && export DOCKER_ENFORCE_NETWORK_VALIDATION="$MOD_PANGOLIN_SITE_DOCKER_ENFORCE_NETWORK_VALIDATION" + [ -n "$MOD_PANGOLIN_SITE_HEALTH_FILE" ] && export HEALTH_FILE="$MOD_PANGOLIN_SITE_HEALTH_FILE" + [ -n "$MOD_PANGOLIN_SITE_AUTH_DAEMON_ENABLED" ] && export AUTH_DAEMON_ENABLED="$MOD_PANGOLIN_SITE_AUTH_DAEMON_ENABLED" + [ -n "$MOD_PANGOLIN_SITE_AD_GENERATE_RANDOM_PASSWORD" ] && export AD_GENERATE_RANDOM_PASSWORD="$MOD_PANGOLIN_SITE_AD_GENERATE_RANDOM_PASSWORD" + [ -n "$MOD_PANGOLIN_SITE_AD_KEY" ] && export AD_KEY="$MOD_PANGOLIN_SITE_AD_KEY" + [ -n "$MOD_PANGOLIN_SITE_AD_PRINCIPALS_FILE" ] && export AD_PRINCIPALS_FILE="$MOD_PANGOLIN_SITE_AD_PRINCIPALS_FILE" + [ -n "$MOD_PANGOLIN_SITE_AD_CA_CERT_PATH" ] && export AD_CA_CERT_PATH="$MOD_PANGOLIN_SITE_AD_CA_CERT_PATH" + [ -n "$MOD_PANGOLIN_SITE_METRICS_PROMETHEUS_ENABLED" ] && export NEWT_METRICS_PROMETHEUS_ENABLED="$MOD_PANGOLIN_SITE_METRICS_PROMETHEUS_ENABLED" + [ -n "$MOD_PANGOLIN_SITE_METRICS_OTLP_ENABLED" ] && export NEWT_METRICS_OTLP_ENABLED="$MOD_PANGOLIN_SITE_METRICS_OTLP_ENABLED" + [ -n "$MOD_PANGOLIN_SITE_ADMIN_ADDR" ] && export NEWT_ADMIN_ADDR="$MOD_PANGOLIN_SITE_ADMIN_ADDR" + [ -n "$MOD_PANGOLIN_SITE_REGION" ] && export NEWT_REGION="$MOD_PANGOLIN_SITE_REGION" + [ -n "$MOD_PANGOLIN_SITE_METRICS_ASYNC_BYTES" ] && export NEWT_METRICS_ASYNC_BYTES="$MOD_PANGOLIN_SITE_METRICS_ASYNC_BYTES" + [ -n "$MOD_PANGOLIN_SITE_PPROF_ENABLED" ] && export NEWT_PPROF_ENABLED="$MOD_PANGOLIN_SITE_PPROF_ENABLED" + [ -n "$MOD_PANGOLIN_SITE_TLS_CLIENT_CERT" ] && export TLS_CLIENT_CERT="$MOD_PANGOLIN_SITE_TLS_CLIENT_CERT" + [ -n "$MOD_PANGOLIN_SITE_TLS_CLIENT_KEY" ] && export TLS_CLIENT_KEY="$MOD_PANGOLIN_SITE_TLS_CLIENT_KEY" + [ -n "$MOD_PANGOLIN_SITE_TLS_CLIENT_CAS" ] && export TLS_CLIENT_CAS="$MOD_PANGOLIN_SITE_TLS_CLIENT_CAS" + [ -n "$MOD_PANGOLIN_SITE_TLS_CLIENT_CERT_PKCS12" ] && export TLS_CLIENT_CERT_PKCS12="$MOD_PANGOLIN_SITE_TLS_CLIENT_CERT_PKCS12" + [ -n "$MOD_PANGOLIN_SITE_BLUEPRINT_FILE" ] && export BLUEPRINT_FILE="$MOD_PANGOLIN_SITE_BLUEPRINT_FILE" + [ -n "$MOD_PANGOLIN_SITE_PROVISIONING_BLUEPRINT_FILE" ] && export PROVISIONING_BLUEPRINT_FILE="$MOD_PANGOLIN_SITE_PROVISIONING_BLUEPRINT_FILE" + [ -n "$MOD_PANGOLIN_SITE_NO_CLOUD" ] && export NO_CLOUD="$MOD_PANGOLIN_SITE_NO_CLOUD" + [ -n "$MOD_PANGOLIN_SITE_PROVISIONING_KEY" ] && export NEWT_PROVISIONING_KEY="$MOD_PANGOLIN_SITE_PROVISIONING_KEY" + [ -n "$MOD_PANGOLIN_SITE_NAME" ] && export NEWT_NAME="$MOD_PANGOLIN_SITE_NAME" + [ -n "$MOD_PANGOLIN_SITE_CONFIG_FILE" ] && export CONFIG_FILE="$MOD_PANGOLIN_SITE_CONFIG_FILE" + + export NEWT_SYSTEM_SUBSTRATE="ADVANTECH_ROUTER_APP" + export NEWT_PID_FILE="$PIDFILE" + + echo "Starting $MODNAME..." + if [ -n "$MOD_PANGOLIN_SITE_PREFER_ENDPOINT" ]; then + /opt/$MODNAME/bin/newt --prefer-endpoint "$MOD_PANGOLIN_SITE_PREFER_ENDPOINT" >> "$LOGFILE" 2>&1 & + else + /opt/$MODNAME/bin/newt >> "$LOGFILE" 2>&1 & + fi + echo $! > "$PIDFILE" + echo "Started $MODNAME (PID: $(cat "$PIDFILE"))" + exit 0 + ;; + stop) + echo "Stopping $MODNAME..." + if [ -f "$PIDFILE" ]; then + kill "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null + rm -f "$PIDFILE" + fi + killall newt 2>/dev/null + # Stopping newt implies disable at boot. + set_setting_raw "MOD_PANGOLIN_SITE_ENABLED" "0" + echo "Stopped $MODNAME" + exit 0 + ;; + restart) + $0 stop + sleep 1 + # Restart should remain enabled after reboot. + set_setting_raw "MOD_PANGOLIN_SITE_ENABLED" "1" + $0 start + ;; + status) + if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null; then + echo "$MODNAME is running (PID: $(cat "$PIDFILE"))" + exit 0 + else + echo "$MODNAME is not running" + exit 1 + fi + ;; + defaults) + cp /opt/$MODNAME/etc/defaults /opt/$MODNAME/etc/settings 2>/dev/null + ;; + *) + echo "Usage: $0 {start|stop|restart|status|defaults}" + exit 1 +esac diff --git a/packages/advantech/merge/etc/install b/packages/advantech/merge/etc/install new file mode 100644 index 0000000..f83fb05 --- /dev/null +++ b/packages/advantech/merge/etc/install @@ -0,0 +1,15 @@ +#!/bin/sh + +MODNAME=newt + +# Ensure scripts are executable +chmod +x /opt/$MODNAME/etc/init +chmod +x /opt/$MODNAME/etc/install +chmod +x /opt/$MODNAME/etc/uninstall +chmod +x /opt/$MODNAME/bin/newt +chmod +x /opt/$MODNAME/www/index.cgi + +# Secure the web interface with router authentication +ln -sf /etc/htpasswd /opt/$MODNAME/www/.htpasswd + +exit 0 diff --git a/packages/advantech/merge/etc/name b/packages/advantech/merge/etc/name new file mode 100644 index 0000000..0656222 --- /dev/null +++ b/packages/advantech/merge/etc/name @@ -0,0 +1 @@ +Pangolin Site diff --git a/packages/advantech/merge/etc/summary b/packages/advantech/merge/etc/summary new file mode 100644 index 0000000..64f982c --- /dev/null +++ b/packages/advantech/merge/etc/summary @@ -0,0 +1 @@ +Tunnel client for Pangolin secure remote access. diff --git a/packages/advantech/merge/etc/uninstall b/packages/advantech/merge/etc/uninstall new file mode 100644 index 0000000..70a9e38 --- /dev/null +++ b/packages/advantech/merge/etc/uninstall @@ -0,0 +1,9 @@ +#!/bin/sh + +MODNAME=newt + +rm -f /opt/$MODNAME/www/.htpasswd +rm -f /tmp/newt.pid +rm -f /tmp/newt.log + +exit 0 diff --git a/packages/advantech/merge/etc/version b/packages/advantech/merge/etc/version new file mode 100644 index 0000000..e0a6b34 --- /dev/null +++ b/packages/advantech/merge/etc/version @@ -0,0 +1 @@ +1.12.5 diff --git a/packages/advantech/merge/www/index.cgi b/packages/advantech/merge/www/index.cgi new file mode 100644 index 0000000..c21233e --- /dev/null +++ b/packages/advantech/merge/www/index.cgi @@ -0,0 +1,282 @@ +#!/bin/sh + +MODNAME=newt +SETTINGS=/opt/$MODNAME/etc/settings +LOGFILE=/tmp/newt.log +PIDFILE=/tmp/newt.pid + +# Escape special HTML characters +htmlesc() { + printf '%s' "$1" | sed \ + -e 's/&/\&/g' \ + -e 's//\>/g' \ + -e 's/"/\"/g' +} + +# Decode URL-encoded form values (handles common chars in endpoints/ids/secrets) +urldecode() { + printf '%s' "$1" | sed \ + -e 's/+/ /g' \ + -e 's/%3[Aa]/:/g' -e 's/%3[aa]/:/g' \ + -e 's/%2[Ff]/\//g' -e 's/%2[ff]/\//g' \ + -e 's/%40/@/g' \ + -e 's/%2[Ee]/./g' \ + -e 's/%2[Dd]/-/g' \ + -e 's/%5[Ff]/_/g' \ + -e 's/%3[Dd]/=/g' \ + -e 's/%3[Ff]/?/g' \ + -e 's/%23/#/g' \ + -e 's/%25/%/g' +} + +# Extract a named field from URL-encoded POST data (awk splits on & without tr) +get_field() { + printf '%s' "$2" | awk -v f="${1}=" 'BEGIN{RS="&"} index($0,f)==1 {print substr($0,length(f)+1); exit}' +} + +# Check whether newt process is alive +is_running() { + [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null +} + +ensure_settings_file() { + [ -f "$SETTINGS" ] && return + if [ -f "/opt/$MODNAME/etc/defaults" ]; then + cp "/opt/$MODNAME/etc/defaults" "$SETTINGS" 2>/dev/null + else + printf 'MOD_PANGOLIN_SITE_ENABLED=0\n' > "$SETTINGS" + fi +} + +set_setting_raw() { + key="$1" + value="$2" + ensure_settings_file + awk -v k="$key" -v v="$value" ' + BEGIN { done=0 } + index($0, k "=") == 1 { print k "=" v; done=1; next } + { print } + END { if (!done) print k "=" v } + ' "$SETTINGS" > "$SETTINGS.tmp" && mv "$SETTINGS.tmp" "$SETTINGS" +} + +quote_sh_value() { + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' +} + +# ── Read POST body ────────────────────────────────────────────────────────── +POST_DATA="" +if [ "$REQUEST_METHOD" = "POST" ] && [ -n "$CONTENT_LENGTH" ] && [ "$CONTENT_LENGTH" -gt 0 ] 2>/dev/null; then + POST_DATA=$(dd bs="$CONTENT_LENGTH" count=1 2>/dev/null) +fi + +# ── Load current settings ─────────────────────────────────────────────────── +MOD_PANGOLIN_SITE_ENABLED=0 +MOD_PANGOLIN_SITE_ENDPOINT="" +MOD_PANGOLIN_SITE_ID="" +MOD_PANGOLIN_SITE_SECRET="" +[ -f "$SETTINGS" ] && . "$SETTINGS" + +# ── Handle actions ────────────────────────────────────────────────────────── +if [ -n "$POST_DATA" ]; then + ACTION=$(get_field "action" "$POST_DATA") + case "$ACTION" in + save) + EP=$(urldecode "$(get_field "endpoint" "$POST_DATA")") + ID=$(urldecode "$(get_field "id" "$POST_DATA")") + SEC=$(urldecode "$(get_field "secret" "$POST_DATA")") + set_setting_raw "MOD_PANGOLIN_SITE_ENDPOINT" "\"$(quote_sh_value "$EP")\"" + set_setting_raw "MOD_PANGOLIN_SITE_ID" "\"$(quote_sh_value "$ID")\"" + set_setting_raw "MOD_PANGOLIN_SITE_SECRET" "\"$(quote_sh_value "$SEC")\"" + ;; + start) + set_setting_raw "MOD_PANGOLIN_SITE_ENABLED" "1" + /opt/$MODNAME/etc/init start >/dev/null 2>&1 + ;; + stop) + /opt/$MODNAME/etc/init stop >/dev/null 2>&1 + set_setting_raw "MOD_PANGOLIN_SITE_ENABLED" "0" + ;; + restart) + set_setting_raw "MOD_PANGOLIN_SITE_ENABLED" "1" + /opt/$MODNAME/etc/init restart >/dev/null 2>&1 + ;; + clearlog) + printf '' > "$LOGFILE" + ;; + esac +fi + +# Reload settings after actions. +MOD_PANGOLIN_SITE_ENABLED=0 +MOD_PANGOLIN_SITE_ENDPOINT="" +MOD_PANGOLIN_SITE_ID="" +MOD_PANGOLIN_SITE_SECRET="" +[ -f "$SETTINGS" ] && . "$SETTINGS" + +# ── Status ────────────────────────────────────────────────────────────────── +if is_running; then + STATUS_TEXT="Running" + STATUS_CLASS="running" + DISABLED="disabled" + SETTINGS_HINT='
Stop Newt first to edit settings.
' +else + STATUS_TEXT="Stopped" + STATUS_CLASS="stopped" + DISABLED="" + SETTINGS_HINT="" +fi + +EP_ESC=$(htmlesc "$MOD_PANGOLIN_SITE_ENDPOINT") +ID_ESC=$(htmlesc "$MOD_PANGOLIN_SITE_ID") +SEC_ESC=$(htmlesc "$MOD_PANGOLIN_SITE_SECRET") +EP_INPUT_ESC="$EP_ESC" +[ -z "$MOD_PANGOLIN_SITE_ENDPOINT" ] && EP_INPUT_ESC="https://app.pangolin.net" + +# ── Log (escape HTML and dollar signs to prevent shell expansion in heredoc) ─ +LOG_HTML="" +if [ -f "$LOGFILE" ]; then + LOG_HTML=$(tail -100 "$LOGFILE" 2>/dev/null | sed \ + -e 's/&/\&/g' \ + -e 's//\>/g' \ + -e 's/\$/\$/g') +fi + +# ── Output ────────────────────────────────────────────────────────────────── +printf 'Content-type: text/html\r\n\r\n' + +# Static head — split around the conditional refresh meta tag +cat << 'HEAD_START' + + + + +HEAD_START + +# Only auto-refresh while newt is running (avoids wiping settings form mid-edit) +[ "$STATUS_CLASS" = "running" ] && printf '\n' + +cat << 'STATIC_HEAD' +Pangolin Site + + + + +
Router Apps - Pangolin Site
+
+STATIC_HEAD + +# Status card (double-quoted heredoc — variables expand) +cat << STATUS_CARD +
+
Status
+
+
${STATUS_TEXT}
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+STATUS_CARD + +# Settings card +cat << SETTINGS_CARD +
+
Settings
+
+${SETTINGS_HINT}
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+SETTINGS_CARD + +# Log card header +cat << 'LOG_HEADER' +
+
+Log (last 100 lines, auto-refreshes every 10s while running) + +
+ + +
+
+ +
+
+
+
+
+LOG_HEADER + +# Log content — printed with printf to prevent any shell expansion +printf '%s' "$LOG_HTML" + +# Close tags (static) +cat << 'STATIC_FOOTER' +
+
+
+ + +STATIC_FOOTER diff --git a/packages/openwrt/.gitkeep b/packages/openwrt/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/teltonika/.gitkeep b/packages/teltonika/.gitkeep new file mode 100644 index 0000000..e69de29 From 34d2cad512c14979f4554cee72796e879f3f3dae Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 25 May 2026 20:37:53 -0700 Subject: [PATCH 140/161] Add to cicd Former-commit-id: 3d57d37c9eee33f4d5052108730fa340dffe8712 --- .github/workflows/cicd.yml | 15 +++++++++++++++ packages/advantech/Makefile | 3 ++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index cb6eecd..09be3df 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -897,6 +897,20 @@ jobs: set -euo pipefail make -j 10 go-build-release VERSION="${TAG}" + - name: Build Advantech packages + shell: bash + run: | + set -euo pipefail + ADVANTECH_DIR="packages/advantech" + + mkdir -p "${ADVANTECH_DIR}/bin" + install -m 0755 "bin/newt_linux_arm64" "${ADVANTECH_DIR}/bin/newt_linux_arm64" + install -m 0755 "bin/newt_linux_arm32" "${ADVANTECH_DIR}/bin/newt_linux_arm32" + + for platform in v2 v2i v3 v4 v4i; do + make -C "${ADVANTECH_DIR}" PLATFORM="${platform}" + done + - name: Create GitHub Release (draft) uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 with: @@ -905,6 +919,7 @@ jobs: prerelease: ${{ env.IS_RC == 'true' }} files: | bin/* + packages/advantech/*.tgz fail_on_unmatched_files: true draft: true body: | diff --git a/packages/advantech/Makefile b/packages/advantech/Makefile index 2250025..9598e62 100644 --- a/packages/advantech/Makefile +++ b/packages/advantech/Makefile @@ -7,11 +7,12 @@ PLATFORM ?= v4 arch_v4 := arm64 arch_v4i := arm64 arch_v3 := arm32 +arch_v2 := arm32 arch_v2i := arm32 NEWT_ARCH := $(arch_$(PLATFORM)) ifeq ($(NEWT_ARCH),) -$(error Unknown platform '$(PLATFORM)'. Supported: v4, v4i, v3, v2i) +$(error Unknown platform '$(PLATFORM)'. Supported: v4, v4i, v3, v2, v2i) endif BINARY := newt_linux_$(NEWT_ARCH) From 71eb81182952eab7155fb8bd25538f7716aa5033 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 25 May 2026 21:58:27 -0700 Subject: [PATCH 141/161] Add sighup and block flag to block connections Former-commit-id: 694a7986f5b7c5f0610073b882e7a3203fe27108 --- clients.go | 7 +++++ clients/clients.go | 27 ++++++++++++++++++ main.go | 60 +++++++++++++++++++++++++++++++++++++++ netstack2/handlers.go | 18 ++++++++---- netstack2/http_handler.go | 5 ---- netstack2/proxy.go | 26 ++++++++++++++++- netstack2/tun.go | 5 ---- proxy/manager.go | 46 +++++++++++++++++++++++------- websocket/client.go | 5 ++++ websocket/types.go | 3 +- 10 files changed, 175 insertions(+), 27 deletions(-) diff --git a/clients.go b/clients.go index d650eeb..53ce63f 100644 --- a/clients.go +++ b/clients.go @@ -63,6 +63,13 @@ func closeClients() { } } +// 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 diff --git a/clients/clients.go b/clients/clients.go index 3862160..cbd2816 100644 --- a/clients/clients.go +++ b/clients/clients.go @@ -13,6 +13,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "time" "github.com/fosrl/newt/bind" @@ -114,6 +115,9 @@ type WireGuardService struct { netstackListener net.PacketConn netstackListenerMu sync.Mutex wgTesterServer *wgtester.Server + + // connection blocking: when true, all new incoming connections are dropped + blocked atomic.Bool } // generateChainId generates a random chain ID for deduplicating round-trip messages. @@ -286,6 +290,21 @@ func (s *WireGuardService) GetPublicKey() wgtypes.Key { return s.key.PublicKey() } +// SetBlocked enables or disables connection blocking for this WireGuard service. +// The state is persisted and applied immediately to any active proxy handler. +func (s *WireGuardService) SetBlocked(v bool) { + s.blocked.Store(v) + s.mu.Lock() + tnet := s.tnet + s.mu.Unlock() + if tnet == nil { + return + } + if ph := tnet.GetProxyHandler(); ph != nil { + ph.SetBlocked(v) + } +} + // SetOnNetstackReady sets a callback function to be called when the netstack interface is ready func (s *WireGuardService) SetOnNetstackReady(callback func(*netstack2.Net)) { s.onNetstackReady = callback @@ -891,6 +910,14 @@ func (s *WireGuardService) ensureWireguardInterface(wgconfig WgConfig) error { } // Note: we already unlocked above, so don't use defer unlock + + // Apply any pending blocked state to the newly created proxy handler + if s.blocked.Load() { + if ph := s.tnet.GetProxyHandler(); ph != nil { + ph.SetBlocked(true) + } + } + return nil } diff --git a/main.go b/main.go index 0c8e7dd..85d15e7 100644 --- a/main.go +++ b/main.go @@ -18,6 +18,7 @@ import ( "os/signal" "strconv" "strings" + "sync/atomic" "syscall" "time" @@ -736,11 +737,20 @@ func runNewtMain(ctx context.Context) { var connected bool var wgData WgData var dockerEventMonitor *docker.EventMonitor + var connectionBlocked atomic.Bool + var currentPM atomic.Pointer[proxy.ProxyManager] if !disableClients { setupClients(client) } + // 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)) @@ -780,6 +790,7 @@ func runNewtMain(ctx context.Context) { // Stop proxy manager if running if pm != nil { pm.Stop() + currentPM.Store(nil) pm = nil } @@ -950,6 +961,8 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( 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 @@ -1925,6 +1938,53 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( 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, exit so the supervisor can restart with new values + if newCfg.Endpoint != oldCfg.Endpoint || newCfg.ID != oldCfg.ID || newCfg.Secret != oldCfg.Secret { + logger.Info("Config credentials changed (endpoint/id/secret), exiting for supervisor restart...") + os.Exit(0) + } + // If blocked state changed, apply in-place without restart + if newCfg.Blocked != connectionBlocked.Load() { + connectionBlocked.Store(newCfg.Blocked) + if newCfg.Blocked { + logger.Info("Config reload: connection blocking enabled") + } else { + logger.Info("Config reload: connection blocking disabled") + } + if p := currentPM.Load(); p != nil { + p.SetBlocked(newCfg.Blocked) + } + setClientsBlocked(newCfg.Blocked) + } else { + logger.Info("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) diff --git a/netstack2/handlers.go b/netstack2/handlers.go index e28a543..e6ea62e 100644 --- a/netstack2/handlers.go +++ b/netstack2/handlers.go @@ -1,8 +1,3 @@ -/* SPDX-License-Identifier: MIT - * - * Copyright (C) 2017-2025 WireGuard LLC. All Rights Reserved. - */ - package netstack2 import ( @@ -144,6 +139,13 @@ func (h *TCPHandler) handleTCPConn(netstackConn *gonet.TCPConn, id stack.Transpo dstIP := id.LocalAddress.String() dstPort := id.LocalPort + // Drop connection if blocking is enabled + if h.proxyHandler != nil && h.proxyHandler.IsBlocked() { + logger.Debug("TCP Forwarder: connection blocked: %s:%d -> %s:%d", srcIP, srcPort, dstIP, dstPort) + netstackConn.Close() + return + } + // For HTTP/HTTPS ports, look up the matching subnet rule. If the rule has // Protocol configured, hand the connection off to the HTTP handler which // takes full ownership of the lifecycle (the defer close must not be @@ -315,6 +317,12 @@ func (h *UDPHandler) handleUDPConn(netstackConn *gonet.UDPConn, id stack.Transpo logger.Info("UDP Forwarder: Handling connection %s:%d -> %s:%d", srcIP, srcPort, dstIP, dstPort) + // Drop connection if blocking is enabled + if h.proxyHandler != nil && h.proxyHandler.IsBlocked() { + logger.Debug("UDP Forwarder: connection blocked: %s:%d -> %s:%d", srcIP, srcPort, dstIP, dstPort) + return + } + // Check if there's a destination rewrite for this connection (e.g., localhost targets) actualDstIP := dstIP if h.proxyHandler != nil { diff --git a/netstack2/http_handler.go b/netstack2/http_handler.go index ece82e9..7a4d69d 100644 --- a/netstack2/http_handler.go +++ b/netstack2/http_handler.go @@ -1,8 +1,3 @@ -/* SPDX-License-Identifier: MIT - * - * Copyright (C) 2017-2025 WireGuard LLC. All Rights Reserved. - */ - package netstack2 import ( diff --git a/netstack2/proxy.go b/netstack2/proxy.go index 95fab6a..2df5c83 100644 --- a/netstack2/proxy.go +++ b/netstack2/proxy.go @@ -6,6 +6,7 @@ import ( "net" "net/netip" "sync" + "sync/atomic" "time" "github.com/fosrl/newt/logger" @@ -134,6 +135,7 @@ type ProxyHandler struct { notifiable channel.Notification // Notification handler for triggering reads accessLogger *AccessLogger // Access logger for tracking sessions httpRequestLogger *HTTPRequestLogger // HTTP request logger for proxied HTTP/HTTPS requests + blocked atomic.Bool // when true, all new connections are dropped } // ProxyHandlerOptions configures the proxy handler @@ -240,6 +242,28 @@ func (p *ProxyHandler) AddSubnetRule(rule SubnetRule) { p.subnetLookup.AddSubnet(rule) } +// SetBlocked enables or disables connection blocking on this proxy handler. +// When enabled, all new TCP/UDP connections from the tunnel are dropped immediately. +func (p *ProxyHandler) SetBlocked(v bool) { + if p == nil { + return + } + p.blocked.Store(v) + if v { + logger.Info("ProxyHandler: connection blocking enabled") + } else { + logger.Info("ProxyHandler: connection blocking disabled") + } +} + +// IsBlocked returns true if connection blocking is currently enabled. +func (p *ProxyHandler) IsBlocked() bool { + if p == nil { + return false + } + return p.blocked.Load() +} + // RemoveSubnetRule removes a subnet from the proxy handler func (p *ProxyHandler) RemoveSubnetRule(sourcePrefix, destPrefix netip.Prefix) { if p == nil || !p.enabled { @@ -612,7 +636,7 @@ func (p *ProxyHandler) HandleIncomingPacket(packet []byte) bool { } // logger.Debug("HandleIncomingPacket: No matching rule for %s -> %s (proto=%d, port=%d)", - // srcAddr, dstAddr, protocol, dstPort) + // srcAddr, dstAddr, protocol, dstPort) return false } diff --git a/netstack2/tun.go b/netstack2/tun.go index fae90dd..d104f2e 100644 --- a/netstack2/tun.go +++ b/netstack2/tun.go @@ -1,8 +1,3 @@ -/* SPDX-License-Identifier: MIT - * - * Copyright (C) 2017-2025 WireGuard LLC. All Rights Reserved. - */ - package netstack2 import ( diff --git a/proxy/manager.go b/proxy/manager.go index 0d1f750..b04be26 100644 --- a/proxy/manager.go +++ b/proxy/manager.go @@ -70,6 +70,9 @@ type ProxyManager struct { asyncBytes bool flushStop chan struct{} udpIdleTimeout time.Duration + + // connection blocking + blocked atomic.Bool } // tunnelEntry holds per-tunnel attributes and (optional) async counters. @@ -149,12 +152,12 @@ func classifyProxyError(err error) string { // NewProxyManager creates a new proxy manager instance func NewProxyManager(tnet *netstack.Net) *ProxyManager { return &ProxyManager{ - tnet: tnet, - tcpTargets: make(map[string]map[int]string), - udpTargets: make(map[string]map[int]string), - listeners: make([]*gonet.TCPListener, 0), - udpConns: make([]*gonet.UDPConn, 0), - tunnels: make(map[string]*tunnelEntry), + tnet: tnet, + tcpTargets: make(map[string]map[int]string), + udpTargets: make(map[string]map[int]string), + listeners: make([]*gonet.TCPListener, 0), + udpConns: make([]*gonet.UDPConn, 0), + tunnels: make(map[string]*tunnelEntry), udpIdleTimeout: defaultUDPIdleTimeout, } } @@ -229,10 +232,10 @@ func (pm *ProxyManager) ClearTunnelID() { // init function without tnet func NewProxyManagerWithoutTNet() *ProxyManager { return &ProxyManager{ - tcpTargets: make(map[string]map[int]string), - udpTargets: make(map[string]map[int]string), - listeners: make([]*gonet.TCPListener, 0), - udpConns: make([]*gonet.UDPConn, 0), + tcpTargets: make(map[string]map[int]string), + udpTargets: make(map[string]map[int]string), + listeners: make([]*gonet.TCPListener, 0), + udpConns: make([]*gonet.UDPConn, 0), udpIdleTimeout: defaultUDPIdleTimeout, } } @@ -244,6 +247,18 @@ func (pm *ProxyManager) SetTNet(tnet *netstack.Net) { pm.tnet = tnet } +// SetBlocked enables or disables connection blocking. +// When enabled, all new incoming TCP connections are immediately closed +// and all incoming UDP packets are silently dropped. +func (pm *ProxyManager) SetBlocked(v bool) { + pm.blocked.Store(v) + if v { + logger.Info("ProxyManager: connection blocking enabled, new connections will be dropped") + } else { + logger.Info("ProxyManager: connection blocking disabled, accepting connections") + } +} + // AddTarget adds as new target for proxying func (pm *ProxyManager) AddTarget(proto, listenIP string, port int, targetAddr string) error { pm.mutex.Lock() @@ -535,6 +550,12 @@ func (pm *ProxyManager) handleTCPProxy(listener net.Listener, targetAddr string) } tunnelID := pm.currentTunnelID + // Drop connection if blocking is enabled + if pm.blocked.Load() { + conn.Close() + logger.Debug("TCP proxy: connection dropped (blocking enabled)") + continue + } telemetry.IncProxyAccept(context.Background(), tunnelID, "tcp", "success", "") telemetry.IncProxyConnectionEvent(context.Background(), tunnelID, "tcp", telemetry.ProxyConnectionOpened) if tunnelID != "" { @@ -631,6 +652,11 @@ func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) { } clientKey := remoteAddr.String() + // Drop packet if blocking is enabled + if pm.blocked.Load() { + logger.Debug("UDP proxy: packet dropped (blocking enabled)") + continue + } // bytes from client -> target (direction=in) if pm.currentTunnelID != "" && n > 0 { if pm.asyncBytes { diff --git a/websocket/client.go b/websocket/client.go index 22187ac..0f4ea39 100644 --- a/websocket/client.go +++ b/websocket/client.go @@ -172,6 +172,11 @@ func (c *Client) GetConfig() *Config { return c.config } +// GetConfigFilePath returns the resolved path to the config file used by this client. +func (c *Client) GetConfigFilePath() string { + return getConfigPath(c.clientType, c.configFilePath) +} + func (c *Client) GetServerVersion() string { return c.serverVersion } diff --git a/websocket/types.go b/websocket/types.go index 195e06f..58b304e 100644 --- a/websocket/types.go +++ b/websocket/types.go @@ -7,6 +7,7 @@ type Config struct { TlsClientCert string `json:"tlsClientCert"` ProvisioningKey string `json:"provisioningKey,omitempty"` Name string `json:"name,omitempty"` + Blocked bool `json:"blocked,omitempty"` } type TokenResponse struct { @@ -31,4 +32,4 @@ type WSMessage struct { Type string `json:"type"` Data interface{} `json:"data"` ConfigVersion int64 `json:"configVersion,omitempty"` -} \ No newline at end of file +} From e223a35a629d119f91c044a13e37e363da52bbbf Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 26 May 2026 14:09:31 -0700 Subject: [PATCH 142/161] Reexec if the config changes Former-commit-id: a7b028b154b926f0f0424b3eaccbf148ecce29d9 --- main.go | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/main.go b/main.go index 85d15e7..2e50f27 100644 --- a/main.go +++ b/main.go @@ -1959,10 +1959,24 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( continue } oldCfg := client.GetConfig() - // If credentials changed, exit so the supervisor can restart with new values + // 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), exiting for supervisor restart...") - os.Exit(0) + logger.Info("Config credentials changed (endpoint/id/secret), restarting...") + closeWgTunnel() + closeClients() + if healthMonitor != nil { + healthMonitor.Stop() + } + client.Close() + exe, exeErr := os.Executable() + if exeErr != nil { + logger.Error("Failed to get executable path for restart: %v", exeErr) + os.Exit(0) + } + if err := syscall.Exec(exe, os.Args, os.Environ()); err != nil { + logger.Error("Failed to re-exec for restart: %v", err) + os.Exit(1) + } } // If blocked state changed, apply in-place without restart if newCfg.Blocked != connectionBlocked.Load() { From 7ff97eda51779073be18b0381ecfbd8e48dc70a3 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 27 May 2026 16:33:16 -0700 Subject: [PATCH 143/161] Add restart endpoint Former-commit-id: 734542c3ed83ba5a681aaacfce607aae1b88f901 --- main.go | 22 +++++++++++++++------- reexec_unix.go | 21 +++++++++++++++++++++ reexec_windows.go | 30 ++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 7 deletions(-) create mode 100644 reexec_unix.go create mode 100644 reexec_windows.go diff --git a/main.go b/main.go index 2e50f27..3366f89 100644 --- a/main.go +++ b/main.go @@ -1032,6 +1032,19 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( 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 != "" { @@ -1968,13 +1981,8 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( healthMonitor.Stop() } client.Close() - exe, exeErr := os.Executable() - if exeErr != nil { - logger.Error("Failed to get executable path for restart: %v", exeErr) - os.Exit(0) - } - if err := syscall.Exec(exe, os.Args, os.Environ()); err != nil { - logger.Error("Failed to re-exec for restart: %v", err) + if err := reexec(); err != nil { + logger.Error("Failed to restart: %v", err) os.Exit(1) } } diff --git a/reexec_unix.go b/reexec_unix.go new file mode 100644 index 0000000..b6c01cc --- /dev/null +++ b/reexec_unix.go @@ -0,0 +1,21 @@ +//go:build !windows + +package main + +import ( + "fmt" + "os" + "syscall" +) + +// reexec replaces the current process image with a fresh copy of itself, +// preserving all arguments and environment variables. On success it never +// returns (execve replaces the process in-place). On failure it returns an +// error describing why the exec could not be performed. +func reexec() error { + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to get executable path: %w", err) + } + return syscall.Exec(exe, os.Args, os.Environ()) +} diff --git a/reexec_windows.go b/reexec_windows.go new file mode 100644 index 0000000..770f544 --- /dev/null +++ b/reexec_windows.go @@ -0,0 +1,30 @@ +//go:build windows + +package main + +import ( + "fmt" + "os" + "os/exec" +) + +// reexec spawns a new copy of the current process with the same arguments and +// environment, then exits the current process. On Windows, execve is not +// available, so we start a child process and exit. On success it never returns +// (os.Exit terminates the current process). On failure it returns an error. +func reexec() error { + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to get executable path: %w", err) + } + cmd := exec.Command(exe, os.Args[1:]...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + cmd.Env = os.Environ() + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start new process: %w", err) + } + os.Exit(0) + return nil // unreachable +} From e3fe89b43183df508f0fca2205aec8bf391a2438 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 29 May 2026 15:57:39 -0700 Subject: [PATCH 144/161] Remove github.com/msteinert/pam/v2 Former-commit-id: b33a12a528ebdaa81a49c445d581be3969d9de48 --- go.mod | 1 - go.sum | 2 - nativessh/pam_linux.go | 275 ++++++++++++++++++++++++++++++++++++++--- 3 files changed, 257 insertions(+), 21 deletions(-) diff --git a/go.mod b/go.mod index 0dc00e8..b5a8f8e 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,6 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/moby/moby/api v1.54.2 github.com/moby/moby/client v0.4.1 - github.com/msteinert/pam/v2 v2.1.0 github.com/prometheus/client_golang v1.23.2 github.com/vishvananda/netlink v1.3.1 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 diff --git a/go.sum b/go.sum index 3ad78d4..bb059f0 100644 --- a/go.sum +++ b/go.sum @@ -57,8 +57,6 @@ github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg= github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY= github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ= -github.com/msteinert/pam/v2 v2.1.0 h1:er5F9TKV5nGFuTt12ubtqPHEUdeBwReP7vd3wovidGY= -github.com/msteinert/pam/v2 v2.1.0/go.mod h1:KT28NNIcDFf3PcBmNI2mIGO4zZJ+9RSs/At2PB3IDVc= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= diff --git a/nativessh/pam_linux.go b/nativessh/pam_linux.go index b6f359d..53e8506 100644 --- a/nativessh/pam_linux.go +++ b/nativessh/pam_linux.go @@ -3,32 +3,271 @@ package nativessh import ( + "bufio" + "bytes" + "crypto/sha512" + "errors" "fmt" + "os" + "strconv" + "strings" - "github.com/msteinert/pam/v2" + "golang.org/x/crypto/bcrypt" ) -// VerifySystemPassword authenticates username/password via PAM using the -// "sshd" service stack. It returns nil on success and an error on failure. -// The caller must not reveal the error detail to the client. +// VerifySystemPassword authenticates username/password by reading /etc/shadow +// and verifying the stored hash using pure-Go cryptography (no CGo required). +// Supported hash schemes: bcrypt ($2a$/$2b$/$2y$) and SHA-512 crypt ($6$). func VerifySystemPassword(username, password string) error { - tx, err := pam.StartFunc("sshd", username, func(s pam.Style, msg string) (string, error) { - switch s { - case pam.PromptEchoOff, pam.PromptEchoOn: - return password, nil - default: - return "", nil - } - }) + hash, err := readShadowHash(username) if err != nil { - return fmt.Errorf("PAM start: %w", err) + return fmt.Errorf("shadow: %w", err) + } + return cryptVerify(password, hash) +} + +// readShadowHash reads /etc/shadow and returns the password hash for username. +func readShadowHash(username string) (string, error) { + f, err := os.Open("/etc/shadow") + if err != nil { + return "", err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + fields := strings.SplitN(scanner.Text(), ":", 3) + if len(fields) < 2 || fields[0] != username { + continue + } + h := fields[1] + if h == "" || h == "*" || strings.HasPrefix(h, "!") || h == "x" { + return "", errors.New("account locked or has no password") + } + return h, nil + } + if err := scanner.Err(); err != nil { + return "", err + } + return "", errors.New("user not found in shadow database") +} + +// cryptVerify verifies password against a crypt(3) hash string. +func cryptVerify(password, hash string) error { + switch { + case strings.HasPrefix(hash, "$2a$"), strings.HasPrefix(hash, "$2b$"), strings.HasPrefix(hash, "$2y$"): + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + case strings.HasPrefix(hash, "$6$"): + computed, err := sha512CryptHash([]byte(password), hash) + if err != nil { + return err + } + if computed != hash { + return errors.New("authentication failed") + } + return nil + default: + return fmt.Errorf("unsupported password hash scheme") + } +} + +// --- SHA-512 crypt ($6$) --- +// Specification: https://www.akkadia.org/docs/sha-crypt.txt + +const ( + sha512CryptMagic = "$6$" + sha512CryptRoundsDefault = 5000 + sha512CryptRoundsMin = 1000 + sha512CryptRoundsMax = 999999999 + sha512CryptSaltLenMax = 16 + sha512CryptAlphabet = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" +) + +var sha512CryptRoundsPrefix = []byte("rounds=") + +// sha512CryptHash computes a SHA-512 crypt hash for key. saltStr may be a +// full stored hash string (for verification) or just the salt parameters. +func sha512CryptHash(key []byte, saltStr string) (string, error) { + salt := []byte(saltStr) + + if !bytes.HasPrefix(salt, []byte(sha512CryptMagic)) { + return "", errors.New("sha512crypt: invalid prefix") + } + salt = salt[len(sha512CryptMagic):] + + rounds := sha512CryptRoundsDefault + isRoundsDef := false + + if bytes.HasPrefix(salt, sha512CryptRoundsPrefix) { + salt = salt[len(sha512CryptRoundsPrefix):] + i := bytes.IndexByte(salt, '$') + if i < 0 { + return "", errors.New("sha512crypt: malformed rounds field") + } + r, err := strconv.Atoi(string(salt[:i])) + if err != nil { + return "", fmt.Errorf("sha512crypt: invalid rounds: %w", err) + } + salt = salt[i+1:] + isRoundsDef = true + rounds = r + if rounds < sha512CryptRoundsMin { + rounds = sha512CryptRoundsMin + } else if rounds > sha512CryptRoundsMax { + rounds = sha512CryptRoundsMax + } } - if err := tx.Authenticate(0); err != nil { - return fmt.Errorf("PAM authenticate: %w", err) + // When saltStr is a full hash, strip the stored hash after the last '$'. + if i := bytes.IndexByte(salt, '$'); i >= 0 { + salt = salt[:i] } - if err := tx.AcctMgmt(0); err != nil { - return fmt.Errorf("PAM acct_mgmt: %w", err) + if len(salt) > sha512CryptSaltLenMax { + salt = salt[:sha512CryptSaltLenMax] } - return nil + + // Alternate = SHA512(key + salt + key) + altH := sha512.New() + altH.Write(key) + altH.Write(salt) + altH.Write(key) + altSum := altH.Sum(nil) + + // Digest A = SHA512(key + salt + altSum-cycling-to-len(key) + bit-pattern) + aH := sha512.New() + aH.Write(key) + aH.Write(salt) + for i := len(key); i > 0; i -= 64 { + if i > 64 { + aH.Write(altSum) + } else { + aH.Write(altSum[:i]) + } + } + for i := len(key); i > 0; i >>= 1 { + if i&1 != 0 { + aH.Write(altSum) + } else { + aH.Write(key) + } + } + aSum := aH.Sum(nil) + + // P-sequence: SHA512(key×len(key)) cycled to len(key) bytes + pH := sha512.New() + for i := 0; i < len(key); i++ { + pH.Write(key) + } + pSeq := sha512CryptCycle(pH.Sum(nil), len(key)) + + // S-sequence: SHA512(salt×(16+aSum[0])) cycled to len(salt) bytes + sH := sha512.New() + for i := 0; i < 16+int(aSum[0]); i++ { + sH.Write(salt) + } + sSeq := sha512CryptCycle(sH.Sum(nil), len(salt)) + + // Iterative hashing rounds + cSum := aSum + for i := 0; i < rounds; i++ { + c := sha512.New() + if i&1 != 0 { + c.Write(pSeq) + } else { + c.Write(cSum) + } + if i%3 != 0 { + c.Write(sSeq) + } + if i%7 != 0 { + c.Write(pSeq) + } + if i&1 != 0 { + c.Write(cSum) + } else { + c.Write(pSeq) + } + cSum = c.Sum(nil) + } + + // Build the output string + out := []byte(sha512CryptMagic) + if isRoundsDef { + out = append(out, fmt.Sprintf("rounds=%d$", rounds)...) + } + out = append(out, salt...) + out = append(out, '$') + out = append(out, sha512CryptEncode(cSum)...) + return string(out), nil +} + +// sha512CryptCycle returns exactly n bytes by cycling the 64-byte src slice. +func sha512CryptCycle(src []byte, n int) []byte { + dst := make([]byte, 0, n) + for i := n; i > 64; i -= 64 { + dst = append(dst, src...) + } + if rem := n % 64; rem == 0 && n > 0 { + dst = append(dst, src...) + } else if rem > 0 { + dst = append(dst, src[:rem]...) + } + return dst +} + +// sha512CryptEncode applies the sha512crypt byte permutation and encodes the +// result with the crypt(3) base-64 alphabet (86 output characters for 64 input bytes). +func sha512CryptEncode(sum []byte) []byte { + perm := []byte{ + sum[42], sum[21], sum[0], + sum[1], sum[43], sum[22], + sum[23], sum[2], sum[44], + sum[45], sum[24], sum[3], + sum[4], sum[46], sum[25], + sum[26], sum[5], sum[47], + sum[48], sum[27], sum[6], + sum[7], sum[49], sum[28], + sum[29], sum[8], sum[50], + sum[51], sum[30], sum[9], + sum[10], sum[52], sum[31], + sum[32], sum[11], sum[53], + sum[54], sum[33], sum[12], + sum[13], sum[55], sum[34], + sum[35], sum[14], sum[56], + sum[57], sum[36], sum[15], + sum[16], sum[58], sum[37], + sum[38], sum[17], sum[59], + sum[60], sum[39], sum[18], + sum[19], sum[61], sum[40], + sum[41], sum[20], sum[62], + sum[63], + } + src := perm + out := make([]byte, 0, 86) + for len(src) > 0 { + switch len(src) { + default: + out = append(out, + sha512CryptAlphabet[src[0]&0x3f], + sha512CryptAlphabet[((src[0]>>6)|(src[1]<<2))&0x3f], + sha512CryptAlphabet[((src[1]>>4)|(src[2]<<4))&0x3f], + sha512CryptAlphabet[(src[2]>>2)&0x3f], + ) + src = src[3:] + case 2: + out = append(out, + sha512CryptAlphabet[src[0]&0x3f], + sha512CryptAlphabet[((src[0]>>6)|(src[1]<<2))&0x3f], + sha512CryptAlphabet[(src[1]>>4)&0x3f], + ) + src = src[2:] + case 1: + out = append(out, + sha512CryptAlphabet[src[0]&0x3f], + sha512CryptAlphabet[(src[0]>>6)&0x3f], + ) + src = src[1:] + } + } + return out } From 763ef3726eaeb9aeff5ea26140e5ac23ae0272b2 Mon Sep 17 00:00:00 2001 From: immanuwell Date: Sat, 30 May 2026 21:55:46 +0400 Subject: [PATCH 145/161] fix: remove duplicate wrong error log in socket/fetch handler and dead code in reliablePing Former-commit-id: d57b7ccb53e917e37d11a03796251c6de4a3956f --- common.go | 14 +++----------- main.go | 4 ---- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/common.go b/common.go index 8fd89f1..6b8ab80 100644 --- a/common.go +++ b/common.go @@ -121,19 +121,11 @@ func reliablePing(tnet *netstack.Net, dst string, baseTimeout time.Duration, max totalLatency += latency successCount++ - // If we get at least one success, we can return early for health checks - if successCount > 0 { - avgLatency := totalLatency / time.Duration(successCount) - // logger.Debug("Reliable ping succeeded after %d attempts, avg latency: %v", attempt, avgLatency) - return avgLatency, nil - } + // Return on first success + return totalLatency / time.Duration(successCount), nil } - if successCount == 0 { - return 0, fmt.Errorf("all %d ping attempts failed, last error: %v", maxAttempts, lastErr) - } - - return totalLatency / time.Duration(successCount), nil + return 0, fmt.Errorf("all %d ping attempts failed, last error: %v", maxAttempts, lastErr) } func pingWithRetry(tnet *netstack.Net, dst string, timeout time.Duration) (stopChan chan struct{}, err error) { diff --git a/main.go b/main.go index 448f71d..2a5bdbf 100644 --- a/main.go +++ b/main.go @@ -1482,10 +1482,6 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( err = client.SendMessage("newt/socket/containers", map[string]interface{}{ "containers": containers, }) - if err != nil { - logger.Error("Failed to send registration message: %v", err) - } - if err != nil { logger.Error("Failed to send Docker container list: %v", err) } else { From b35dd025bdc2a9fd000598a412da4651bd60dbf9 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 30 May 2026 11:53:31 -0700 Subject: [PATCH 146/161] Get native auth working Former-commit-id: e5345f67172b4eade91dc926dd96a8584a0f206d --- browsergateway/browsergateway.go | 16 ++ browsergateway/ssh.go | 11 +- go.mod | 5 +- go.sum | 22 +++ main.go | 2 +- nativessh/auth.go | 17 +- nativessh/pam_linux.go | 280 ++++++------------------------- newt.REMOVED.git-id | 1 + 8 files changed, 119 insertions(+), 235 deletions(-) create mode 100644 newt.REMOVED.git-id diff --git a/browsergateway/browsergateway.go b/browsergateway/browsergateway.go index 2d0c6a8..60fecb5 100644 --- a/browsergateway/browsergateway.go +++ b/browsergateway/browsergateway.go @@ -96,6 +96,22 @@ func (g *Gateway) isAllowed(targetType, host string, port int, authToken string) return false } +// isTokenValid reports whether the given authToken matches any registered +// target of the specified type. Used for native SSH mode where there is no +// external destination to match against. +func (g *Gateway) isTokenValid(targetType, authToken string) bool { + g.mu.RLock() + defer g.mu.RUnlock() + for _, t := range g.targets { + if t.Type == targetType { + if subtle.ConstantTimeCompare([]byte(authToken), []byte(t.AuthToken)) == 1 { + return true + } + } + } + return false +} + // Start serves the browser gateway HTTP server on the provided listener. // It returns nil when the listener is closed (normal shutdown). func (g *Gateway) Start(ln net.Listener) error { diff --git a/browsergateway/ssh.go b/browsergateway/ssh.go index eaea313..bf5a83a 100644 --- a/browsergateway/ssh.go +++ b/browsergateway/ssh.go @@ -2,7 +2,6 @@ package browsergateway import ( "context" - "crypto/subtle" "encoding/json" "fmt" "log" @@ -12,6 +11,7 @@ import ( "time" "github.com/coder/websocket" + "github.com/fosrl/newt/logger" "golang.org/x/crypto/ssh" ) @@ -36,11 +36,14 @@ type sshServerMsg struct { // HandleSSH is an http.HandlerFunc for SSH-over-WebSocket connections. func (g *Gateway) HandleSSH(w http.ResponseWriter, r *http.Request) { + logger.Debug("SSH connection request from %s", r.RemoteAddr) ctx := r.Context() token := r.URL.Query().Get("authToken") - var nativeSSH = false + // "mode=native" (default) connects to the local SSH daemon on this host. + // "mode=proxy" connects to an arbitrary host+port supplied in query params. + nativeSSH := r.URL.Query().Get("mode") != "proxy" // In proxy mode we also need host + username from query params. var target, username string @@ -62,8 +65,8 @@ func (g *Gateway) HandleSSH(w http.ResponseWriter, r *http.Request) { } target = net.JoinHostPort(host, port) } else { - // Native SSH mode: validate the gateway token then read the target username. - if subtle.ConstantTimeCompare([]byte(token), []byte(g.authToken)) != 1 { + // Native SSH mode: validate the token against any registered ssh target. + if !g.isTokenValid("ssh", token) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } diff --git a/go.mod b/go.mod index b5a8f8e..3207ed1 100644 --- a/go.mod +++ b/go.mod @@ -23,7 +23,7 @@ require ( golang.org/x/crypto v0.50.0 golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 golang.org/x/net v0.53.0 - golang.org/x/sys v0.43.0 + golang.org/x/sys v0.45.0 golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 golang.zx2c4.com/wireguard/windows v0.5.3 @@ -44,11 +44,14 @@ require ( github.com/docker/go-connections v0.7.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-crypt/crypt v0.14.15 // indirect + github.com/go-crypt/x v0.4.16 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect + github.com/jsimonetti/pwscheme v0.0.0-20220922140336-67a4d090f150 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect diff --git a/go.sum b/go.sum index bb059f0..4db0e23 100644 --- a/go.sum +++ b/go.sum @@ -26,6 +26,10 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo= github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c= +github.com/go-crypt/crypt v0.14.15 h1:q1i5OMpL05r935IxWmXgpDAVF0nvi4SMoHhGXLBQUEQ= +github.com/go-crypt/crypt v0.14.15/go.mod h1:0n/to1VqIZPENj2yEUa/sLLYYnmupma6cp+QMX4zfF0= +github.com/go-crypt/x v0.4.16 h1:WXdY28H/0MsXnH+gwerxuCcvBTJPkBG90u6oS4gIPZI= +github.com/go-crypt/x v0.4.16/go.mod h1:vmVFA/d/oLrEaCbqsLcjBMlTqF8u8pvH/c4+EJ/ped8= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -43,6 +47,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= +github.com/jsimonetti/pwscheme v0.0.0-20220922140336-67a4d090f150 h1:ta6N7DaOQEACq28cLa0iRqXIbchByN9Lfll08CT2GBc= +github.com/jsimonetti/pwscheme v0.0.0-20220922140336-67a4d090f150/go.mod h1:SiNTKDgjKQORnazFVHXhpny7UtU0iJOqtxd7R7sCfDI= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -113,22 +119,38 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +golang.org/x/crypto v0.0.0-20220919173607-35f4265a4bc0/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0= golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220921155015-db77216a4ee9/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220919091848-fb04ddd9f9c8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.0.0-20220919170432-7a66f970e087/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A= diff --git a/main.go b/main.go index 281abf9..25d4102 100644 --- a/main.go +++ b/main.go @@ -534,7 +534,7 @@ func runNewtMain(ctx context.Context) { // Start auth daemon if enabled if !disableSSH { if err := startAuthDaemon(ctx); err != nil { - logger.Fatal("Failed to start auth daemon: %v", err) + logger.Warn("Did not start on site auth daemon: %v", err) } } diff --git a/nativessh/auth.go b/nativessh/auth.go index a060ef7..2de7a21 100644 --- a/nativessh/auth.go +++ b/nativessh/auth.go @@ -3,6 +3,7 @@ package nativessh import ( "bufio" "fmt" + "log" "os" "os/user" "path/filepath" @@ -58,19 +59,31 @@ func SystemUserExists(username string) bool { // // Returns nil on the first method that succeeds, or an error if all fail. func Authenticate(username, password, privateKeyPEM string) error { + log.Printf("nativessh: authenticating user %q (hasPassword=%v, hasPrivateKey=%v)", username, password != "", privateKeyPEM != "") if !SystemUserExists(username) { + log.Printf("nativessh: user %q not found on system", username) return fmt.Errorf("user %q does not exist", username) } if privateKeyPEM != "" { signer, err := ssh.ParsePrivateKey([]byte(privateKeyPEM)) - if err == nil && CheckAuthorizedKeys(username, signer.PublicKey()) { + if err != nil { + log.Printf("nativessh: failed to parse private key for %q: %v", username, err) + } else if CheckAuthorizedKeys(username, signer.PublicKey()) { + log.Printf("nativessh: private key auth succeeded for %q", username) return nil + } else { + log.Printf("nativessh: private key not in authorized_keys for %q", username) } } if password != "" { - if err := VerifySystemPassword(username, password); err == nil { + if err := VerifySystemPassword(username, password); err != nil { + log.Printf("nativessh: password auth failed for %q: %v", username, err) + } else { + log.Printf("nativessh: password auth succeeded for %q", username) return nil } + } else { + log.Printf("nativessh: no password provided for %q", username) } return fmt.Errorf("authentication failed for user %q", username) } diff --git a/nativessh/pam_linux.go b/nativessh/pam_linux.go index 53e8506..f33828b 100644 --- a/nativessh/pam_linux.go +++ b/nativessh/pam_linux.go @@ -5,25 +5,71 @@ package nativessh import ( "bufio" "bytes" - "crypto/sha512" "errors" "fmt" + "log" "os" - "strconv" "strings" - "golang.org/x/crypto/bcrypt" + "github.com/go-crypt/crypt" + "github.com/go-crypt/x/yescrypt" ) -// VerifySystemPassword authenticates username/password by reading /etc/shadow -// and verifying the stored hash using pure-Go cryptography (no CGo required). -// Supported hash schemes: bcrypt ($2a$/$2b$/$2y$) and SHA-512 crypt ($6$). +// VerifySystemPassword authenticates username/password by reading /etc/shadow. +// Supports yescrypt ($y$), bcrypt ($2b$/$2a$/$2y$), SHA-512 ($6$), SHA-256 +// ($5$), argon2, scrypt, and other schemes handled by go-crypt/crypt. func VerifySystemPassword(username, password string) error { hash, err := readShadowHash(username) if err != nil { + log.Printf("nativessh/pam: readShadowHash for %q failed: %v", username, err) return fmt.Errorf("shadow: %w", err) } - return cryptVerify(password, hash) + + // Log the scheme prefix only (never the full hash). + scheme := "unknown" + for _, prefix := range []string{"$y$", "$2a$", "$2b$", "$2y$", "$6$", "$5$", "$1$"} { + if strings.HasPrefix(hash, prefix) { + scheme = prefix + break + } + } + log.Printf("nativessh/pam: verifying password for %q using scheme %s", username, scheme) + + // Yescrypt ($y$) is not in go-crypt/crypt's default decoder; handle it directly. + if strings.HasPrefix(hash, "$y$") { + computed, err := yescrypt.Hash([]byte(password), []byte(hash)) + if err != nil { + log.Printf("nativessh/pam: yescrypt.Hash for %q failed: %v", username, err) + return fmt.Errorf("yescrypt: %w", err) + } + if !bytes.Equal(computed, []byte(hash)) { + log.Printf("nativessh/pam: yescrypt mismatch for %q", username) + return errors.New("authentication failed") + } + return nil + } + + decoder, err := crypt.NewDefaultDecoder() + if err != nil { + return fmt.Errorf("crypt decoder: %w", err) + } + + digest, err := decoder.Decode(hash) + if err != nil { + log.Printf("nativessh/pam: failed to decode hash for %q: %v", username, err) + return fmt.Errorf("unsupported password hash scheme %q: %w", scheme, err) + } + + match, err := digest.MatchAdvanced(password) + if err != nil { + log.Printf("nativessh/pam: MatchAdvanced for %q failed: %v", username, err) + return err + } + if !match { + log.Printf("nativessh/pam: password mismatch for %q", username) + return errors.New("authentication failed") + } + return nil } // readShadowHash reads /etc/shadow and returns the password hash for username. @@ -51,223 +97,3 @@ func readShadowHash(username string) (string, error) { } return "", errors.New("user not found in shadow database") } - -// cryptVerify verifies password against a crypt(3) hash string. -func cryptVerify(password, hash string) error { - switch { - case strings.HasPrefix(hash, "$2a$"), strings.HasPrefix(hash, "$2b$"), strings.HasPrefix(hash, "$2y$"): - return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) - case strings.HasPrefix(hash, "$6$"): - computed, err := sha512CryptHash([]byte(password), hash) - if err != nil { - return err - } - if computed != hash { - return errors.New("authentication failed") - } - return nil - default: - return fmt.Errorf("unsupported password hash scheme") - } -} - -// --- SHA-512 crypt ($6$) --- -// Specification: https://www.akkadia.org/docs/sha-crypt.txt - -const ( - sha512CryptMagic = "$6$" - sha512CryptRoundsDefault = 5000 - sha512CryptRoundsMin = 1000 - sha512CryptRoundsMax = 999999999 - sha512CryptSaltLenMax = 16 - sha512CryptAlphabet = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" -) - -var sha512CryptRoundsPrefix = []byte("rounds=") - -// sha512CryptHash computes a SHA-512 crypt hash for key. saltStr may be a -// full stored hash string (for verification) or just the salt parameters. -func sha512CryptHash(key []byte, saltStr string) (string, error) { - salt := []byte(saltStr) - - if !bytes.HasPrefix(salt, []byte(sha512CryptMagic)) { - return "", errors.New("sha512crypt: invalid prefix") - } - salt = salt[len(sha512CryptMagic):] - - rounds := sha512CryptRoundsDefault - isRoundsDef := false - - if bytes.HasPrefix(salt, sha512CryptRoundsPrefix) { - salt = salt[len(sha512CryptRoundsPrefix):] - i := bytes.IndexByte(salt, '$') - if i < 0 { - return "", errors.New("sha512crypt: malformed rounds field") - } - r, err := strconv.Atoi(string(salt[:i])) - if err != nil { - return "", fmt.Errorf("sha512crypt: invalid rounds: %w", err) - } - salt = salt[i+1:] - isRoundsDef = true - rounds = r - if rounds < sha512CryptRoundsMin { - rounds = sha512CryptRoundsMin - } else if rounds > sha512CryptRoundsMax { - rounds = sha512CryptRoundsMax - } - } - - // When saltStr is a full hash, strip the stored hash after the last '$'. - if i := bytes.IndexByte(salt, '$'); i >= 0 { - salt = salt[:i] - } - if len(salt) > sha512CryptSaltLenMax { - salt = salt[:sha512CryptSaltLenMax] - } - - // Alternate = SHA512(key + salt + key) - altH := sha512.New() - altH.Write(key) - altH.Write(salt) - altH.Write(key) - altSum := altH.Sum(nil) - - // Digest A = SHA512(key + salt + altSum-cycling-to-len(key) + bit-pattern) - aH := sha512.New() - aH.Write(key) - aH.Write(salt) - for i := len(key); i > 0; i -= 64 { - if i > 64 { - aH.Write(altSum) - } else { - aH.Write(altSum[:i]) - } - } - for i := len(key); i > 0; i >>= 1 { - if i&1 != 0 { - aH.Write(altSum) - } else { - aH.Write(key) - } - } - aSum := aH.Sum(nil) - - // P-sequence: SHA512(key×len(key)) cycled to len(key) bytes - pH := sha512.New() - for i := 0; i < len(key); i++ { - pH.Write(key) - } - pSeq := sha512CryptCycle(pH.Sum(nil), len(key)) - - // S-sequence: SHA512(salt×(16+aSum[0])) cycled to len(salt) bytes - sH := sha512.New() - for i := 0; i < 16+int(aSum[0]); i++ { - sH.Write(salt) - } - sSeq := sha512CryptCycle(sH.Sum(nil), len(salt)) - - // Iterative hashing rounds - cSum := aSum - for i := 0; i < rounds; i++ { - c := sha512.New() - if i&1 != 0 { - c.Write(pSeq) - } else { - c.Write(cSum) - } - if i%3 != 0 { - c.Write(sSeq) - } - if i%7 != 0 { - c.Write(pSeq) - } - if i&1 != 0 { - c.Write(cSum) - } else { - c.Write(pSeq) - } - cSum = c.Sum(nil) - } - - // Build the output string - out := []byte(sha512CryptMagic) - if isRoundsDef { - out = append(out, fmt.Sprintf("rounds=%d$", rounds)...) - } - out = append(out, salt...) - out = append(out, '$') - out = append(out, sha512CryptEncode(cSum)...) - return string(out), nil -} - -// sha512CryptCycle returns exactly n bytes by cycling the 64-byte src slice. -func sha512CryptCycle(src []byte, n int) []byte { - dst := make([]byte, 0, n) - for i := n; i > 64; i -= 64 { - dst = append(dst, src...) - } - if rem := n % 64; rem == 0 && n > 0 { - dst = append(dst, src...) - } else if rem > 0 { - dst = append(dst, src[:rem]...) - } - return dst -} - -// sha512CryptEncode applies the sha512crypt byte permutation and encodes the -// result with the crypt(3) base-64 alphabet (86 output characters for 64 input bytes). -func sha512CryptEncode(sum []byte) []byte { - perm := []byte{ - sum[42], sum[21], sum[0], - sum[1], sum[43], sum[22], - sum[23], sum[2], sum[44], - sum[45], sum[24], sum[3], - sum[4], sum[46], sum[25], - sum[26], sum[5], sum[47], - sum[48], sum[27], sum[6], - sum[7], sum[49], sum[28], - sum[29], sum[8], sum[50], - sum[51], sum[30], sum[9], - sum[10], sum[52], sum[31], - sum[32], sum[11], sum[53], - sum[54], sum[33], sum[12], - sum[13], sum[55], sum[34], - sum[35], sum[14], sum[56], - sum[57], sum[36], sum[15], - sum[16], sum[58], sum[37], - sum[38], sum[17], sum[59], - sum[60], sum[39], sum[18], - sum[19], sum[61], sum[40], - sum[41], sum[20], sum[62], - sum[63], - } - src := perm - out := make([]byte, 0, 86) - for len(src) > 0 { - switch len(src) { - default: - out = append(out, - sha512CryptAlphabet[src[0]&0x3f], - sha512CryptAlphabet[((src[0]>>6)|(src[1]<<2))&0x3f], - sha512CryptAlphabet[((src[1]>>4)|(src[2]<<4))&0x3f], - sha512CryptAlphabet[(src[2]>>2)&0x3f], - ) - src = src[3:] - case 2: - out = append(out, - sha512CryptAlphabet[src[0]&0x3f], - sha512CryptAlphabet[((src[0]>>6)|(src[1]<<2))&0x3f], - sha512CryptAlphabet[(src[1]>>4)&0x3f], - ) - src = src[2:] - case 1: - out = append(out, - sha512CryptAlphabet[src[0]&0x3f], - sha512CryptAlphabet[(src[0]>>6)&0x3f], - ) - src = src[1:] - } - } - return out -} diff --git a/newt.REMOVED.git-id b/newt.REMOVED.git-id new file mode 100644 index 0000000..2233bac --- /dev/null +++ b/newt.REMOVED.git-id @@ -0,0 +1 @@ +34062da7bd1b98e4a7953d2f8b43860d2222add0 \ No newline at end of file From 8191f30e77b15353a43d2f32b09e4694f6744b42 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 31 May 2026 16:07:33 -0700 Subject: [PATCH 147/161] Make usernames work with native auth Former-commit-id: 55eabb16cb567d4da15c036a5061e3c1a24aed5d --- nativessh/server.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nativessh/server.go b/nativessh/server.go index e6109b1..c2ff57b 100644 --- a/nativessh/server.go +++ b/nativessh/server.go @@ -159,13 +159,13 @@ func (s *Server) handleConn(conn net.Conn, cfg *ssh.ServerConfig) { log.Printf("nativessh: channel accept error: %v", err) return } - go s.handleSession(ch, requests) + go s.handleSession(ch, requests, sshConn.User()) } } // handleSession drives a single SSH session channel. It waits for a pty-req // followed by a shell request and then bridges the PTY to the channel. -func (s *Server) handleSession(ch ssh.Channel, requests <-chan *ssh.Request) { +func (s *Server) handleSession(ch ssh.Channel, requests <-chan *ssh.Request, username string) { defer ch.Close() var ( @@ -178,7 +178,7 @@ func (s *Server) handleSession(ch ssh.Channel, requests <-chan *ssh.Request) { case "pty-req": var err error if sess == nil { - sess, err = NewPTYSession() + sess, err = NewPTYSessionAs(username) if err != nil { log.Printf("nativessh: PTY start error: %v", err) if req.WantReply { From e77b1d93636337acf829481c1458cc45dddbffbf Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 31 May 2026 16:24:57 -0700 Subject: [PATCH 148/161] Fall back gracefully when home dir does not exist Former-commit-id: 07bf861179c6c168fe5ef81f750dd8f217be817b --- nativessh/pty.go | 39 +++++++++++++++++++++++++++++++++++++++ nativessh/pty_unix.go | 13 +++++++++++-- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/nativessh/pty.go b/nativessh/pty.go index cb5fa88..3d20e35 100644 --- a/nativessh/pty.go +++ b/nativessh/pty.go @@ -1,10 +1,13 @@ package nativessh import ( + "bufio" "errors" "fmt" "os" "os/exec" + "os/user" + "strings" "sync" "github.com/creack/pty" @@ -31,6 +34,42 @@ func findShell() string { return "/bin/sh" } +// userShell returns the login shell configured for u in /etc/passwd. +// If the field is empty or the binary does not exist, it falls back to +// findShell so there is always a usable shell. +func userShell(u *user.User) string { + if shell := passwdShell(u.Username); shell != "" { + if _, err := exec.LookPath(shell); err == nil { + return shell + } + } + return findShell() +} + +// passwdShell reads /etc/passwd and returns the login shell for the named user. +// Returns "" if the user is not found or the file cannot be read. +func passwdShell(username string) string { + f, err := os.Open("/etc/passwd") + if err != nil { + return "" + } + defer f.Close() + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if line == "" || line[0] == '#' { + continue + } + // Fields: username:password:uid:gid:gecos:home:shell + fields := strings.SplitN(line, ":", 7) + if len(fields) == 7 && fields[0] == username { + return fields[6] + } + } + _ = scanner.Err() + return "" +} + // NewPTYSession spawns the best available shell in a PTY. func NewPTYSession() (*PTYSession, error) { shell := findShell() diff --git a/nativessh/pty_unix.go b/nativessh/pty_unix.go index 7e89c15..1203535 100644 --- a/nativessh/pty_unix.go +++ b/nativessh/pty_unix.go @@ -4,6 +4,7 @@ package nativessh import ( "fmt" + "os" "os/exec" "os/user" "strconv" @@ -42,7 +43,15 @@ func NewPTYSessionAs(username string) (*PTYSession, error) { } } - shell := findShell() + shell := userShell(u) + + // Prefer the user's home directory as the working directory, but fall back + // to / if it does not exist (e.g. useradd was run without -m). + homeDir := u.HomeDir + if _, err := os.Stat(homeDir); err != nil { + homeDir = "/" + } + cmd := exec.Command(shell, "--login") cmd.Env = []string{ "TERM=xterm-256color", @@ -52,7 +61,7 @@ func NewPTYSessionAs(username string) (*PTYSession, error) { "SHELL=" + shell, "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", } - cmd.Dir = u.HomeDir + cmd.Dir = homeDir cmd.SysProcAttr = &syscall.SysProcAttr{ Credential: &syscall.Credential{ Uid: uint32(uid), From e5bf31d118b0b855881d3fb056a7ef41328ad2b0 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 31 May 2026 20:59:50 -0700 Subject: [PATCH 149/161] Cert auth working with mode push Former-commit-id: b0d894f05ae5003e9ad64496d4d2011b433b5569 --- nativessh/server.go | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/nativessh/server.go b/nativessh/server.go index c2ff57b..0ccd7c3 100644 --- a/nativessh/server.go +++ b/nativessh/server.go @@ -21,6 +21,14 @@ type CredentialStore struct { principals map[string]map[string]struct{} // username -> set of allowed principals } +// connMetaWithUser wraps ConnMetadata while overriding User() for cert checks. +type connMetaWithUser struct { + ssh.ConnMetadata + user string +} + +func (m connMetaWithUser) User() string { return m.user } + // NewCredentialStore returns an empty, ready-to-use CredentialStore. func NewCredentialStore() *CredentialStore { return &CredentialStore{ @@ -267,13 +275,23 @@ func makePublicKeyCallback(store *CredentialStore) func(ssh.ConnMetadata, ssh.Pu return ssh.FingerprintSHA256(auth) == ssh.FingerprintSHA256(caKey) }, } - perms, err := checker.Authenticate(meta, key) - if err == nil { - if len(userPrincipals) == 0 { - return nil, fmt.Errorf("user %q not in allowed principals list", meta.User()) + + if len(userPrincipals) == 0 { + return nil, fmt.Errorf("user %q not in allowed principals list", meta.User()) + } + + var lastErr error + for principal := range userPrincipals { + perms, err := checker.Authenticate(connMetaWithUser{ConnMetadata: meta, user: principal}, key) + if err == nil { + log.Printf("nativessh: CA cert auth for user %q principal=%q", meta.User(), principal) + return perms, nil } - log.Printf("nativessh: CA cert auth for user %q", meta.User()) - return perms, nil + lastErr = err + } + + if lastErr != nil { + log.Printf("nativessh: CA cert rejected for user %q: %v", meta.User(), lastErr) } } } From a8d91756ca5b649031369ae75999f6d0b3c57a94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Sch=C3=A4fer?= Date: Thu, 4 Jun 2026 12:53:53 +0200 Subject: [PATCH 150/161] fix(deps): update golang.org/x modules for security fixes Update golang.org/x/crypto, golang.org/x/net and golang.org/x/sys to versions containing security fixes reported by Docker Scout. Former-commit-id: f91deeede7b07233d3ddab8e4b026baa6ded4691 --- go.mod | 8 ++++---- go.sum | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/go.mod b/go.mod index e6d39d7..a89c0f7 100644 --- a/go.mod +++ b/go.mod @@ -18,10 +18,10 @@ require ( go.opentelemetry.io/otel/metric v1.43.0 go.opentelemetry.io/otel/sdk v1.43.0 go.opentelemetry.io/otel/sdk/metric v1.43.0 - golang.org/x/crypto v0.50.0 + golang.org/x/crypto v0.52.0 golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 - golang.org/x/net v0.53.0 - golang.org/x/sys v0.43.0 + golang.org/x/net v0.55.0 + golang.org/x/sys v0.45.0 golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 golang.zx2c4.com/wireguard/windows v0.5.3 @@ -61,7 +61,7 @@ require ( go.opentelemetry.io/otel/trace v1.43.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect - golang.org/x/text v0.36.0 // indirect + golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.12.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect diff --git a/go.sum b/go.sum index 102924e..a559bf4 100644 --- a/go.sum +++ b/go.sum @@ -109,18 +109,18 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0= golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= From fde46cb58758b63aba5add4d5bbb75103edcdcaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Sch=C3=A4fer?= Date: Thu, 4 Jun 2026 14:28:28 +0200 Subject: [PATCH 151/161] fix(nix): update Go module vendor hash Former-commit-id: ac9852a3cbadac38c53c4c35137c6690a5d79766 --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 78d0291..daf8e0e 100644 --- a/flake.nix +++ b/flake.nix @@ -35,7 +35,7 @@ inherit version; src = pkgs.nix-gitignore.gitignoreSource [ ] ./.; - vendorHash = "sha256-WfIK+Q8WQ372NzLw6DRapv1nYPduShi4KnVJBPk0Oz0="; + vendorHash = "sha256-tTj1ffyZDLLVbcYajGj3uCpAESpW4hCk9vEB0K/g4Ic="; nativeInstallCheckInputs = [ pkgs.versionCheckHook ]; From b2c73a40dfcfab1d4cf142537798d4e41fa683fd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:21:44 +0000 Subject: [PATCH 152/161] chore(deps): bump the prod-patch-updates group across 1 directory with 2 updates Bumps the prod-patch-updates group with 2 updates in the / directory: [google.golang.org/grpc](https://github.com/grpc/grpc-go) and software.sslmate.com/src/go-pkcs12. Updates `google.golang.org/grpc` from 1.81.0 to 1.81.1 - [Release notes](https://github.com/grpc/grpc-go/releases) - [Commits](https://github.com/grpc/grpc-go/compare/v1.81.0...v1.81.1) Updates `software.sslmate.com/src/go-pkcs12` from 0.7.0 to 0.7.1 --- updated-dependencies: - dependency-name: google.golang.org/grpc dependency-version: 1.81.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates - dependency-name: software.sslmate.com/src/go-pkcs12 dependency-version: 0.7.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates ... Signed-off-by: dependabot[bot] Former-commit-id: 69ed08222fcb0d65ef96e136cbbc6ae7ee50605d --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index a89c0f7..fb340d7 100644 --- a/go.mod +++ b/go.mod @@ -25,10 +25,10 @@ require ( golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 golang.zx2c4.com/wireguard/windows v0.5.3 - google.golang.org/grpc v1.81.0 + google.golang.org/grpc v1.81.1 gopkg.in/yaml.v3 v3.0.1 gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c - software.sslmate.com/src/go-pkcs12 v0.7.0 + software.sslmate.com/src/go-pkcs12 v0.7.1 ) require ( diff --git a/go.sum b/go.sum index a559bf4..f946551 100644 --- a/go.sum +++ b/go.sum @@ -137,8 +137,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1: google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw= -google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -152,5 +152,5 @@ gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgI gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= -software.sslmate.com/src/go-pkcs12 v0.7.0 h1:Db8W44cB54TWD7stUFFSWxdfpdn6fZVcDl0w3R4RVM0= -software.sslmate.com/src/go-pkcs12 v0.7.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= +software.sslmate.com/src/go-pkcs12 v0.7.1 h1:bxkUPRsvTPNRBZa4M/aSX4PyMOEbq3V8I6hbkG4F4Q8= +software.sslmate.com/src/go-pkcs12 v0.7.1/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= From a868719459697c7cb75f265e1d2e1804561e9509 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" Date: Thu, 4 Jun 2026 17:22:48 +0000 Subject: [PATCH 153/161] chore(nix): fix hash for updated go dependencies Former-commit-id: 88c1c2e0b50ef411080dbba6aef7de8640ab2adc --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index daf8e0e..f94746c 100644 --- a/flake.nix +++ b/flake.nix @@ -35,7 +35,7 @@ inherit version; src = pkgs.nix-gitignore.gitignoreSource [ ] ./.; - vendorHash = "sha256-tTj1ffyZDLLVbcYajGj3uCpAESpW4hCk9vEB0K/g4Ic="; + vendorHash = "sha256-DJ3sEKzMUQB5qMXGfiAfVz1w4NQnMHf6S321nnKe43Y="; nativeInstallCheckInputs = [ pkgs.versionCheckHook ]; From acb21b12030924eacd452cd55cfc127ec213284e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:53:25 +0000 Subject: [PATCH 154/161] chore(deps): bump golang.zx2c4.com/wireguard/windows from 0.5.3 to 1.0.1 Bumps golang.zx2c4.com/wireguard/windows from 0.5.3 to 1.0.1. --- updated-dependencies: - dependency-name: golang.zx2c4.com/wireguard/windows dependency-version: 1.0.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Former-commit-id: d1f4b5bc80db17b10deb0ccedd4244d174be5de5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index fb340d7..60b2447 100644 --- a/go.mod +++ b/go.mod @@ -24,7 +24,7 @@ require ( golang.org/x/sys v0.45.0 golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 - golang.zx2c4.com/wireguard/windows v0.5.3 + golang.zx2c4.com/wireguard/windows v1.0.1 google.golang.org/grpc v1.81.1 gopkg.in/yaml.v3 v3.0.1 gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c diff --git a/go.sum b/go.sum index f946551..1ce4da1 100644 --- a/go.sum +++ b/go.sum @@ -129,8 +129,8 @@ golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+Z golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU= golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ= -golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= -golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= +golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH8ysFIbw8= +golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= From da794460e542091699328f1fdf77fe76aa6fb9ea Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:53:31 +0000 Subject: [PATCH 155/161] chore(deps): bump docker/setup-buildx-action from 4.0.0 to 4.1.0 Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 4.0.0 to 4.1.0. - [Release notes](https://github.com/docker/setup-buildx-action/releases) - [Commits](https://github.com/docker/setup-buildx-action/compare/4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd...d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5) --- updated-dependencies: - dependency-name: docker/setup-buildx-action dependency-version: 4.1.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Former-commit-id: bb094ce3fc6bd9de974617f4ab608ce1b55d2032 --- .github/workflows/cicd.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 746f1b1..67e4dd3 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -240,7 +240,7 @@ jobs: # uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 #- name: Set up Docker Buildx - # uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + # uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - name: Log in to Docker Hub uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 @@ -264,7 +264,7 @@ jobs: echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 # Build ONLY amd64 and push arch-specific tag suffixes used later for manifest creation. - name: Build and push (amd64 -> *:amd64-TAG) @@ -389,7 +389,7 @@ jobs: echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 # Build ONLY arm64 and push arch-specific tag suffixes used later for manifest creation. - name: Build and push (arm64 -> *:arm64-TAG) @@ -507,7 +507,7 @@ jobs: uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - name: Build and push (arm/v7 -> *:armv7-TAG) id: build_armv7 @@ -577,7 +577,7 @@ jobs: echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV" - name: Set up Docker Buildx (needed for imagetools) - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - name: Create & push multi-arch index (GHCR :TAG) via imagetools shell: bash @@ -692,7 +692,7 @@ jobs: sudo apt-get install -y jq - name: Set up Docker Buildx (needed for imagetools) - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - name: Resolve multi-arch digest refs (by TAG) shell: bash From 9f014b19f8e931a3ee31b65109d14b91dd41707a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:53:38 +0000 Subject: [PATCH 156/161] chore(deps): bump actions/stale from 10.2.0 to 10.3.0 Bumps [actions/stale](https://github.com/actions/stale) from 10.2.0 to 10.3.0. - [Release notes](https://github.com/actions/stale/releases) - [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/stale/compare/b5d41d4e1d5dceea10e7104786b73624c18a190f...eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899) --- updated-dependencies: - dependency-name: actions/stale dependency-version: 10.3.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Former-commit-id: eca54190b0214253111820c28fdcf6193f960b4b --- .github/workflows/stale-bot.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale-bot.yml b/.github/workflows/stale-bot.yml index 2db8632..e7a03a9 100644 --- a/.github/workflows/stale-bot.yml +++ b/.github/workflows/stale-bot.yml @@ -14,7 +14,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 + - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 with: days-before-stale: 14 days-before-close: 14 From 5fb1ba5b356af61199313c3e12ddb689ec88efcb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:53:40 +0000 Subject: [PATCH 157/161] chore(deps): bump docker/build-push-action from 7.1.0 to 7.2.0 Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 7.1.0 to 7.2.0. - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](https://github.com/docker/build-push-action/compare/bcafcacb16a39f128d818304e6c9c0c18556b85f...f9f3042f7e2789586610d6e8b85c8f03e5195baf) --- updated-dependencies: - dependency-name: docker/build-push-action dependency-version: 7.2.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Former-commit-id: efc17644d1867a84b2d69bbad0e006d23c2e9b2b --- .github/workflows/cicd.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 746f1b1..352c6f7 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -269,7 +269,7 @@ jobs: # Build ONLY amd64 and push arch-specific tag suffixes used later for manifest creation. - name: Build and push (amd64 -> *:amd64-TAG) id: build_amd - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . push: true @@ -394,7 +394,7 @@ jobs: # Build ONLY arm64 and push arch-specific tag suffixes used later for manifest creation. - name: Build and push (arm64 -> *:arm64-TAG) id: build_arm - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . push: true @@ -511,7 +511,7 @@ jobs: - name: Build and push (arm/v7 -> *:armv7-TAG) id: build_armv7 - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . push: true From ac0079b7e254d6413c9d1ff7ecd93ab042ba717c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" Date: Thu, 4 Jun 2026 17:54:30 +0000 Subject: [PATCH 158/161] chore(nix): fix hash for updated go dependencies Former-commit-id: b6f00a9500afbb518a3989c95348eb9fa217dea4 --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index f94746c..3657fc3 100644 --- a/flake.nix +++ b/flake.nix @@ -35,7 +35,7 @@ inherit version; src = pkgs.nix-gitignore.gitignoreSource [ ] ./.; - vendorHash = "sha256-DJ3sEKzMUQB5qMXGfiAfVz1w4NQnMHf6S321nnKe43Y="; + vendorHash = "sha256-M3MjtU4t0iGskNZhAdN3RKny8TOZbiuljK4HThShfXs="; nativeInstallCheckInputs = [ pkgs.versionCheckHook ]; From 8eddb908e07dfcbaf7a0ba196652f63d67d49578 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 4 Jun 2026 11:28:20 -0700 Subject: [PATCH 159/161] Handle browser gateway push pam auth Former-commit-id: 1ba9a419cb8cc0f6c3033aa81c38026846b9a519 --- browsergateway/browsergateway.go | 7 +++ browsergateway/ssh.go | 3 +- browsergateway/ssh_native.go | 4 +- main.go | 10 ++--- nativessh/auth.go | 73 +++++++++++++++++++++++++++++++- netstack2/proxy.go | 4 +- proxy/manager.go | 4 +- 7 files changed, 91 insertions(+), 14 deletions(-) diff --git a/browsergateway/browsergateway.go b/browsergateway/browsergateway.go index 60fecb5..df54b29 100644 --- a/browsergateway/browsergateway.go +++ b/browsergateway/browsergateway.go @@ -7,6 +7,8 @@ import ( "net/http" "strings" "sync" + + "github.com/fosrl/newt/nativessh" ) // Forwarding buffer size. RDP graphics traffic is bursty and TLS records cap @@ -36,6 +38,9 @@ type Config struct { // to match against). For all proxy targets (RDP/SSH/VNC), auth tokens are // stored per-Target and validated by isAllowed. AuthToken string + // SSHCredentials are used by native SSH browser sessions for certificate + // validation against the in-memory CA/principal store. + SSHCredentials *nativessh.CredentialStore } // Gateway is a browser-based RDP/SSH/VNC WebSocket proxy. @@ -43,6 +48,7 @@ type Config struct { // HandleRDP / HandleSSH / HandleVNC http.HandlerFunc methods. type Gateway struct { authToken string + sshCreds *nativessh.CredentialStore mu sync.RWMutex targets map[int]Target // keyed by Target.ID @@ -54,6 +60,7 @@ type Gateway struct { func New(cfg Config) *Gateway { return &Gateway{ authToken: cfg.AuthToken, + sshCreds: cfg.SSHCredentials, targets: make(map[int]Target), } } diff --git a/browsergateway/ssh.go b/browsergateway/ssh.go index bf5a83a..06ca9a0 100644 --- a/browsergateway/ssh.go +++ b/browsergateway/ssh.go @@ -21,6 +21,7 @@ type sshClientMsg struct { Type string `json:"type"` Password string `json:"password,omitempty"` // used when type="auth" PrivateKey string `json:"privateKey,omitempty"` // used when type="auth" + Certificate string `json:"certificate,omitempty"` // used when type="auth" Data string `json:"data,omitempty"` // used when type="data" Cols uint32 `json:"cols,omitempty"` // used when type="resize" Rows uint32 `json:"rows,omitempty"` // used when type="resize" @@ -89,7 +90,7 @@ func (g *Gateway) HandleSSH(w http.ResponseWriter, r *http.Request) { defer ws.CloseNow() //nolint:errcheck if nativeSSH { - if err := serveNativeSSHSession(ctx, ws, username); err != nil { + if err := serveNativeSSHSession(ctx, ws, username, g.sshCreds); err != nil { log.Printf("SSH native session error: %v", err) } } else { diff --git a/browsergateway/ssh_native.go b/browsergateway/ssh_native.go index d81e762..992f875 100644 --- a/browsergateway/ssh_native.go +++ b/browsergateway/ssh_native.go @@ -17,7 +17,7 @@ import ( // The auth frame from the browser must be a JSON sshClientMsg with type="auth" // carrying the same password/privateKey fields used by the proxy SSH path. // The target username is passed in from the HTTP layer (query param). -func serveNativeSSHSession(ctx context.Context, ws *websocket.Conn, username string) error { +func serveNativeSSHSession(ctx context.Context, ws *websocket.Conn, username string, creds *nativessh.CredentialStore) error { // Read the auth frame. _, authBytes, err := ws.Read(ctx) if err != nil { @@ -29,7 +29,7 @@ func serveNativeSSHSession(ctx context.Context, ws *websocket.Conn, username str } // Authenticate using host authorized_keys or PAM password. - if err := nativessh.Authenticate(username, authMsg.Password, authMsg.PrivateKey); err != nil { + if err := nativessh.AuthenticateWithCertificate(creds, username, authMsg.Password, authMsg.PrivateKey, authMsg.Certificate); err != nil { sendSSHError(ctx, ws, "Authentication failed") return fmt.Errorf("auth for user %q: %w", username, err) } diff --git a/main.go b/main.go index 25d4102..cf4cf7f 100644 --- a/main.go +++ b/main.go @@ -1044,7 +1044,7 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( }) } - browserGateway = browsergateway.New(browsergateway.Config{}) + browserGateway = browsergateway.New(browsergateway.Config{SSHCredentials: sshCredStore}) browserGateway.SetTargets(bgTargets) ln, bgErr := tnet.ListenTCP(&net.TCPAddr{Port: browsergateway.ListenPort}) @@ -2024,7 +2024,7 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( // If the gateway doesn't exist yet but we have a tunnel, start it if browserGateway == nil && tnet != nil { - browserGateway = browsergateway.New(browsergateway.Config{}) + browserGateway = browsergateway.New(browsergateway.Config{SSHCredentials: sshCredStore}) ln, bgErr := tnet.ListenTCP(&net.TCPAddr{Port: browsergateway.ListenPort}) if bgErr != nil { logger.Error("Failed to start browser gateway listener: %v", bgErr) @@ -2198,16 +2198,16 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( if newCfg.Blocked != connectionBlocked.Load() { connectionBlocked.Store(newCfg.Blocked) if newCfg.Blocked { - logger.Info("Config reload: connection blocking enabled") + logger.Debug("Config reload: connection blocking enabled") } else { - logger.Info("Config reload: connection blocking disabled") + logger.Debug("Config reload: connection blocking disabled") } if p := currentPM.Load(); p != nil { p.SetBlocked(newCfg.Blocked) } setClientsBlocked(newCfg.Blocked) } else { - logger.Info("Config reload: no relevant changes detected") + logger.Debug("Config reload: no relevant changes detected") } case <-ctx.Done(): return diff --git a/nativessh/auth.go b/nativessh/auth.go index 2de7a21..9f9289b 100644 --- a/nativessh/auth.go +++ b/nativessh/auth.go @@ -4,6 +4,7 @@ import ( "bufio" "fmt" "log" + "net" "os" "os/user" "path/filepath" @@ -12,6 +13,17 @@ import ( "golang.org/x/crypto/ssh" ) +type staticConnMeta struct { + user string +} + +func (m staticConnMeta) User() string { return m.user } +func (m staticConnMeta) SessionID() []byte { return nil } +func (m staticConnMeta) ClientVersion() []byte { return nil } +func (m staticConnMeta) ServerVersion() []byte { return nil } +func (m staticConnMeta) RemoteAddr() net.Addr { return nil } +func (m staticConnMeta) LocalAddr() net.Addr { return nil } + // CheckAuthorizedKeys reports whether key matches any entry in the system // user's ~/.ssh/authorized_keys file. Returns false (not an error) when the // user or file does not exist. @@ -59,22 +71,79 @@ func SystemUserExists(username string) bool { // // Returns nil on the first method that succeeds, or an error if all fail. func Authenticate(username, password, privateKeyPEM string) error { + return AuthenticateWithCertificate(nil, username, password, privateKeyPEM, "") +} + +// AuthenticateWithCertificate authenticates a user for a browser-based native +// SSH session using the same method ordering as the native SSH server: +// 1. Private key in host ~/.ssh/authorized_keys. +// 2. SSH certificate signed by the configured CA (when provided). +// 3. Password via host PAM stack. +func AuthenticateWithCertificate(store *CredentialStore, username, password, privateKeyPEM, certificate string) error { log.Printf("nativessh: authenticating user %q (hasPassword=%v, hasPrivateKey=%v)", username, password != "", privateKeyPEM != "") if !SystemUserExists(username) { log.Printf("nativessh: user %q not found on system", username) return fmt.Errorf("user %q does not exist", username) } + + var signer ssh.Signer if privateKeyPEM != "" { - signer, err := ssh.ParsePrivateKey([]byte(privateKeyPEM)) + parsedSigner, err := ssh.ParsePrivateKey([]byte(privateKeyPEM)) if err != nil { log.Printf("nativessh: failed to parse private key for %q: %v", username, err) - } else if CheckAuthorizedKeys(username, signer.PublicKey()) { + } else if CheckAuthorizedKeys(username, parsedSigner.PublicKey()) { log.Printf("nativessh: private key auth succeeded for %q", username) return nil } else { + signer = parsedSigner log.Printf("nativessh: private key not in authorized_keys for %q", username) } } + + if store != nil && certificate != "" { + if signer == nil { + log.Printf("nativessh: certificate provided for %q but no matching private key was provided", username) + } else { + pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(certificate)) + if err != nil { + log.Printf("nativessh: failed to parse certificate for %q: %v", username, err) + } else { + cert, ok := pub.(*ssh.Certificate) + if !ok { + log.Printf("nativessh: provided cert data for %q is not an SSH certificate", username) + } else if ssh.FingerprintSHA256(cert.Key) != ssh.FingerprintSHA256(signer.PublicKey()) { + log.Printf("nativessh: certificate key mismatch for %q", username) + } else { + caKey, userPrincipals := store.get(username) + if caKey == nil { + log.Printf("nativessh: CA key is not set for certificate auth user %q", username) + } else if len(userPrincipals) == 0 { + log.Printf("nativessh: no allowed principals found for certificate auth user %q", username) + } else { + checker := &ssh.CertChecker{ + IsUserAuthority: func(auth ssh.PublicKey) bool { + return ssh.FingerprintSHA256(auth) == ssh.FingerprintSHA256(caKey) + }, + } + + var lastErr error + for principal := range userPrincipals { + _, authErr := checker.Authenticate(staticConnMeta{user: principal}, cert) + if authErr == nil { + log.Printf("nativessh: certificate auth succeeded for %q (principal=%q)", username, principal) + return nil + } + lastErr = authErr + } + if lastErr != nil { + log.Printf("nativessh: certificate auth failed for %q: %v", username, lastErr) + } + } + } + } + } + } + if password != "" { if err := VerifySystemPassword(username, password); err != nil { log.Printf("nativessh: password auth failed for %q: %v", username, err) diff --git a/netstack2/proxy.go b/netstack2/proxy.go index 2df5c83..00d6763 100644 --- a/netstack2/proxy.go +++ b/netstack2/proxy.go @@ -250,9 +250,9 @@ func (p *ProxyHandler) SetBlocked(v bool) { } p.blocked.Store(v) if v { - logger.Info("ProxyHandler: connection blocking enabled") + logger.Debug("ProxyHandler: connection blocking enabled") } else { - logger.Info("ProxyHandler: connection blocking disabled") + logger.Debug("ProxyHandler: connection blocking disabled") } } diff --git a/proxy/manager.go b/proxy/manager.go index b04be26..9203f18 100644 --- a/proxy/manager.go +++ b/proxy/manager.go @@ -253,9 +253,9 @@ func (pm *ProxyManager) SetTNet(tnet *netstack.Net) { func (pm *ProxyManager) SetBlocked(v bool) { pm.blocked.Store(v) if v { - logger.Info("ProxyManager: connection blocking enabled, new connections will be dropped") + logger.Debug("ProxyManager: connection blocking enabled, new connections will be dropped") } else { - logger.Info("ProxyManager: connection blocking disabled, accepting connections") + logger.Debug("ProxyManager: connection blocking disabled, accepting connections") } } From 3d8a26981901dae2ff58b9dc77015997ea39d641 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 4 Jun 2026 11:42:06 -0700 Subject: [PATCH 160/161] Fix windows compatibility issues Former-commit-id: 0e690a6a84ba4413f911e03f2a3edf14e9a5b81b --- browsergateway/ssh_native.go | 2 + browsergateway/ssh_native_windows.go | 16 +++++++ nativessh/pty.go | 2 + nativessh/server.go | 2 + nativessh/server_windows.go | 64 ++++++++++++++++++++++++++++ updates/advantech_windows.go | 8 ++++ 6 files changed, 94 insertions(+) create mode 100644 browsergateway/ssh_native_windows.go create mode 100644 nativessh/server_windows.go create mode 100644 updates/advantech_windows.go diff --git a/browsergateway/ssh_native.go b/browsergateway/ssh_native.go index 992f875..87adf92 100644 --- a/browsergateway/ssh_native.go +++ b/browsergateway/ssh_native.go @@ -1,3 +1,5 @@ +//go:build !windows + package browsergateway import ( diff --git a/browsergateway/ssh_native_windows.go b/browsergateway/ssh_native_windows.go new file mode 100644 index 0000000..3caff0a --- /dev/null +++ b/browsergateway/ssh_native_windows.go @@ -0,0 +1,16 @@ +//go:build windows + +package browsergateway + +import ( + "context" + "errors" + + "github.com/coder/websocket" + "github.com/fosrl/newt/nativessh" +) + +// serveNativeSSHSession is not supported on Windows. +func serveNativeSSHSession(_ context.Context, _ *websocket.Conn, _ string, _ *nativessh.CredentialStore) error { + return errors.New("native SSH is not supported on Windows") +} diff --git a/nativessh/pty.go b/nativessh/pty.go index 3d20e35..42cdd05 100644 --- a/nativessh/pty.go +++ b/nativessh/pty.go @@ -1,3 +1,5 @@ +//go:build !windows + package nativessh import ( diff --git a/nativessh/server.go b/nativessh/server.go index 0ccd7c3..20a13d9 100644 --- a/nativessh/server.go +++ b/nativessh/server.go @@ -1,3 +1,5 @@ +//go:build !windows + package nativessh import ( diff --git a/nativessh/server_windows.go b/nativessh/server_windows.go new file mode 100644 index 0000000..3394e13 --- /dev/null +++ b/nativessh/server_windows.go @@ -0,0 +1,64 @@ +//go:build windows + +package nativessh + +import ( + "errors" + "log" + "net" + "sync" + + "golang.org/x/crypto/ssh" +) + +// CredentialStore is a stub on Windows. Native SSH is not supported on Windows. +type CredentialStore struct { + mu sync.RWMutex + principals map[string]map[string]struct{} +} + +// NewCredentialStore returns an empty CredentialStore stub. +// Native SSH is not supported on Windows; a warning is logged. +func NewCredentialStore() *CredentialStore { + log.Println("WARNING: native SSH is not supported on Windows and will be disabled") + return &CredentialStore{ + principals: make(map[string]map[string]struct{}), + } +} + +// SetCAKey is a no-op stub on Windows. +func (s *CredentialStore) SetCAKey(_ string) error { + return errors.New("native SSH not supported on Windows") +} + +// AddPrincipals is a no-op stub on Windows. +func (s *CredentialStore) AddPrincipals(_, _ string) {} + +// get returns nil CA key and empty principals on Windows. +func (s *CredentialStore) get(_ string) (ssh.PublicKey, map[string]struct{}) { + return nil, nil +} + +// ServerConfig holds configuration for the native SSH server (stub on Windows). +type ServerConfig struct { + ListenAddr string + Credentials *CredentialStore +} + +// Server is a stub on Windows. +type Server struct{} + +// NewServer returns a stub Server and logs a warning. +func NewServer(cfg ServerConfig) *Server { + return &Server{} +} + +// ListenAndServe always returns an error on Windows. +func (s *Server) ListenAndServe() error { + return errors.New("native SSH not supported on Windows") +} + +// Serve always returns an error on Windows. +func (s *Server) Serve(_ net.Listener) error { + return errors.New("native SSH not supported on Windows") +} diff --git a/updates/advantech_windows.go b/updates/advantech_windows.go new file mode 100644 index 0000000..e6e302c --- /dev/null +++ b/updates/advantech_windows.go @@ -0,0 +1,8 @@ +//go:build windows + +package updates + +// postUpdateAdvantech is not supported on Windows. +func postUpdateAdvantech(newVersion, pidFile string) error { + return nil +} From 17a5689cf2085eb272c0b2a940adc269495eaa58 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 4 Jun 2026 11:51:40 -0700 Subject: [PATCH 161/161] Update Flake Former-commit-id: 49d6cf595d1d5be190488c36d02d99f45bcfee85 --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 3657fc3..488f75d 100644 --- a/flake.nix +++ b/flake.nix @@ -35,7 +35,7 @@ inherit version; src = pkgs.nix-gitignore.gitignoreSource [ ] ./.; - vendorHash = "sha256-M3MjtU4t0iGskNZhAdN3RKny8TOZbiuljK4HThShfXs="; + vendorHash = "sha256-X70emc3uPN2YpsbudtoeEZDHilaTvFMqfRaDmIgRVZE="; nativeInstallCheckInputs = [ pkgs.versionCheckHook ];