From 9513433c07eab2d621aa3b4d39e79e4f25f9ce23 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 15 Jul 2026 15:51:13 -0400 Subject: [PATCH 01/10] Add match domains config --- api/api.go | 1 + config.go | 28 +++++++++++ dns/dns_proxy.go | 119 ++++++++++++++++++++++++++++++++++++++++++++++- main.go | 1 + olm/connect.go | 2 +- olm/olm.go | 6 +++ olm/types.go | 7 +++ 7 files changed, 161 insertions(+), 3 deletions(-) diff --git a/api/api.go b/api/api.go index 895140b..4dea66b 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 diff --git a/config.go b/config.go index 5959270..dc439d9 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:"matchDomains"` + // Logging LogLevel string `json:"logLevel"` @@ -99,6 +106,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) @@ -229,6 +237,10 @@ func loadConfigFromEnv(config *OlmConfig) { config.UpstreamDNS = []string{val} config.sources["upstreamDNS"] = string(SourceEnv) } + if val := os.Getenv("MATCH_DOMAINS"); 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) @@ -293,6 +305,7 @@ func loadConfigFromCLI(config *OlmConfig, args []string) (bool, bool, error) { "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, @@ -317,6 +330,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", "", "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')") @@ -348,6 +363,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 +393,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) } @@ -481,6 +504,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) @@ -598,6 +625,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 = %v [%s]\n", c.MatchDomains, getSource("matchDomains")) fmt.Printf(" interface = %s [%s]\n", c.InterfaceName, getSource("interface")) // Logging 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/main.go b/main.go index 05a2cee..ddd4876 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, diff --git a/olm/connect.go b/olm/connect.go index 6337290..8eac7a4 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) } diff --git a/olm/olm.go b/olm/olm.go index ef00414..64208eb 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 @@ -427,6 +428,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 diff --git a/olm/types.go b/olm/types.go index a58d637..56c7aac 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 From 7929ce9cf900c1d5f8bfcc50e0fa7da8c34e5e5c Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 16 Jul 2026 10:57:42 -0400 Subject: [PATCH 02/10] Test local connections and choose those first --- peers/manager.go | 79 ++++++++++++- peers/monitor/monitor.go | 236 ++++++++++++++++++++++++++++++++++++++- peers/peer.go | 30 +++-- peers/types.go | 25 +++-- 4 files changed, 345 insertions(+), 25 deletions(-) diff --git a/peers/manager.go b/peers/manager.go index 8af5805..26ec4fb 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 { @@ -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 { @@ -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 @@ -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 @@ -926,6 +938,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 +975,66 @@ 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 + } + + 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) + } +} + // 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..9072d9c 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 siteID (informational messages only) + localSendStops map[int]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 + localSendStops: make(map[int]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. @@ -359,15 +403,25 @@ 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() + + pm.localSendMu.Lock() + if stop, ok := pm.localSendStops[siteID]; ok { + stop() + delete(pm.localSendStops, siteID) + } + pm.localSendMu.Unlock() } func (pm *PeerMonitor) RemoveHolepunchEndpoint(siteID int) { @@ -481,6 +535,47 @@ func (pm *PeerMonitor) sendUnRelay(siteID int) error { return nil } +// sendLocal notifies the server that this peer switched to a local network endpoint. +// This is informational only (e.g. so the server can relay the information to newt) - +// olm does not wait for an acknowledgement before using the local connection. +func (pm *PeerMonitor) sendLocal(siteID int, endpoint string) { + if pm.wsClient == nil { + return + } + + pm.localSendMu.Lock() + if stop, ok := pm.localSendStops[siteID]; ok { + stop() + } + stopFunc, _ := pm.wsClient.SendMessageInterval("olm/wg/local", map[string]interface{}{ + "siteId": siteID, + "endpoint": endpoint, + }, 2*time.Second, 5) + pm.localSendStops[siteID] = stopFunc + pm.localSendMu.Unlock() + + logger.Info("Sent local-connection message for site %d (%s)", siteID, endpoint) +} + +// sendUnLocal notifies the server that this peer fell back from its local network endpoint. +func (pm *PeerMonitor) sendUnLocal(siteID int) { + if pm.wsClient == nil { + return + } + + pm.localSendMu.Lock() + if stop, ok := pm.localSendStops[siteID]; ok { + stop() + } + stopFunc, _ := pm.wsClient.SendMessageInterval("olm/wg/unlocal", map[string]interface{}{ + "siteId": siteID, + }, 2*time.Second, 5) + pm.localSendStops[siteID] = stopFunc + pm.localSendMu.Unlock() + + logger.Info("Sent unlocal-connection message for site %d", siteID) +} + // 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 +723,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 +746,127 @@ 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 + pm.mutex.Unlock() + + anyChanged = true + pm.deactivateLocalEndpoint(siteID) + } + 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 +878,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 @@ -777,6 +997,16 @@ func (pm *PeerMonitor) Close() { } pm.relaySendMu.Unlock() + // Stop all pending local-connection senders + pm.localSendMu.Lock() + for siteID, stop := range pm.localSendStops { + if stop != nil { + stop() + } + delete(pm.localSendStops, siteID) + } + 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..f6b3d3b 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 { From 0e06fb0152efdd60ed0d5da1ddb79f3bc5731b9b Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 16 Jul 2026 11:11:02 -0400 Subject: [PATCH 03/10] Rapid test the local endpoints as well --- peers/manager.go | 40 +++++++++++++++++++++++++++++++------ peers/monitor/monitor.go | 43 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/peers/manager.go b/peers/manager.go index 26ec4fb..24af689 100644 --- a/peers/manager.go +++ b/peers/manager.go @@ -196,7 +196,7 @@ func (pm *PeerManager) AddPeer(siteConfig SiteConfig) error { // 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 } @@ -868,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 diff --git a/peers/monitor/monitor.go b/peers/monitor/monitor.go index 9072d9c..88d3edf 100644 --- a/peers/monitor/monitor.go +++ b/peers/monitor/monitor.go @@ -370,6 +370,49 @@ 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 "" +} + // UpdatePeerEndpoint updates the monitor endpoint for a peer func (pm *PeerMonitor) UpdatePeerEndpoint(siteID int, monitorPeer string) { pm.mutex.Lock() From a2f0f64c2f8481dc571901429408fef2d5c71ab6 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 16 Jul 2026 11:29:55 -0400 Subject: [PATCH 04/10] Cancel local send from chainId --- olm/olm.go | 2 + olm/peer.go | 65 ++++++++++++++++++++++++++++++ peers/monitor/monitor.go | 85 +++++++++++++++++++++++++--------------- peers/types.go | 7 ++++ 4 files changed, 127 insertions(+), 32 deletions(-) diff --git a/olm/olm.go b/olm/olm.go index 64208eb..c30c17d 100644 --- a/olm/olm.go +++ b/olm/olm.go @@ -536,6 +536,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/peers/monitor/monitor.go b/peers/monitor/monitor.go index 88d3edf..7c22915 100644 --- a/peers/monitor/monitor.go +++ b/peers/monitor/monitor.go @@ -80,9 +80,9 @@ type PeerMonitor struct { 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 siteID (informational messages only) - localSendStops map[int]func() - localSendMu sync.Mutex + // 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) @@ -139,7 +139,7 @@ func NewPeerMonitor(wsClient *websocket.Client, middleDev *middleDevice.MiddleDe localActiveEndpoint: make(map[int]string), localFailures: make(map[int]int), localTestTimeout: 300 * time.Millisecond, // local network round trips should be fast - localSendStops: make(map[int]func()), + 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 @@ -458,13 +458,6 @@ func (pm *PeerMonitor) RemovePeer(siteID int) { pm.removePeerUnlocked(siteID) pm.mutex.Unlock() - - pm.localSendMu.Lock() - if stop, ok := pm.localSendStops[siteID]; ok { - stop() - delete(pm.localSendStops, siteID) - } - pm.localSendMu.Unlock() } func (pm *PeerMonitor) RemoveHolepunchEndpoint(siteID int) { @@ -578,45 +571,73 @@ func (pm *PeerMonitor) sendUnRelay(siteID int) error { return nil } -// sendLocal notifies the server that this peer switched to a local network endpoint. -// This is informational only (e.g. so the server can relay the information to newt) - -// olm does not wait for an acknowledgement before using the local connection. +// 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 } - pm.localSendMu.Lock() - if stop, ok := pm.localSendStops[siteID]; ok { - stop() - } + chainId := generateChainId() stopFunc, _ := pm.wsClient.SendMessageInterval("olm/wg/local", map[string]interface{}{ "siteId": siteID, "endpoint": endpoint, - }, 2*time.Second, 5) - pm.localSendStops[siteID] = stopFunc + "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)", siteID, endpoint) + 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. +// 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 } - pm.localSendMu.Lock() - if stop, ok := pm.localSendStops[siteID]; ok { - stop() - } + chainId := generateChainId() stopFunc, _ := pm.wsClient.SendMessageInterval("olm/wg/unlocal", map[string]interface{}{ - "siteId": siteID, - }, 2*time.Second, 5) - pm.localSendStops[siteID] = stopFunc + "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", siteID) + 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. @@ -1042,11 +1063,11 @@ func (pm *PeerMonitor) Close() { // Stop all pending local-connection senders pm.localSendMu.Lock() - for siteID, stop := range pm.localSendStops { + for chainId, stop := range pm.localSends { if stop != nil { stop() } - delete(pm.localSendStops, siteID) + delete(pm.localSends, chainId) } pm.localSendMu.Unlock() diff --git a/peers/types.go b/peers/types.go index f6b3d3b..8d4f04f 100644 --- a/peers/types.go +++ b/peers/types.go @@ -46,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"` From e1214e21cddb72cb6b3c7a3f2c0de82790b8918b Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 17 Jul 2026 13:58:20 -0400 Subject: [PATCH 05/10] Reflect local status in the api --- api/api.go | 34 +++++++++++++++++++++++++++++++--- olm/connect.go | 2 +- peers/manager.go | 11 ++++++++++- peers/monitor/monitor.go | 17 ++++++++++++++--- 4 files changed, 56 insertions(+), 8 deletions(-) diff --git a/api/api.go b/api/api.go index 4dea66b..b8f4e56 100644 --- a/api/api.go +++ b/api/api.go @@ -51,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"` } @@ -229,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() @@ -247,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() @@ -267,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 @@ -363,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/olm/connect.go b/olm/connect.go index 8eac7a4..ff2b5f4 100644 --- a/olm/connect.go +++ b/olm/connect.go @@ -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/peers/manager.go b/peers/manager.go index 24af689..b7a9925 100644 --- a/peers/manager.go +++ b/peers/manager.go @@ -192,7 +192,7 @@ 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 @@ -1031,6 +1031,10 @@ endpoint=%s`, util.FixKey(peer.PublicKey), localEndpoint) return } + if pm.APIServer != nil { + pm.APIServer.UpdatePeerLocalStatus(siteId, localEndpoint, true) + } + logger.Info("Switched peer %d to local connection at %s", siteId, localEndpoint) } @@ -1060,6 +1064,11 @@ func (pm *PeerManager) UnLocalPeer(siteId int) { 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) } } diff --git a/peers/monitor/monitor.go b/peers/monitor/monitor.go index 7c22915..a93c796 100644 --- a/peers/monitor/monitor.go +++ b/peers/monitor/monitor.go @@ -502,9 +502,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 { @@ -516,7 +525,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 @@ -1002,8 +1011,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 From 3d88b321e8652a7a4e63c70a380127b3f692a893 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 17 Jul 2026 15:55:31 -0400 Subject: [PATCH 06/10] Rapid test again when we fail local --- peers/monitor/monitor.go | 90 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/peers/monitor/monitor.go b/peers/monitor/monitor.go index a93c796..b4b1fcb 100644 --- a/peers/monitor/monitor.go +++ b/peers/monitor/monitor.go @@ -413,6 +413,83 @@ func (pm *PeerMonitor) RapidTestLocalEndpoints(siteID int, endpoints []string) s 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() @@ -877,10 +954,23 @@ func (pm *PeerMonitor) checkLocalEndpoints() bool { 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 } From 39aaaafa17d29c0f104d976faee5002f8c7728b2 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 17 Jul 2026 17:39:24 -0400 Subject: [PATCH 07/10] Dont remove non controlled routes --- go.mod | 2 +- peers/manager.go | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 4510632..557d1c1 100644 --- a/go.mod +++ b/go.mod @@ -32,4 +32,4 @@ require ( ) // To be used ONLY for local development -// replace github.com/fosrl/newt => ../newt +replace github.com/fosrl/newt => ../newt diff --git a/peers/manager.go b/peers/manager.go index b7a9925..0d5eda7 100644 --- a/peers/manager.go +++ b/peers/manager.go @@ -259,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) } } @@ -465,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) } } @@ -733,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 } } From 9c3c04c728d7bb657c2c280df9243b1be5dab37f Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 17 Jul 2026 17:57:26 -0400 Subject: [PATCH 08/10] Match domains dns rename --- config.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/config.go b/config.go index dc439d9..edebfe7 100644 --- a/config.go +++ b/config.go @@ -32,7 +32,7 @@ type OlmConfig struct { // 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:"matchDomains"` + MatchDomains []string `json:"matchDomainsDNS"` // Logging LogLevel string `json:"logLevel"` @@ -237,7 +237,7 @@ func loadConfigFromEnv(config *OlmConfig) { config.UpstreamDNS = []string{val} config.sources["upstreamDNS"] = string(SourceEnv) } - if val := os.Getenv("MATCH_DOMAINS"); val != "" { + if val := os.Getenv("MATCH_DOMAINS_DNS"); val != "" { config.MatchDomains = splitComma(val) config.sources["matchDomains"] = string(SourceEnv) } @@ -331,7 +331,7 @@ func loadConfigFromCLI(config *OlmConfig, args []string) (bool, bool, error) { 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", "", "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(&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')") @@ -625,7 +625,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 = %v [%s]\n", c.MatchDomains, getSource("matchDomains")) + fmt.Printf(" match-domains-dns = %v [%s]\n", c.MatchDomains, getSource("matchDomains")) fmt.Printf(" interface = %s [%s]\n", c.InterfaceName, getSource("interface")) // Logging From 99d249db2df062dfca1ebbe68de3c1b964352e8f Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 18 Jul 2026 17:28:56 -0400 Subject: [PATCH 09/10] add PreferLocalRoutes option --- config.go | 66 ++++++++++++++++++++++++++++++++-------------------- main.go | 1 + olm/olm.go | 1 + olm/types.go | 8 +++++++ 4 files changed, 51 insertions(+), 25 deletions(-) diff --git a/config.go b/config.go index edebfe7..44b030c 100644 --- a/config.go +++ b/config.go @@ -47,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) @@ -118,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 @@ -281,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) @@ -297,26 +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), - "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, - "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, } @@ -342,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") @@ -426,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) } @@ -557,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) @@ -649,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/main.go b/main.go index ddd4876..060dc79 100644 --- a/main.go +++ b/main.go @@ -272,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/olm.go b/olm/olm.go index c30c17d..fa638be 100644 --- a/olm/olm.go +++ b/olm/olm.go @@ -398,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 diff --git a/olm/types.go b/olm/types.go index 56c7aac..b5b3a5c 100644 --- a/olm/types.go +++ b/olm/types.go @@ -109,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 } From 8ef735185feee783e48507f0d6b0a7f081244bc9 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 18 Jul 2026 21:24:56 -0400 Subject: [PATCH 10/10] Update go mod --- go.mod | 4 ++-- go.sum | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 557d1c1..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 @@ -32,4 +32,4 @@ require ( ) // To be used ONLY for local development -replace github.com/fosrl/newt => ../newt +// replace github.com/fosrl/newt => ../newt 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=