diff --git a/api/api.go b/api/api.go index 895140b..b8f4e56 100644 --- a/api/api.go +++ b/api/api.go @@ -29,6 +29,7 @@ type ConnectionRequest struct { PingInterval string `json:"pingInterval,omitempty"` PingTimeout string `json:"pingTimeout,omitempty"` OrgID string `json:"orgId,omitempty"` + MatchDomains []string `json:"matchDomains,omitempty"` } // SwitchOrgRequest defines the structure for switching organizations @@ -50,6 +51,7 @@ type PeerStatus struct { LastSeen time.Time `json:"lastSeen"` Endpoint string `json:"endpoint,omitempty"` IsRelay bool `json:"isRelay"` + IsLocal bool `json:"isLocal"` // true when connected via a local network endpoint, bypassing both the public endpoint and relay PeerIP string `json:"peerAddress,omitempty"` HolepunchConnected bool `json:"holepunchConnected"` } @@ -228,7 +230,7 @@ func (s *API) Stop() error { return nil } -func (s *API) AddPeerStatus(siteID int, siteName string, connected bool, rtt time.Duration, endpoint string, isRelay bool) { +func (s *API) AddPeerStatus(siteID int, siteName string, connected bool, rtt time.Duration, endpoint string, isRelay bool, isLocal bool) { s.statusMu.Lock() defer s.statusMu.Unlock() @@ -246,10 +248,11 @@ func (s *API) AddPeerStatus(siteID int, siteName string, connected bool, rtt tim status.LastSeen = time.Now() status.Endpoint = endpoint status.IsRelay = isRelay + status.IsLocal = isLocal } -// UpdatePeerStatus updates the status of a peer including endpoint and relay info -func (s *API) UpdatePeerStatus(siteID int, connected bool, rtt time.Duration, endpoint string, isRelay bool) { +// UpdatePeerStatus updates the status of a peer including endpoint, relay, and local info +func (s *API) UpdatePeerStatus(siteID int, connected bool, rtt time.Duration, endpoint string, isRelay bool, isLocal bool) { s.statusMu.Lock() defer s.statusMu.Unlock() @@ -266,6 +269,7 @@ func (s *API) UpdatePeerStatus(siteID int, connected bool, rtt time.Duration, en status.LastSeen = time.Now() status.Endpoint = endpoint status.IsRelay = isRelay + status.IsLocal = isLocal } func (s *API) RemovePeerStatus(siteID int) { // remove the peer from the status map @@ -362,6 +366,31 @@ func (s *API) UpdatePeerRelayStatus(siteID int, endpoint string, isRelay bool) { status.Endpoint = endpoint status.IsRelay = isRelay + if isRelay { + // Relay and local are mutually exclusive; local always wins when viable. + status.IsLocal = false + } +} + +// UpdatePeerLocalStatus updates only the local-connection status of a peer. A peer using a +// local connection is never simultaneously relayed. +func (s *API) UpdatePeerLocalStatus(siteID int, endpoint string, isLocal bool) { + s.statusMu.Lock() + defer s.statusMu.Unlock() + + status, exists := s.peerStatuses[siteID] + if !exists { + status = &PeerStatus{ + SiteID: siteID, + } + s.peerStatuses[siteID] = status + } + + status.Endpoint = endpoint + status.IsLocal = isLocal + if isLocal { + status.IsRelay = false + } } // UpdatePeerHolepunchStatus updates the holepunch connection status of a peer diff --git a/config.go b/config.go index 5959270..44b030c 100644 --- a/config.go +++ b/config.go @@ -27,6 +27,13 @@ type OlmConfig struct { UpstreamDNS []string `json:"upstreamDNS"` InterfaceName string `json:"interface"` + // MatchDomains lists FQDN wildcard patterns (using * and ? wildcards, e.g. + // "*.proxy.internal") that olm should check against local records / resolve + // via UpstreamDNS. Queries for domains that don't match any pattern are sent + // directly to the host's own system DNS servers instead. Empty means match + // every domain (i.e. the feature is disabled). + MatchDomains []string `json:"matchDomainsDNS"` + // Logging LogLevel string `json:"logLevel"` @@ -40,11 +47,12 @@ type OlmConfig struct { PingTimeout string `json:"pingTimeout"` // Advanced - DisableHolepunch bool `json:"disableHolepunch"` - TlsClientCert string `json:"tlsClientCert"` - OverrideDNS bool `json:"overrideDNS"` - TunnelDNS bool `json:"tunnelDNS"` - DisableRelay bool `json:"disableRelay"` + DisableHolepunch bool `json:"disableHolepunch"` + TlsClientCert string `json:"tlsClientCert"` + OverrideDNS bool `json:"overrideDNS"` + TunnelDNS bool `json:"tunnelDNS"` + DisableRelay bool `json:"disableRelay"` + PreferLocalRoutes bool `json:"preferLocalRoutes"` // DoNotCreateNewClient bool `json:"doNotCreateNewClient"` // Parsed values (not in JSON) @@ -99,6 +107,7 @@ func DefaultConfig() *OlmConfig { config.sources["mtu"] = string(SourceDefault) config.sources["dns"] = string(SourceDefault) config.sources["upstreamDNS"] = string(SourceDefault) + config.sources["matchDomains"] = string(SourceDefault) config.sources["logLevel"] = string(SourceDefault) config.sources["interface"] = string(SourceDefault) config.sources["enableApi"] = string(SourceDefault) @@ -110,6 +119,7 @@ func DefaultConfig() *OlmConfig { config.sources["overrideDNS"] = string(SourceDefault) config.sources["tunnelDNS"] = string(SourceDefault) config.sources["disableRelay"] = string(SourceDefault) + config.sources["preferLocalRoutes"] = string(SourceDefault) // config.sources["doNotCreateNewClient"] = string(SourceDefault) return config @@ -229,6 +239,10 @@ func loadConfigFromEnv(config *OlmConfig) { config.UpstreamDNS = []string{val} config.sources["upstreamDNS"] = string(SourceEnv) } + if val := os.Getenv("MATCH_DOMAINS_DNS"); val != "" { + config.MatchDomains = splitComma(val) + config.sources["matchDomains"] = string(SourceEnv) + } if val := os.Getenv("LOG_LEVEL"); val != "" { config.LogLevel = val config.sources["logLevel"] = string(SourceEnv) @@ -269,6 +283,10 @@ func loadConfigFromEnv(config *OlmConfig) { config.DisableRelay = true config.sources["disableRelay"] = string(SourceEnv) } + if val := os.Getenv("PREFER_LOCAL_ROUTES"); val == "true" { + config.PreferLocalRoutes = true + config.sources["preferLocalRoutes"] = string(SourceEnv) + } if val := os.Getenv("TUNNEL_DNS"); val == "true" { config.TunnelDNS = true config.sources["tunnelDNS"] = string(SourceEnv) @@ -285,25 +303,27 @@ func loadConfigFromCLI(config *OlmConfig, args []string) (bool, bool, error) { // Store original values to detect changes origValues := map[string]interface{}{ - "endpoint": config.Endpoint, - "id": config.ID, - "secret": config.Secret, - "org": config.OrgID, - "userToken": config.UserToken, - "mtu": config.MTU, - "dns": config.DNS, - "upstreamDNS": fmt.Sprintf("%v", config.UpstreamDNS), - "logLevel": config.LogLevel, - "interface": config.InterfaceName, - "httpAddr": config.HTTPAddr, - "socketPath": config.SocketPath, - "pingInterval": config.PingInterval, - "pingTimeout": config.PingTimeout, - "enableApi": config.EnableAPI, - "disableHolepunch": config.DisableHolepunch, - "overrideDNS": config.OverrideDNS, - "disableRelay": config.DisableRelay, - "tunnelDNS": config.TunnelDNS, + "endpoint": config.Endpoint, + "id": config.ID, + "secret": config.Secret, + "org": config.OrgID, + "userToken": config.UserToken, + "mtu": config.MTU, + "dns": config.DNS, + "upstreamDNS": fmt.Sprintf("%v", config.UpstreamDNS), + "matchDomains": fmt.Sprintf("%v", config.MatchDomains), + "logLevel": config.LogLevel, + "interface": config.InterfaceName, + "httpAddr": config.HTTPAddr, + "socketPath": config.SocketPath, + "pingInterval": config.PingInterval, + "pingTimeout": config.PingTimeout, + "enableApi": config.EnableAPI, + "disableHolepunch": config.DisableHolepunch, + "overrideDNS": config.OverrideDNS, + "disableRelay": config.DisableRelay, + "preferLocalRoutes": config.PreferLocalRoutes, + "tunnelDNS": config.TunnelDNS, // "doNotCreateNewClient": config.DoNotCreateNewClient, } @@ -317,6 +337,8 @@ func loadConfigFromCLI(config *OlmConfig, args []string) (bool, bool, error) { serviceFlags.StringVar(&config.DNS, "dns", config.DNS, "DNS server to use") var upstreamDNSFlag string serviceFlags.StringVar(&upstreamDNSFlag, "upstream-dns", "", "Upstream DNS server(s) (comma-separated, default: 8.8.8.8:53)") + var matchDomainsFlag string + serviceFlags.StringVar(&matchDomainsFlag, "match-domains-dns", "", "FQDN wildcard patterns (comma-separated, e.g. '*.proxy.internal,*.host-0?.autoco.internal') to check against local records/upstream DNS; queries for non-matching domains are sent directly to the system's DNS servers (default: match all domains)") serviceFlags.StringVar(&config.LogLevel, "log-level", config.LogLevel, "Log level (DEBUG, INFO, WARN, ERROR, FATAL)") serviceFlags.StringVar(&config.InterfaceName, "interface", config.InterfaceName, "Name of the WireGuard interface") serviceFlags.StringVar(&config.HTTPAddr, "http-addr", config.HTTPAddr, "HTTP server address (e.g., ':9452')") @@ -327,6 +349,7 @@ func loadConfigFromCLI(config *OlmConfig, args []string) (bool, bool, error) { serviceFlags.BoolVar(&config.DisableHolepunch, "disable-holepunch", config.DisableHolepunch, "Disable hole punching") serviceFlags.BoolVar(&config.OverrideDNS, "override-dns", config.OverrideDNS, "When enabled, the client uses custom DNS servers to resolve internal resources and aliases. This overrides your system's default DNS settings. Queries that cannot be resolved as a Pangolin resource will be forwarded to your configured Upstream DNS Server. (default false)") serviceFlags.BoolVar(&config.DisableRelay, "disable-relay", config.DisableRelay, "Disable relay connections") + serviceFlags.BoolVar(&config.PreferLocalRoutes, "prefer-local-routes", config.PreferLocalRoutes, "Add tunnel routes with a high metric so overlapping local/connected routes take precedence (default false)") serviceFlags.BoolVar(&config.TunnelDNS, "tunnel-dns", config.TunnelDNS, "When enabled, DNS queries are routed through the tunnel for remote resolution. To ensure queries are tunneled correctly, you must define the DNS server as a Pangolin resource and enter its address as an Upstream DNS Server. (default false)") // serviceFlags.BoolVar(&config.DoNotCreateNewClient, "do-not-create-new-client", config.DoNotCreateNewClient, "Do not create new client") @@ -348,6 +371,11 @@ func loadConfigFromCLI(config *OlmConfig, args []string) (bool, bool, error) { } } + // Parse match domains flag if provided + if matchDomainsFlag != "" { + config.MatchDomains = splitComma(matchDomainsFlag) + } + // Track which values were changed by CLI args if config.Endpoint != origValues["endpoint"].(string) { config.sources["endpoint"] = string(SourceCLI) @@ -373,6 +401,9 @@ func loadConfigFromCLI(config *OlmConfig, args []string) (bool, bool, error) { if fmt.Sprintf("%v", config.UpstreamDNS) != origValues["upstreamDNS"].(string) { config.sources["upstreamDNS"] = string(SourceCLI) } + if fmt.Sprintf("%v", config.MatchDomains) != origValues["matchDomains"].(string) { + config.sources["matchDomains"] = string(SourceCLI) + } if config.LogLevel != origValues["logLevel"].(string) { config.sources["logLevel"] = string(SourceCLI) } @@ -403,6 +434,9 @@ func loadConfigFromCLI(config *OlmConfig, args []string) (bool, bool, error) { if config.DisableRelay != origValues["disableRelay"].(bool) { config.sources["disableRelay"] = string(SourceCLI) } + if config.PreferLocalRoutes != origValues["preferLocalRoutes"].(bool) { + config.sources["preferLocalRoutes"] = string(SourceCLI) + } if config.TunnelDNS != origValues["tunnelDNS"].(bool) { config.sources["tunnelDNS"] = string(SourceCLI) } @@ -481,6 +515,10 @@ func mergeConfigs(dest, src *OlmConfig) { dest.UpstreamDNS = src.UpstreamDNS dest.sources["upstreamDNS"] = string(SourceFile) } + if len(src.MatchDomains) > 0 { + dest.MatchDomains = src.MatchDomains + dest.sources["matchDomains"] = string(SourceFile) + } if src.LogLevel != "" && src.LogLevel != "INFO" { dest.LogLevel = src.LogLevel dest.sources["logLevel"] = string(SourceFile) @@ -530,6 +568,10 @@ func mergeConfigs(dest, src *OlmConfig) { dest.DisableRelay = src.DisableRelay dest.sources["disableRelay"] = string(SourceFile) } + if src.PreferLocalRoutes { + dest.PreferLocalRoutes = src.PreferLocalRoutes + dest.sources["preferLocalRoutes"] = string(SourceFile) + } // if src.DoNotCreateNewClient { // dest.DoNotCreateNewClient = src.DoNotCreateNewClient // dest.sources["doNotCreateNewClient"] = string(SourceFile) @@ -598,6 +640,7 @@ func (c *OlmConfig) ShowConfig() { fmt.Printf(" mtu = %d [%s]\n", c.MTU, getSource("mtu")) fmt.Printf(" dns = %s [%s]\n", c.DNS, getSource("dns")) fmt.Printf(" upstream-dns = %v [%s]\n", c.UpstreamDNS, getSource("upstreamDNS")) + fmt.Printf(" match-domains-dns = %v [%s]\n", c.MatchDomains, getSource("matchDomains")) fmt.Printf(" interface = %s [%s]\n", c.InterfaceName, getSource("interface")) // Logging @@ -621,6 +664,7 @@ func (c *OlmConfig) ShowConfig() { fmt.Printf(" override-dns = %v [%s]\n", c.OverrideDNS, getSource("overrideDNS")) fmt.Printf(" tunnel-dns = %v [%s]\n", c.TunnelDNS, getSource("tunnelDNS")) fmt.Printf(" disable-relay = %v [%s]\n", c.DisableRelay, getSource("disableRelay")) + fmt.Printf(" prefer-local-routes = %v [%s]\n", c.PreferLocalRoutes, getSource("preferLocalRoutes")) // fmt.Printf(" do-not-create-new-client = %v [%s]\n", c.DoNotCreateNewClient, getSource("doNotCreateNewClient")) if c.TlsClientCert != "" { fmt.Printf(" tls-cert = %s [%s]\n", c.TlsClientCert, getSource("tlsClientCert")) diff --git a/dns/dns_proxy.go b/dns/dns_proxy.go index a78992a..979f42e 100644 --- a/dns/dns_proxy.go +++ b/dns/dns_proxy.go @@ -5,6 +5,7 @@ import ( "fmt" "net" "net/netip" + "strings" "sync" "time" @@ -38,6 +39,20 @@ type DNSProxy struct { middleDevice *device.MiddleDevice // Reference to MiddleDevice for packet filtering and TUN writes recordStore *DNSRecordStore // Local DNS records + // matchDomains lists the FQDN wildcard patterns (using * and ? wildcards, see + // matchWildcard) that this proxy is responsible for. Queries whose name matches + // one of these patterns are checked against local records and, failing that, + // forwarded to upstreamDNS. Queries that match none of the patterns are sent + // directly to localDNS instead, bypassing local records and upstreamDNS + // entirely. An empty matchDomains means "match everything" (i.e. behave as if + // this feature were not configured). + matchDomains []string + // localDNS holds the host's own system DNS servers (as reported by + // SystemDNSMonitor / PublicDNS), used to resolve queries that don't match + // matchDomains rather than sending them upstream or through the tunnel. + localDNS []string + matchMu sync.RWMutex + // Tunnel DNS fields - for sending queries over WireGuard tunnelIP netip.Addr // WireGuard interface IP (source for tunneled queries) tunnelStack *stack.Stack // Separate netstack for outbound tunnel queries @@ -55,8 +70,14 @@ type DNSProxy struct { wg sync.WaitGroup } -// NewDNSProxy creates a new DNS proxy -func NewDNSProxy(middleDevice *device.MiddleDevice, mtu int, utilitySubnet string, upstreamDns []string, tunnelDns bool, tunnelIP string) (*DNSProxy, error) { +// NewDNSProxy creates a new DNS proxy. +// +// matchDomains, if non-empty, restricts local-record lookup and upstream +// forwarding to queries whose name matches one of the given wildcard patterns +// (see matchWildcard). Queries that match none of the patterns are instead +// forwarded directly to localDNS (the host's own system DNS servers). Pass an +// empty matchDomains to match every query, preserving prior behavior. +func NewDNSProxy(middleDevice *device.MiddleDevice, mtu int, utilitySubnet string, upstreamDns []string, tunnelDns bool, tunnelIP string, matchDomains []string, localDNS []string) (*DNSProxy, error) { proxyIP, err := PickIPFromSubnet(utilitySubnet) if err != nil { return nil, fmt.Errorf("failed to pick DNS proxy IP from subnet: %v", err) @@ -76,6 +97,8 @@ func NewDNSProxy(middleDevice *device.MiddleDevice, mtu int, utilitySubnet strin tunnelDNS: tunnelDns, recordStore: NewDNSRecordStore(), tunnelActivePorts: make(map[uint16]bool), + matchDomains: matchDomains, + localDNS: localDNS, ctx: ctx, cancel: cancel, } @@ -383,6 +406,27 @@ func (p *DNSProxy) handleDNSQuery(udpConn *gonet.UDPConn, queryData []byte, clie question := msg.Question[0] logger.Debug("DNS query for %s (type %s)", question.Name, dns.TypeToString[question.Qtype]) + // If matchDomains is configured and this query's name doesn't match any of + // the configured patterns, skip local records and upstream entirely and + // send it straight to the host's own system DNS servers. + if !p.matchesConfiguredDomains(question.Name) { + logger.Debug("Query for %s does not match configured domains, forwarding to local DNS %v", question.Name, p.getLocalDNS()) + response := p.forwardToLocalDNS(msg) + if response == nil { + logger.Error("Failed to get DNS response for %s from local DNS", question.Name) + return + } + responseData, err := response.Pack() + if err != nil { + logger.Error("Failed to pack DNS response: %v", err) + return + } + if _, err := udpConn.WriteTo(responseData, clientAddr); err != nil { + logger.Error("Failed to send DNS response: %v", err) + } + return + } + // Check if we have local records for this query var response *dns.Msg if question.Qtype == dns.TypeA || question.Qtype == dns.TypeAAAA || question.Qtype == dns.TypePTR { @@ -505,6 +549,77 @@ func (p *DNSProxy) checkLocalRecords(query *dns.Msg, question dns.Question) *dns return response } +// matchesConfiguredDomains reports whether name matches one of the configured +// matchDomains wildcard patterns. If matchDomains is empty, every name is +// considered a match (i.e. the feature is disabled). +func (p *DNSProxy) matchesConfiguredDomains(name string) bool { + p.matchMu.RLock() + patterns := p.matchDomains + p.matchMu.RUnlock() + + if len(patterns) == 0 { + return true + } + + name = strings.ToLower(dns.Fqdn(name)) + for _, pattern := range patterns { + pattern = strings.ToLower(dns.Fqdn(pattern)) + if matchWildcard(pattern, name) { + return true + } + } + return false +} + +// getLocalDNS returns the currently configured local (system) DNS servers. +func (p *DNSProxy) getLocalDNS() []string { + p.matchMu.RLock() + defer p.matchMu.RUnlock() + return p.localDNS +} + +// forwardToLocalDNS forwards a DNS query directly to the host's own system DNS +// servers (localDNS), always using host networking regardless of tunnelDNS - +// these queries are for domains the caller has explicitly excluded from +// Pangolin resolution, so they should never traverse the tunnel. +func (p *DNSProxy) forwardToLocalDNS(query *dns.Msg) *dns.Msg { + servers := p.getLocalDNS() + if len(servers) == 0 { + logger.Warn("No local DNS servers configured, dropping query for %s", query.Question[0].Name) + return nil + } + + var lastErr error + for _, server := range servers { + response, err := p.queryUpstreamDirect(server, query, 2*time.Second) + if err == nil { + return response + } + lastErr = err + } + logger.Error("All local DNS servers failed: %v", lastErr) + return nil +} + +// SetMatchDomains replaces the list of wildcard domain patterns (see +// matchWildcard) that this proxy checks against local records / upstream DNS. +// Queries not matching any pattern are sent to localDNS instead. Pass an +// empty slice to match every query (i.e. disable filtering). +func (p *DNSProxy) SetMatchDomains(patterns []string) { + p.matchMu.Lock() + defer p.matchMu.Unlock() + p.matchDomains = patterns +} + +// SetLocalDNS replaces the list of local (host system) DNS servers used to +// resolve queries that don't match matchDomains. Servers must be in +// "host:port" format (e.g. "192.168.1.1:53"). +func (p *DNSProxy) SetLocalDNS(servers []string) { + p.matchMu.Lock() + defer p.matchMu.Unlock() + p.localDNS = servers +} + // forwardToUpstream forwards a DNS query to upstream DNS servers func (p *DNSProxy) forwardToUpstream(query *dns.Msg) *dns.Msg { // Try primary DNS server diff --git a/go.mod b/go.mod index 4510632..96a3c23 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.25.0 require ( github.com/Microsoft/go-winio v0.6.2 - github.com/fosrl/newt v1.14.0 + github.com/fosrl/newt v1.15.0 github.com/godbus/dbus/v5 v5.2.2 github.com/gorilla/websocket v1.5.3 github.com/miekg/dns v1.1.70 diff --git a/go.sum b/go.sum index 3273e4a..a46f567 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/fosrl/newt v1.14.0 h1:9jpyfCNAtsH7rPojyIGJwqOqnLZdxm8b+njY5ZuY/6c= -github.com/fosrl/newt v1.14.0/go.mod h1:l6kWoZPSaXT+ZRUjiyPgwflRqZWYaXpUj9oQ0sOPh4o= +github.com/fosrl/newt v1.15.0 h1:WpL0whZM1FMjUe2Vy5jSH1bgbxm1O9k1qCyF/mqZT+s= +github.com/fosrl/newt v1.15.0/go.mod h1:l6kWoZPSaXT+ZRUjiyPgwflRqZWYaXpUj9oQ0sOPh4o= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= diff --git a/main.go b/main.go index 05a2cee..060dc79 100644 --- a/main.go +++ b/main.go @@ -263,6 +263,7 @@ func runOlmMainWithArgs(ctx context.Context, cancel context.CancelFunc, signalCt MTU: config.MTU, DNS: config.DNS, UpstreamDNS: config.UpstreamDNS, + MatchDomains: config.MatchDomains, InterfaceName: config.InterfaceName, Holepunch: !config.DisableHolepunch, TlsClientCert: config.TlsClientCert, @@ -271,6 +272,7 @@ func runOlmMainWithArgs(ctx context.Context, cancel context.CancelFunc, signalCt OrgID: config.OrgID, OverrideDNS: config.OverrideDNS, DisableRelay: config.DisableRelay, + PreferLocalRoutes: config.PreferLocalRoutes, EnableUAPI: true, } go olm.StartTunnel(tunnelConfig) diff --git a/olm/connect.go b/olm/connect.go index 6337290..ff2b5f4 100644 --- a/olm/connect.go +++ b/olm/connect.go @@ -145,7 +145,7 @@ func (o *Olm) handleConnect(msg websocket.WSMessage) { } // Create and start DNS proxy - o.dnsProxy, err = dns.NewDNSProxy(o.middleDev, o.tunnelConfig.MTU, wgData.UtilitySubnet, o.tunnelConfig.UpstreamDNS, o.tunnelConfig.TunnelDNS, interfaceIP) + o.dnsProxy, err = dns.NewDNSProxy(o.middleDev, o.tunnelConfig.MTU, wgData.UtilitySubnet, o.tunnelConfig.UpstreamDNS, o.tunnelConfig.TunnelDNS, interfaceIP, o.tunnelConfig.MatchDomains, o.tunnelConfig.PublicDNS) if err != nil { logger.Error("Failed to create DNS proxy: %v", err) } @@ -192,7 +192,7 @@ func (o *Olm) handleConnect(msg websocket.WSMessage) { siteEndpoint = site.Endpoint } - o.apiServer.AddPeerStatus(site.SiteId, site.Name, false, 0, siteEndpoint, false) + o.apiServer.AddPeerStatus(site.SiteId, site.Name, false, 0, siteEndpoint, false, false) } // we still call this to add the aliases for jit lookup but we just do that then pass inside. need to skip the above so we dont add to the api diff --git a/olm/olm.go b/olm/olm.go index ef00414..fa638be 100644 --- a/olm/olm.go +++ b/olm/olm.go @@ -231,6 +231,7 @@ func (o *Olm) registerAPICallbacks() { Holepunch: req.Holepunch, TlsClientCert: req.TlsClientCert, OrgID: req.OrgID, + MatchDomains: req.MatchDomains, } var err error @@ -397,6 +398,7 @@ func (o *Olm) StartTunnel(config TunnelConfig) { o.tunnelRunning = true // Also set it here in case it is called externally o.tunnelConfig = config + network.PreferLocalRoutes = config.PreferLocalRoutes // Determine whether the system DNS monitor should also manage UpstreamDNS. // If the caller did not provide an explicit UpstreamDNS (it was defaulted to @@ -427,6 +429,11 @@ func (o *Olm) StartTunnel(config TunnelConfig) { if pm := o.getPeerManager(); pm != nil { pm.SetPublicDNS(servers) } + // Keep the DNS proxy's local-DNS fallback (used for MatchDomains + // misses) in sync with the host's real system DNS servers. + if o.dnsProxy != nil { + o.dnsProxy.SetLocalDNS(servers) + } // UpstreamDNS is updated only when the caller did not supply an // explicit value; dynamic updates keep the proxy forwarding to the @@ -530,6 +537,8 @@ func (o *Olm) StartTunnel(config TunnelConfig) { o.websocket.RegisterHandler("olm/wg/peer/update", o.handleWgPeerUpdate) o.websocket.RegisterHandler("olm/wg/peer/relay", o.handleWgPeerRelay) o.websocket.RegisterHandler("olm/wg/peer/unrelay", o.handleWgPeerUnrelay) + o.websocket.RegisterHandler("olm/wg/peer/local", o.handleWgPeerLocal) + o.websocket.RegisterHandler("olm/wg/peer/unlocal", o.handleWgPeerUnlocal) // Handlers for managing remote subnets to a peer o.websocket.RegisterHandler("olm/wg/peer/data/add", o.handleWgPeerAddData) diff --git a/olm/peer.go b/olm/peer.go index 2747d7b..7c4609b 100644 --- a/olm/peer.go +++ b/olm/peer.go @@ -293,6 +293,71 @@ func (o *Olm) handleWgPeerUnrelay(msg websocket.WSMessage) { pm.UnRelayPeer(relayData.SiteId, primaryRelay) } +// handleWgPeerLocal handles the server's acknowledgement of an "olm/wg/local" message. +// olm already switched the peer to the local endpoint before sending that message (it +// doesn't wait for permission, unlike relay), so all this needs to do is stop the retry +// sender for the given chain. +func (o *Olm) handleWgPeerLocal(msg websocket.WSMessage) { + logger.Debug("Received local-peer ack message: %v", msg.Data) + + pm := o.getPeerManager() + if pm == nil { + logger.Debug("Ignoring local ack message: peerManager is nil (shutdown in progress)") + return + } + + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Error("Error marshaling data: %v", err) + return + } + + var localData struct { + peers.LocalPeerAckData + ChainId string `json:"chainId"` + } + if err := json.Unmarshal(jsonData, &localData); err != nil { + logger.Error("Error unmarshaling local ack data: %v", err) + return + } + + if monitor := pm.GetPeerMonitor(); monitor != nil { + monitor.CancelLocalSend(localData.ChainId) + } +} + +// handleWgPeerUnlocal handles the server's acknowledgement of an "olm/wg/unlocal" message. +// Same as handleWgPeerLocal, olm has already fallen back from the local endpoint by the time +// it sends the notification, so this just stops the retry sender. +func (o *Olm) handleWgPeerUnlocal(msg websocket.WSMessage) { + logger.Debug("Received unlocal-peer ack message: %v", msg.Data) + + pm := o.getPeerManager() + if pm == nil { + logger.Debug("Ignoring unlocal ack message: peerManager is nil (shutdown in progress)") + return + } + + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Error("Error marshaling data: %v", err) + return + } + + var localData struct { + peers.LocalPeerAckData + ChainId string `json:"chainId"` + } + if err := json.Unmarshal(jsonData, &localData); err != nil { + logger.Error("Error unmarshaling unlocal ack data: %v", err) + return + } + + if monitor := pm.GetPeerMonitor(); monitor != nil { + monitor.CancelLocalSend(localData.ChainId) + } +} + func (o *Olm) handleWgPeerHolepunchAddSite(msg websocket.WSMessage) { logger.Debug("Received peer-handshake message: %v", msg.Data) diff --git a/olm/types.go b/olm/types.go index a58d637..b5b3a5c 100644 --- a/olm/types.go +++ b/olm/types.go @@ -79,6 +79,13 @@ type TunnelConfig struct { PublicDNS []string InterfaceName string + // MatchDomains lists FQDN wildcard patterns (using * and ? wildcards) that + // olm should check against local records / resolve via UpstreamDNS. Queries + // that don't match any pattern are sent directly to the host's own system + // DNS servers (PublicDNS) instead of being handled by the DNS proxy at all. + // An empty MatchDomains matches every query, preserving prior behavior. + MatchDomains []string + // Advanced Holepunch bool TlsClientCert string @@ -102,4 +109,12 @@ type TunnelConfig struct { InitialPostures map[string]any DisableRelay bool + + // PreferLocalRoutes, when enabled, adds tunnel routes with an explicit + // high metric/priority so that an overlapping local/connected route to + // the same destination always takes precedence over the VPN route, + // rather than the two racing based on insertion order. Defaults to + // false, preserving the routing behavior from before this option was + // introduced. + PreferLocalRoutes bool } diff --git a/peers/manager.go b/peers/manager.go index 8af5805..0d5eda7 100644 --- a/peers/manager.go +++ b/peers/manager.go @@ -86,6 +86,8 @@ func NewPeerManager(config PeerManagerConfig) *PeerManager { pm.optimizerTrigger = make(chan struct{}, 1) + pm.peerMonitor.SetLocalConnectionCallbacks(pm.LocalPeer, pm.UnLocalPeer) + return pm } @@ -181,7 +183,7 @@ func (pm *PeerManager) AddPeer(siteConfig SiteConfig) error { monitorAddress := strings.Split(siteConfig.ServerIP, "/")[0] monitorPeer := net.JoinHostPort(monitorAddress, strconv.Itoa(int(siteConfig.ServerPort+1))) // +1 for the monitor port - err := pm.peerMonitor.AddPeer(siteConfig.SiteId, monitorPeer, siteConfig.Endpoint) // always use the real site endpoint for hole punch monitoring + err := pm.peerMonitor.AddPeer(siteConfig.SiteId, monitorPeer, siteConfig.Endpoint, siteConfig.LocalEndpoints) // always use the real site endpoint for hole punch monitoring if err != nil { logger.Warn("Failed to setup monitoring for site %d: %v", siteConfig.SiteId, err) } else { @@ -190,11 +192,11 @@ func (pm *PeerManager) AddPeer(siteConfig SiteConfig) error { pm.peers[siteConfig.SiteId] = siteConfig - pm.APIServer.AddPeerStatus(siteConfig.SiteId, siteConfig.Name, false, 0, siteConfig.Endpoint, false) + pm.APIServer.AddPeerStatus(siteConfig.SiteId, siteConfig.Name, false, 0, siteConfig.Endpoint, false, false) // Perform rapid initial holepunch test (outside of lock to avoid blocking) // This quickly determines if holepunch is viable and triggers relay if not - go pm.performRapidInitialTest(siteConfig.SiteId, siteConfig.Endpoint) + go pm.performRapidInitialTest(siteConfig.SiteId, siteConfig.Endpoint, siteConfig.LocalEndpoints) return nil } @@ -257,7 +259,7 @@ func (pm *PeerManager) RemovePeer(siteId int) error { } } if !subnetStillInUse { - if err := network.RemoveRoutes([]string{subnet}); err != nil { + if err := network.RemoveRoutes([]string{subnet}, pm.interfaceName); err != nil { logger.Error("Failed to remove route for remote subnet %s: %v", subnet, err) } } @@ -326,6 +328,10 @@ func (pm *PeerManager) UpdatePeer(siteConfig SiteConfig) error { return fmt.Errorf("peer with site ID %d not found", siteConfig.SiteId) } + // Preserve the currently active local endpoint (if any) across updates so an in-progress + // local connection isn't disrupted by an unrelated site update. + siteConfig.ActiveLocalEndpoint = oldPeer.ActiveLocalEndpoint + // Update aliases // Remove old aliases for _, alias := range oldPeer.Aliases { @@ -459,7 +465,7 @@ func (pm *PeerManager) UpdatePeer(siteConfig SiteConfig) error { } } if !subnetStillInUse { - if err := network.RemoveRoutes([]string{subnet}); err != nil { + if err := network.RemoveRoutes([]string{subnet}, pm.interfaceName); err != nil { logger.Error("Failed to remove route for subnet %s: %v", subnet, err) } } @@ -473,6 +479,7 @@ func (pm *PeerManager) UpdatePeer(siteConfig SiteConfig) error { } pm.peerMonitor.UpdateHolepunchEndpoint(siteConfig.SiteId, siteConfig.Endpoint) + pm.peerMonitor.UpdateLocalEndpoints(siteConfig.SiteId, siteConfig.LocalEndpoints) monitorAddress := strings.Split(siteConfig.ServerIP, "/")[0] monitorPeer := net.JoinHostPort(monitorAddress, strconv.Itoa(int(siteConfig.ServerPort+1))) // +1 for the monitor port @@ -726,7 +733,7 @@ func (pm *PeerManager) RemoveRemoteSubnet(siteId int, ip string) error { // Only remove route if no other peer needs it if !subnetStillInUse { - if err := network.RemoveRoutes([]string{ip}); err != nil { + if err := network.RemoveRoutes([]string{ip}, pm.interfaceName); err != nil { return err } } @@ -814,6 +821,11 @@ func (pm *PeerManager) RemoveAlias(siteId int, aliasName string) error { func (pm *PeerManager) RelayPeer(siteId int, relayEndpoint string, relayPort uint16) { pm.mu.Lock() peer, exists := pm.peers[siteId] + if exists && peer.ActiveLocalEndpoint != "" { + pm.mu.Unlock() + logger.Info("Ignoring relay request for site %d: local connection is active", siteId) + return + } if exists { // Store the relay endpoint peer.RelayEndpoint = relayEndpoint @@ -856,15 +868,43 @@ endpoint=%s:%d`, util.FixKey(peer.PublicKey), formattedEndpoint, relayPort) } // performRapidInitialTest performs a rapid holepunch test for a newly added peer. -// If the test fails, it immediately requests relay to minimize connection delay. -// This runs in a goroutine to avoid blocking AddPeer. -func (pm *PeerManager) performRapidInitialTest(siteId int, endpoint string) { +// It races a test of the public endpoint against a test of the local candidate endpoints +// (if any) and waits for both to finish before acting, so we never request relay only to +// have it immediately superseded by a local connection (or vice versa). Local wins if it's +// viable at all; otherwise relay is requested only if the public endpoint isn't viable. +// This runs in a goroutine to avoid blocking AddPeer - the peer already starts out pointed +// at the public endpoint (set synchronously in AddPeer), so this just settles the peer onto +// its steady-state connection within ~1-2 seconds. +func (pm *PeerManager) performRapidInitialTest(siteId int, endpoint string, localEndpoints []string) { if pm.peerMonitor == nil { return } - // Perform rapid test - this takes ~1-2 seconds max - holepunchViable := pm.peerMonitor.RapidTestPeer(siteId, endpoint) + var wg sync.WaitGroup + var localWinner string + var holepunchViable bool + + if len(localEndpoints) > 0 { + wg.Add(1) + go func() { + defer wg.Done() + localWinner = pm.peerMonitor.RapidTestLocalEndpoints(siteId, localEndpoints) + }() + } + + wg.Add(1) + go func() { + defer wg.Done() + holepunchViable = pm.peerMonitor.RapidTestPeer(siteId, endpoint) + }() + + wg.Wait() + + if localWinner != "" { + logger.Info("Rapid test: local connection viable for site %d, switching to %s", siteId, localWinner) + pm.LocalPeer(siteId, localWinner) + return + } if !holepunchViable { // Holepunch failed rapid test, request relay immediately @@ -926,6 +966,11 @@ func (pm *PeerManager) MarkPeerRelayed(siteID int, relayed bool) { func (pm *PeerManager) UnRelayPeer(siteId int, endpoint string) error { pm.mu.Lock() peer, exists := pm.peers[siteId] + if exists && peer.ActiveLocalEndpoint != "" { + pm.mu.Unlock() + logger.Info("Ignoring unrelay request for site %d: local connection is active", siteId) + return nil + } if exists { // Store the relay endpoint peer.Endpoint = endpoint @@ -958,6 +1003,75 @@ endpoint=%s`, util.FixKey(peer.PublicKey), endpoint) return nil } +// LocalPeer switches a peer to a local network endpoint discovered by the peer monitor. +// Local endpoints take priority over both the public endpoint and the relay, so this +// bypasses relay/public-endpoint bookkeeping entirely and just updates the WireGuard +// endpoint directly. +func (pm *PeerManager) LocalPeer(siteId int, localEndpoint string) { + pm.mu.Lock() + peer, exists := pm.peers[siteId] + if exists { + peer.ActiveLocalEndpoint = localEndpoint + pm.peers[siteId] = peer + } + pm.mu.Unlock() + + if !exists { + logger.Error("Cannot switch to local connection: peer with site ID %d not found", siteId) + return + } + + // Update only the endpoint for this peer (update_only preserves other settings) + wgConfig := fmt.Sprintf(`public_key=%s +update_only=true +endpoint=%s`, util.FixKey(peer.PublicKey), localEndpoint) + + if err := pm.device.IpcSet(wgConfig); err != nil { + logger.Error("Failed to switch peer %d to local connection: %v", siteId, err) + return + } + + if pm.APIServer != nil { + pm.APIServer.UpdatePeerLocalStatus(siteId, localEndpoint, true) + } + + logger.Info("Switched peer %d to local connection at %s", siteId, localEndpoint) +} + +// UnLocalPeer switches a peer away from its active local endpoint back to the public +// endpoint, resuming the normal public/relay monitoring logic from scratch (which will +// re-trigger relay on its own if the public endpoint also turns out to be unreachable). +func (pm *PeerManager) UnLocalPeer(siteId int) { + pm.mu.Lock() + peer, exists := pm.peers[siteId] + publicDNS := pm.publicDNS + if exists { + peer.ActiveLocalEndpoint = "" + pm.peers[siteId] = peer + } + pm.mu.Unlock() + + if !exists { + logger.Error("Cannot fall back from local connection: peer with site ID %d not found", siteId) + return + } + + resolved, err := util.ResolveDomainUpstream(formatEndpoint(peer.Endpoint), publicDNS) + if err != nil { + logger.Error("Failed to resolve fallback endpoint for peer %d: %v", siteId, err) + return + } + + if err := pm.UnRelayPeer(siteId, resolved); err != nil { + logger.Error("Failed to fall back peer %d from local connection: %v", siteId, err) + return + } + + if pm.APIServer != nil { + pm.APIServer.UpdatePeerLocalStatus(siteId, resolved, false) + } +} + // isBetterConnection returns true if connection quality (a) is better than (b). // Priority: connected > disconnected, then direct > relayed, then lower RTT. func isBetterConnection(aConn bool, aRelay bool, aRTT time.Duration, diff --git a/peers/monitor/monitor.go b/peers/monitor/monitor.go index f49b397..b4b1fcb 100644 --- a/peers/monitor/monitor.go +++ b/peers/monitor/monitor.go @@ -67,6 +67,23 @@ type PeerMonitor struct { holepunchMaxAttempts int // max consecutive failures before triggering relay holepunchFailures map[int]int // siteID -> consecutive failure count + // Local endpoint testing fields. Local endpoints are ip:port addresses on the + // site host's local network interfaces (ordered best-to-worst by the server). + // When one is reachable it takes priority over both the public endpoint and + // the relay. + localEndpoints map[int][]string // siteID -> ordered candidate local endpoints + localActiveEndpoint map[int]string // siteID -> currently active local endpoint ("" = not using local) + localFailures map[int]int // siteID -> consecutive failures of the active local endpoint + localTestTimeout time.Duration // timeout for each local endpoint probe + + // Local connection switch callbacks, set by the PeerManager + localSwitchCallback func(siteId int, endpoint string) // invoked when a local endpoint becomes active + localFallbackCallback func(siteId int) // invoked when we fall back from a local endpoint + + // Local connection sender tracking, keyed by chainId (informational messages only) + localSends map[string]func() + localSendMu sync.Mutex + // Exponential backoff fields for holepunch monitor defaultHolepunchMinInterval time.Duration // Minimum interval (initial) defaultHolepunchMaxInterval time.Duration @@ -118,6 +135,11 @@ func NewPeerMonitor(wsClient *websocket.Client, middleDev *middleDevice.MiddleDe relaySends: make(map[string]func()), holepunchMaxAttempts: 3, // Trigger relay after 3 consecutive failures holepunchFailures: make(map[int]int), + localEndpoints: make(map[int][]string), + localActiveEndpoint: make(map[int]string), + localFailures: make(map[int]int), + localTestTimeout: 300 * time.Millisecond, // local network round trips should be fast + localSends: make(map[string]func()), // Rapid initial test settings: complete within ~1.5 seconds rapidTestInterval: 200 * time.Millisecond, // 200ms between attempts rapidTestTimeout: 400 * time.Millisecond, // 400ms timeout per attempt @@ -235,7 +257,7 @@ func (pm *PeerMonitor) ResetPeerHolepunchInterval() { } // AddPeer adds a new peer to monitor -func (pm *PeerMonitor) AddPeer(siteID int, endpoint string, holepunchEndpoint string) error { +func (pm *PeerMonitor) AddPeer(siteID int, endpoint string, holepunchEndpoint string, localEndpoints []string) error { pm.mutex.Lock() defer pm.mutex.Unlock() @@ -253,6 +275,9 @@ func (pm *PeerMonitor) AddPeer(siteID int, endpoint string, holepunchEndpoint st pm.holepunchEndpoints[siteID] = holepunchEndpoint pm.holepunchStatus[siteID] = false // Initially unknown/disconnected + pm.localEndpoints[siteID] = localEndpoints + pm.localActiveEndpoint[siteID] = "" + pm.localFailures[siteID] = 0 if pm.running { if err := client.StartMonitor(func(status ConnectionStatus) { @@ -275,6 +300,25 @@ func (pm *PeerMonitor) UpdateHolepunchEndpoint(siteID int, endpoint string) { logger.Debug("Updated holepunch endpoint for site %d to %s", siteID, endpoint) } +// UpdateLocalEndpoints updates the candidate local endpoints for a peer +func (pm *PeerMonitor) UpdateLocalEndpoints(siteID int, localEndpoints []string) { + pm.mutex.Lock() + defer pm.mutex.Unlock() + pm.localEndpoints[siteID] = localEndpoints + logger.Debug("Updated local endpoints for site %d: %v", siteID, localEndpoints) +} + +// SetLocalConnectionCallbacks registers the callbacks invoked when a peer switches to +// or falls back from a local network endpoint. onLocal is called with the endpoint that +// became active; onFallback is called when we give up on the active local endpoint and +// resume the normal public/relay monitoring logic. +func (pm *PeerMonitor) SetLocalConnectionCallbacks(onLocal func(siteId int, endpoint string), onFallback func(siteId int)) { + pm.mutex.Lock() + defer pm.mutex.Unlock() + pm.localSwitchCallback = onLocal + pm.localFallbackCallback = onFallback +} + // RapidTestPeer performs a rapid connectivity test for a newly added peer. // This is designed to quickly determine if holepunch is viable within ~1-2 seconds. // Returns true if the connection is viable (holepunch works), false if it should relay. @@ -326,6 +370,126 @@ func (pm *PeerMonitor) RapidTestPeer(siteID int, endpoint string) bool { return false } +// RapidTestLocalEndpoints performs a rapid connectivity test of local candidate endpoints +// for a newly added peer, so local viability is known within the same ~1-2 second window as +// RapidTestPeer's public-endpoint test (rather than waiting for the next backoff-loop tick, +// which could be tens of seconds away). Candidates are tried in order (best-to-worst) and +// the first reachable one wins. Returns the winning endpoint, or "" if none are reachable. +func (pm *PeerMonitor) RapidTestLocalEndpoints(siteID int, endpoints []string) string { + if pm.holepunchTester == nil || len(endpoints) == 0 { + return "" + } + + pm.mutex.Lock() + timeout := pm.rapidTestTimeout + pm.mutex.Unlock() + + for _, endpoint := range endpoints { + result := pm.holepunchTester.TestEndpoint(endpoint, timeout) + if !result.Success { + continue + } + + logger.Info("Rapid test: local endpoint %s for site %d SUCCEEDED (RTT: %v)", endpoint, siteID, result.RTT) + + pm.mutex.Lock() + // Peer may have been removed while we were testing. + stillTracked := false + if _, tracked := pm.localEndpoints[siteID]; tracked { + stillTracked = true + pm.localActiveEndpoint[siteID] = endpoint + pm.localFailures[siteID] = 0 + } + pm.mutex.Unlock() + + if stillTracked { + pm.sendLocal(siteID, endpoint) + } + + return endpoint + } + + logger.Info("Rapid test: no local endpoint reachable for site %d", siteID) + return "" +} + +// remainingLocalCandidates returns all of endpoints except exclude, preserving order. +func remainingLocalCandidates(endpoints []string, exclude string) []string { + remaining := make([]string, 0, len(endpoints)) + for _, ep := range endpoints { + if ep != exclude { + remaining = append(remaining, ep) + } + } + return remaining +} + +// rapidTestOnLocalFallback runs a fast (~1-2 second) test of the public endpoint, racing it +// against any remaining untried local candidates, immediately after we fall back from a dead +// active local endpoint. Without this, the peer would sit on the public endpoint - which may +// itself be unreachable - relying on the normal checkHolepunchEndpoints loop to notice, which +// can take tens of seconds if the holepunch backoff interval had climbed while the local +// endpoint was stable. If neither the public endpoint nor a local candidate is reachable, relay +// is requested immediately. Mirrors PeerManager.performRapidInitialTest's race, but is triggered +// by local-endpoint failure rather than initial peer setup. +func (pm *PeerMonitor) rapidTestOnLocalFallback(siteID int, publicEndpoint string, remainingLocal []string) { + if pm.holepunchTester == nil { + return + } + + var wg sync.WaitGroup + var localWinner string + var holepunchViable bool + + if len(remainingLocal) > 0 { + wg.Add(1) + go func() { + defer wg.Done() + localWinner = pm.RapidTestLocalEndpoints(siteID, remainingLocal) + }() + } + + if publicEndpoint != "" { + wg.Add(1) + go func() { + defer wg.Done() + holepunchViable = pm.RapidTestPeer(siteID, publicEndpoint) + }() + } + + wg.Wait() + + pm.mutex.Lock() + _, stillTracked := pm.localEndpoints[siteID] + noLocalActiveYet := pm.localActiveEndpoint[siteID] == "" + switchCb := pm.localSwitchCallback + pm.mutex.Unlock() + + if !stillTracked { + return // peer was removed while we were testing + } + + if localWinner != "" { + // RapidTestLocalEndpoints already recorded the new active endpoint and notified the + // server, but doesn't move the WireGuard peer itself - do that here, unless a + // concurrent checkLocalEndpoints tick already beat us to activating something. + if noLocalActiveYet && switchCb != nil { + switchCb(siteID, localWinner) + } + logger.Info("Rapid fallback test: local connection %s viable for site %d", localWinner, siteID) + return + } + + if !holepunchViable { + logger.Warn("Rapid fallback test: site %d unreachable on public endpoint after local fallback, requesting relay", siteID) + if pm.wsClient != nil { + pm.sendRelay(siteID) + } + } else { + logger.Info("Rapid fallback test: site %d reachable on public endpoint after local fallback", siteID) + } +} + // UpdatePeerEndpoint updates the monitor endpoint for a peer func (pm *PeerMonitor) UpdatePeerEndpoint(siteID int, monitorPeer string) { pm.mutex.Lock() @@ -359,15 +523,18 @@ func (pm *PeerMonitor) removePeerUnlocked(siteID int) { // RemovePeer stops monitoring a peer and removes it from the monitor func (pm *PeerMonitor) RemovePeer(siteID int) { pm.mutex.Lock() - defer pm.mutex.Unlock() // remove the holepunch endpoint info delete(pm.holepunchEndpoints, siteID) delete(pm.holepunchStatus, siteID) delete(pm.relayedPeers, siteID) delete(pm.holepunchFailures, siteID) + delete(pm.localEndpoints, siteID) + delete(pm.localActiveEndpoint, siteID) + delete(pm.localFailures, siteID) pm.removePeerUnlocked(siteID) + pm.mutex.Unlock() } func (pm *PeerMonitor) RemoveHolepunchEndpoint(siteID int) { @@ -412,9 +579,18 @@ func (pm *PeerMonitor) handleConnectionStatusChange(siteID int, status Connectio pm.wgConnectionRTT[siteID] = status.RTT } isRelayed := pm.relayedPeers[siteID] + localEndpoint := pm.localActiveEndpoint[siteID] endpoint := pm.holepunchEndpoints[siteID] pm.mutex.Unlock() + isLocal := localEndpoint != "" + if isLocal { + // Report the active local endpoint rather than the public one; local and relay + // are mutually exclusive. + endpoint = localEndpoint + isRelayed = false + } + // Log status changes if !exists || previousStatus != status.Connected { if status.Connected { @@ -426,7 +602,7 @@ func (pm *PeerMonitor) handleConnectionStatusChange(siteID int, status Connectio // Update API with connection status if pm.apiServer != nil { - pm.apiServer.UpdatePeerStatus(siteID, status.Connected, status.RTT, endpoint, isRelayed) + pm.apiServer.UpdatePeerStatus(siteID, status.Connected, status.RTT, endpoint, isRelayed, isLocal) } // Notify route optimizer of status change @@ -481,6 +657,75 @@ func (pm *PeerMonitor) sendUnRelay(siteID int) error { return nil } +// sendLocal notifies the server that this peer switched to a local network endpoint, with +// retry keyed by chainId. This is informational (e.g. so the server can relay the information +// to newt) - olm does not wait for an acknowledgement before using the local connection, but +// it does stop retrying once the server acks via CancelLocalSend, same as relay/unrelay. +func (pm *PeerMonitor) sendLocal(siteID int, endpoint string) { + if pm.wsClient == nil { + return + } + + chainId := generateChainId() + stopFunc, _ := pm.wsClient.SendMessageInterval("olm/wg/local", map[string]interface{}{ + "siteId": siteID, + "endpoint": endpoint, + "chainId": chainId, + }, 2*time.Second, 10) + + pm.localSendMu.Lock() + pm.localSends[chainId] = stopFunc + pm.localSendMu.Unlock() + + logger.Info("Sent local-connection message for site %d (%s, chain %s)", siteID, endpoint, chainId) +} + +// sendUnLocal notifies the server that this peer fell back from its local network endpoint, +// with retry keyed by chainId. +func (pm *PeerMonitor) sendUnLocal(siteID int) { + if pm.wsClient == nil { + return + } + + chainId := generateChainId() + stopFunc, _ := pm.wsClient.SendMessageInterval("olm/wg/unlocal", map[string]interface{}{ + "siteId": siteID, + "chainId": chainId, + }, 2*time.Second, 10) + + pm.localSendMu.Lock() + pm.localSends[chainId] = stopFunc + pm.localSendMu.Unlock() + + logger.Info("Sent unlocal-connection message for site %d (chain %s)", siteID, chainId) +} + +// CancelLocalSend stops the interval sender for the given chainId, if one exists. +// If chainId is empty, all active local-connection senders are stopped. +func (pm *PeerMonitor) CancelLocalSend(chainId string) { + pm.localSendMu.Lock() + defer pm.localSendMu.Unlock() + + if chainId == "" { + for id, stop := range pm.localSends { + if stop != nil { + stop() + } + delete(pm.localSends, id) + } + logger.Info("Cancelled all local-connection senders") + return + } + + if stop, ok := pm.localSends[chainId]; ok { + stop() + delete(pm.localSends, chainId) + logger.Info("Cancelled local-connection sender for chain %s", chainId) + } else { + logger.Warn("CancelLocalSend: no active sender for chain %s", chainId) + } +} + // CancelRelaySend stops the interval sender for the given chainId, if one exists. // If chainId is empty, all active relay senders are stopped. func (pm *PeerMonitor) CancelRelaySend(chainId string) { @@ -628,7 +873,8 @@ func (pm *PeerMonitor) runHolepunchMonitor() { timer.Reset(currentInterval) logger.Debug("Holepunch monitor interval updated, reset to %v", currentInterval) case <-timer.C: - anyStatusChanged := pm.checkHolepunchEndpoints() + localChanged := pm.checkLocalEndpoints() + anyStatusChanged := pm.checkHolepunchEndpoints() || localChanged pm.mutex.Lock() if anyStatusChanged { @@ -650,6 +896,140 @@ func (pm *PeerMonitor) runHolepunchMonitor() { } } +// checkLocalEndpoints tests local network endpoints for sites that have them configured. +// For a site not currently using a local endpoint, it probes each candidate in order +// (candidates are ordered best-to-worst by the server) and switches to the first one that +// succeeds. For a site already using a local endpoint, it re-tests that endpoint and falls +// back to the normal public/relay logic after a few consecutive failures. +// Returns true if any site's local-connection status changed. +func (pm *PeerMonitor) checkLocalEndpoints() bool { + pm.mutex.Lock() + if !pm.running { + pm.mutex.Unlock() + return false + } + if pm.holepunchTester == nil { + pm.mutex.Unlock() + return false + } + candidates := make(map[int][]string, len(pm.localEndpoints)) + for siteID, eps := range pm.localEndpoints { + if len(eps) > 0 { + candidates[siteID] = eps + } + } + active := make(map[int]string, len(pm.localActiveEndpoint)) + for siteID, ep := range pm.localActiveEndpoint { + active[siteID] = ep + } + timeout := pm.localTestTimeout + maxAttempts := pm.holepunchMaxAttempts + pm.mutex.Unlock() + + anyChanged := false + + for siteID, endpoints := range candidates { + if activeEndpoint := active[siteID]; activeEndpoint != "" { + // Already using a local endpoint - verify it's still working. + result := pm.holepunchTester.TestEndpoint(activeEndpoint, timeout) + + pm.mutex.Lock() + if _, stillTracked := pm.localEndpoints[siteID]; !stillTracked { + pm.mutex.Unlock() + continue // peer was removed while we were testing + } + if result.Success { + pm.localFailures[siteID] = 0 + pm.mutex.Unlock() + continue + } + pm.localFailures[siteID]++ + failureCount := pm.localFailures[siteID] + pm.mutex.Unlock() + + if failureCount >= maxAttempts { + logger.Warn("Local endpoint %s for site %d failed %d times, falling back to public/relay logic", activeEndpoint, siteID, failureCount) + + pm.mutex.Lock() + pm.localActiveEndpoint[siteID] = "" + pm.localFailures[siteID] = 0 + pm.holepunchFailures[siteID] = 0 // don't immediately re-trigger relay on stale failures + // The holepunch backoff timer keeps climbing while a local endpoint is + // active (checkHolepunchEndpoints skips those sites but backoff still + // applies), so reset it here to avoid the resumed public/relay logic + // being stuck polling at a stale, heavily-backed-off interval. + pm.holepunchCurrentInterval = pm.holepunchMinInterval + publicEndpoint := pm.holepunchEndpoints[siteID] + remainingLocal := remainingLocalCandidates(pm.localEndpoints[siteID], activeEndpoint) + pm.mutex.Unlock() + + anyChanged = true + pm.deactivateLocalEndpoint(siteID) + + // Don't wait out the next backed-off checkHolepunchEndpoints tick to find out + // whether the public endpoint is reachable - rapidly test it (and any untried + // local candidates) now so a total connectivity loss triggers relay within + // ~1-2 seconds instead of potentially tens of seconds. + go pm.rapidTestOnLocalFallback(siteID, publicEndpoint, remainingLocal) + } + continue + } + + // Not currently using a local endpoint - probe candidates in order. + for _, endpoint := range endpoints { + result := pm.holepunchTester.TestEndpoint(endpoint, timeout) + + pm.mutex.Lock() + if _, stillTracked := pm.localEndpoints[siteID]; !stillTracked { + pm.mutex.Unlock() + break // peer was removed while we were testing + } + if !result.Success { + pm.mutex.Unlock() + continue + } + pm.localActiveEndpoint[siteID] = endpoint + pm.localFailures[siteID] = 0 + pm.mutex.Unlock() + + logger.Info("Local endpoint %s for site %d is reachable (RTT: %v), switching to local connection", endpoint, siteID, result.RTT) + anyChanged = true + pm.activateLocalEndpoint(siteID, endpoint) + break + } + } + + return anyChanged +} + +// activateLocalEndpoint invokes the switch callback and notifies the server that a local +// endpoint became active for the given site. +func (pm *PeerMonitor) activateLocalEndpoint(siteID int, endpoint string) { + pm.mutex.Lock() + cb := pm.localSwitchCallback + pm.mutex.Unlock() + + if cb != nil { + cb(siteID, endpoint) + } + + pm.sendLocal(siteID, endpoint) +} + +// deactivateLocalEndpoint invokes the fallback callback and notifies the server that the +// given site fell back from its local endpoint. +func (pm *PeerMonitor) deactivateLocalEndpoint(siteID int) { + pm.mutex.Lock() + cb := pm.localFallbackCallback + pm.mutex.Unlock() + + if cb != nil { + cb(siteID) + } + + pm.sendUnLocal(siteID) +} + // checkHolepunchEndpoints tests all holepunch endpoints // Returns true if any endpoint's status changed func (pm *PeerMonitor) checkHolepunchEndpoints() bool { @@ -661,6 +1041,9 @@ func (pm *PeerMonitor) checkHolepunchEndpoints() bool { } endpoints := make(map[int]string, len(pm.holepunchEndpoints)) for siteID, endpoint := range pm.holepunchEndpoints { + if pm.localActiveEndpoint[siteID] != "" { + continue // using a local connection, skip public/relay monitoring + } endpoints[siteID] = endpoint } timeout := pm.holepunchTimeout @@ -718,8 +1101,10 @@ func (pm *PeerMonitor) checkHolepunchEndpoints() bool { wgConnected := pm.wgConnectionStatus[siteID] pm.mutex.Unlock() - // Update API - use holepunch endpoint and relay status - pm.apiServer.UpdatePeerStatus(siteID, wgConnected, result.RTT, endpoint, isRelayed) + // Update API - use holepunch endpoint and relay status. Sites with an active + // local endpoint are filtered out of this loop above, so isLocal is always + // false here. + pm.apiServer.UpdatePeerStatus(siteID, wgConnected, result.RTT, endpoint, isRelayed, false) } // Handle relay logic based on holepunch status @@ -777,6 +1162,16 @@ func (pm *PeerMonitor) Close() { } pm.relaySendMu.Unlock() + // Stop all pending local-connection senders + pm.localSendMu.Lock() + for chainId, stop := range pm.localSends { + if stop != nil { + stop() + } + delete(pm.localSends, chainId) + } + pm.localSendMu.Unlock() + pm.mutex.Lock() defer pm.mutex.Unlock() diff --git a/peers/peer.go b/peers/peer.go index 7301a9c..e5d9c7c 100644 --- a/peers/peer.go +++ b/peers/peer.go @@ -10,17 +10,26 @@ import ( "golang.zx2c4.com/wireguard/wgctrl/wgtypes" ) -// ConfigurePeer sets up or updates a peer within the WireGuard device +// ConfigurePeer sets up or updates a peer within the WireGuard device. +// If siteConfig.ActiveLocalEndpoint is set, it takes priority over both the relay and the +// public endpoint since it's a directly-reachable address on the site host's local network. func ConfigurePeer(dev *device.Device, siteConfig SiteConfig, privateKey wgtypes.Key, relay bool, persistentKeepalive int, publicDNS []string) error { - var endpoint string - if relay && siteConfig.RelayEndpoint != "" { - endpoint = formatEndpoint(siteConfig.RelayEndpoint) + var siteHost string + if siteConfig.ActiveLocalEndpoint != "" { + // Local endpoints are already literal ip:port pairs on the local network, no DNS resolution needed. + siteHost = siteConfig.ActiveLocalEndpoint } else { - endpoint = formatEndpoint(siteConfig.Endpoint) - } - siteHost, err := util.ResolveDomainUpstream(endpoint, publicDNS) - if err != nil { - return fmt.Errorf("failed to resolve endpoint for site %d: %v", siteConfig.SiteId, err) + var endpoint string + if relay && siteConfig.RelayEndpoint != "" { + endpoint = formatEndpoint(siteConfig.RelayEndpoint) + } else { + endpoint = formatEndpoint(siteConfig.Endpoint) + } + var err error + siteHost, err = util.ResolveDomainUpstream(endpoint, publicDNS) + if err != nil { + return fmt.Errorf("failed to resolve endpoint for site %d: %v", siteConfig.SiteId, err) + } } // Split off the CIDR of the server IP which is just a string and add /32 for the allowed IP @@ -66,8 +75,7 @@ func ConfigurePeer(dev *device.Device, siteConfig SiteConfig, privateKey wgtypes config := configBuilder.String() logger.Debug("Configuring peer with config: %s", config) - err = dev.IpcSet(config) - if err != nil { + if err := dev.IpcSet(config); err != nil { return fmt.Errorf("failed to configure WireGuard peer: %v", err) } diff --git a/peers/types.go b/peers/types.go index 9ef1462..8d4f04f 100644 --- a/peers/types.go +++ b/peers/types.go @@ -8,16 +8,21 @@ type PeerAction struct { // UpdatePeerData represents the data needed to update a peer type SiteConfig struct { - SiteId int `json:"siteId"` - Name string `json:"name,omitempty"` - Endpoint string `json:"endpoint,omitempty"` - RelayEndpoint string `json:"relayEndpoint,omitempty"` - PublicKey string `json:"publicKey,omitempty"` - ServerIP string `json:"serverIP,omitempty"` - ServerPort uint16 `json:"serverPort,omitempty"` - RemoteSubnets []string `json:"remoteSubnets,omitempty"` // optional, array of subnets that this site can access - AllowedIps []string `json:"allowedIps,omitempty"` // optional, array of allowed IPs for the peer - Aliases []Alias `json:"aliases,omitempty"` // optional, array of alias configurations + SiteId int `json:"siteId"` + Name string `json:"name,omitempty"` + Endpoint string `json:"endpoint,omitempty"` + LocalEndpoints []string `json:"localEndpoints,omitempty"` // optional, ip:port endpoints on the site host's local network interfaces, ordered best-to-worst + RelayEndpoint string `json:"relayEndpoint,omitempty"` + PublicKey string `json:"publicKey,omitempty"` + ServerIP string `json:"serverIP,omitempty"` + ServerPort uint16 `json:"serverPort,omitempty"` + RemoteSubnets []string `json:"remoteSubnets,omitempty"` // optional, array of subnets that this site can access + AllowedIps []string `json:"allowedIps,omitempty"` // optional, array of allowed IPs for the peer + Aliases []Alias `json:"aliases,omitempty"` // optional, array of alias configurations + + // ActiveLocalEndpoint tracks the local network endpoint currently in use for this + // peer, if any. Not part of the wire protocol; set internally by the PeerManager. + ActiveLocalEndpoint string `json:"-"` } type Alias struct { @@ -41,6 +46,13 @@ type UnRelayPeerData struct { Endpoint string `json:"endpoint"` } +// LocalPeerAckData represents the server's acknowledgement of an "olm/wg/local" or +// "olm/wg/unlocal" message. olm has already applied the local connection switch by the time +// it sends the notification, so the ack is only used to stop the retry sender. +type LocalPeerAckData struct { + SiteId int `json:"siteId"` +} + // PeerAdd represents the data needed to add remote subnets to a peer type PeerAdd struct { SiteId int `json:"siteId"`