diff --git a/bind/shared_bind.go b/bind/shared_bind.go index 502e401..4e56e90 100644 --- a/bind/shared_bind.go +++ b/bind/shared_bind.go @@ -683,6 +683,23 @@ func (b *SharedBind) receiveIPv4Simple(conn *net.UDPConn, bufs [][]byte, sizes [ } } +// IsMagicPacket reports whether payload is one of our connectivity-test magic +// packets (a MagicTestRequest or MagicTestResponse). These packets are meant to +// travel directly between physical UDP sockets and must never be encapsulated by +// WireGuard - e.g. if OS routing mistakenly sends one into a WireGuard TUN +// interface (because the destination falls inside a routed tunnel subnet), it +// should be dropped there rather than tunneled, which would otherwise make a +// LAN-local endpoint test falsely appear to succeed over the tunnel. +func IsMagicPacket(payload []byte) bool { + if len(payload) >= MagicTestRequestLen && bytes.HasPrefix(payload, MagicTestRequest) { + return true + } + if len(payload) >= MagicTestResponseLen && bytes.HasPrefix(payload, MagicTestResponse) { + return true + } + return false +} + // handleMagicPacket checks if the packet is a magic test packet and responds if so. // Returns true if the packet was a magic packet and was handled (should not be passed to WireGuard). func (b *SharedBind) handleMagicPacket(data []byte, addr *net.UDPAddr) bool { diff --git a/clients/clients.go b/clients/clients.go index 6661151..d9916b4 100644 --- a/clients/clients.go +++ b/clients/clients.go @@ -35,10 +35,11 @@ import ( ) type WgConfig struct { - IpAddress string `json:"ipAddress"` - Peers []Peer `json:"peers"` - Targets []Target `json:"targets"` - ChainId string `json:"chainId"` + IpAddress string `json:"ipAddress"` + Peers []Peer `json:"peers"` + Targets []Target `json:"targets"` + Certs []CertData `json:"certs"` + ChainId string `json:"chainId"` } type Target struct { @@ -53,6 +54,23 @@ type Target struct { 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 + TLSCertID string `json:"tlsCertId,omitempty"` // references an entry in the sync message's Certs list instead of inlining TLSCert/TLSKey +} + +// CertData is a single shared TLS certificate/key pair, referenced by ID from +// one or more Targets via TLSCertID. Sent once per sync message so that many +// targets backed by the same certificate (e.g. a wildcard cert) don't each +// carry a full copy of the PEM data. +type CertData struct { + ID string `json:"id"` + Cert string `json:"cert"` + Key string `json:"key"` +} + +// CertPair holds the resolved PEM certificate/key material for a CertData entry. +type CertPair struct { + Cert string + Key string } type PortRange struct { @@ -122,6 +140,11 @@ type WireGuardService struct { // connection blocking: when true, all new incoming connections are dropped blocked atomic.Bool + + // certs resolves TLSCertID references on incoming Targets to their PEM + // cert/key material. Replaced wholesale on every full sync. + certs map[string]CertPair + certsMu sync.RWMutex } // generateChainId generates a random chain ID for deduplicating round-trip messages. @@ -196,6 +219,8 @@ 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/certs/add", service.handleAddCerts) + wsClient.RegisterHandler("newt/certs/remove", service.handleRemoveCerts) return service, nil } @@ -544,6 +569,7 @@ func (s *WireGuardService) handleConfig(msg websocket.WSMessage) { } s.config = config + s.SetCerts(config.Certs) if s.stopGetConfig != nil { s.stopGetConfig() @@ -568,6 +594,107 @@ func (s *WireGuardService) handleConfig(msg websocket.WSMessage) { logger.Info("Client connectivity setup. Ready to accept connections from clients!") } +// SetCerts replaces the TLSCertID lookup table used by resolveTLS. The server +// sends the complete set of referenced certs on every full sync, so this is a +// wholesale replacement rather than an incremental merge. +func (s *WireGuardService) SetCerts(certs []CertData) { + m := make(map[string]CertPair, len(certs)) + for _, c := range certs { + m[c.ID] = CertPair{Cert: c.Cert, Key: c.Key} + } + s.certsMu.Lock() + s.certs = m + s.certsMu.Unlock() +} + +// resolveTLS returns the PEM cert/key to use for target's incoming HTTPS +// termination: target.TLSCertID looked up in the certs table if set, falling +// back to the target's own inline TLSCert/TLSKey otherwise. +func (s *WireGuardService) resolveTLS(target Target) (cert, key string) { + if target.TLSCertID == "" { + return target.TLSCert, target.TLSKey + } + s.certsMu.RLock() + pair, ok := s.certs[target.TLSCertID] + s.certsMu.RUnlock() + if !ok { + logger.Warn("No cert found for tlsCertId %s, falling back to inline cert on target", target.TLSCertID) + return target.TLSCert, target.TLSKey + } + return pair.Cert, pair.Key +} + +// AddCerts upserts the given certs into the lookup table used by resolveTLS, +// without discarding any certs already present. Used for incremental cert +// pushes (e.g. after a renewal) outside of a full newt/sync or +// newt/wg/receive-config, which replace the table wholesale via SetCerts. +func (s *WireGuardService) AddCerts(certs []CertData) { + if len(certs) == 0 { + return + } + s.certsMu.Lock() + if s.certs == nil { + s.certs = make(map[string]CertPair, len(certs)) + } + for _, c := range certs { + s.certs[c.ID] = CertPair{Cert: c.Cert, Key: c.Key} + } + s.certsMu.Unlock() +} + +// RemoveCerts deletes the given cert IDs from the lookup table, e.g. once the +// server knows no target references them anymore. +func (s *WireGuardService) RemoveCerts(ids []string) { + if len(ids) == 0 { + return + } + s.certsMu.Lock() + for _, id := range ids { + delete(s.certs, id) + } + s.certsMu.Unlock() +} + +// handleAddCerts processes a "newt/certs/add" message: an array of CertData +// to upsert into the cert lookup table. +func (s *WireGuardService) handleAddCerts(msg websocket.WSMessage) { + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Info("Error marshaling cert add data: %v", err) + return + } + + var certs []CertData + if err := json.Unmarshal(jsonData, &certs); err != nil { + logger.Warn("Error unmarshaling cert add data: %v", err) + return + } + + s.AddCerts(certs) + logger.Info("Added %d certs", len(certs)) +} + +// handleRemoveCerts processes a "newt/certs/remove" message: {ids: [...]} +// naming the cert IDs to drop from the lookup table. +func (s *WireGuardService) handleRemoveCerts(msg websocket.WSMessage) { + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Info("Error marshaling cert remove data: %v", err) + return + } + + var data struct { + IDs []string `json:"ids"` + } + if err := json.Unmarshal(jsonData, &data); err != nil { + logger.Warn("Error unmarshaling cert remove data: %v", err) + return + } + + s.RemoveCerts(data.IDs) + logger.Info("Removed %d certs", len(data.IDs)) +} + // Sync synchronizes the clients WireGuard peers and targets with the desired state // received as part of the main newt/sync message. func (s *WireGuardService) Sync(peers []Peer, targets []Target) { @@ -680,6 +807,7 @@ func (s *WireGuardService) syncTargets(desiredTargets []Target) error { continue } + tlsCert, tlsKey := s.resolveTLS(target) rules = append(rules, netstack2.SubnetRule{ SourcePrefix: sourcePrefix, DestPrefix: destPrefix, @@ -689,8 +817,8 @@ func (s *WireGuardService) syncTargets(desiredTargets []Target) error { ResourceId: target.ResourceId, Protocol: target.Protocol, HTTPTargets: target.HTTPTargets, - TLSCert: target.TLSCert, - TLSKey: target.TLSKey, + TLSCert: tlsCert, + TLSKey: tlsKey, }) } } @@ -976,6 +1104,7 @@ func (s *WireGuardService) ensureTargets(targets []Target) error { if err != nil { return fmt.Errorf("invalid CIDR %s: %v", sp, err) } + tlsCert, tlsKey := s.resolveTLS(target) s.tnet.AddProxySubnetRule(netstack2.SubnetRule{ SourcePrefix: sourcePrefix, DestPrefix: destPrefix, @@ -985,8 +1114,8 @@ func (s *WireGuardService) ensureTargets(targets []Target) error { ResourceId: target.ResourceId, Protocol: target.Protocol, HTTPTargets: target.HTTPTargets, - TLSCert: target.TLSCert, - TLSKey: target.TLSKey, + TLSCert: tlsCert, + TLSKey: tlsKey, }) logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", sp, target.DestPrefix, target.RewriteTo, target.PortRange) } @@ -1380,6 +1509,7 @@ func (s *WireGuardService) handleAddTarget(msg websocket.WSMessage) { logger.Info("Invalid CIDR %s: %v", sp, err) continue } + tlsCert, tlsKey := s.resolveTLS(target) s.tnet.AddProxySubnetRule(netstack2.SubnetRule{ SourcePrefix: sourcePrefix, DestPrefix: destPrefix, @@ -1389,8 +1519,8 @@ func (s *WireGuardService) handleAddTarget(msg websocket.WSMessage) { ResourceId: target.ResourceId, Protocol: target.Protocol, HTTPTargets: target.HTTPTargets, - TLSCert: target.TLSCert, - TLSKey: target.TLSKey, + TLSCert: tlsCert, + TLSKey: tlsKey, }) logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", sp, target.DestPrefix, target.RewriteTo, target.PortRange) } @@ -1509,6 +1639,7 @@ func (s *WireGuardService) handleUpdateTarget(msg websocket.WSMessage) { logger.Info("Invalid CIDR %s: %v", sp, err) continue } + tlsCert, tlsKey := s.resolveTLS(target) s.tnet.AddProxySubnetRule(netstack2.SubnetRule{ SourcePrefix: sourcePrefix, DestPrefix: destPrefix, @@ -1518,8 +1649,8 @@ func (s *WireGuardService) handleUpdateTarget(msg websocket.WSMessage) { ResourceId: target.ResourceId, Protocol: target.Protocol, HTTPTargets: target.HTTPTargets, - TLSCert: target.TLSCert, - TLSKey: target.TLSKey, + TLSCert: tlsCert, + TLSKey: 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/exitnode/exitnode.go b/exitnode/exitnode.go new file mode 100644 index 0000000..1555796 --- /dev/null +++ b/exitnode/exitnode.go @@ -0,0 +1,176 @@ +// Package exitnode implements the exit-node ping dance run before +// registering with the server: request the candidate exit nodes, ping each +// one over HTTP, and report the results so the server can pick the best one. +// It is shared between newt and olm, which both register the same way. +package exitnode + +import ( + "net/http" + "strings" + "time" + + "github.com/fosrl/newt/logger" +) + +// ExitNodeData is the payload the server sends in response to a +// "*/ping/request" message. +type ExitNodeData struct { + ExitNodes []ExitNode `json:"exitNodes"` + ChainId string `json:"chainId"` +} + +// ExitNode is a candidate exit node offered by the server for ping selection. +type ExitNode struct { + ID int `json:"exitNodeId"` + Name string `json:"exitNodeName"` + Endpoint string `json:"endpoint"` + Weight float64 `json:"weight"` + WasPreviouslyConnected bool `json:"wasPreviouslyConnected"` +} + +// ExitNodePingResult is the measured latency (or error) for one exit node, +// sent back to the server in the "*/wg/register" message's pingResults field. +type ExitNodePingResult struct { + ExitNodeID int `json:"exitNodeId"` + LatencyMs int64 `json:"latencyMs"` + Weight float64 `json:"weight"` + Error string `json:"error,omitempty"` + Name string `json:"exitNodeName"` + Endpoint string `json:"endpoint"` + WasPreviouslyConnected bool `json:"wasPreviouslyConnected"` +} + +// PingExitNodes pings the given exit nodes over HTTP and returns a per-node +// ExitNodePingResult suitable for inclusion in a wg/register message's +// pingResults field, so the server can select the best exit node. +// +// If there's only one exit node, or preferEndpoint names one of them, the +// matching node is returned immediately with LatencyMs 0 and no pinging is +// done. Otherwise every node is pinged pingAttempts times over HTTP GET +// /ping and the average latency of successful attempts is used. +// +// When alreadyConnected is true, a node flagged WasPreviouslyConnected is +// excluded from the results as long as at least one other healthy node is +// available, biasing reconnects toward switching away from a possibly +// degraded node. +func PingExitNodes(exitNodes []ExitNode, preferEndpoint string, alreadyConnected bool) []ExitNodePingResult { + if len(exitNodes) == 0 { + return nil + } + + if len(exitNodes) == 1 || preferEndpoint != "" { + selected := exitNodes[0] + if preferEndpoint != "" { + for _, node := range exitNodes { + if node.Endpoint == preferEndpoint { + selected = node + break + } + } + } + + logger.Debug("Only one exit node available, using it directly: %s", selected.Endpoint) + + return []ExitNodePingResult{ + { + ExitNodeID: selected.ID, + LatencyMs: 0, + Weight: selected.Weight, + Error: "", + Name: selected.Name, + Endpoint: selected.Endpoint, + WasPreviouslyConnected: selected.WasPreviouslyConnected, + }, + } + } + + type nodeResult struct { + Node ExitNode + Latency time.Duration + Err error + } + + results := make([]nodeResult, len(exitNodes)) + const pingAttempts = 3 + for i, node := range exitNodes { + var totalLatency time.Duration + var lastErr error + successes := 0 + httpClient := &http.Client{ + Timeout: 5 * time.Second, + } + url := node.Endpoint + if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { + url = "http://" + url + } + if !strings.HasSuffix(url, "/ping") { + url = strings.TrimRight(url, "/") + "/ping" + } + for j := 0; j < pingAttempts; j++ { + start := time.Now() + resp, err := httpClient.Get(url) + latency := time.Since(start) + if err != nil { + lastErr = err + logger.Warn("Failed to ping exit node %d (%s) attempt %d: %v", node.ID, url, j+1, err) + continue + } + resp.Body.Close() + totalLatency += latency + successes++ + } + var avgLatency time.Duration + if successes > 0 { + avgLatency = totalLatency / time.Duration(successes) + } + if successes == 0 { + results[i] = nodeResult{Node: node, Latency: 0, Err: lastErr} + } else { + results[i] = nodeResult{Node: node, Latency: avgLatency, Err: nil} + } + } + + var pingResults []ExitNodePingResult + for _, res := range results { + errMsg := "" + if res.Err != nil { + errMsg = res.Err.Error() + } + pingResults = append(pingResults, ExitNodePingResult{ + ExitNodeID: res.Node.ID, + LatencyMs: res.Latency.Milliseconds(), + Weight: res.Node.Weight, + Error: errMsg, + Name: res.Node.Name, + Endpoint: res.Node.Endpoint, + WasPreviouslyConnected: res.Node.WasPreviouslyConnected, + }) + } + + if alreadyConnected { + var filteredPingResults []ExitNodePingResult + previouslyConnectedNodeIdx := -1 + for i, res := range pingResults { + if res.WasPreviouslyConnected { + previouslyConnectedNodeIdx = i + } + } + goodNodeCount := 0 + for i, res := range pingResults { + if i != previouslyConnectedNodeIdx && res.LatencyMs > 0 && res.Error == "" { + goodNodeCount++ + } + } + if previouslyConnectedNodeIdx != -1 && goodNodeCount > 0 { + for i, res := range pingResults { + if i != previouslyConnectedNodeIdx { + filteredPingResults = append(filteredPingResults, res) + } + } + pingResults = filteredPingResults + logger.Info("Excluding previously connected exit node from ping results due to other available nodes") + } + } + + return pingResults +} diff --git a/netstack2/http_handler.go b/netstack2/http_handler.go index 674412f..2ec7ff8 100644 --- a/netstack2/http_handler.go +++ b/netstack2/http_handler.go @@ -3,9 +3,15 @@ package netstack2 import ( "bufio" "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" "errors" "fmt" + "math/big" "net" "net/http" "net/http/httputil" @@ -65,6 +71,13 @@ type HTTPHandler struct { // 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 + + // fallbackTLSOnce/fallbackTLSCfg hold a lazily-generated self-signed + // certificate used when a rule's configured cert/key fails to parse, so + // that a misconfigured rule degrades to a browser cert warning instead of + // silently dropping every connection. + fallbackTLSOnce sync.Once + fallbackTLSCfg *tls.Config } // --------------------------------------------------------------------------- @@ -262,7 +275,13 @@ func (h *HTTPHandler) getTLSConfig(rule *SubnetRule) (*tls.Config, error) { cert, err := tls.X509KeyPair([]byte(rule.TLSCert), []byte(rule.TLSKey)) if err != nil { - return nil, fmt.Errorf("failed to parse TLS keypair: %w", err) + // A misconfigured rule (bad/missing PEM data) must not take the whole + // connection down: fall back to a self-signed cert so the handshake + // still completes and the request reaches handleRequest, which routes + // independently of the cert. Clients will see a cert warning instead + // of a silent connection reset. + logger.Warn("HTTP handler: falling back to self-signed cert for rule (invalid configured keypair): %v", err) + return h.getFallbackTLSConfig(), nil } cfg := &tls.Config{ Certificates: []tls.Certificate{cert}, @@ -273,6 +292,57 @@ func (h *HTTPHandler) getTLSConfig(rule *SubnetRule) (*tls.Config, error) { return actual.(*tls.Config), nil } +// getFallbackTLSConfig returns a *tls.Config backed by a self-signed +// certificate, generated once and reused for the lifetime of the handler. +func (h *HTTPHandler) getFallbackTLSConfig() *tls.Config { + h.fallbackTLSOnce.Do(func() { + cert, err := generateSelfSignedCert() + if err != nil { + // Generation of an in-memory self-signed cert has no external + // dependencies and should never fail; if it somehow does, there + // is no sensible fallback left, so surface it loudly. + logger.Error("HTTP handler: failed to generate fallback self-signed cert: %v", err) + return + } + h.fallbackTLSCfg = &tls.Config{Certificates: []tls.Certificate{cert}} + }) + return h.fallbackTLSCfg +} + +// generateSelfSignedCert creates a fresh, in-memory self-signed TLS +// certificate/key pair valid for one year, used as a fallback when a rule's +// configured certificate cannot be parsed. +func generateSelfSignedCert() (tls.Certificate, error) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return tls.Certificate{}, fmt.Errorf("failed to generate private key: %w", err) + } + + serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return tls.Certificate{}, fmt.Errorf("failed to generate serial number: %w", err) + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{CommonName: "newt-fallback"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().AddDate(1, 0, 0), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + if err != nil { + return tls.Certificate{}, fmt.Errorf("failed to create certificate: %w", err) + } + + return tls.Certificate{ + Certificate: [][]byte{derBytes}, + PrivateKey: priv, + }, 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. diff --git a/network/interface.go b/network/interface.go index 089badd..72af5f4 100644 --- a/network/interface.go +++ b/network/interface.go @@ -115,6 +115,10 @@ func FindUnusedUTUN() (string, error) { } func configureDarwin(interfaceName string, ip net.IP, ipNet *net.IPNet) error { + if NativeConfigDisabled { + return nil + } + logger.Info("Configuring darwin interface: %s", interfaceName) prefix, _ := ipNet.Mask.Size() @@ -167,3 +171,101 @@ func configureLinux(interfaceName string, ip net.IP, ipNet *net.IPNet) error { return nil } + +// AddSecondaryAddress adds an additional IP address (given as CIDR, e.g. "10.10.0.5/32") +// to an already-configured interface. It also records the address in the shared +// NetworkSettings (see AddIPv4Address) so mobile (iOS/Android) packet-tunnel providers +// pick it up on their next settings poll - those platforms have no OS-level interface +// to configure directly, so this is the only way they learn about the address. +func AddSecondaryAddress(interfaceName string, addr string) error { + ip, ipNet, err := net.ParseCIDR(addr) + if err != nil { + return fmt.Errorf("invalid IP address: %v", err) + } + + mask := net.IP(ipNet.Mask).String() + if err := AddIPv4Address(ip.String(), mask); err != nil { + return err + } + + if interfaceName == "" { + return nil + } + + switch runtime.GOOS { + case "linux": + return configureLinux(interfaceName, ip, ipNet) + case "darwin": + return configureDarwin(interfaceName, ip, ipNet) + case "windows": + return configureWindows(interfaceName, ip, ipNet) + default: + return nil + } +} + +// RemoveSecondaryAddress removes an IP address (given as CIDR) previously added with +// AddSecondaryAddress, including from the shared NetworkSettings used by mobile +// packet-tunnel providers. +func RemoveSecondaryAddress(interfaceName string, addr string) error { + ip, ipNet, err := net.ParseCIDR(addr) + if err != nil { + return fmt.Errorf("invalid IP address: %v", err) + } + + RemoveIPv4Address(ip.String()) + + if interfaceName == "" { + return nil + } + + switch runtime.GOOS { + case "linux": + return removeLinuxAddress(interfaceName, ip, ipNet) + case "darwin": + return removeDarwinAddress(interfaceName, ip, ipNet) + case "windows": + return removeWindowsAddress(interfaceName, ip, ipNet) + default: + return nil + } +} + +func removeLinuxAddress(interfaceName string, ip net.IP, ipNet *net.IPNet) error { + link, err := netlink.LinkByName(interfaceName) + if err != nil { + return fmt.Errorf("failed to get interface %s: %v", interfaceName, err) + } + + addr := &netlink.Addr{ + IPNet: &net.IPNet{ + IP: ip, + Mask: ipNet.Mask, + }, + } + + if err := netlink.AddrDel(link, addr); err != nil { + return fmt.Errorf("failed to remove IP address: %v", err) + } + + return nil +} + +func removeDarwinAddress(interfaceName string, ip net.IP, ipNet *net.IPNet) error { + if NativeConfigDisabled { + return nil + } + + prefix, _ := ipNet.Mask.Size() + ipStr := fmt.Sprintf("%s/%d", ip.String(), prefix) + + cmd := exec.Command("/sbin/ifconfig", interfaceName, "inet", ipStr, "-alias") + logger.Info("Running command: %v", cmd) + + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("ifconfig command failed: %v, output: %s", err, out) + } + + return nil +} diff --git a/network/interface_notwindows.go b/network/interface_notwindows.go index 5d15ace..3ca6ed0 100644 --- a/network/interface_notwindows.go +++ b/network/interface_notwindows.go @@ -10,3 +10,7 @@ import ( func configureWindows(interfaceName string, ip net.IP, ipNet *net.IPNet) error { return fmt.Errorf("configureWindows called on non-Windows platform") } + +func removeWindowsAddress(interfaceName string, ip net.IP, ipNet *net.IPNet) error { + return fmt.Errorf("removeWindowsAddress called on non-Windows platform") +} diff --git a/network/interface_windows.go b/network/interface_windows.go index 966486b..2b96e0b 100644 --- a/network/interface_windows.go +++ b/network/interface_windows.go @@ -61,3 +61,35 @@ func configureWindows(interfaceName string, ip net.IP, ipNet *net.IPNet) error { return nil } + +func removeWindowsAddress(interfaceName string, ip net.IP, ipNet *net.IPNet) error { + iface, err := net.InterfaceByName(interfaceName) + if err != nil { + return fmt.Errorf("failed to get interface %s: %v", interfaceName, err) + } + + luid, err := winipcfg.LUIDFromIndex(uint32(iface.Index)) + if err != nil { + return fmt.Errorf("failed to get LUID for interface %s: %v", interfaceName, err) + } + + maskBits, _ := ipNet.Mask.Size() + + var addr netip.Addr + if ip4 := ip.To4(); ip4 != nil { + addr, _ = netip.AddrFromSlice(ip4) + } else { + addr, _ = netip.AddrFromSlice(ip) + } + if !addr.IsValid() { + return fmt.Errorf("failed to convert IP address") + } + prefix := netip.PrefixFrom(addr, maskBits) + + logger.Info("Removing IP address %s from interface %s", prefix.String(), interfaceName) + if err := luid.DeleteIPAddress(prefix); err != nil { + return fmt.Errorf("failed to remove IP address: %v", err) + } + + return nil +} diff --git a/network/route.go b/network/route.go index 57fe12d..7081bdf 100644 --- a/network/route.go +++ b/network/route.go @@ -32,6 +32,20 @@ const VPNRouteMetric = 9999 // this to true (e.g. from a config value) before routes are added. var PreferLocalRoutes = false +// NativeConfigDisabled, when true, skips the raw `ifconfig`/`route` subprocess +// calls this package otherwise makes on darwin (configureDarwin, +// removeDarwinAddress, DarwinAddRouteWithSource, DarwinRemoveRoute) while still +// populating the JSON-facing NetworkSettings state. This must be set when the +// TUN device's addresses/routes are instead owned by an external mechanism +// that reconciles them independently - namely Apple's NetworkExtension +// (NEPacketTunnelProvider.setTunnelNetworkSettings), which is the sole +// sanctioned way to configure that virtual interface. Running our own +// ifconfig/route commands in addition to NE applying its own settings was +// observed to install two competing routes to the same destination (one via +// NE's gatewayAddress-based route, one via our own `-ifa` route), so the two +// mechanisms must be mutually exclusive rather than layered. +var NativeConfigDisabled = false + // DarwinAddRoute adds a route via the BSD routing table. Unlike Linux/Windows, // BSD's routing table has no per-route metric - preference between an // overlapping local route and this VPN route is instead resolved by @@ -39,22 +53,43 @@ var PreferLocalRoutes = false // rather than replacing an existing route to the same destination, so a local // route is never displaced by one we add here. func DarwinAddRoute(destination string, gateway string, interfaceName string) error { + return DarwinAddRouteWithSource(destination, gateway, interfaceName, "") +} + +// DarwinAddRouteWithSource is DarwinAddRoute with an explicit source address +// (route(8) `-ifa`). This is required when the interface carries more than +// one address (e.g. an exit node's secondary tunnel address alongside the +// site tunnel's primary address): without `-ifa`, BSD picks a source address +// for the route on its own - typically the interface's primary address - and +// WireGuard's own reverse-path filtering on the remote end will silently drop +// packets whose source doesn't match the peer's configured AllowedIPs, even +// though the tunnel/handshake itself stays up. +func DarwinAddRouteWithSource(destination string, gateway string, interfaceName string, sourceIP string) error { if runtime.GOOS != "darwin" { return nil } + if NativeConfigDisabled { + return nil + } - var cmd *exec.Cmd + var args []string if gateway != "" { // Route with specific gateway - cmd = exec.Command("route", "-q", "-n", "add", "-inet", destination, "-gateway", gateway) + args = []string{"-q", "-n", "add", "-inet", destination, "-gateway", gateway} } else if interfaceName != "" { // Route via interface - cmd = exec.Command("route", "-q", "-n", "add", "-inet", destination, "-interface", interfaceName) + args = []string{"-q", "-n", "add", "-inet", destination, "-interface", interfaceName} } else { return fmt.Errorf("either gateway or interface must be specified") } + if sourceIP != "" { + args = append(args, "-ifa", sourceIP) + } + + cmd := exec.Command("route", args...) + logger.Info("Running command: %v", cmd) out, err := cmd.CombinedOutput() @@ -69,6 +104,9 @@ func DarwinRemoveRoute(destination string) error { if runtime.GOOS != "darwin" { return nil } + if NativeConfigDisabled { + return nil + } cmd := exec.Command("route", "-q", "-n", "delete", "-inet", destination) logger.Info("Running command: %v", cmd) @@ -173,15 +211,30 @@ func LinuxRemoveRoute(destination string, interfaceName string) error { // addRouteForServerIP adds an OS-specific route for the server IP func AddRouteForServerIP(serverIP, interfaceName string) error { + return AddRouteForServerIPWithSource(serverIP, interfaceName, "") +} + +// AddRouteForServerIPWithSource is AddRouteForServerIP with an explicit source +// address for the darwin route (see DarwinAddRouteWithSource) - needed when +// the interface carries more than one address, e.g. an exit node connection +// where the interface's primary address belongs to the site tunnel rather +// than the exit node. +func AddRouteForServerIPWithSource(serverIP, interfaceName string, sourceIP string) error { if interfaceName == "" { return nil } + + // Populate the NetworkSettings entry (and its gatewayAddress, for the + // NetworkExtension source-pinning trick above) unconditionally, same as + // AddRoutesWithSource does for remote subnets - mobile packet-tunnel + // providers rely on this regardless of GOOS. + if err := AddRouteForNetworkConfigWithGateway(serverIP, sourceIP); err != nil { + return err + } + // TODO: does this also need to be ios? if runtime.GOOS == "darwin" { // macos requires routes for each peer to be added but this messes with other platforms - if err := AddRouteForNetworkConfig(serverIP); err != nil { - return err - } - return DarwinAddRoute(serverIP, "", interfaceName) + return DarwinAddRouteWithSource(serverIP, "", interfaceName, sourceIP) } // else if runtime.GOOS == "windows" { // return WindowsAddRoute(serverIP, "", interfaceName) @@ -193,14 +246,24 @@ func AddRouteForServerIP(serverIP, interfaceName string) error { // removeRouteForServerIP removes an OS-specific route for the server IP func RemoveRouteForServerIP(serverIP string, interfaceName string) error { + return RemoveRouteForServerIPWithSource(serverIP, interfaceName, "") +} + +// RemoveRouteForServerIPWithSource is RemoveRouteForServerIP with an explicit +// source/gateway address - must match whatever was passed to +// AddRouteForServerIPWithSource when the route was added (see +// RemoveRouteForNetworkConfigWithGateway). +func RemoveRouteForServerIPWithSource(serverIP string, interfaceName string, sourceIP string) error { if interfaceName == "" { return nil } + + if err := RemoveRouteForNetworkConfigWithGateway(serverIP, sourceIP); err != nil { + return err + } + // TODO: does this also need to be ios? if runtime.GOOS == "darwin" { // macos requires routes for each peer to be added but this messes with other platforms - if err := RemoveRouteForNetworkConfig(serverIP); err != nil { - return err - } return DarwinRemoveRoute(serverIP) } // else if runtime.GOOS == "windows" { @@ -212,6 +275,20 @@ func RemoveRouteForServerIP(serverIP string, interfaceName string) error { } func AddRouteForNetworkConfig(destination string) error { + return AddRouteForNetworkConfigWithGateway(destination, "") +} + +// AddRouteForNetworkConfigWithGateway is AddRouteForNetworkConfig with an +// explicit gateway address for the route entry surfaced via NetworkSettings. +// This is consumed by mobile (iOS/macOS NetworkExtension) packet-tunnel +// providers as NEIPv4Route.gatewayAddress. NetworkExtension gives us no +// direct way to pin a route's source address (no equivalent of BSD's `route +// -ifa`) - but setting gatewayAddress to one of the tunnel interface's own +// addresses makes the OS resolve "how do I reach this gateway" recursively +// to that address/interface pairing, which is what determines the source +// address used for packets matching the route. This is the same underlying +// mechanism as `route add -gateway` (see DarwinAddRoute's gateway branch). +func AddRouteForNetworkConfigWithGateway(destination string, gateway string) error { // Parse the subnet to extract IP and mask _, ipNet, err := net.ParseCIDR(destination) if err != nil { @@ -222,12 +299,21 @@ func AddRouteForNetworkConfig(destination string) error { mask := net.IP(ipNet.Mask).String() destinationAddress := ipNet.IP.String() - AddIPv4IncludedRoute(IPv4Route{DestinationAddress: destinationAddress, SubnetMask: mask}) + AddIPv4IncludedRoute(IPv4Route{DestinationAddress: destinationAddress, SubnetMask: mask, GatewayAddress: gateway}) return nil } func RemoveRouteForNetworkConfig(destination string) error { + return RemoveRouteForNetworkConfigWithGateway(destination, "") +} + +// RemoveRouteForNetworkConfigWithGateway is RemoveRouteForNetworkConfig with +// an explicit gateway address. This must match whatever gateway the route was +// added with (see AddRouteForNetworkConfigWithGateway) - RemoveIPv4IncludedRoute +// matches by full struct equality, so a mismatched gateway means the entry is +// silently never found/removed. +func RemoveRouteForNetworkConfigWithGateway(destination string, gateway string) error { // Parse the subnet to extract IP and mask _, ipNet, err := net.ParseCIDR(destination) if err != nil { @@ -238,13 +324,23 @@ func RemoveRouteForNetworkConfig(destination string) error { mask := net.IP(ipNet.Mask).String() destinationAddress := ipNet.IP.String() - RemoveIPv4IncludedRoute(IPv4Route{DestinationAddress: destinationAddress, SubnetMask: mask}) + RemoveIPv4IncludedRoute(IPv4Route{DestinationAddress: destinationAddress, SubnetMask: mask, GatewayAddress: gateway}) return nil } // addRoutes adds routes for each subnet in RemoteSubnets func AddRoutes(remoteSubnets []string, interfaceName string) error { + return AddRoutesWithSource(remoteSubnets, interfaceName, "") +} + +// AddRoutesWithSource is AddRoutes with an explicit source address for the +// darwin routes (see DarwinAddRouteWithSource) - needed when the interface +// carries more than one address (e.g. a site tunnel address alongside an +// exit node's secondary address), so the routes for these subnets are pinned +// to the address they actually belong to rather than whichever address +// darwin would otherwise default to. +func AddRoutesWithSource(remoteSubnets []string, interfaceName string, sourceIP string) error { if len(remoteSubnets) == 0 { return nil } @@ -268,7 +364,7 @@ func AddRoutes(remoteSubnets []string, interfaceName string) error { switch runtime.GOOS { case "darwin": - if err := DarwinAddRoute(subnet, "", interfaceName); err != nil { + if err := DarwinAddRouteWithSource(subnet, "", interfaceName, sourceIP); err != nil { logger.Error("Failed to add Darwin route for subnet %s: %v", subnet, err) } case "windows": diff --git a/network/settings.go b/network/settings.go index e361ba1..f0520ed 100644 --- a/network/settings.go +++ b/network/settings.go @@ -2,6 +2,7 @@ package network import ( "encoding/json" + "fmt" "sync" "github.com/fosrl/newt/logger" @@ -81,6 +82,56 @@ func SetIPv4Settings(addresses []string, subnetMasks []string) { logger.Info("Set IPv4 addresses: %v, subnet masks: %v", addresses, subnetMasks) } +// AddIPv4Address appends an additional IPv4 address/subnet mask pair to the +// tunnel's network settings. This is how a secondary interface address gets +// exposed to mobile (iOS/Android) packet-tunnel providers, which read the +// full IPv4Addresses/IPv4SubnetMasks arrays (not just the first entry) and +// re-apply them on every settings poll. +// +// It requires a primary address to already be set (via SetIPv4Settings, +// i.e. ConfigureInterface) and refuses to add otherwise: on mobile +// platforms, array order is what determines which address the OS treats as +// primary, so appending to an empty list would silently make this +// "additional" address the primary one instead. +func AddIPv4Address(address string, subnetMask string) error { + networkSettingsMutex.Lock() + defer networkSettingsMutex.Unlock() + + if len(networkSettings.IPv4Addresses) == 0 { + return fmt.Errorf("cannot add secondary IPv4 address %s: no primary address configured yet", address) + } + + for _, a := range networkSettings.IPv4Addresses { + if a == address { + logger.Info("IPv4 address already exists: %s", address) + return nil + } + } + + networkSettings.IPv4Addresses = append(networkSettings.IPv4Addresses, address) + networkSettings.IPv4SubnetMasks = append(networkSettings.IPv4SubnetMasks, subnetMask) + incrementor++ + logger.Info("Added IPv4 address: %s/%s", address, subnetMask) + return nil +} + +// RemoveIPv4Address removes a previously added secondary IPv4 address. +func RemoveIPv4Address(address string) { + networkSettingsMutex.Lock() + defer networkSettingsMutex.Unlock() + + for i, a := range networkSettings.IPv4Addresses { + if a == address { + networkSettings.IPv4Addresses = append(networkSettings.IPv4Addresses[:i], networkSettings.IPv4Addresses[i+1:]...) + networkSettings.IPv4SubnetMasks = append(networkSettings.IPv4SubnetMasks[:i], networkSettings.IPv4SubnetMasks[i+1:]...) + incrementor++ + logger.Info("Removed IPv4 address: %s", address) + return + } + } + logger.Info("IPv4 address not found for removal: %s", address) +} + // SetIPv4IncludedRoutes sets the included IPv4 routes func SetIPv4IncludedRoutes(routes []IPv4Route) { networkSettingsMutex.Lock() diff --git a/network/settings_test.go b/network/settings_test.go new file mode 100644 index 0000000..eac4d5f --- /dev/null +++ b/network/settings_test.go @@ -0,0 +1,32 @@ +package network + +import "testing" + +func TestAddIPv4AddressRequiresPrimary(t *testing.T) { + ClearNetworkSettings() + defer ClearNetworkSettings() + + if err := AddIPv4Address("10.10.0.5", "255.255.255.255"); err == nil { + t.Fatal("expected an error adding a secondary address before any primary address is set") + } + if got := GetSettings().IPv4Addresses; len(got) != 0 { + t.Errorf("IPv4Addresses = %v, want empty after a rejected add", got) + } +} + +func TestAddIPv4AddressAfterPrimarySucceeds(t *testing.T) { + ClearNetworkSettings() + defer ClearNetworkSettings() + + SetIPv4Settings([]string{"10.10.0.1"}, []string{"255.255.255.0"}) + + if err := AddIPv4Address("10.10.0.5", "255.255.255.255"); err != nil { + t.Fatalf("unexpected error adding secondary address: %v", err) + } + + got := GetSettings().IPv4Addresses + want := []string{"10.10.0.1", "10.10.0.5"} + if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Errorf("IPv4Addresses = %v, want %v", got, want) + } +} diff --git a/newt/data.go b/newt/data.go index 719b707..5870b72 100644 --- a/newt/data.go +++ b/newt/data.go @@ -144,6 +144,7 @@ func (n *Newt) handleSync(msg websocket.WSMessage) { // Sync clients WireGuard peers and targets, if clients are set up if n.wgService != nil { + n.wgService.SetCerts(syncData.Certs) n.wgService.Sync(syncData.Peers, syncData.ClientTargets) } diff --git a/newt/handlers.go b/newt/handlers.go index 55c7f4e..6fede7a 100644 --- a/newt/handlers.go +++ b/newt/handlers.go @@ -10,13 +10,13 @@ import ( "net/http" "os" "os/signal" - "strings" "syscall" "time" "github.com/fosrl/newt/authdaemon" "github.com/fosrl/newt/browsergateway" "github.com/fosrl/newt/docker" + "github.com/fosrl/newt/exitnode" "github.com/fosrl/newt/healthcheck" "github.com/fosrl/newt/internal/state" "github.com/fosrl/newt/internal/telemetry" @@ -140,129 +140,7 @@ func (n *Newt) registerHandlers(ctx context.Context) { return } - if len(exitNodes) == 1 || n.config.PreferEndpoint != "" { - logger.Debug("Only one exit node available, using it directly: %s", exitNodes[0].Endpoint) - - if n.config.PreferEndpoint != "" { - for _, node := range exitNodes { - if node.Endpoint == n.config.PreferEndpoint { - exitNodes[0] = node - break - } - } - } - - pingResults := []ExitNodePingResult{ - { - ExitNodeID: exitNodes[0].ID, - LatencyMs: 0, - Weight: exitNodes[0].Weight, - Error: "", - Name: exitNodes[0].Name, - Endpoint: exitNodes[0].Endpoint, - WasPreviouslyConnected: exitNodes[0].WasPreviouslyConnected, - }, - } - - chainId := generateChainId() - n.pendingRegisterChainId = chainId - n.stopFunc = n.client.SendMessageInterval(topicWGRegister, map[string]interface{}{ - "publicKey": n.publicKey.String(), - "pingResults": pingResults, - "newtVersion": n.config.Version, - "chainId": chainId, - }, 2*time.Second) - - return - } - - type nodeResult struct { - Node ExitNode - Latency time.Duration - Err error - } - - results := make([]nodeResult, len(exitNodes)) - const pingAttempts = 3 - for i, node := range exitNodes { - var totalLatency time.Duration - var lastErr error - successes := 0 - httpClient := &http.Client{ - Timeout: 5 * time.Second, - } - url := node.Endpoint - if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { - url = "http://" + url - } - if !strings.HasSuffix(url, "/ping") { - url = strings.TrimRight(url, "/") + "/ping" - } - for j := 0; j < pingAttempts; j++ { - start := time.Now() - resp, err := httpClient.Get(url) - latency := time.Since(start) - if err != nil { - lastErr = err - logger.Warn("Failed to ping exit node %d (%s) attempt %d: %v", node.ID, url, j+1, err) - continue - } - resp.Body.Close() - totalLatency += latency - successes++ - } - var avgLatency time.Duration - if successes > 0 { - avgLatency = totalLatency / time.Duration(successes) - } - if successes == 0 { - results[i] = nodeResult{Node: node, Latency: 0, Err: lastErr} - } else { - results[i] = nodeResult{Node: node, Latency: avgLatency, Err: nil} - } - } - - var pingResults []ExitNodePingResult - for _, res := range results { - errMsg := "" - if res.Err != nil { - errMsg = res.Err.Error() - } - pingResults = append(pingResults, ExitNodePingResult{ - ExitNodeID: res.Node.ID, - LatencyMs: res.Latency.Milliseconds(), - Weight: res.Node.Weight, - Error: errMsg, - Name: res.Node.Name, - Endpoint: res.Node.Endpoint, - WasPreviouslyConnected: res.Node.WasPreviouslyConnected, - }) - } - - if n.connected { - var filteredPingResults []ExitNodePingResult - previouslyConnectedNodeIdx := -1 - for i, res := range pingResults { - if res.WasPreviouslyConnected { - previouslyConnectedNodeIdx = i - } - } - goodNodeCount := 0 - for i, res := range pingResults { - if i != previouslyConnectedNodeIdx && res.LatencyMs > 0 && res.Error == "" { - goodNodeCount++ - } - } - if previouslyConnectedNodeIdx != -1 && goodNodeCount > 0 { - for i, res := range pingResults { - if i != previouslyConnectedNodeIdx { - filteredPingResults = append(filteredPingResults, res) - } - } - pingResults = filteredPingResults - logger.Info("Excluding previously connected exit node from ping results due to other available nodes") - } - } + pingResults := exitnode.PingExitNodes(exitNodes, n.config.PreferEndpoint, n.connected) chainId := generateChainId() n.pendingRegisterChainId = chainId @@ -1010,7 +888,9 @@ func (n *Newt) registerHandlers(ctx context.Context) { } bcChainId := generateChainId() - n.pendingRegisterChainId = bcChainId + // Pangolin intentionally does not answer backwards-compatible + // registrations with newt/wg/connect. Do not replace the chain ID of + // the real registration while its response may already be in flight. if err := n.client.SendMessage(topicWGRegister, map[string]interface{}{ "publicKey": n.publicKey.String(), "newtVersion": n.config.Version, diff --git a/newt/ping.go b/newt/ping.go index 8ea7954..4126231 100644 --- a/newt/ping.go +++ b/newt/ping.go @@ -280,7 +280,8 @@ func (n *Newt) startPingCheck(fn pingFunc, serverIP, tunnelID string) chan struc "chainId": pingChainId, }, 3*time.Second) bcChainId := generateChainId() - n.pendingRegisterChainId = bcChainId + // This compatibility message has no wg/connect response and must + // not supersede the pending real registration chain. if err := n.client.SendMessage("newt/wg/register", map[string]interface{}{ "publicKey": n.publicKey.String(), "backwardsCompatible": true, diff --git a/newt/types.go b/newt/types.go index b534713..8b2a2e8 100644 --- a/newt/types.go +++ b/newt/types.go @@ -2,6 +2,7 @@ package newt import ( wgclients "github.com/fosrl/newt/clients" + "github.com/fosrl/newt/exitnode" "github.com/fosrl/newt/healthcheck" ) @@ -35,28 +36,14 @@ type TargetData struct { Targets []string `json:"targets"` } -type ExitNodeData struct { - ExitNodes []ExitNode `json:"exitNodes"` - ChainId string `json:"chainId"` -} - -type ExitNode struct { - ID int `json:"exitNodeId"` - Name string `json:"exitNodeName"` - Endpoint string `json:"endpoint"` - Weight float64 `json:"weight"` - WasPreviouslyConnected bool `json:"wasPreviouslyConnected"` -} - -type ExitNodePingResult struct { - ExitNodeID int `json:"exitNodeId"` - LatencyMs int64 `json:"latencyMs"` - Weight float64 `json:"weight"` - Error string `json:"error,omitempty"` - Name string `json:"exitNodeName"` - Endpoint string `json:"endpoint"` - WasPreviouslyConnected bool `json:"wasPreviouslyConnected"` -} +// ExitNodeData, ExitNode and ExitNodePingResult are aliases for the shared +// exit-node ping dance types in package exitnode, kept here so existing code +// in this package can keep referring to them unqualified. +type ( + ExitNodeData = exitnode.ExitNodeData + ExitNode = exitnode.ExitNode + ExitNodePingResult = exitnode.ExitNodePingResult +) type BlueprintResult struct { Success bool `json:"success"` @@ -70,5 +57,6 @@ type SyncData struct { RemoteExitNodeSubnets []string `json:"remoteExitNodeSubnets"` Peers []wgclients.Peer `json:"peers"` ClientTargets []wgclients.Target `json:"clientTargets"` + Certs []wgclients.CertData `json:"certs"` BrowserGatewayTargets []BrowserGatewayTarget `json:"browserGatewayTargets"` } diff --git a/updates/selfupdate.go b/updates/selfupdate.go index 2e12a5f..8476d0a 100644 --- a/updates/selfupdate.go +++ b/updates/selfupdate.go @@ -52,8 +52,8 @@ type versionResponse struct { } // ErrAutoUpdateUnsupportedInOfficialContainer indicates auto-update is not -// available when running inside official Fossorial container images. -var ErrAutoUpdateUnsupportedInOfficialContainer = errors.New("auto-update unsupported in official Fossorial container images") +// available when running inside container images. +var ErrAutoUpdateUnsupportedInOfficialContainer = errors.New("auto-update unsupported in container images") // isOfficialContainer returns true when the process is running inside an // official Fossorial-built container image. The image sets diff --git a/websocket/client.go b/websocket/client.go index 2fd036e..69f9b83 100644 --- a/websocket/client.go +++ b/websocket/client.go @@ -28,6 +28,15 @@ import ( "go.opentelemetry.io/otel" ) +// writeDeadline bounds how long a websocket write may block before it is +// treated as a failure. Without this, a write to a TCP connection whose +// underlying network interface has disappeared (e.g. laptop sleep/resume, +// Wi-Fi roam) can sit buffered in the kernel for minutes without erroring. +// This matters even with the read-deadline/pong machinery below: if the +// WriteJSON call in sendPing blocks, execution never reaches the +// WriteControl ping that would otherwise trigger that read-side detection. +const writeDeadline = 10 * time.Second + type Client struct { conn *websocket.Conn config *Config @@ -257,6 +266,9 @@ func (c *Client) SendMessage(messageType string, data interface{}) error { c.writeMux.Lock() defer c.writeMux.Unlock() + if err := c.conn.SetWriteDeadline(time.Now().Add(writeDeadline)); err != nil { + return err + } if err := c.conn.WriteJSON(msg); err != nil { return err } @@ -277,6 +289,9 @@ func (c *Client) SendMessageNoLog(messageType string, data interface{}) error { c.writeMux.Lock() defer c.writeMux.Unlock() + if err := c.conn.SetWriteDeadline(time.Now().Add(writeDeadline)); err != nil { + return err + } if err := c.conn.WriteJSON(msg); err != nil { return err } @@ -760,14 +775,17 @@ func (c *Client) sendPing() { c.writeMux.Unlock() return } - err := c.conn.WriteJSON(pingMsg) + err := c.conn.SetWriteDeadline(time.Now().Add(writeDeadline)) + if err == nil { + err = c.conn.WriteJSON(pingMsg) + } if err == nil { telemetry.IncWSMessage(c.metricsContext(), "out", "ping") // Protocol-level ping: a standards-compliant server replies with a PONG, // which refreshes the read deadline. This is what lets us notice a // half-open connection where writes still succeed (buffered) but the // peer is gone. - _ = c.conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(10*time.Second)) + _ = c.conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(writeDeadline)) } c.writeMux.Unlock()