mirror of
https://github.com/fosrl/olm.git
synced 2026-08-04 02:35:54 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96e1d0f98c | ||
|
|
29663cdb81 | ||
|
|
23597835d8 | ||
|
|
8ef735185f | ||
|
|
99d249db2d | ||
|
|
9c3c04c728 | ||
|
|
39aaaafa17 | ||
|
|
3d88b321e8 | ||
|
|
e1214e21cd | ||
|
|
a2f0f64c2f | ||
|
|
0e06fb0152 | ||
|
|
7929ce9cf9 | ||
|
|
9513433c07 | ||
|
|
1319354914 | ||
|
|
b39be4f5b0 | ||
|
|
929f183ed0 | ||
|
|
0cf5eb2ad0 | ||
|
|
1920f52699 | ||
|
|
7b07745650 | ||
|
|
1f301db892 | ||
|
|
4c600bab15 | ||
|
|
f356aed39b | ||
|
|
7a88fac395 | ||
|
|
efc012b43d | ||
|
|
83678dedcb | ||
|
|
e8e004e5e1 | ||
|
|
b08994e019 | ||
|
|
f87b804051 | ||
|
|
f02045d936 | ||
|
|
55377980b8 | ||
|
|
403e14327d | ||
|
|
31f6d699a8 | ||
|
|
6e41eb99ab | ||
|
|
12c1918e26 | ||
|
|
0d6503c03f | ||
|
|
b789c9af75 | ||
|
|
8cc854e313 | ||
|
|
2cd16e24a1 | ||
|
|
b6b35b4581 | ||
|
|
3f64336b0b | ||
|
|
e94c13f601 | ||
|
|
fff806c53d | ||
|
|
84d7e8d926 | ||
|
|
fde70dd15b | ||
|
|
7bf6da1729 | ||
|
|
9f68f171ba | ||
|
|
7a8f4ab049 | ||
|
|
334ea156b6 | ||
|
|
aa838fec61 | ||
|
|
6eaf8c1475 | ||
|
|
df6a84648b | ||
|
|
5ef6b21a6e | ||
|
|
7d83518951 | ||
|
|
964532777a | ||
|
|
703fe4fe5d | ||
|
|
42ef1f5ee3 | ||
|
|
f250702177 | ||
|
|
8549dc8746 |
@@ -0,0 +1 @@
|
||||
* @oschwartz10612 @miloschwartz
|
||||
@@ -14,12 +14,13 @@ body:
|
||||
label: Environment
|
||||
description: Please fill out the relevant details below for your environment.
|
||||
value: |
|
||||
- OS Type & Version: (e.g., Ubuntu 22.04)
|
||||
- OS Type & Version:
|
||||
- Pangolin Version:
|
||||
- Edition (Community or Enterprise):
|
||||
- Gerbil Version:
|
||||
- Traefik Version:
|
||||
- Newt Version:
|
||||
- Olm Version: (if applicable)
|
||||
- Client Version:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# Olm
|
||||
Olm is being phased out in favor of the [Pangolin CLI](https://github.com/fosrl/cli) and is only meant for advanced use cases.
|
||||
|
||||
Olm is a [WireGuard](https://www.wireguard.com/) tunnel client designed to securely connect your computer to Newt sites running on remote networks.
|
||||
|
||||
|
||||
+32
-3
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
+133
-2
@@ -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
|
||||
@@ -741,6 +856,16 @@ func (p *DNSProxy) SetJITHandler(handler func(siteId int)) {
|
||||
p.jitHandler = handler
|
||||
}
|
||||
|
||||
// SetUpstreamDNS replaces the list of upstream DNS servers used to forward
|
||||
// queries that are not served by local records. The servers must be in
|
||||
// "host:port" format (e.g. "8.8.8.8:53").
|
||||
func (p *DNSProxy) SetUpstreamDNS(servers []string) {
|
||||
if len(servers) == 0 {
|
||||
return
|
||||
}
|
||||
p.upstreamDNS = servers
|
||||
}
|
||||
|
||||
// AddDNSRecord adds a DNS record to the local store
|
||||
// domain should be a domain name (e.g., "example.com" or "example.com.")
|
||||
// ip should be a valid IPv4 or IPv6 address
|
||||
@@ -755,6 +880,12 @@ func (p *DNSProxy) RemoveDNSRecord(domain string, ip net.IP) {
|
||||
p.recordStore.RemoveRecord(domain, ip)
|
||||
}
|
||||
|
||||
// RemoveDNSRecordForSite removes DNS records for a domain that are owned by a specific site.
|
||||
// If ip is nil, removes all records for the domain that are owned by that site.
|
||||
func (p *DNSProxy) RemoveDNSRecordForSite(domain string, ip net.IP, siteId int) {
|
||||
p.recordStore.RemoveRecordForSite(domain, ip, siteId)
|
||||
}
|
||||
|
||||
// GetDNSRecords returns all IP addresses for a domain and record type.
|
||||
// The second return value indicates whether the domain exists.
|
||||
func (p *DNSProxy) GetDNSRecords(domain string, recordType RecordType) ([]net.IP, bool) {
|
||||
|
||||
+73
-2
@@ -23,6 +23,7 @@ type recordSet struct {
|
||||
A []net.IP
|
||||
AAAA []net.IP
|
||||
SiteId int
|
||||
owners map[string]map[int]bool // IP string -> owning site IDs
|
||||
}
|
||||
|
||||
// DNSRecordStore manages local DNS records for A, AAAA, and PTR queries.
|
||||
@@ -71,9 +72,17 @@ func (s *DNSRecordStore) AddRecord(domain string, ip net.IP, siteId int) error {
|
||||
}
|
||||
|
||||
if m[domain] == nil {
|
||||
m[domain] = &recordSet{SiteId: siteId}
|
||||
m[domain] = &recordSet{SiteId: siteId, owners: make(map[string]map[int]bool)}
|
||||
}
|
||||
rs := m[domain]
|
||||
if rs.owners == nil {
|
||||
rs.owners = make(map[string]map[int]bool)
|
||||
}
|
||||
ipKey := ip.String()
|
||||
if rs.owners[ipKey] == nil {
|
||||
rs.owners[ipKey] = make(map[int]bool)
|
||||
}
|
||||
rs.owners[ipKey][siteId] = true
|
||||
if isV4 {
|
||||
for _, existing := range rs.A {
|
||||
if existing.Equal(ip) {
|
||||
@@ -97,7 +106,6 @@ func (s *DNSRecordStore) AddRecord(domain string, ip net.IP, siteId int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
// AddPTRRecord adds a PTR record mapping an IP address to a domain name
|
||||
// ip should be a valid IPv4 or IPv6 address
|
||||
// domain should be in FQDN format (e.g., "example.com.")
|
||||
@@ -123,6 +131,16 @@ func (s *DNSRecordStore) AddPTRRecord(ip net.IP, domain string) error {
|
||||
// If ip is nil, removes all records for the domain (including wildcards)
|
||||
// Automatically removes corresponding PTR records for non-wildcard domains
|
||||
func (s *DNSRecordStore) RemoveRecord(domain string, ip net.IP) {
|
||||
s.removeRecord(domain, ip, 0, false)
|
||||
}
|
||||
|
||||
// RemoveRecordForSite removes DNS records owned by a specific site.
|
||||
// If ip is nil, it removes all records for the domain owned by that site.
|
||||
func (s *DNSRecordStore) RemoveRecordForSite(domain string, ip net.IP, siteId int) {
|
||||
s.removeRecord(domain, ip, siteId, true)
|
||||
}
|
||||
|
||||
func (s *DNSRecordStore) removeRecord(domain string, ip net.IP, siteId int, bySite bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@@ -142,8 +160,20 @@ func (s *DNSRecordStore) RemoveRecord(domain string, ip net.IP) {
|
||||
if rs == nil {
|
||||
return
|
||||
}
|
||||
if rs.owners == nil {
|
||||
rs.owners = make(map[string]map[int]bool)
|
||||
}
|
||||
|
||||
if ip == nil {
|
||||
if bySite {
|
||||
rs.A = s.removeOwnedIPs(rs, rs.A, siteId, !isWildcard, domain)
|
||||
rs.AAAA = s.removeOwnedIPs(rs, rs.AAAA, siteId, !isWildcard, domain)
|
||||
if len(rs.A) == 0 && len(rs.AAAA) == 0 {
|
||||
delete(m, domain)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Remove all records for this domain
|
||||
if !isWildcard {
|
||||
for _, ipAddr := range rs.A {
|
||||
@@ -162,6 +192,19 @@ func (s *DNSRecordStore) RemoveRecord(domain string, ip net.IP) {
|
||||
}
|
||||
|
||||
// Remove specific IP
|
||||
ipKey := ip.String()
|
||||
if bySite {
|
||||
owners := rs.owners[ipKey]
|
||||
if len(owners) == 0 {
|
||||
return
|
||||
}
|
||||
delete(owners, siteId)
|
||||
if len(owners) > 0 {
|
||||
return
|
||||
}
|
||||
delete(rs.owners, ipKey)
|
||||
}
|
||||
|
||||
if ip.To4() != nil {
|
||||
rs.A = removeIP(rs.A, ip)
|
||||
if !isWildcard {
|
||||
@@ -177,6 +220,7 @@ func (s *DNSRecordStore) RemoveRecord(domain string, ip net.IP) {
|
||||
}
|
||||
}
|
||||
}
|
||||
delete(rs.owners, ipKey)
|
||||
|
||||
// Clean up empty record sets
|
||||
if len(rs.A) == 0 && len(rs.AAAA) == 0 {
|
||||
@@ -184,6 +228,33 @@ func (s *DNSRecordStore) RemoveRecord(domain string, ip net.IP) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DNSRecordStore) removeOwnedIPs(rs *recordSet, ips []net.IP, siteId int, removePTR bool, domain string) []net.IP {
|
||||
kept := make([]net.IP, 0, len(ips))
|
||||
for _, ipAddr := range ips {
|
||||
ipKey := ipAddr.String()
|
||||
owners := rs.owners[ipKey]
|
||||
if len(owners) == 0 {
|
||||
kept = append(kept, ipAddr)
|
||||
continue
|
||||
}
|
||||
|
||||
delete(owners, siteId)
|
||||
if len(owners) > 0 {
|
||||
kept = append(kept, ipAddr)
|
||||
continue
|
||||
}
|
||||
|
||||
delete(rs.owners, ipKey)
|
||||
if removePTR {
|
||||
if ptrDomain, exists := s.ptrRecords[ipKey]; exists && ptrDomain == domain {
|
||||
delete(s.ptrRecords, ipKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return kept
|
||||
}
|
||||
|
||||
// RemovePTRRecord removes a PTR record for an IP address
|
||||
func (s *DNSRecordStore) RemovePTRRecord(ip net.IP) {
|
||||
s.mu.Lock()
|
||||
|
||||
@@ -782,6 +782,37 @@ func TestAutomaticPTRRecordOnRemove(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveRecordForSiteKeepsSharedAliasIP(t *testing.T) {
|
||||
store := NewDNSRecordStore()
|
||||
|
||||
domain := "shared.example.com."
|
||||
ip := net.ParseIP("192.168.1.100")
|
||||
|
||||
if err := store.AddRecord(domain, ip, 10); err != nil {
|
||||
t.Fatalf("Failed to add record for site 10: %v", err)
|
||||
}
|
||||
if err := store.AddRecord(domain, ip, 20); err != nil {
|
||||
t.Fatalf("Failed to add record for site 20: %v", err)
|
||||
}
|
||||
|
||||
store.RemoveRecordForSite(domain, ip, 10)
|
||||
|
||||
ips, exists := store.GetRecords(domain, RecordTypeA)
|
||||
if !exists {
|
||||
t.Fatal("Expected shared record to still exist after removing one site owner")
|
||||
}
|
||||
if len(ips) != 1 || !ips[0].Equal(ip) {
|
||||
t.Fatalf("Expected shared IP to remain after first owner removal, got %v", ips)
|
||||
}
|
||||
|
||||
store.RemoveRecordForSite(domain, ip, 20)
|
||||
|
||||
ips, exists = store.GetRecords(domain, RecordTypeA)
|
||||
if exists {
|
||||
t.Fatalf("Expected domain to be removed after last owner removal, got %v", ips)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomaticPTRRecordOnRemoveAll(t *testing.T) {
|
||||
store := NewDNSRecordStore()
|
||||
|
||||
|
||||
@@ -13,4 +13,16 @@ func SetupDNSOverride(interfaceName string, proxyIp netip.Addr) error {
|
||||
// RestoreDNSOverride is a no-op on Android
|
||||
func RestoreDNSOverride() error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// CleanupStaleState is a no-op on Android as DNS configuration is handled by the VpnService API
|
||||
func CleanupStaleState(interfaceName string) error {
|
||||
_ = interfaceName
|
||||
return nil
|
||||
}
|
||||
|
||||
// ForceResetDNS is a no-op on Android.
|
||||
func ForceResetDNS(interfaceName string) error {
|
||||
_ = interfaceName
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -15,6 +15,13 @@ var configurator platform.DNSConfigurator
|
||||
// SetupDNSOverride configures the system DNS to use the DNS proxy on macOS
|
||||
// Uses scutil for DNS configuration
|
||||
func SetupDNSOverride(interfaceName string, proxyIp netip.Addr) error {
|
||||
// Defensively clear any stale DNS state from a previous unclean shutdown
|
||||
// before installing the new override. This makes a second tunnel start
|
||||
// safe even if the previous client crashed without restoring DNS.
|
||||
if err := CleanupStaleState(interfaceName); err != nil {
|
||||
logger.Warn("Pre-setup stale DNS cleanup failed (continuing): %v", err)
|
||||
}
|
||||
|
||||
var err error
|
||||
configurator, err = platform.NewDarwinDNSConfigurator()
|
||||
if err != nil {
|
||||
@@ -61,3 +68,48 @@ func RestoreDNSOverride() error {
|
||||
logger.Info("DNS configuration restored successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupStaleState removes any stale DNS configuration left over from a previous
|
||||
// unclean shutdown (e.g., system crash, power loss while tunnel was active).
|
||||
// This function should be called early during startup, before any network operations,
|
||||
// to ensure DNS is working properly.
|
||||
//
|
||||
// On macOS, this cleans up any scutil DNS keys that were created but not removed.
|
||||
func CleanupStaleState(interfaceName string) error {
|
||||
_ = interfaceName
|
||||
if err := platform.CleanupStaleDarwinDNS(); err != nil {
|
||||
logger.Warn("Failed to cleanup stale Darwin DNS config: %v", err)
|
||||
return fmt.Errorf("Darwin DNS cleanup: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("Stale DNS state cleanup completed successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ForceResetDNS forcibly clears any DNS override state, whether or not the
|
||||
// current process installed it. This is intended for the "reset-dns" CLI
|
||||
// command and for the watchdog process to recover from a stuck override
|
||||
// left behind by a crashed client.
|
||||
func ForceResetDNS(interfaceName string) error {
|
||||
logger.Info("Forcing DNS reset on Darwin (interface=%s)", interfaceName)
|
||||
|
||||
// First clean up any persisted state from a previous session.
|
||||
cleanupErr := CleanupStaleState(interfaceName)
|
||||
|
||||
// Then, if the current process happens to hold a live configurator,
|
||||
// instruct it to restore DNS as well so in-memory state is consistent.
|
||||
if configurator != nil {
|
||||
if err := configurator.RestoreDNS(); err != nil {
|
||||
logger.Warn("ForceResetDNS: in-memory restore failed: %v", err)
|
||||
}
|
||||
configurator = nil
|
||||
}
|
||||
|
||||
// As a last-resort defense, sweep any scutil keys matching our naming
|
||||
// convention even if no state file exists.
|
||||
if err := platform.SweepOlmScutilKeys(); err != nil {
|
||||
logger.Warn("ForceResetDNS: scutil sweep failed: %v", err)
|
||||
}
|
||||
|
||||
return cleanupErr
|
||||
}
|
||||
|
||||
@@ -12,4 +12,16 @@ func SetupDNSOverride(interfaceName string, proxyIp netip.Addr) error {
|
||||
// RestoreDNSOverride is a no-op on iOS as DNS configuration is handled by the system
|
||||
func RestoreDNSOverride() error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// CleanupStaleState is a no-op on iOS as DNS configuration is handled by the system
|
||||
func CleanupStaleState(interfaceName string) error {
|
||||
_ = interfaceName
|
||||
return nil
|
||||
}
|
||||
|
||||
// ForceResetDNS is a no-op on iOS.
|
||||
func ForceResetDNS(interfaceName string) error {
|
||||
_ = interfaceName
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -15,6 +15,13 @@ var configurator platform.DNSConfigurator
|
||||
// SetupDNSOverride configures the system DNS to use the DNS proxy on Linux/FreeBSD
|
||||
// Detects the DNS manager by reading /etc/resolv.conf and verifying runtime availability
|
||||
func SetupDNSOverride(interfaceName string, proxyIp netip.Addr) error {
|
||||
// Defensively clear any stale DNS state from a previous unclean shutdown
|
||||
// before installing the new override. This makes a second tunnel start
|
||||
// safe even if the previous client crashed without restoring DNS.
|
||||
if err := CleanupStaleState(interfaceName); err != nil {
|
||||
logger.Warn("Pre-setup stale DNS cleanup failed (continuing): %v", err)
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
// Detect which DNS manager is in use by checking /etc/resolv.conf and runtime availability
|
||||
@@ -98,3 +105,71 @@ func RestoreDNSOverride() error {
|
||||
logger.Info("DNS configuration restored successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupStaleState removes any stale DNS configuration left over from a previous
|
||||
// unclean shutdown (e.g., system crash, power loss while tunnel was active).
|
||||
// This function should be called early during startup, before any network operations,
|
||||
// to ensure DNS is working properly.
|
||||
//
|
||||
// It checks and cleans up stale state from all supported DNS managers:
|
||||
// - NetworkManager: removes /etc/NetworkManager/conf.d/olm-dns.conf
|
||||
// - resolvconf: removes entry for the provided interface
|
||||
// - File-based: restores /etc/resolv.conf from backup if it exists
|
||||
//
|
||||
// This is safe to call even if no stale state exists.
|
||||
func CleanupStaleState(interfaceName string) error {
|
||||
var errs []error
|
||||
|
||||
// Clean up NetworkManager stale config
|
||||
if err := platform.CleanupStaleNetworkManagerDNS(); err != nil {
|
||||
logger.Warn("Failed to cleanup stale NetworkManager DNS config: %v", err)
|
||||
errs = append(errs, fmt.Errorf("NetworkManager cleanup: %w", err))
|
||||
} else {
|
||||
logger.Debug("NetworkManager DNS cleanup completed")
|
||||
}
|
||||
|
||||
// Clean up resolvconf stale entries for the provided interface
|
||||
if err := platform.CleanupStaleResolvconfDNS(interfaceName); err != nil {
|
||||
logger.Warn("Failed to cleanup stale resolvconf DNS config: %v", err)
|
||||
errs = append(errs, fmt.Errorf("resolvconf cleanup: %w", err))
|
||||
} else {
|
||||
logger.Debug("resolvconf DNS cleanup completed")
|
||||
}
|
||||
|
||||
// Clean up file-based stale backup
|
||||
if err := platform.CleanupStaleFileDNS(); err != nil {
|
||||
logger.Warn("Failed to cleanup stale file-based DNS config: %v", err)
|
||||
errs = append(errs, fmt.Errorf("file DNS cleanup: %w", err))
|
||||
} else {
|
||||
logger.Debug("File-based DNS cleanup completed")
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("some DNS cleanup operations failed: %v", errs)
|
||||
}
|
||||
|
||||
logger.Info("Stale DNS state cleanup completed successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ForceResetDNS forcibly clears any DNS override state, whether or not the
|
||||
// current process installed it. This is intended for the "reset-dns" CLI
|
||||
// command and for the watchdog process to recover from a stuck override
|
||||
// left behind by a crashed client.
|
||||
func ForceResetDNS(interfaceName string) error {
|
||||
logger.Info("Forcing DNS reset on Linux/FreeBSD (interface=%s)", interfaceName)
|
||||
|
||||
// First clean up any persisted state from a previous session.
|
||||
cleanupErr := CleanupStaleState(interfaceName)
|
||||
|
||||
// Then, if the current process happens to hold a live configurator,
|
||||
// instruct it to restore DNS as well so in-memory state is consistent.
|
||||
if configurator != nil {
|
||||
if err := configurator.RestoreDNS(); err != nil {
|
||||
logger.Warn("ForceResetDNS: in-memory restore failed: %v", err)
|
||||
}
|
||||
configurator = nil
|
||||
}
|
||||
|
||||
return cleanupErr
|
||||
}
|
||||
|
||||
@@ -15,6 +15,12 @@ var configurator platform.DNSConfigurator
|
||||
// SetupDNSOverride configures the system DNS to use the DNS proxy on Windows
|
||||
// Uses registry-based configuration (automatically extracts interface GUID)
|
||||
func SetupDNSOverride(interfaceName string, proxyIp netip.Addr) error {
|
||||
// Defensively clear any stale DNS state from a previous unclean shutdown
|
||||
// before installing the new override.
|
||||
if err := CleanupStaleState(interfaceName); err != nil {
|
||||
logger.Warn("Pre-setup stale DNS cleanup failed (continuing): %v", err)
|
||||
}
|
||||
|
||||
var err error
|
||||
configurator, err = platform.NewWindowsDNSConfigurator(interfaceName)
|
||||
if err != nil {
|
||||
@@ -61,3 +67,33 @@ func RestoreDNSOverride() error {
|
||||
logger.Info("DNS configuration restored successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupStaleState removes any stale DNS configuration left over from a previous
|
||||
// unclean shutdown (e.g., system crash, power loss while tunnel was active).
|
||||
// This function should be called early during startup, before any network operations,
|
||||
// to ensure DNS is working properly.
|
||||
//
|
||||
// On Windows, DNS configuration is tied to the interface GUID. When the WireGuard
|
||||
// interface is recreated, it gets a new GUID, so there's no stale state to clean up.
|
||||
func CleanupStaleState(interfaceName string) error {
|
||||
// Windows DNS configuration via registry is interface-specific.
|
||||
// When the WireGuard interface is recreated, it gets a new GUID,
|
||||
// so there's no leftover state to clean up from previous sessions.
|
||||
_ = interfaceName
|
||||
logger.Debug("Windows DNS cleanup: no stale state to clean (interface-specific)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ForceResetDNS forcibly clears any DNS override state. On Windows this is
|
||||
// largely a no-op because the registry override is tied to the interface
|
||||
// GUID and is reclaimed when the interface is torn down.
|
||||
func ForceResetDNS(interfaceName string) error {
|
||||
logger.Info("Forcing DNS reset on Windows (interface=%s)", interfaceName)
|
||||
if configurator != nil {
|
||||
if err := configurator.RestoreDNS(); err != nil {
|
||||
logger.Warn("ForceResetDNS: in-memory restore failed: %v", err)
|
||||
}
|
||||
configurator = nil
|
||||
}
|
||||
return CleanupStaleState(interfaceName)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package olm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
)
|
||||
|
||||
// WatchdogConfig configures the DNS override watchdog. The watchdog runs as
|
||||
// an external process (spawned via SpawnWatchdog) and monitors a parent olm
|
||||
// process. When the parent appears to have died without restoring DNS, the
|
||||
// watchdog forcibly resets the system DNS configuration.
|
||||
type WatchdogConfig struct {
|
||||
// ParentPID is the PID of the olm process that installed the DNS
|
||||
// override. The watchdog exits when this PID is no longer alive.
|
||||
ParentPID int
|
||||
|
||||
// SocketPath is the path to the olm Unix domain socket (or named pipe
|
||||
// on Windows). The watchdog uses it as a secondary liveness signal.
|
||||
// May be empty if no socket-based API is enabled.
|
||||
SocketPath string
|
||||
|
||||
// InterfaceName is the name of the WireGuard interface whose DNS
|
||||
// override should be reset on parent death.
|
||||
InterfaceName string
|
||||
|
||||
// CheckInterval is how often to poll the parent's liveness.
|
||||
// Defaults to 5 seconds when zero.
|
||||
CheckInterval time.Duration
|
||||
|
||||
// FailureThreshold is the number of consecutive failed liveness checks
|
||||
// before the watchdog declares the parent dead and resets DNS.
|
||||
// Defaults to 3 when zero.
|
||||
FailureThreshold int
|
||||
}
|
||||
|
||||
// RunWatchdog runs the watchdog loop in the current process until either
|
||||
// (a) the parent dies and DNS is reset, or (b) ctx is cancelled.
|
||||
func RunWatchdog(ctx context.Context, cfg WatchdogConfig) error {
|
||||
if cfg.ParentPID <= 0 {
|
||||
return fmt.Errorf("watchdog: invalid parent PID %d", cfg.ParentPID)
|
||||
}
|
||||
if cfg.CheckInterval <= 0 {
|
||||
cfg.CheckInterval = 5 * time.Second
|
||||
}
|
||||
if cfg.FailureThreshold <= 0 {
|
||||
cfg.FailureThreshold = 3
|
||||
}
|
||||
|
||||
logger.Info("DNS watchdog started: parent=%d interval=%s threshold=%d socket=%q interface=%q",
|
||||
cfg.ParentPID, cfg.CheckInterval, cfg.FailureThreshold, cfg.SocketPath, cfg.InterfaceName)
|
||||
|
||||
ticker := time.NewTicker(cfg.CheckInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
consecutiveFailures := 0
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logger.Info("DNS watchdog context cancelled, exiting cleanly")
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
}
|
||||
|
||||
alive := isParentAlive(cfg.ParentPID, cfg.SocketPath)
|
||||
if alive {
|
||||
if consecutiveFailures > 0 {
|
||||
logger.Debug("DNS watchdog: parent recovered after %d failures", consecutiveFailures)
|
||||
}
|
||||
consecutiveFailures = 0
|
||||
continue
|
||||
}
|
||||
|
||||
consecutiveFailures++
|
||||
logger.Warn("DNS watchdog: parent liveness check failed (%d/%d)",
|
||||
consecutiveFailures, cfg.FailureThreshold)
|
||||
|
||||
if consecutiveFailures >= cfg.FailureThreshold {
|
||||
logger.Warn("DNS watchdog: parent declared dead, forcing DNS reset")
|
||||
if err := ForceResetDNS(cfg.InterfaceName); err != nil {
|
||||
logger.Error("DNS watchdog: ForceResetDNS failed: %v", err)
|
||||
return err
|
||||
}
|
||||
logger.Info("DNS watchdog: DNS reset complete, exiting")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isParentAlive returns true if the parent process appears to be alive. It
|
||||
// considers the parent alive if EITHER the PID is still running OR the
|
||||
// socket-based health endpoint responds. This dual check avoids false
|
||||
// positives where one signal is flaky (e.g., socket blocked but process
|
||||
// still recovering).
|
||||
func isParentAlive(pid int, socketPath string) bool {
|
||||
if pidAlive(pid) {
|
||||
return true
|
||||
}
|
||||
// Process is gone; double-check via socket to avoid races where PID
|
||||
// recycling or signal-0 quirks lie to us. Socket should already be
|
||||
// gone too.
|
||||
if socketPath != "" && socketHealthy(socketPath) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// socketHealthy attempts a fast /health request over the unix socket.
|
||||
func socketHealthy(socketPath string) bool {
|
||||
if _, err := os.Stat(socketPath); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 2 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
d := net.Dialer{Timeout: 2 * time.Second}
|
||||
return d.DialContext(ctx, "unix", socketPath)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := client.Get("http://localhost/health")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return resp.StatusCode == http.StatusOK
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//go:build !windows
|
||||
|
||||
package olm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"syscall"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
)
|
||||
|
||||
// SpawnWatchdogConfig captures the inputs needed to launch the external
|
||||
// watchdog subprocess that monitors the calling olm process and forces a
|
||||
// DNS reset if the parent dies before restoring DNS.
|
||||
type SpawnWatchdogConfig struct {
|
||||
// Executable is the path to the binary that will host the watchdog
|
||||
// (typically os.Executable()). The binary must understand the
|
||||
// watchdog subcommand layout described below.
|
||||
Executable string
|
||||
|
||||
// Subcommand is the argv prefix the binary uses to enter watchdog
|
||||
// mode (e.g., []string{"watchdog"} or []string{"dns", "watchdog"}).
|
||||
Subcommand []string
|
||||
|
||||
// InterfaceName is the WireGuard interface whose DNS override should
|
||||
// be reset if the parent dies.
|
||||
InterfaceName string
|
||||
|
||||
// SocketPath is the parent's olm API socket path (may be empty).
|
||||
SocketPath string
|
||||
|
||||
// LogFile, if non-empty, is the path the watchdog writes its stdout
|
||||
// and stderr to. If empty, /dev/null is used.
|
||||
LogFile string
|
||||
}
|
||||
|
||||
// SpawnWatchdog launches the watchdog subprocess in a detached process group
|
||||
// so that it survives the death of the parent. The returned *exec.Cmd is the
|
||||
// handle the parent should call StopWatchdog on during clean shutdown.
|
||||
//
|
||||
// The spawned process is invoked as:
|
||||
//
|
||||
// <Executable> <Subcommand...> --parent-pid=<ppid> \
|
||||
// --interface=<InterfaceName> [--socket=<SocketPath>]
|
||||
//
|
||||
// Both pangolin (cli) and olm should map their watchdog subcommand to
|
||||
// RunWatchdog.
|
||||
func SpawnWatchdog(cfg SpawnWatchdogConfig) (*exec.Cmd, error) {
|
||||
if cfg.Executable == "" {
|
||||
return nil, fmt.Errorf("watchdog: executable is required")
|
||||
}
|
||||
if len(cfg.Subcommand) == 0 {
|
||||
return nil, fmt.Errorf("watchdog: subcommand is required")
|
||||
}
|
||||
|
||||
args := append([]string{}, cfg.Subcommand...)
|
||||
args = append(args,
|
||||
"--parent-pid="+strconv.Itoa(os.Getpid()),
|
||||
"--interface="+cfg.InterfaceName,
|
||||
)
|
||||
if cfg.SocketPath != "" {
|
||||
args = append(args, "--socket="+cfg.SocketPath)
|
||||
}
|
||||
|
||||
cmd := exec.Command(cfg.Executable, args...)
|
||||
|
||||
// Detach: new session so the watchdog is not killed by a signal
|
||||
// delivered to the parent's process group.
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Setsid: true,
|
||||
}
|
||||
|
||||
// Direct watchdog output to a log file or /dev/null so it doesn't
|
||||
// share file descriptors with the parent's TTY.
|
||||
logTarget := cfg.LogFile
|
||||
if logTarget == "" {
|
||||
logTarget = os.DevNull
|
||||
}
|
||||
logFile, err := os.OpenFile(logTarget, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("watchdog: open log file: %w", err)
|
||||
}
|
||||
cmd.Stdin = nil
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
_ = logFile.Close()
|
||||
return nil, fmt.Errorf("watchdog: start: %w", err)
|
||||
}
|
||||
|
||||
// We don't need our handle on the log file after the subprocess
|
||||
// inherits it.
|
||||
_ = logFile.Close()
|
||||
|
||||
logger.Info("DNS watchdog spawned (pid=%d, exe=%s)", cmd.Process.Pid, cfg.Executable)
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
// StopWatchdog asks the watchdog to exit cleanly via SIGTERM and reaps it.
|
||||
// Safe to call with a nil cmd.
|
||||
func StopWatchdog(cmd *exec.Cmd) {
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
return
|
||||
}
|
||||
pid := cmd.Process.Pid
|
||||
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
|
||||
logger.Debug("DNS watchdog stop signal failed (pid=%d): %v", pid, err)
|
||||
}
|
||||
// Reap in the background; we don't want to block shutdown if the
|
||||
// watchdog is wedged.
|
||||
go func() {
|
||||
_ = cmd.Wait()
|
||||
logger.Debug("DNS watchdog (pid=%d) reaped", pid)
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//go:build windows
|
||||
|
||||
package olm
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// SpawnWatchdogConfig is provided on Windows for API symmetry but the
|
||||
// watchdog itself is effectively a no-op there (see watchdog_windows.go).
|
||||
type SpawnWatchdogConfig struct {
|
||||
Executable string
|
||||
Subcommand []string
|
||||
InterfaceName string
|
||||
SocketPath string
|
||||
LogFile string
|
||||
}
|
||||
|
||||
// SpawnWatchdog is a no-op on Windows; DNS overrides are interface-GUID
|
||||
// scoped and reclaimed when the interface is removed.
|
||||
func SpawnWatchdog(cfg SpawnWatchdogConfig) (*exec.Cmd, error) {
|
||||
_ = cfg
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// StopWatchdog is a no-op on Windows.
|
||||
func StopWatchdog(cmd *exec.Cmd) {
|
||||
_ = cmd
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//go:build !windows
|
||||
|
||||
package olm
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// pidAlive returns true if the process with the given PID is still alive.
|
||||
// On Unix-like systems we use signal 0, which performs error checking but
|
||||
// does not deliver an actual signal.
|
||||
func pidAlive(pid int) bool {
|
||||
if pid <= 0 {
|
||||
return false
|
||||
}
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if err := proc.Signal(syscall.Signal(0)); err != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//go:build windows
|
||||
|
||||
package olm
|
||||
|
||||
// pidAlive on Windows. Reliable PID probing on Windows requires syscall
|
||||
// OpenProcess with PROCESS_QUERY_LIMITED_INFORMATION followed by
|
||||
// GetExitCodeProcess, which is non-trivial. Since DNS override on Windows
|
||||
// is interface-GUID-scoped and is naturally cleaned up when the WireGuard
|
||||
// interface goes away, the watchdog is effectively a no-op on Windows.
|
||||
// We always report the parent as alive so the watchdog never tears down
|
||||
// DNS based on PID checks.
|
||||
func pidAlive(pid int) bool {
|
||||
_ = pid
|
||||
return true
|
||||
}
|
||||
@@ -417,3 +417,126 @@ func (d *DarwinDNSConfigurator) clearState() error {
|
||||
logger.Debug("Cleared DNS state file")
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupStaleDarwinDNS removes any stale DNS configuration left by the Darwin
|
||||
// configurator from a previous unclean shutdown. This is a static function that can be
|
||||
// called without creating a configurator instance, useful for cleanup before network operations.
|
||||
func CleanupStaleDarwinDNS() error {
|
||||
// Always sweep orphaned Olm scutil keys regardless of whether a state
|
||||
// file exists. This protects against cases where the state file was
|
||||
// lost (e.g., user home wiped, write failed) but DNS keys are still
|
||||
// installed in the running scutil session.
|
||||
defer func() {
|
||||
_ = SweepOlmScutilKeys()
|
||||
// Flush DNS cache after any sweep so changes take effect.
|
||||
_ = exec.Command(dscacheutilPath, "-flushcache").Run()
|
||||
_ = exec.Command("killall", "-HUP", "mDNSResponder").Run()
|
||||
}()
|
||||
|
||||
stateFilePath := getDNSStateFilePath()
|
||||
|
||||
// Check if state file exists
|
||||
data, err := os.ReadFile(stateFilePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// No state file, nothing to clean up
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("read state file: %w", err)
|
||||
}
|
||||
|
||||
var state DNSPersistentState
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
// Invalid state file, remove it
|
||||
os.Remove(stateFilePath)
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(state.CreatedKeys) == 0 {
|
||||
// No keys to clean up
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Info("Found DNS state from previous session, cleaning up %d keys", len(state.CreatedKeys))
|
||||
|
||||
// Remove all keys from previous session using scutil directly
|
||||
for _, key := range state.CreatedKeys {
|
||||
logger.Debug("Removing leftover DNS key: %s", key)
|
||||
cmd := fmt.Sprintf("open\nremove %s\nquit\n", key)
|
||||
scutilCmd := exec.Command(scutilPath)
|
||||
scutilCmd.Stdin = strings.NewReader(cmd)
|
||||
if err := scutilCmd.Run(); err != nil {
|
||||
logger.Warn("Failed to remove DNS key %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear state file
|
||||
if err := os.Remove(stateFilePath); err != nil && !os.IsNotExist(err) {
|
||||
logger.Warn("Failed to clear DNS state file: %v", err)
|
||||
}
|
||||
|
||||
// Flush DNS cache after cleanup
|
||||
cacheCmd := exec.Command(dscacheutilPath, "-flushcache")
|
||||
_ = cacheCmd.Run()
|
||||
|
||||
killCmd := exec.Command("killall", "-HUP", "mDNSResponder")
|
||||
_ = killCmd.Run()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SweepOlmScutilKeys enumerates scutil State:/Network/Service/Olm-* keys and
|
||||
// removes any that are present. This is a best-effort safety net used when
|
||||
// state files have been lost or never written.
|
||||
func SweepOlmScutilKeys() error {
|
||||
// list scutil keys matching our naming convention
|
||||
listOutput, err := runScutilOnce("list State:/Network/Service/Olm-.*/DNS\n")
|
||||
if err != nil {
|
||||
return fmt.Errorf("scutil list: %w", err)
|
||||
}
|
||||
|
||||
var keys []string
|
||||
scanner := bufio.NewScanner(bytes.NewReader(listOutput))
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
// scutil output format: subKey [0] = State:/Network/Service/Olm-Override/DNS
|
||||
idx := strings.Index(line, "State:/Network/Service/Olm-")
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(line[idx:])
|
||||
if key != "" {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Info("Sweeping %d orphaned Olm scutil DNS keys", len(keys))
|
||||
|
||||
var commands strings.Builder
|
||||
for _, key := range keys {
|
||||
commands.WriteString(fmt.Sprintf("remove %s\n", key))
|
||||
}
|
||||
|
||||
if _, err := runScutilOnce(commands.String()); err != nil {
|
||||
return fmt.Errorf("scutil sweep remove: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runScutilOnce runs a one-shot scutil command sequence wrapped with open/quit
|
||||
// without requiring a configurator instance.
|
||||
func runScutilOnce(commands string) ([]byte, error) {
|
||||
wrapped := fmt.Sprintf("open\n%squit\n", commands)
|
||||
cmd := exec.Command(scutilPath)
|
||||
cmd.Stdin = strings.NewReader(wrapped)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scutil command failed: %w, output: %s", err, output)
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
@@ -218,3 +218,27 @@ func copyFile(src, dst string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupStaleFileDNS removes any stale DNS configuration left by the file-based
|
||||
// configurator from a previous unclean shutdown. This is a static function that can be
|
||||
// called without creating a configurator instance, useful for cleanup before network operations.
|
||||
func CleanupStaleFileDNS() error {
|
||||
// Check if backup file exists from a previous session
|
||||
if _, err := os.Stat(resolvConfBackupPath); os.IsNotExist(err) {
|
||||
// No backup file, nothing to clean up
|
||||
return nil
|
||||
}
|
||||
|
||||
// A backup exists, which means we crashed while DNS was configured
|
||||
// Restore the original resolv.conf
|
||||
if err := copyFile(resolvConfBackupPath, resolvConfPath); err != nil {
|
||||
return fmt.Errorf("restore from backup during cleanup: %w", err)
|
||||
}
|
||||
|
||||
// Remove backup file
|
||||
if err := os.Remove(resolvConfBackupPath); err != nil {
|
||||
return fmt.Errorf("remove backup file during cleanup: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ package dns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
@@ -16,12 +17,25 @@ import (
|
||||
|
||||
const (
|
||||
// NetworkManager D-Bus constants
|
||||
networkManagerDest = "org.freedesktop.NetworkManager"
|
||||
networkManagerDbusObjectNode = "/org/freedesktop/NetworkManager"
|
||||
networkManagerDbusDNSManagerInterface = "org.freedesktop.NetworkManager.DnsManager"
|
||||
networkManagerDbusDNSManagerObjectNode = networkManagerDbusObjectNode + "/DnsManager"
|
||||
networkManagerDbusDNSManagerModeProperty = networkManagerDbusDNSManagerInterface + ".Mode"
|
||||
networkManagerDbusVersionProperty = "org.freedesktop.NetworkManager.Version"
|
||||
networkManagerDest = "org.freedesktop.NetworkManager"
|
||||
networkManagerDbusObjectNode = "/org/freedesktop/NetworkManager"
|
||||
networkManagerDbusDNSManagerInterface = "org.freedesktop.NetworkManager.DnsManager"
|
||||
networkManagerDbusDNSManagerObjectNode = networkManagerDbusObjectNode + "/DnsManager"
|
||||
networkManagerDbusDNSManagerModeProperty = networkManagerDbusDNSManagerInterface + ".Mode"
|
||||
networkManagerDbusVersionProperty = "org.freedesktop.NetworkManager.Version"
|
||||
networkManagerDbusActiveConnsProperty = networkManagerDest + ".ActiveConnections"
|
||||
networkManagerDbusActiveInterface = "org.freedesktop.NetworkManager.Connection.Active"
|
||||
networkManagerDbusActiveIP4ConfigProperty = networkManagerDbusActiveInterface + ".Ip4Config"
|
||||
networkManagerDbusActiveIP6ConfigProperty = networkManagerDbusActiveInterface + ".Ip6Config"
|
||||
networkManagerDbusActiveDevicesProperty = networkManagerDbusActiveInterface + ".Devices"
|
||||
networkManagerDbusIP4ConfigInterface = "org.freedesktop.NetworkManager.IP4Config"
|
||||
networkManagerDbusIP6ConfigInterface = "org.freedesktop.NetworkManager.IP6Config"
|
||||
networkManagerDbusDeviceInterface = "org.freedesktop.NetworkManager.Device"
|
||||
networkManagerDbusDeviceDhcp4ConfigProp = networkManagerDbusDeviceInterface + ".Dhcp4Config"
|
||||
networkManagerDbusDeviceDhcp6ConfigProp = networkManagerDbusDeviceInterface + ".Dhcp6Config"
|
||||
networkManagerDbusDhcp4ConfigInterface = "org.freedesktop.NetworkManager.DHCP4Config"
|
||||
networkManagerDbusDhcp6ConfigInterface = "org.freedesktop.NetworkManager.DHCP6Config"
|
||||
networkManagerDbusGetAppliedConnMethod = networkManagerDbusDeviceInterface + ".GetAppliedConnection"
|
||||
|
||||
// NetworkManager dispatcher script path
|
||||
networkManagerDispatcherDir = "/etc/NetworkManager/dispatcher.d"
|
||||
@@ -301,6 +315,220 @@ func GetNetworkManagerDNSMode() (string, error) {
|
||||
return mode, nil
|
||||
}
|
||||
|
||||
// GetNetworkManagerNameservers returns the DNS servers NetworkManager knows
|
||||
// about for every active connection, read live via D-Bus.
|
||||
//
|
||||
// olm's own NetworkManager DNS override (see NetworkManagerDNSConfigurator)
|
||||
// works by writing a [global-dns-domain-*] section to
|
||||
// /etc/NetworkManager/conf.d/olm-dns.conf and reloading NetworkManager. That
|
||||
// is NetworkManager's global DNS override mechanism: it replaces the DNS
|
||||
// servers NetworkManager's DnsManager computes as "effective" system-wide,
|
||||
// for every connection - not just what gets written to /etc/resolv.conf. So
|
||||
// once olm's override is active, even each connection's merged
|
||||
// IP4Config/IP6Config.NameserverData (the previous, sole source used here)
|
||||
// can end up reporting olm's own proxy address instead of the real network
|
||||
// DNS.
|
||||
//
|
||||
// To recover the real DNS regardless, this also reads two further sources
|
||||
// that NetworkManager's DNS merging - and therefore olm's global-dns override
|
||||
// - never touches, since both are populated independently of it:
|
||||
// - Dhcp4Config/Dhcp6Config.Options["*name_servers"]: the raw nameserver
|
||||
// list straight from the DHCP lease.
|
||||
// - Device.GetAppliedConnection()'s ipv4.dns/ipv6.dns: the DNS servers
|
||||
// explicitly configured on the connection profile itself, e.g. a static
|
||||
// DNS override set by the user directly in NetworkManager (the
|
||||
// NetworkManager equivalent of a manually-set Windows adapter DNS).
|
||||
//
|
||||
// IP4Config/IP6Config.NameserverData is still queried too, as a fallback for
|
||||
// setups the other two don't cover. Any of olm's own address that leaks
|
||||
// through any of these sources is expected to be dropped by the caller via
|
||||
// SystemDNSMonitor.SetExcludeIP.
|
||||
func GetNetworkManagerNameservers() ([]netip.Addr, error) {
|
||||
conn, err := dbus.SystemBus()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect to system bus: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
nm := conn.Object(networkManagerDest, networkManagerDbusObjectNode)
|
||||
|
||||
activeVariant, err := nm.GetProperty(networkManagerDbusActiveConnsProperty)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get active connections: %w", err)
|
||||
}
|
||||
activePaths, ok := activeVariant.Value().([]dbus.ObjectPath)
|
||||
if !ok {
|
||||
return nil, errors.New("ActiveConnections is not a list of object paths")
|
||||
}
|
||||
|
||||
ipConfigSources := []struct {
|
||||
activeProperty string
|
||||
configIface string
|
||||
}{
|
||||
{networkManagerDbusActiveIP4ConfigProperty, networkManagerDbusIP4ConfigInterface},
|
||||
{networkManagerDbusActiveIP6ConfigProperty, networkManagerDbusIP6ConfigInterface},
|
||||
}
|
||||
|
||||
seen := make(map[netip.Addr]bool)
|
||||
var servers []netip.Addr
|
||||
add := func(addr netip.Addr) {
|
||||
addr = addr.Unmap()
|
||||
if !addr.IsValid() || addr.IsLoopback() || addr.IsLinkLocalUnicast() {
|
||||
return
|
||||
}
|
||||
if !seen[addr] {
|
||||
seen[addr] = true
|
||||
servers = append(servers, addr)
|
||||
}
|
||||
}
|
||||
|
||||
for _, activePath := range activePaths {
|
||||
active := conn.Object(networkManagerDest, activePath)
|
||||
|
||||
for _, src := range ipConfigSources {
|
||||
cfgVariant, err := active.GetProperty(src.activeProperty)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
cfgPath, ok := cfgVariant.Value().(dbus.ObjectPath)
|
||||
if !ok || cfgPath == "" || cfgPath == "/" {
|
||||
continue
|
||||
}
|
||||
|
||||
nsVariant, err := conn.Object(networkManagerDest, cfgPath).GetProperty(src.configIface + ".NameserverData")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
entries, ok := nsVariant.Value().([]map[string]dbus.Variant)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, entry := range entries {
|
||||
addrVariant, ok := entry["address"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
addrStr, ok := addrVariant.Value().(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if addr, err := netip.ParseAddr(addrStr); err == nil {
|
||||
add(addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
devicesVariant, err := active.GetProperty(networkManagerDbusActiveDevicesProperty)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
devicePaths, ok := devicesVariant.Value().([]dbus.ObjectPath)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, devicePath := range devicePaths {
|
||||
device := conn.Object(networkManagerDest, devicePath)
|
||||
|
||||
for _, addr := range dhcpLeaseNameservers(conn, device, networkManagerDbusDeviceDhcp4ConfigProp, networkManagerDbusDhcp4ConfigInterface, "domain_name_servers") {
|
||||
add(addr)
|
||||
}
|
||||
for _, addr := range dhcpLeaseNameservers(conn, device, networkManagerDbusDeviceDhcp6ConfigProp, networkManagerDbusDhcp6ConfigInterface, "dhcp6_name_servers") {
|
||||
add(addr)
|
||||
}
|
||||
for _, addr := range appliedConnectionNameservers(device) {
|
||||
add(addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return servers, nil
|
||||
}
|
||||
|
||||
// dhcpLeaseNameservers reads a space-separated nameserver list out of a
|
||||
// device's Dhcp4Config/Dhcp6Config Options, straight from the DHCP lease -
|
||||
// data NetworkManager's DNS merging (and therefore olm's own global-dns
|
||||
// override) never touches.
|
||||
func dhcpLeaseNameservers(conn *dbus.Conn, device dbus.BusObject, configProperty, configIface, optionsKey string) []netip.Addr {
|
||||
cfgVariant, err := device.GetProperty(configProperty)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
cfgPath, ok := cfgVariant.Value().(dbus.ObjectPath)
|
||||
if !ok || cfgPath == "" || cfgPath == "/" {
|
||||
return nil
|
||||
}
|
||||
|
||||
optsVariant, err := conn.Object(networkManagerDest, cfgPath).GetProperty(configIface + ".Options")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
opts, ok := optsVariant.Value().(map[string]dbus.Variant)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
raw, ok := opts[optionsKey]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
str, ok := raw.Value().(string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
var addrs []netip.Addr
|
||||
for _, field := range strings.Fields(str) {
|
||||
if addr, err := netip.ParseAddr(field); err == nil {
|
||||
addrs = append(addrs, addr)
|
||||
}
|
||||
}
|
||||
return addrs
|
||||
}
|
||||
|
||||
// appliedConnectionNameservers reads the ipv4.dns/ipv6.dns servers configured
|
||||
// on the device's currently-applied connection profile - e.g. a static DNS
|
||||
// override set by the user directly in NetworkManager - independent of DHCP
|
||||
// and of olm's own global-dns override.
|
||||
func appliedConnectionNameservers(device dbus.BusObject) []netip.Addr {
|
||||
var settings map[string]map[string]dbus.Variant
|
||||
var versionID uint64
|
||||
if err := device.Call(networkManagerDbusGetAppliedConnMethod, 0, uint32(0)).Store(&settings, &versionID); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var addrs []netip.Addr
|
||||
|
||||
if ipv4, ok := settings["ipv4"]; ok {
|
||||
if dnsVariant, ok := ipv4["dns"]; ok {
|
||||
if raw, ok := dnsVariant.Value().([]uint32); ok {
|
||||
for _, v := range raw {
|
||||
var b [4]byte
|
||||
// NetworkManager encodes IPv4 addresses in this setting as
|
||||
// network-byte-order bytes reinterpreted as a native uint32.
|
||||
binary.LittleEndian.PutUint32(b[:], v)
|
||||
addrs = append(addrs, netip.AddrFrom4(b))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ipv6, ok := settings["ipv6"]; ok {
|
||||
if dnsVariant, ok := ipv6["dns"]; ok {
|
||||
if raw, ok := dnsVariant.Value().([][]byte); ok {
|
||||
for _, b := range raw {
|
||||
if len(b) == 16 {
|
||||
var arr [16]byte
|
||||
copy(arr[:], b)
|
||||
addrs = append(addrs, netip.AddrFrom16(arr))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return addrs
|
||||
}
|
||||
|
||||
// GetNetworkManagerVersion returns the version of NetworkManager
|
||||
func GetNetworkManagerVersion() (string, error) {
|
||||
conn, err := dbus.SystemBus()
|
||||
@@ -323,3 +551,41 @@ func GetNetworkManagerVersion() (string, error) {
|
||||
|
||||
return version, nil
|
||||
}
|
||||
|
||||
// CleanupStaleNetworkManagerDNS removes any stale DNS configuration left by NetworkManager
|
||||
// configurator from a previous unclean shutdown. This is a static function that can be called
|
||||
// without creating a configurator instance, useful for cleanup before network operations.
|
||||
func CleanupStaleNetworkManagerDNS() error {
|
||||
confPath := networkManagerConfDir + "/" + networkManagerDNSConfFile
|
||||
|
||||
// Check if our config file exists from a previous session
|
||||
if _, err := os.Stat(confPath); os.IsNotExist(err) {
|
||||
// No config file, nothing to clean up
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove the stale configuration file
|
||||
if err := os.Remove(confPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove stale DNS config file: %w", err)
|
||||
}
|
||||
|
||||
// Try to reload NetworkManager if it's available
|
||||
if IsNetworkManagerAvailable() {
|
||||
conn, err := dbus.SystemBus()
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to system bus for reload: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
obj := conn.Object(networkManagerDest, networkManagerDbusObjectNode)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := obj.CallWithContext(ctx, networkManagerDest+".Reload", 0, uint32(0)).Store(); err != nil {
|
||||
return fmt.Errorf("reload NetworkManager after cleanup: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -219,3 +219,37 @@ func IsResolvconfAvailable() bool {
|
||||
cmd := exec.Command(resolvconfCommand, "--version")
|
||||
return cmd.Run() == nil
|
||||
}
|
||||
|
||||
// CleanupStaleResolvconfDNS removes any stale DNS configuration left by the resolvconf
|
||||
// configurator from a previous unclean shutdown. This is a static function that can be
|
||||
// called without creating a configurator instance, useful for cleanup before network operations.
|
||||
// The interfaceName parameter specifies which interface entry to clean up (typically "olm").
|
||||
func CleanupStaleResolvconfDNS(interfaceName string) error {
|
||||
if !IsResolvconfAvailable() {
|
||||
// resolvconf not available, nothing to clean up
|
||||
return nil
|
||||
}
|
||||
|
||||
// Detect resolvconf implementation type
|
||||
implType, err := detectResolvconfType()
|
||||
if err != nil {
|
||||
// Can't detect type, try default
|
||||
implType = "resolvconf"
|
||||
}
|
||||
|
||||
// Try to delete any existing entry for this interface
|
||||
// This is idempotent - if no entry exists, resolvconf will just return success
|
||||
var cmd *exec.Cmd
|
||||
|
||||
switch implType {
|
||||
case "openresolv":
|
||||
cmd = exec.Command(resolvconfCommand, "-f", "-d", interfaceName)
|
||||
default:
|
||||
cmd = exec.Command(resolvconfCommand, "-d", interfaceName)
|
||||
}
|
||||
|
||||
// Ignore errors - the entry may not exist, which is fine
|
||||
_ = cmd.Run()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
package dns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
const defaultPollInterval = 30 * time.Second
|
||||
|
||||
// dnsHealthCheckTimeout bounds how long we wait for a candidate DNS server to
|
||||
// answer a health-check query before considering it unusable.
|
||||
const dnsHealthCheckTimeout = 2 * time.Second
|
||||
|
||||
// SystemDNSMonitor monitors the host system's DNS configuration and notifies
|
||||
// callers when it changes. The reported servers are in "host:port" format
|
||||
// (e.g. "8.8.8.8:53") and can be used directly as UpstreamDNS and PublicDNS.
|
||||
//
|
||||
// Platform behaviour:
|
||||
// - Linux: reads /run/systemd/resolve/resolv.conf when present (updated by
|
||||
// systemd-resolved on every DHCP change), then falls back to
|
||||
// /etc/resolv.conf.olm.backup (written before olm overrides DNS), and
|
||||
// finally /etc/resolv.conf.
|
||||
// - macOS: reads the unscoped resolvers from `scutil --dns`, falling back
|
||||
// to /etc/resolv.conf if scutil is unavailable. This includes olm's own
|
||||
// supplemental scutil DNS override entry, which is expected to be
|
||||
// filtered out via SetExcludeIP.
|
||||
// - Windows: enumerates every network adapter's effective DNS servers
|
||||
// (static if set, else DHCP-assigned) from the registry.
|
||||
// - Other platforms: returns an empty list (no-op monitor).
|
||||
type SystemDNSMonitor struct {
|
||||
mu sync.RWMutex
|
||||
current []string // last health-checked, applied server list
|
||||
lastRaw []string // last raw (exclude-filtered but unvalidated) candidate list seen
|
||||
onChange func(servers []string)
|
||||
interval time.Duration
|
||||
stopCh chan struct{}
|
||||
excludeMu sync.RWMutex
|
||||
excludeIPs map[netip.Addr]bool
|
||||
}
|
||||
|
||||
// NewSystemDNSMonitor creates a new monitor. onChange is called with the new
|
||||
// server list whenever a change is detected; it is also called once from Start
|
||||
// with the initial values. A zero interval uses the 30-second default.
|
||||
func NewSystemDNSMonitor(interval time.Duration, onChange func(servers []string)) *SystemDNSMonitor {
|
||||
if interval <= 0 {
|
||||
interval = defaultPollInterval
|
||||
}
|
||||
return &SystemDNSMonitor{
|
||||
interval: interval,
|
||||
onChange: onChange,
|
||||
stopCh: make(chan struct{}),
|
||||
excludeIPs: make(map[netip.Addr]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// SetExcludeIP registers an IP address that must never appear in the reported
|
||||
// DNS server list. Call this after olm's DNS proxy is created to prevent the
|
||||
// proxy's own IP from being returned as an upstream server when the OS DNS has
|
||||
// been overridden to point at the proxy.
|
||||
func (m *SystemDNSMonitor) SetExcludeIP(ip netip.Addr) {
|
||||
m.excludeMu.Lock()
|
||||
m.excludeIPs[ip.Unmap()] = true
|
||||
m.excludeMu.Unlock()
|
||||
}
|
||||
|
||||
// Start reads the current system DNS immediately, fires onChange, then polls
|
||||
// in the background until Stop is called or ctx is cancelled.
|
||||
func (m *SystemDNSMonitor) Start(ctx context.Context) {
|
||||
m.applyCandidates(m.readFiltered())
|
||||
go m.run(ctx)
|
||||
}
|
||||
|
||||
// Stop halts the background polling goroutine.
|
||||
func (m *SystemDNSMonitor) Stop() {
|
||||
select {
|
||||
case <-m.stopCh:
|
||||
default:
|
||||
close(m.stopCh)
|
||||
}
|
||||
}
|
||||
|
||||
// Current returns the most recently observed system DNS servers.
|
||||
func (m *SystemDNSMonitor) Current() []string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
out := make([]string, len(m.current))
|
||||
copy(out, m.current)
|
||||
return out
|
||||
}
|
||||
|
||||
// readFiltered calls the platform-specific readSystemDNS and removes any
|
||||
// addresses that have been excluded via SetExcludeIP. If all addresses are
|
||||
// excluded the function returns nil so the caller can retain the last
|
||||
// known-good value.
|
||||
func (m *SystemDNSMonitor) readFiltered() []string {
|
||||
return m.filterExcluded(readSystemDNS())
|
||||
}
|
||||
|
||||
// filterExcluded removes any addresses that have been excluded via
|
||||
// SetExcludeIP from servers. Used both for the internally-polled server list
|
||||
// (readFiltered) and for server lists reported externally (ReportExternal) by
|
||||
// platforms - Android, iOS - where olm cannot read the OS's DNS configuration
|
||||
// itself.
|
||||
func (m *SystemDNSMonitor) filterExcluded(servers []string) []string {
|
||||
m.excludeMu.RLock()
|
||||
excludeIPs := m.excludeIPs
|
||||
m.excludeMu.RUnlock()
|
||||
|
||||
if len(excludeIPs) == 0 {
|
||||
return servers
|
||||
}
|
||||
|
||||
var filtered []string
|
||||
for _, s := range servers {
|
||||
host, _, err := net.SplitHostPort(s)
|
||||
if err != nil {
|
||||
filtered = append(filtered, s)
|
||||
continue
|
||||
}
|
||||
addr, err := netip.ParseAddr(host)
|
||||
if err != nil || excludeIPs[addr.Unmap()] {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, s)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// ReportExternal applies an externally-observed DNS server list (e.g. from
|
||||
// Android's ConnectivityManager or iOS's SCDynamicStore, where the platform
|
||||
// itself - not olm - must detect the OS's real DNS configuration) through the
|
||||
// same exclude-IP filtering, health-check validation, and change-detection as
|
||||
// the internal poll loop, firing onChange if the result differs from the last
|
||||
// known value.
|
||||
func (m *SystemDNSMonitor) ReportExternal(servers []string) {
|
||||
m.applyCandidates(m.filterExcluded(servers))
|
||||
}
|
||||
|
||||
func (m *SystemDNSMonitor) run(ctx context.Context) {
|
||||
ticker := time.NewTicker(m.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-m.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
m.applyCandidates(m.readFiltered())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// applyCandidates takes an exclude-filtered (but not yet health-checked) list
|
||||
// of candidate DNS servers - from either the internal poll loop or
|
||||
// ReportExternal - and, only if it differs from the last raw list seen (to
|
||||
// avoid re-running network health checks on every 30-second poll tick when
|
||||
// nothing has actually changed), health-checks it via filterUnreachable and
|
||||
// applies whatever passes, firing onChange if the result changed.
|
||||
//
|
||||
// If none of the candidates pass the health check, the previous known-good
|
||||
// value is retained rather than clobbered - this is what protects against
|
||||
// e.g. a carrier reporting a DNS server (such as T-Mobile's internal ULA
|
||||
// DNS64 resolvers) that is technically "the system DNS" but not actually
|
||||
// reachable/usable from wherever queries are sent.
|
||||
func (m *SystemDNSMonitor) applyCandidates(raw []string) {
|
||||
if len(raw) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
if dnsSlicesEqual(m.lastRaw, raw) {
|
||||
m.mu.Unlock()
|
||||
logger.Debug("System DNS candidates unchanged, skipping health check: %v", raw)
|
||||
return
|
||||
}
|
||||
m.lastRaw = raw
|
||||
m.mu.Unlock()
|
||||
|
||||
logger.Debug("System DNS candidates changed, health-checking: %v", raw)
|
||||
validated := filterUnreachable(raw)
|
||||
if len(validated) == 0 {
|
||||
logger.Warn("None of the detected DNS servers answered a health-check query, keeping previous value: %v", raw)
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
changed := !dnsSlicesEqual(m.current, validated)
|
||||
if changed {
|
||||
m.current = validated
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
if changed && m.onChange != nil {
|
||||
logger.Info("System DNS changed: %v", validated)
|
||||
m.onChange(validated)
|
||||
}
|
||||
}
|
||||
|
||||
// dnsServerReachable is a seam for tests; production code always uses probeDNSServerErr.
|
||||
var dnsServerReachable = probeDNSServerErr
|
||||
|
||||
// filterUnreachable validates that each candidate server actually answers a
|
||||
// DNS query before it's trusted, rather than statically guessing from the
|
||||
// address (e.g. rejecting all private/ULA addresses, which would also reject
|
||||
// a perfectly valid home router forwarding to a real resolver). Checks run
|
||||
// concurrently so multiple candidates don't serialize the timeout.
|
||||
func filterUnreachable(servers []string) []string {
|
||||
if len(servers) == 0 {
|
||||
return servers
|
||||
}
|
||||
|
||||
reachable := make([]bool, len(servers))
|
||||
errs := make([]error, len(servers))
|
||||
var wg sync.WaitGroup
|
||||
for i, server := range servers {
|
||||
wg.Add(1)
|
||||
go func(i int, server string) {
|
||||
defer wg.Done()
|
||||
reachable[i], errs[i] = dnsServerReachable(server)
|
||||
}(i, server)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
var result []string
|
||||
for i, server := range servers {
|
||||
if reachable[i] {
|
||||
result = append(result, server)
|
||||
} else {
|
||||
logger.Debug("Discarding DNS server %s: failed health check: %v", server, errs[i])
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// probeDNSServerErr sends a minimal root NS query to confirm a candidate server
|
||||
// actually answers, without depending on any specific external hostname being
|
||||
// reachable (which could itself be blocked/filtered independently of whether
|
||||
// the resolver works). The returned error is kept (rather than just a bool) so
|
||||
// callers can log why a candidate was rejected (unreachable route, timeout, etc.).
|
||||
func probeDNSServerErr(server string) (bool, error) {
|
||||
client := &dns.Client{Timeout: dnsHealthCheckTimeout}
|
||||
msg := new(dns.Msg)
|
||||
msg.SetQuestion(".", dns.TypeNS)
|
||||
_, _, err := client.Exchange(msg, server)
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
// dnsSlicesEqual reports whether two server lists are equal regardless of order.
|
||||
func dnsSlicesEqual(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
ac := make([]string, len(a))
|
||||
bc := make([]string, len(b))
|
||||
copy(ac, a)
|
||||
copy(bc, b)
|
||||
sort.Strings(ac)
|
||||
sort.Strings(bc)
|
||||
for i := range ac {
|
||||
if ac[i] != bc[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//go:build android
|
||||
|
||||
package dns
|
||||
|
||||
// readSystemDNS returns nil on Android: olm cannot read the OS's DNS
|
||||
// configuration itself here, so the app detects it (via ConnectivityManager)
|
||||
// and pushes it in through Olm.SetSystemDNS instead (see SystemDnsMonitor.java).
|
||||
//
|
||||
// This is a dedicated file (rather than falling through the general
|
||||
// sysresolver_stub.go catch-all) because wireguard-android's build passes
|
||||
// "-tags linux" to share Linux netlink code with Android, and that custom tag
|
||||
// makes "!linux" evaluate to false even on a real GOOS=android build, which
|
||||
// would otherwise make sysresolver_stub.go stop applying and leave
|
||||
// readSystemDNS undefined. An explicit "android" constraint isn't affected by
|
||||
// that, since nothing passes a conflicting "-tags android".
|
||||
func readSystemDNS() []string {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//go:build darwin && !ios && !nosysresolver
|
||||
|
||||
package dns
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// scutilPath is the well-known location of scutil on macOS.
|
||||
const scutilPath = "/usr/sbin/scutil"
|
||||
|
||||
// readSystemDNS returns the current system DNS servers in "host:53" format.
|
||||
//
|
||||
// olm's own DNS override is itself a scutil supplemental resolver (see
|
||||
// dns/platform/darwin.go), and macOS gives supplemental resolvers priority
|
||||
// over the primary network service's resolver when generating the merged
|
||||
// configuration - which is also what gets mirrored into /etc/resolv.conf. So
|
||||
// once olm's override is active, /etc/resolv.conf (and a naive read of just
|
||||
// the top of "scutil --dns") reflects olm's own proxy address, not the
|
||||
// physical network's real DNS.
|
||||
//
|
||||
// Instead this reads every resolver in the unscoped "DNS configuration"
|
||||
// section of `scutil --dns` (the "(for scoped queries)" section that follows
|
||||
// only duplicates per-interface resolvers and is skipped), which includes
|
||||
// both the real physical-network resolver and olm's own supplemental one.
|
||||
// olm's own address is expected to be filtered out by the caller via
|
||||
// SystemDNSMonitor.SetExcludeIP, the same mechanism used on Windows to drop
|
||||
// olm's own adapter DNS entry.
|
||||
//
|
||||
// /etc/resolv.conf is kept as a fallback for when scutil is unavailable.
|
||||
func readSystemDNS() []string {
|
||||
if out, err := exec.Command(scutilPath, "--dns").Output(); err == nil {
|
||||
if servers := parseScutilDNS(string(out)); len(servers) > 0 {
|
||||
return servers
|
||||
}
|
||||
}
|
||||
return parseMacResolvConf("/etc/resolv.conf")
|
||||
}
|
||||
|
||||
// parseScutilDNS extracts nameserver addresses from the unscoped "DNS
|
||||
// configuration" section at the top of `scutil --dns` output, stopping at
|
||||
// the "DNS configuration (for scoped queries)" section that follows it.
|
||||
func parseScutilDNS(output string) []string {
|
||||
var result []string
|
||||
seen := make(map[string]bool)
|
||||
|
||||
scanner := bufio.NewScanner(strings.NewReader(output))
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if strings.HasPrefix(line, "DNS configuration (for scoped queries)") {
|
||||
break
|
||||
}
|
||||
if !strings.HasPrefix(line, "nameserver[") {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
addr, err := netip.ParseAddr(strings.TrimSpace(parts[1]))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if addr.IsLoopback() || addr.IsLinkLocalUnicast() {
|
||||
continue
|
||||
}
|
||||
hp := net.JoinHostPort(addr.String(), "53")
|
||||
if !seen[hp] {
|
||||
seen[hp] = true
|
||||
result = append(result, hp)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parseMacResolvConf(path string) []string {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var result []string
|
||||
seen := make(map[string]bool)
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if !strings.HasPrefix(line, "nameserver") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
addr, err := netip.ParseAddr(fields[1])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if addr.IsLoopback() || addr.IsLinkLocalUnicast() {
|
||||
continue
|
||||
}
|
||||
s := net.JoinHostPort(addr.String(), "53")
|
||||
if !seen[s] {
|
||||
seen[s] = true
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build darwin && !ios && nosysresolver
|
||||
|
||||
package dns
|
||||
|
||||
// readSystemDNS is disabled by the nosysresolver build tag. This is used for
|
||||
// the macOS app build: unlike the CLI, the app's PacketTunnel system
|
||||
// extension additionally applies NEDNSSettings (see apple/PacketTunnel),
|
||||
// which can become the system's primary resolver and make /etc/resolv.conf
|
||||
// reflect olm's own proxy IP instead of the real upstream DNS. Rather than
|
||||
// have olm poll a value that may be self-referential, the app pushes the
|
||||
// real DNS servers in via SetSystemDNS (detected in Swift via
|
||||
// SCDynamicStore) exactly like Android and iOS. The CLI keeps the real
|
||||
// implementation in sysresolver_darwin.go, since it has no such override
|
||||
// mechanism and /etc/resolv.conf always reflects the physical network there.
|
||||
func readSystemDNS() []string {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//go:build ios
|
||||
|
||||
package dns
|
||||
|
||||
// readSystemDNS returns nil on iOS: olm cannot read the OS's DNS
|
||||
// configuration itself here, so the app must detect it and push it in
|
||||
// through the equivalent of Olm.SetSystemDNS instead.
|
||||
//
|
||||
// This is a dedicated file (rather than falling through the general
|
||||
// sysresolver_stub.go catch-all) because Go's build constraint evaluator
|
||||
// treats GOOS=ios as implicitly satisfying the "darwin" tag as well as
|
||||
// "ios". sysresolver_stub.go excludes with "!darwin", which is false for an
|
||||
// iOS build, so the stub silently stops applying and would leave
|
||||
// readSystemDNS undefined. An explicit "ios" constraint isn't affected by
|
||||
// that ambiguity.
|
||||
func readSystemDNS() []string {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
//go:build linux && !android
|
||||
|
||||
package dns
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
platform "github.com/fosrl/olm/dns/platform"
|
||||
)
|
||||
|
||||
// readSystemDNS returns the current system DNS servers in "host:53" format.
|
||||
//
|
||||
// Resolution order:
|
||||
// 1. /run/systemd/resolve/resolv.conf, but only when systemd-resolved is
|
||||
// actually running (checked live via D-Bus) — the file lives in /run and
|
||||
// can linger there, frozen at whatever it last contained, long after the
|
||||
// service that maintained it has stopped (e.g. it ran earlier in the
|
||||
// boot and was since disabled). Trusting its mere existence would report
|
||||
// that stale snapshot forever instead of falling through to a live
|
||||
// source. When the service is actually up the file is maintained with
|
||||
// the real per-link DNS servers, updated on every DHCP change and never
|
||||
// touched by olm's D-Bus DNS override.
|
||||
// 2. NetworkManager, queried live over D-Bus — NetworkManager's own view of
|
||||
// each active connection's DNS servers, independent of what is currently
|
||||
// written to /etc/resolv.conf. This covers NetworkManager's "dnsmasq" and
|
||||
// "unbound" DNS modes, where /etc/resolv.conf only contains a loopback
|
||||
// stub address, and stays accurate even if olm's own override has
|
||||
// directly overwritten /etc/resolv.conf, without going stale the way a
|
||||
// one-time backup snapshot would if the real DNS changes mid-override
|
||||
// (e.g. the user switches WiFi networks). olm's own NetworkManager
|
||||
// override is itself a NetworkManager-level global DNS override (see
|
||||
// platform.GetNetworkManagerNameservers), so this also reads each
|
||||
// device's raw DHCP lease and applied-connection settings, which that
|
||||
// override does not touch, to recover the real servers.
|
||||
// 3. /etc/resolv.conf.olm.backup — written by olm before it overrides
|
||||
// /etc/resolv.conf on non-systemd systems, for when NetworkManager isn't
|
||||
// in use at all.
|
||||
// 4. /etc/resolv.conf — plain fallback.
|
||||
//
|
||||
// Loopback and link-local addresses (e.g. 127.0.0.53, ::1) are excluded
|
||||
// because they are stub resolver addresses, not real upstream servers.
|
||||
func readSystemDNS() []string {
|
||||
// Prefer systemd-resolved's resolved (non-stub) resolv.conf, but only if
|
||||
// systemd-resolved is actually alive right now - see resolution order
|
||||
// note above on why the file's existence alone isn't enough.
|
||||
if platform.IsSystemdResolvedAvailable() {
|
||||
if servers := parseResolvConf("/run/systemd/resolve/resolv.conf"); len(servers) > 0 {
|
||||
return servers
|
||||
}
|
||||
}
|
||||
|
||||
if servers := readNetworkManagerDNS(); len(servers) > 0 {
|
||||
return servers
|
||||
}
|
||||
|
||||
// If olm has already overridden /etc/resolv.conf the backup holds the
|
||||
// original pre-override DNS servers.
|
||||
if _, err := os.Stat("/etc/resolv.conf.olm.backup"); err == nil {
|
||||
if servers := parseResolvConf("/etc/resolv.conf.olm.backup"); len(servers) > 0 {
|
||||
return servers
|
||||
}
|
||||
}
|
||||
|
||||
return parseResolvConf("/etc/resolv.conf")
|
||||
}
|
||||
|
||||
// readNetworkManagerDNS returns the DNS servers NetworkManager reports over
|
||||
// D-Bus for its active connections, in "host:53" format. Returns nil if
|
||||
// NetworkManager isn't running or reports nothing usable.
|
||||
func readNetworkManagerDNS() []string {
|
||||
addrs, err := platform.GetNetworkManagerNameservers()
|
||||
if err != nil || len(addrs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(addrs))
|
||||
for _, addr := range addrs {
|
||||
result = append(result, addrToHostPort(addr))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// parseResolvConf reads nameserver lines from a resolv.conf-style file,
|
||||
// skipping loopback and link-local addresses.
|
||||
func parseResolvConf(path string) []string {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var result []string
|
||||
seen := make(map[string]bool)
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if !strings.HasPrefix(line, "nameserver") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
addr, err := netip.ParseAddr(fields[1])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if addr.IsLoopback() || addr.IsLinkLocalUnicast() {
|
||||
continue
|
||||
}
|
||||
s := addrToHostPort(addr)
|
||||
if !seen[s] {
|
||||
seen[s] = true
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// addrToHostPort converts a netip.Addr to "addr:53" format, wrapping IPv6
|
||||
// addresses in brackets as required by net.JoinHostPort.
|
||||
func addrToHostPort(addr netip.Addr) string {
|
||||
return net.JoinHostPort(addr.String(), "53")
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//go:build !linux && !darwin && !windows && !android
|
||||
|
||||
package dns
|
||||
|
||||
// readSystemDNS returns nil on platforms where automatic DNS discovery is not
|
||||
// implemented (freebsd, etc.). Callers should fall back to a statically
|
||||
// configured DNS server.
|
||||
//
|
||||
// android and ios are excluded from this constraint (and have their own
|
||||
// sysresolver_android.go / sysresolver_ios.go with an explicit "android" /
|
||||
// "ios" tag) rather than falling through the "!linux" / "!darwin" catch-all
|
||||
// here:
|
||||
// - wireguard-android's build passes "-tags linux" to share Linux netlink code
|
||||
// (a deliberate, long-standing convention, since Android's kernel is Linux), and Go's
|
||||
// build constraint evaluator can't distinguish a custom "-tags linux" from the real
|
||||
// GOOS=linux - so with that tag set, "!linux" is false even though GOOS is actually
|
||||
// android, and this file would silently stop applying, leaving readSystemDNS undefined.
|
||||
// - Go's build constraint evaluator treats GOOS=ios as implicitly satisfying the
|
||||
// "darwin" tag as well as "ios", so "!darwin" is false on an iOS build too, which
|
||||
// would otherwise leave readSystemDNS undefined there as well.
|
||||
func readSystemDNS() []string {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package dns
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// stubReachable overrides dnsServerReachable for the duration of the test so
|
||||
// tests don't depend on real network access, restoring the original on
|
||||
// cleanup.
|
||||
func stubReachable(t *testing.T, fn func(server string) bool) {
|
||||
t.Helper()
|
||||
orig := dnsServerReachable
|
||||
dnsServerReachable = func(server string) (bool, error) { return fn(server), nil }
|
||||
t.Cleanup(func() { dnsServerReachable = orig })
|
||||
}
|
||||
|
||||
func allReachable(t *testing.T) {
|
||||
stubReachable(t, func(string) bool { return true })
|
||||
}
|
||||
|
||||
func TestReportExternalFiltersExcludedIP(t *testing.T) {
|
||||
allReachable(t)
|
||||
|
||||
var got []string
|
||||
m := &SystemDNSMonitor{
|
||||
excludeIPs: make(map[netip.Addr]bool),
|
||||
onChange: func(servers []string) {
|
||||
got = servers
|
||||
},
|
||||
}
|
||||
m.SetExcludeIP(netip.MustParseAddr("10.0.0.1"))
|
||||
|
||||
m.ReportExternal([]string{"10.0.0.1:53", "8.8.8.8:53"})
|
||||
|
||||
want := []string{"8.8.8.8:53"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("onChange servers = %v, want %v", got, want)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Current(), want) {
|
||||
t.Fatalf("Current() = %v, want %v", m.Current(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportExternalAllExcludedIsNoop(t *testing.T) {
|
||||
allReachable(t)
|
||||
|
||||
called := false
|
||||
m := &SystemDNSMonitor{
|
||||
excludeIPs: make(map[netip.Addr]bool),
|
||||
current: []string{"1.1.1.1:53"},
|
||||
onChange: func(servers []string) {
|
||||
called = true
|
||||
},
|
||||
}
|
||||
m.SetExcludeIP(netip.MustParseAddr("10.0.0.1"))
|
||||
|
||||
m.ReportExternal([]string{"10.0.0.1:53"})
|
||||
|
||||
if called {
|
||||
t.Fatal("onChange should not fire when all reported servers are excluded")
|
||||
}
|
||||
want := []string{"1.1.1.1:53"}
|
||||
if !reflect.DeepEqual(m.Current(), want) {
|
||||
t.Fatalf("Current() = %v, want unchanged %v", m.Current(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportExternalOnlyFiresOnChange(t *testing.T) {
|
||||
allReachable(t)
|
||||
|
||||
calls := 0
|
||||
m := &SystemDNSMonitor{
|
||||
excludeIPs: make(map[netip.Addr]bool),
|
||||
onChange: func(servers []string) {
|
||||
calls++
|
||||
},
|
||||
}
|
||||
|
||||
m.ReportExternal([]string{"8.8.8.8:53"})
|
||||
m.ReportExternal([]string{"8.8.8.8:53"})
|
||||
|
||||
if calls != 1 {
|
||||
t.Fatalf("onChange fired %d times, want 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportExternalDropsUnreachableServer(t *testing.T) {
|
||||
// Simulates e.g. T-Mobile's private ULA DNS64 resolver: technically "the
|
||||
// system DNS" per the OS, but doesn't actually answer queries.
|
||||
stubReachable(t, func(server string) bool {
|
||||
return server != "[fd00:976a::9]:53"
|
||||
})
|
||||
|
||||
var got []string
|
||||
m := &SystemDNSMonitor{
|
||||
excludeIPs: make(map[netip.Addr]bool),
|
||||
onChange: func(servers []string) {
|
||||
got = servers
|
||||
},
|
||||
}
|
||||
|
||||
m.ReportExternal([]string{"[fd00:976a::9]:53", "8.8.8.8:53"})
|
||||
|
||||
want := []string{"8.8.8.8:53"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("onChange servers = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportExternalKeepsPreviousValueWhenAllUnreachable(t *testing.T) {
|
||||
stubReachable(t, func(string) bool { return true })
|
||||
|
||||
called := false
|
||||
m := &SystemDNSMonitor{
|
||||
excludeIPs: make(map[netip.Addr]bool),
|
||||
current: []string{"1.1.1.1:53"},
|
||||
onChange: func(servers []string) {
|
||||
called = true
|
||||
},
|
||||
}
|
||||
|
||||
// Now simulate every candidate failing the health check (e.g. moved to a
|
||||
// network where none of the reported servers actually respond).
|
||||
stubReachable(t, func(string) bool { return false })
|
||||
|
||||
m.ReportExternal([]string{"[fd00:976a::9]:53", "[fd00:976a::10]:53"})
|
||||
|
||||
if called {
|
||||
t.Fatal("onChange should not fire when no candidate passes the health check")
|
||||
}
|
||||
want := []string{"1.1.1.1:53"}
|
||||
if !reflect.DeepEqual(m.Current(), want) {
|
||||
t.Fatalf("Current() = %v, want unchanged %v", m.Current(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterUnreachable(t *testing.T) {
|
||||
stubReachable(t, func(server string) bool {
|
||||
return server == "8.8.8.8:53"
|
||||
})
|
||||
|
||||
got := filterUnreachable([]string{"10.0.0.1:53", "8.8.8.8:53", "9.9.9.9:53"})
|
||||
want := []string{"8.8.8.8:53"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("filterUnreachable() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//go:build windows
|
||||
|
||||
package dns
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
|
||||
"golang.org/x/sys/windows/registry"
|
||||
)
|
||||
|
||||
const (
|
||||
tcpipInterfacesPath = `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces`
|
||||
dhcpNameServerKey = "DhcpNameServer"
|
||||
staticNameServerKey = "NameServer"
|
||||
)
|
||||
|
||||
// readSystemDNS returns the current system DNS servers in "host:53" format by
|
||||
// enumerating every network adapter in the Windows registry.
|
||||
//
|
||||
// For each adapter olm reads the effective DNS servers: static (NameServer)
|
||||
// if set, since a static entry overrides DHCP for that adapter and is what
|
||||
// the OS resolver actually uses, otherwise falling back to the DHCP-assigned
|
||||
// servers (DhcpNameServer). This also picks up olm's own WireGuard adapter,
|
||||
// which olm points at its local DNS proxy via a static NameServer entry; that
|
||||
// address is expected to be filtered out by the caller via
|
||||
// SystemDNSMonitor.SetExcludeIP. Loopback and link-local addresses are
|
||||
// excluded.
|
||||
func readSystemDNS() []string {
|
||||
key, err := registry.OpenKey(registry.LOCAL_MACHINE, tcpipInterfacesPath, registry.ENUMERATE_SUB_KEYS)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer key.Close()
|
||||
|
||||
subkeys, err := key.ReadSubKeyNames(-1)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
var result []string
|
||||
|
||||
for _, guid := range subkeys {
|
||||
path := fmt.Sprintf(`%s\%s`, tcpipInterfacesPath, guid)
|
||||
iKey, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
servers, _, err := iKey.GetStringValue(staticNameServerKey)
|
||||
if err != nil || servers == "" {
|
||||
servers, _, err = iKey.GetStringValue(dhcpNameServerKey)
|
||||
}
|
||||
iKey.Close()
|
||||
if err != nil || servers == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, s := range splitWinDNSList(servers) {
|
||||
addr, err := netip.ParseAddr(s)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if addr.IsLoopback() || addr.IsLinkLocalUnicast() {
|
||||
continue
|
||||
}
|
||||
hp := net.JoinHostPort(addr.String(), "53")
|
||||
if !seen[hp] {
|
||||
seen[hp] = true
|
||||
result = append(result, hp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// splitWinDNSList splits a Windows DNS server list that may be comma- or
|
||||
// space-separated.
|
||||
func splitWinDNSList(s string) []string {
|
||||
var out []string
|
||||
for _, part := range splitByRunes(s, []rune{',', ' '}) {
|
||||
if part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func splitByRunes(s string, delims []rune) []string {
|
||||
var result []string
|
||||
start := 0
|
||||
for i, r := range s {
|
||||
for _, d := range delims {
|
||||
if r == d {
|
||||
if i > start {
|
||||
result = append(result, s[start:i])
|
||||
}
|
||||
start = i + len(string(r))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if start < len(s) {
|
||||
result = append(result, s[start:])
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
dnsOverride "github.com/fosrl/olm/dns/override"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultWatchdogInterval = 5 * time.Second
|
||||
defaultWatchdogThreshold = 3
|
||||
)
|
||||
|
||||
// runDNSWatchdogCommand handles the `olm watchdog` subcommand. The watchdog
|
||||
// is meant to be spawned by an olm process after it installs a DNS
|
||||
// override, and forcibly resets the system DNS if that parent dies before
|
||||
// restoring it.
|
||||
func runDNSWatchdogCommand(ctx context.Context, args []string) error {
|
||||
fs := flag.NewFlagSet("watchdog", flag.ContinueOnError)
|
||||
parentPID := fs.Int("parent-pid", 0, "PID of the olm process to monitor")
|
||||
socketPath := fs.String("socket", "", "Path to the olm API unix socket (optional)")
|
||||
interfaceName := fs.String("interface", "", "WireGuard interface name (used for cleanup)")
|
||||
interval := fs.Duration("interval", defaultWatchdogInterval, "Liveness check interval")
|
||||
threshold := fs.Int("threshold", defaultWatchdogThreshold, "Consecutive failures before DNS reset")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if *parentPID <= 0 {
|
||||
return fmt.Errorf("--parent-pid is required and must be positive")
|
||||
}
|
||||
|
||||
// Ensure logger is initialised for the watchdog process.
|
||||
logger.Init(nil)
|
||||
|
||||
return dnsOverride.RunWatchdog(ctx, dnsOverride.WatchdogConfig{
|
||||
ParentPID: *parentPID,
|
||||
SocketPath: *socketPath,
|
||||
InterfaceName: *interfaceName,
|
||||
CheckInterval: *interval,
|
||||
FailureThreshold: *threshold,
|
||||
})
|
||||
}
|
||||
|
||||
// runResetDNSCommand handles the `olm reset-dns` subcommand. It forcibly
|
||||
// removes any DNS override state left behind on the system.
|
||||
func runResetDNSCommand(args []string) error {
|
||||
fs := flag.NewFlagSet("reset-dns", flag.ContinueOnError)
|
||||
interfaceName := fs.String("interface", "olm", "WireGuard interface name")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Init(nil)
|
||||
|
||||
if err := dnsOverride.ForceResetDNS(*interfaceName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("DNS reset complete")
|
||||
return nil
|
||||
}
|
||||
@@ -4,15 +4,15 @@ go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.2
|
||||
github.com/fosrl/newt v1.10.3
|
||||
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
|
||||
golang.org/x/sys v0.41.0
|
||||
golang.org/x/sys v0.46.0
|
||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
|
||||
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.0
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.3
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -20,15 +20,15 @@ require (
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/vishvananda/netlink v1.3.1 // indirect
|
||||
github.com/vishvananda/netns v0.0.5 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/crypto v0.53.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
|
||||
golang.org/x/mod v0.32.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/mod v0.34.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/time v0.12.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
golang.org/x/tools v0.43.0 // indirect
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
|
||||
golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
|
||||
)
|
||||
|
||||
// To be used ONLY for local development
|
||||
|
||||
@@ -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.10.3 h1:JO9gFK9LP/w2EeDIn4wU+jKggAFPo06hX5hxFSETqcw=
|
||||
github.com/fosrl/newt v1.10.3/go.mod h1:iYuuCAG7iabheiogMOX87r61uQN31S39nKxMKRuLS+s=
|
||||
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=
|
||||
@@ -16,33 +16,33 @@ github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW
|
||||
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
|
||||
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
|
||||
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
|
||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
|
||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU=
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ=
|
||||
golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE=
|
||||
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
|
||||
golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH8ysFIbw8=
|
||||
golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
|
||||
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgIMBW8ZZhT4gLnUyDIhzI=
|
||||
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g=
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.0 h1:Db8W44cB54TWD7stUFFSWxdfpdn6fZVcDl0w3R4RVM0=
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.3 h1:JBQD3FDqYjTeyDAeZQklj2ar88ykBLtALloPJHyAauU=
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.3/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
|
||||
|
||||
@@ -164,6 +164,26 @@ func main() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Internal DNS subcommands. These are handled before normal flag
|
||||
// parsing because they have their own argument layouts and need to
|
||||
// run without setting up the full olm runtime.
|
||||
if len(os.Args) > 1 {
|
||||
switch os.Args[1] {
|
||||
case "watchdog", "dns-watchdog":
|
||||
if err := runDNSWatchdogCommand(signalCtx, os.Args[2:]); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "watchdog failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
case "reset-dns":
|
||||
if err := runResetDNSCommand(os.Args[2:]); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "reset-dns failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Run in console mode
|
||||
runOlmMainWithArgs(ctx, cancel, signalCtx, os.Args[1:])
|
||||
}
|
||||
@@ -221,6 +241,8 @@ func runOlmMainWithArgs(ctx context.Context, cancel context.CancelFunc, signalCt
|
||||
OnExit: cancel, // Pass cancel function directly to trigger shutdown
|
||||
OnTerminated: cancel,
|
||||
PprofAddr: ":4444", // TODO: REMOVE OR MAKE CONFIGURABLE
|
||||
// Re-invoke this binary in watchdog mode to clean up DNS if we die.
|
||||
WatchdogSubcommand: []string{"watchdog"},
|
||||
}
|
||||
|
||||
olm, err := olmpkg.Init(ctx, olmConfig)
|
||||
@@ -241,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,
|
||||
@@ -249,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)
|
||||
|
||||
+19
-4
@@ -145,11 +145,19 @@ 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)
|
||||
}
|
||||
|
||||
// Tell the system DNS monitor to exclude the proxy IP so that subsequent
|
||||
// polls never mistake the proxy for a real upstream server (on Linux the OS
|
||||
// DNS is overridden to point at this IP, which would otherwise feed back
|
||||
// into UpstreamDNS or PublicDNS on the next poll).
|
||||
if o.dnsMonitor != nil && o.dnsProxy != nil {
|
||||
o.dnsMonitor.SetExcludeIP(o.dnsProxy.GetProxyIP())
|
||||
}
|
||||
|
||||
if err = network.ConfigureInterface(o.tunnelConfig.InterfaceName, wgData.TunnelIP, o.tunnelConfig.MTU); err != nil {
|
||||
logger.Error("Failed to o.tunnelConfigure interface: %v", err)
|
||||
}
|
||||
@@ -184,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
|
||||
@@ -205,12 +213,13 @@ func (o *Olm) handleConnect(msg websocket.WSMessage) {
|
||||
// Register JIT handler: when the DNS proxy resolves a local record, check whether
|
||||
// the owning site is already connected and, if not, initiate a JIT connection.
|
||||
o.dnsProxy.SetJITHandler(func(siteId int) {
|
||||
if o.peerManager == nil || o.websocket == nil {
|
||||
pm := o.getPeerManager()
|
||||
if pm == nil || o.websocket == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Site already has an active peer connection - nothing to do.
|
||||
if _, exists := o.peerManager.GetPeer(siteId); exists {
|
||||
if _, exists := pm.GetPeer(siteId); exists {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -239,6 +248,12 @@ func (o *Olm) handleConnect(msg websocket.WSMessage) {
|
||||
return
|
||||
}
|
||||
|
||||
// Start the external watchdog (if configured). The watchdog will
|
||||
// reset DNS if this process dies before it can call
|
||||
// RestoreDNSOverride. This is a no-op when no watchdog
|
||||
// subcommand has been configured on the OlmConfig.
|
||||
o.startDNSWatchdog(o.tunnelConfig.InterfaceName)
|
||||
|
||||
network.SetDNSServers([]string{o.dnsProxy.GetProxyIP().String()})
|
||||
}
|
||||
|
||||
|
||||
+44
-22
@@ -32,21 +32,27 @@ func (o *Olm) handleWgPeerAddData(msg websocket.WSMessage) {
|
||||
return
|
||||
}
|
||||
|
||||
if _, exists := o.peerManager.GetPeer(addSubnetsData.SiteId); !exists {
|
||||
pm := o.getPeerManager()
|
||||
if pm == nil {
|
||||
logger.Debug("Ignoring add-remote-subnets-aliases message: peerManager is nil (shutdown in progress)")
|
||||
return
|
||||
}
|
||||
|
||||
if _, exists := pm.GetPeer(addSubnetsData.SiteId); !exists {
|
||||
logger.Debug("Peer %d not found for removing remote subnets and aliases", addSubnetsData.SiteId)
|
||||
return
|
||||
}
|
||||
|
||||
// Add new subnets
|
||||
for _, subnet := range addSubnetsData.RemoteSubnets {
|
||||
if err := o.peerManager.AddRemoteSubnet(addSubnetsData.SiteId, subnet); err != nil {
|
||||
if err := pm.AddRemoteSubnet(addSubnetsData.SiteId, subnet); err != nil {
|
||||
logger.Error("Failed to add allowed IP %s: %v", subnet, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Add new aliases
|
||||
for _, alias := range addSubnetsData.Aliases {
|
||||
if err := o.peerManager.AddAlias(addSubnetsData.SiteId, alias); err != nil {
|
||||
if err := pm.AddAlias(addSubnetsData.SiteId, alias); err != nil {
|
||||
logger.Error("Failed to add alias %s: %v", alias.Alias, err)
|
||||
}
|
||||
}
|
||||
@@ -73,21 +79,27 @@ func (o *Olm) handleWgPeerRemoveData(msg websocket.WSMessage) {
|
||||
return
|
||||
}
|
||||
|
||||
if _, exists := o.peerManager.GetPeer(removeSubnetsData.SiteId); !exists {
|
||||
pm := o.getPeerManager()
|
||||
if pm == nil {
|
||||
logger.Debug("Ignoring remove-remote-subnets-aliases message: peerManager is nil (shutdown in progress)")
|
||||
return
|
||||
}
|
||||
|
||||
if _, exists := pm.GetPeer(removeSubnetsData.SiteId); !exists {
|
||||
logger.Debug("Peer %d not found for removing remote subnets and aliases", removeSubnetsData.SiteId)
|
||||
return
|
||||
}
|
||||
|
||||
// Remove subnets
|
||||
for _, subnet := range removeSubnetsData.RemoteSubnets {
|
||||
if err := o.peerManager.RemoveRemoteSubnet(removeSubnetsData.SiteId, subnet); err != nil {
|
||||
if err := pm.RemoveRemoteSubnet(removeSubnetsData.SiteId, subnet); err != nil {
|
||||
logger.Error("Failed to remove allowed IP %s: %v", subnet, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove aliases
|
||||
for _, alias := range removeSubnetsData.Aliases {
|
||||
if err := o.peerManager.RemoveAlias(removeSubnetsData.SiteId, alias.Alias); err != nil {
|
||||
if err := pm.RemoveAlias(removeSubnetsData.SiteId, alias.Alias); err != nil {
|
||||
logger.Error("Failed to remove alias %s: %v", alias.Alias, err)
|
||||
}
|
||||
}
|
||||
@@ -114,7 +126,13 @@ func (o *Olm) handleWgPeerUpdateData(msg websocket.WSMessage) {
|
||||
return
|
||||
}
|
||||
|
||||
if _, exists := o.peerManager.GetPeer(updateSubnetsData.SiteId); !exists {
|
||||
pm := o.getPeerManager()
|
||||
if pm == nil {
|
||||
logger.Debug("Ignoring update-remote-subnets-aliases message: peerManager is nil (shutdown in progress)")
|
||||
return
|
||||
}
|
||||
|
||||
if _, exists := pm.GetPeer(updateSubnetsData.SiteId); !exists {
|
||||
logger.Debug("Peer %d not found for updating remote subnets and aliases", updateSubnetsData.SiteId)
|
||||
return
|
||||
}
|
||||
@@ -123,14 +141,14 @@ func (o *Olm) handleWgPeerUpdateData(msg websocket.WSMessage) {
|
||||
// This ensures that if an old and new subnet are the same on different peers,
|
||||
// the route won't be temporarily removed
|
||||
for _, subnet := range updateSubnetsData.NewRemoteSubnets {
|
||||
if err := o.peerManager.AddRemoteSubnet(updateSubnetsData.SiteId, subnet); err != nil {
|
||||
if err := pm.AddRemoteSubnet(updateSubnetsData.SiteId, subnet); err != nil {
|
||||
logger.Error("Failed to add allowed IP %s: %v", subnet, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove old subnets after new ones are added
|
||||
for _, subnet := range updateSubnetsData.OldRemoteSubnets {
|
||||
if err := o.peerManager.RemoveRemoteSubnet(updateSubnetsData.SiteId, subnet); err != nil {
|
||||
if err := pm.RemoveRemoteSubnet(updateSubnetsData.SiteId, subnet); err != nil {
|
||||
logger.Error("Failed to remove allowed IP %s: %v", subnet, err)
|
||||
}
|
||||
}
|
||||
@@ -139,14 +157,14 @@ func (o *Olm) handleWgPeerUpdateData(msg websocket.WSMessage) {
|
||||
// This ensures that if an old and new alias share the same IP, the IP won't be
|
||||
// temporarily removed from the allowed IPs list
|
||||
for _, alias := range updateSubnetsData.NewAliases {
|
||||
if err := o.peerManager.AddAlias(updateSubnetsData.SiteId, alias); err != nil {
|
||||
if err := pm.AddAlias(updateSubnetsData.SiteId, alias); err != nil {
|
||||
logger.Error("Failed to add alias %s: %v", alias.Alias, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove old aliases after new ones are added
|
||||
for _, alias := range updateSubnetsData.OldAliases {
|
||||
if err := o.peerManager.RemoveAlias(updateSubnetsData.SiteId, alias.Alias); err != nil {
|
||||
if err := pm.RemoveAlias(updateSubnetsData.SiteId, alias.Alias); err != nil {
|
||||
logger.Error("Failed to remove alias %s: %v", alias.Alias, err)
|
||||
}
|
||||
}
|
||||
@@ -163,7 +181,8 @@ func (o *Olm) handleSync(msg websocket.WSMessage) {
|
||||
return
|
||||
}
|
||||
|
||||
if o.peerManager == nil {
|
||||
pm := o.getPeerManager()
|
||||
if pm == nil {
|
||||
logger.Warn("Peer manager not initialized, ignoring sync request")
|
||||
return
|
||||
}
|
||||
@@ -190,7 +209,7 @@ func (o *Olm) handleSync(msg websocket.WSMessage) {
|
||||
}
|
||||
|
||||
// Get all current peers
|
||||
currentPeers := o.peerManager.GetAllPeers()
|
||||
currentPeers := pm.GetAllPeers()
|
||||
currentPeerMap := make(map[int]peers.SiteConfig)
|
||||
for _, peer := range currentPeers {
|
||||
currentPeerMap[peer.SiteId] = peer
|
||||
@@ -200,7 +219,7 @@ func (o *Olm) handleSync(msg websocket.WSMessage) {
|
||||
for siteId := range currentPeerMap {
|
||||
if _, exists := expectedPeers[siteId]; !exists {
|
||||
logger.Info("Sync: Removing peer for site %d (no longer in expected config)", siteId)
|
||||
if err := o.peerManager.RemovePeer(siteId); err != nil {
|
||||
if err := pm.RemovePeer(siteId); err != nil {
|
||||
logger.Error("Sync: Failed to remove peer %d: %v", siteId, err)
|
||||
} else {
|
||||
// Remove any exit nodes associated with this peer from hole punching
|
||||
@@ -217,19 +236,22 @@ func (o *Olm) handleSync(msg websocket.WSMessage) {
|
||||
// Find peers to add (in expected but not in current) and peers to update
|
||||
for siteId, expectedSite := range expectedPeers {
|
||||
if _, exists := currentPeerMap[siteId]; !exists {
|
||||
// Only trigger add if this is NOT a JIT-only config (i.e., has more than just siteId and aliases)
|
||||
jitOnly := expectedSite.PublicKey == ""
|
||||
if jitOnly {
|
||||
logger.Debug("Sync: Registering aliases for JIT-only site %d", siteId)
|
||||
if err := pm.AddPeer(expectedSite); err != nil {
|
||||
logger.Error("Sync: Failed to register aliases for JIT site %d: %v", siteId, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// New peer - add it using the add flow (with holepunch)
|
||||
logger.Info("Sync: Adding new peer for site %d", siteId)
|
||||
|
||||
o.holePunchManager.TriggerHolePunch()
|
||||
o.holePunchManager.ResetServerHolepunchInterval() // start sending immediately again so we fill in the endpoint on the cloud
|
||||
|
||||
// // TODO: do we need to send the message to the cloud to add the peer that way?
|
||||
// if err := o.peerManager.AddPeer(expectedSite); err != nil {
|
||||
// logger.Error("Sync: Failed to add peer %d: %v", siteId, err)
|
||||
// } else {
|
||||
// logger.Info("Sync: Successfully added peer for site %d", siteId)
|
||||
// }
|
||||
|
||||
// add the peer via the server
|
||||
// this is important because newt needs to get triggered as well to add the peer once the hp is complete
|
||||
chainId := fmt.Sprintf("sync-%d", expectedSite.SiteId)
|
||||
@@ -301,7 +323,7 @@ func (o *Olm) handleSync(msg websocket.WSMessage) {
|
||||
siteConfig.Aliases = expectedSite.Aliases
|
||||
}
|
||||
|
||||
if err := o.peerManager.UpdatePeer(siteConfig); err != nil {
|
||||
if err := pm.UpdatePeer(siteConfig); err != nil {
|
||||
logger.Error("Sync: Failed to update peer %d: %v", siteId, err)
|
||||
} else {
|
||||
// If the endpoint changed, trigger holepunch to refresh NAT mappings
|
||||
|
||||
+204
-24
@@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
_ "net/http/pprof"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -42,11 +43,18 @@ type Olm struct {
|
||||
middleDev *olmDevice.MiddleDevice
|
||||
sharedBind *bind.SharedBind
|
||||
|
||||
dnsProxy *dns.DNSProxy
|
||||
apiServer *api.API
|
||||
websocket *websocket.Client
|
||||
holePunchManager *holepunch.Manager
|
||||
peerManager *peers.PeerManager
|
||||
dnsProxy *dns.DNSProxy
|
||||
dnsMonitor *dns.SystemDNSMonitor
|
||||
// pendingSystemDNS holds a SetSystemDNS report received before dnsMonitor exists
|
||||
// (e.g. Android/iOS push a value while the tunnel is still starting up), so it
|
||||
// isn't silently dropped. Drained into dnsMonitor as soon as StartTunnel creates it.
|
||||
pendingSystemDNSMu sync.Mutex
|
||||
pendingSystemDNS []string
|
||||
apiServer *api.API
|
||||
websocket *websocket.Client
|
||||
holePunchManager *holepunch.Manager
|
||||
peerManager *peers.PeerManager
|
||||
peerManagerMu sync.RWMutex
|
||||
// Power mode management
|
||||
currentPowerMode string
|
||||
powerModeMu sync.Mutex
|
||||
@@ -67,13 +75,26 @@ type Olm struct {
|
||||
stopRegister func()
|
||||
updateRegister func(newData any)
|
||||
|
||||
stopPeerSends map[string]func()
|
||||
stopPeerInits map[string]func()
|
||||
stopPeerSends map[string]func()
|
||||
stopPeerInits map[string]func()
|
||||
jitPendingSites map[int]string // siteId -> chainId for in-flight JIT requests
|
||||
peerSendMu sync.Mutex
|
||||
peerSendMu sync.Mutex
|
||||
|
||||
// WaitGroup to track tunnel lifecycle
|
||||
tunnelWg sync.WaitGroup
|
||||
|
||||
// External DNS watchdog process (spawned after DNS override is installed).
|
||||
// nil when no watchdog is running.
|
||||
dnsWatchdogCmd *exec.Cmd
|
||||
}
|
||||
|
||||
// getPeerManager safely returns the current peerManager under a read-lock.
|
||||
// Callers must check the returned value for nil before using it.
|
||||
func (o *Olm) getPeerManager() *peers.PeerManager {
|
||||
o.peerManagerMu.RLock()
|
||||
pm := o.peerManager
|
||||
o.peerManagerMu.RUnlock()
|
||||
return pm
|
||||
}
|
||||
|
||||
// initTunnelInfo creates the shared UDP socket and holepunch manager.
|
||||
@@ -178,10 +199,10 @@ func Init(ctx context.Context, config OlmConfig) (*Olm, error) {
|
||||
apiServer.SetAgent(config.Agent)
|
||||
|
||||
newOlm := &Olm{
|
||||
logFile: logFile,
|
||||
olmCtx: ctx,
|
||||
apiServer: apiServer,
|
||||
olmConfig: config,
|
||||
logFile: logFile,
|
||||
olmCtx: ctx,
|
||||
apiServer: apiServer,
|
||||
olmConfig: config,
|
||||
stopPeerSends: make(map[string]func()),
|
||||
stopPeerInits: make(map[string]func()),
|
||||
jitPendingSites: make(map[int]string),
|
||||
@@ -210,6 +231,7 @@ func (o *Olm) registerAPICallbacks() {
|
||||
Holepunch: req.Holepunch,
|
||||
TlsClientCert: req.TlsClientCert,
|
||||
OrgID: req.OrgID,
|
||||
MatchDomains: req.MatchDomains,
|
||||
}
|
||||
|
||||
var err error
|
||||
@@ -317,6 +339,54 @@ func (o *Olm) registerAPICallbacks() {
|
||||
)
|
||||
}
|
||||
|
||||
// startDNSWatchdog launches an external watchdog process that will reset
|
||||
// system DNS if this olm process dies before it can call
|
||||
// RestoreDNSOverride. It is a no-op when the OlmConfig has no
|
||||
// WatchdogSubcommand configured, or when the watchdog has already been
|
||||
// started for this Olm instance.
|
||||
func (o *Olm) startDNSWatchdog(interfaceName string) {
|
||||
if o.dnsWatchdogCmd != nil {
|
||||
return
|
||||
}
|
||||
if len(o.olmConfig.WatchdogSubcommand) == 0 {
|
||||
logger.Debug("DNS watchdog disabled (no WatchdogSubcommand configured)")
|
||||
return
|
||||
}
|
||||
|
||||
executable := o.olmConfig.WatchdogExecutable
|
||||
if executable == "" {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
logger.Warn("DNS watchdog: failed to resolve executable: %v", err)
|
||||
return
|
||||
}
|
||||
executable = exe
|
||||
}
|
||||
|
||||
cmd, err := dnsOverride.SpawnWatchdog(dnsOverride.SpawnWatchdogConfig{
|
||||
Executable: executable,
|
||||
Subcommand: o.olmConfig.WatchdogSubcommand,
|
||||
InterfaceName: interfaceName,
|
||||
SocketPath: o.olmConfig.SocketPath,
|
||||
LogFile: o.olmConfig.WatchdogLogFile,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warn("DNS watchdog: spawn failed: %v", err)
|
||||
return
|
||||
}
|
||||
o.dnsWatchdogCmd = cmd
|
||||
}
|
||||
|
||||
// stopDNSWatchdog stops any previously spawned DNS watchdog process.
|
||||
// Safe to call when no watchdog was started.
|
||||
func (o *Olm) stopDNSWatchdog() {
|
||||
if o.dnsWatchdogCmd == nil {
|
||||
return
|
||||
}
|
||||
dnsOverride.StopWatchdog(o.dnsWatchdogCmd)
|
||||
o.dnsWatchdogCmd = nil
|
||||
}
|
||||
|
||||
func (o *Olm) StartTunnel(config TunnelConfig) {
|
||||
if o.tunnelRunning {
|
||||
logger.Info("Tunnel already running")
|
||||
@@ -328,12 +398,73 @@ 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
|
||||
|
||||
// TODO: we are hardcoding this for now but we should really pull it from the current config of the system
|
||||
if o.tunnelConfig.DNS != "" {
|
||||
o.tunnelConfig.PublicDNS = []string{o.tunnelConfig.DNS + ":53"}
|
||||
} else {
|
||||
o.tunnelConfig.PublicDNS = []string{"8.8.8.8:53"}
|
||||
// Determine whether the system DNS monitor should also manage UpstreamDNS.
|
||||
// If the caller did not provide an explicit UpstreamDNS (it was defaulted to
|
||||
// 8.8.8.8:53 by the API handler), we want the monitor to keep it updated
|
||||
// with whatever DNS the host network is currently using.
|
||||
upstreamFromConfig := len(config.UpstreamDNS) > 0 &&
|
||||
!(len(config.UpstreamDNS) == 1 && config.UpstreamDNS[0] == "8.8.8.8:53")
|
||||
if upstreamFromConfig {
|
||||
logger.Info("UpstreamDNS is statically configured (%v); automatic system DNS detection will only update PublicDNS, DNS forwarding will keep using the configured value even if it becomes unreachable on a new network", config.UpstreamDNS)
|
||||
}
|
||||
|
||||
// Start the system DNS monitor. The callback fires synchronously once with
|
||||
// the initial values so that PublicDNS (and optionally UpstreamDNS) are
|
||||
// populated before the tunnel goroutine proceeds.
|
||||
o.dnsMonitor = dns.NewSystemDNSMonitor(0, func(servers []string) {
|
||||
if len(servers) == 0 {
|
||||
return
|
||||
}
|
||||
logger.Info("Applying system DNS: %v", servers)
|
||||
|
||||
// PublicDNS must always reflect the physical-network DNS so that
|
||||
// WireGuard endpoint hostnames and hole-punch targets can be resolved
|
||||
// even after the system resolver has been overridden by olm.
|
||||
o.tunnelConfig.PublicDNS = servers
|
||||
if o.holePunchManager != nil {
|
||||
o.holePunchManager.SetPublicDNS(servers)
|
||||
}
|
||||
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
|
||||
// network's real resolver as the host moves between networks.
|
||||
if !upstreamFromConfig {
|
||||
o.tunnelConfig.UpstreamDNS = servers
|
||||
if o.dnsProxy != nil {
|
||||
o.dnsProxy.SetUpstreamDNS(servers)
|
||||
}
|
||||
} else {
|
||||
logger.Debug("Not updating UpstreamDNS: statically configured to %v", config.UpstreamDNS)
|
||||
}
|
||||
})
|
||||
o.dnsMonitor.Start(o.olmCtx)
|
||||
|
||||
// Apply any SetSystemDNS report that arrived before dnsMonitor existed (e.g. an
|
||||
// Android/iOS push that raced ahead of this goroutine).
|
||||
if pending := o.takePendingSystemDNS(); len(pending) > 0 {
|
||||
o.dnsMonitor.ReportExternal(pending)
|
||||
}
|
||||
|
||||
// Fall back to hardcoded DNS if the system monitor could not detect any.
|
||||
if len(o.tunnelConfig.PublicDNS) == 0 {
|
||||
if o.tunnelConfig.DNS != "" {
|
||||
o.tunnelConfig.PublicDNS = []string{o.tunnelConfig.DNS + ":53"}
|
||||
} else {
|
||||
o.tunnelConfig.PublicDNS = []string{"8.8.8.8:53"}
|
||||
}
|
||||
}
|
||||
if len(o.tunnelConfig.UpstreamDNS) == 0 {
|
||||
o.tunnelConfig.UpstreamDNS = []string{"8.8.8.8:53"}
|
||||
}
|
||||
|
||||
// Reset terminated status when tunnel starts
|
||||
@@ -406,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)
|
||||
@@ -457,7 +590,8 @@ func (o *Olm) StartTunnel(config TunnelConfig) {
|
||||
"userToken": userToken,
|
||||
"fingerprint": o.fingerprint,
|
||||
"postures": o.postures,
|
||||
}, 2*time.Second, 10)
|
||||
"chainId": generateChainId(), // use a random chainId for registration updates - it won't be used for cancellation since registration is a one-time message but for tracking the session
|
||||
}, 2*time.Second, 20) // after 18 tries on the server side we send the error so dont change this without changing that
|
||||
|
||||
// Invoke onRegistered callback if configured
|
||||
if o.olmConfig.OnRegistered != nil {
|
||||
@@ -585,16 +719,30 @@ func (o *Olm) Close() {
|
||||
logger.Error("Failed to restore DNS: %v", err)
|
||||
}
|
||||
|
||||
// Stop the watchdog *after* a successful DNS restore so that if we
|
||||
// somehow crash mid-restore the watchdog still has a chance to clean
|
||||
// up. The watchdog itself is a no-op if it was never spawned.
|
||||
o.stopDNSWatchdog()
|
||||
|
||||
if o.holePunchManager != nil {
|
||||
o.holePunchManager.Stop()
|
||||
o.holePunchManager = nil
|
||||
}
|
||||
|
||||
// Stop the system DNS monitor after hole punch is stopped (it feeds
|
||||
// publicDNS into the hole punch manager).
|
||||
if o.dnsMonitor != nil {
|
||||
o.dnsMonitor.Stop()
|
||||
o.dnsMonitor = nil
|
||||
}
|
||||
|
||||
// Close() also calls Stop() internally
|
||||
o.peerManagerMu.Lock()
|
||||
if o.peerManager != nil {
|
||||
o.peerManager.Close()
|
||||
o.peerManager = nil
|
||||
}
|
||||
o.peerManagerMu.Unlock()
|
||||
|
||||
if o.uapiListener != nil {
|
||||
_ = o.uapiListener.Close()
|
||||
@@ -756,6 +904,38 @@ func (o *Olm) SetPostures(data map[string]any) {
|
||||
o.postures = data
|
||||
}
|
||||
|
||||
// SetSystemDNS reports DNS servers observed by platform-native code. On
|
||||
// Android and iOS olm cannot read the OS's DNS configuration itself (unlike
|
||||
// Linux/macOS/Windows, see dns.readSystemDNS), so the app/extension detects
|
||||
// the real pre-override DNS servers and pushes them here as the network
|
||||
// changes. The list is applied through the same exclude-IP filtering and
|
||||
// change detection as the internally-polled SystemDNSMonitor.
|
||||
func (o *Olm) SetSystemDNS(servers []string) {
|
||||
logger.Info("SetSystemDNS called with: %v", servers)
|
||||
if o.dnsMonitor == nil {
|
||||
// StartTunnel hasn't created the monitor yet (mobile platforms may push a
|
||||
// value the moment they start observing, before the tunnel goroutine has
|
||||
// gotten far enough to construct it). Stash it so StartTunnel can apply it
|
||||
// instead of falling back to a hardcoded default DNS server.
|
||||
o.pendingSystemDNSMu.Lock()
|
||||
o.pendingSystemDNS = servers
|
||||
o.pendingSystemDNSMu.Unlock()
|
||||
logger.Debug("dnsMonitor not yet started, queued SetSystemDNS value")
|
||||
return
|
||||
}
|
||||
o.dnsMonitor.ReportExternal(servers)
|
||||
}
|
||||
|
||||
// takePendingSystemDNS returns and clears any SetSystemDNS value reported before
|
||||
// dnsMonitor existed.
|
||||
func (o *Olm) takePendingSystemDNS() []string {
|
||||
o.pendingSystemDNSMu.Lock()
|
||||
defer o.pendingSystemDNSMu.Unlock()
|
||||
pending := o.pendingSystemDNS
|
||||
o.pendingSystemDNS = nil
|
||||
return pending
|
||||
}
|
||||
|
||||
// SetPowerMode switches between normal and low power modes
|
||||
// In low power mode: websocket is closed (stopping pings) and monitoring intervals are set to 10 minutes
|
||||
// In normal power mode: websocket is reconnected (restarting pings) and monitoring intervals are restored
|
||||
@@ -806,14 +986,14 @@ func (o *Olm) SetPowerMode(mode string) error {
|
||||
|
||||
lowPowerInterval := 10 * time.Minute
|
||||
|
||||
if o.peerManager != nil {
|
||||
peerMonitor := o.peerManager.GetPeerMonitor()
|
||||
if pm := o.getPeerManager(); pm != nil {
|
||||
peerMonitor := pm.GetPeerMonitor()
|
||||
if peerMonitor != nil {
|
||||
peerMonitor.SetPeerInterval(lowPowerInterval, lowPowerInterval)
|
||||
peerMonitor.SetPeerHolepunchInterval(lowPowerInterval, lowPowerInterval)
|
||||
logger.Info("Set monitoring intervals to 10 minutes for low power mode")
|
||||
}
|
||||
o.peerManager.UpdateAllPeersPersistentKeepalive(0) // disable
|
||||
pm.UpdateAllPeersPersistentKeepalive(0) // disable
|
||||
}
|
||||
|
||||
if o.holePunchManager != nil {
|
||||
@@ -858,14 +1038,14 @@ func (o *Olm) SetPowerMode(mode string) error {
|
||||
}
|
||||
|
||||
// Restore intervals and reconnect websocket
|
||||
if o.peerManager != nil {
|
||||
peerMonitor := o.peerManager.GetPeerMonitor()
|
||||
if pm := o.getPeerManager(); pm != nil {
|
||||
peerMonitor := pm.GetPeerMonitor()
|
||||
if peerMonitor != nil {
|
||||
peerMonitor.ResetPeerHolepunchInterval()
|
||||
peerMonitor.ResetPeerInterval()
|
||||
}
|
||||
|
||||
o.peerManager.UpdateAllPeersPersistentKeepalive(5)
|
||||
pm.UpdateAllPeersPersistentKeepalive(5)
|
||||
}
|
||||
|
||||
if o.holePunchManager != nil {
|
||||
|
||||
+126
-13
@@ -20,6 +20,18 @@ func (o *Olm) handleWgPeerAdd(msg websocket.WSMessage) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if connection setup is complete
|
||||
if !o.registered {
|
||||
logger.Warn("Not connected, ignoring add-peer message")
|
||||
return
|
||||
}
|
||||
|
||||
pm := o.getPeerManager()
|
||||
if pm == nil {
|
||||
logger.Debug("Ignoring add-peer message: peerManager is nil (shutdown in progress)")
|
||||
return
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(msg.Data)
|
||||
if err != nil {
|
||||
logger.Error("Error marshaling data: %v", err)
|
||||
@@ -59,7 +71,7 @@ func (o *Olm) handleWgPeerAdd(msg websocket.WSMessage) {
|
||||
|
||||
_ = o.holePunchManager.TriggerHolePunch() // Trigger immediate hole punch attempt so that if the peer decides to relay we have already punched close to when we need it
|
||||
|
||||
if err := o.peerManager.AddPeer(siteConfigMsg.SiteConfig); err != nil {
|
||||
if err := pm.AddPeer(siteConfigMsg.SiteConfig); err != nil {
|
||||
logger.Error("Failed to add peer: %v", err)
|
||||
return
|
||||
}
|
||||
@@ -76,6 +88,18 @@ func (o *Olm) handleWgPeerRemove(msg websocket.WSMessage) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if connection setup is complete
|
||||
if !o.registered {
|
||||
logger.Warn("Not connected, ignoring remove-peer message")
|
||||
return
|
||||
}
|
||||
|
||||
pm := o.getPeerManager()
|
||||
if pm == nil {
|
||||
logger.Debug("Ignoring remove-peer message: peerManager is nil (shutdown in progress)")
|
||||
return
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(msg.Data)
|
||||
if err != nil {
|
||||
logger.Error("Error marshaling data: %v", err)
|
||||
@@ -88,7 +112,7 @@ func (o *Olm) handleWgPeerRemove(msg websocket.WSMessage) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := o.peerManager.RemovePeer(removeData.SiteId); err != nil {
|
||||
if err := pm.RemovePeer(removeData.SiteId); err != nil {
|
||||
logger.Error("Failed to remove peer: %v", err)
|
||||
return
|
||||
}
|
||||
@@ -113,6 +137,18 @@ func (o *Olm) handleWgPeerUpdate(msg websocket.WSMessage) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if connection setup is complete
|
||||
if !o.registered {
|
||||
logger.Warn("Not connected, ignoring update-peer message")
|
||||
return
|
||||
}
|
||||
|
||||
pm := o.getPeerManager()
|
||||
if pm == nil {
|
||||
logger.Debug("Ignoring update-peer message: peerManager is nil (shutdown in progress)")
|
||||
return
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(msg.Data)
|
||||
if err != nil {
|
||||
logger.Error("Error marshaling data: %v", err)
|
||||
@@ -126,7 +162,7 @@ func (o *Olm) handleWgPeerUpdate(msg websocket.WSMessage) {
|
||||
}
|
||||
|
||||
// Get existing peer from PeerManager
|
||||
existingPeer, exists := o.peerManager.GetPeer(updateData.SiteId)
|
||||
existingPeer, exists := pm.GetPeer(updateData.SiteId)
|
||||
if !exists {
|
||||
logger.Warn("Peer with site ID %d not found", updateData.SiteId)
|
||||
return
|
||||
@@ -153,8 +189,11 @@ func (o *Olm) handleWgPeerUpdate(msg websocket.WSMessage) {
|
||||
if updateData.RemoteSubnets != nil {
|
||||
siteConfig.RemoteSubnets = updateData.RemoteSubnets
|
||||
}
|
||||
if updateData.Aliases != nil {
|
||||
siteConfig.Aliases = updateData.Aliases
|
||||
}
|
||||
|
||||
if err := o.peerManager.UpdatePeer(siteConfig); err != nil {
|
||||
if err := pm.UpdatePeer(siteConfig); err != nil {
|
||||
logger.Error("Failed to update peer: %v", err)
|
||||
return
|
||||
}
|
||||
@@ -162,8 +201,10 @@ func (o *Olm) handleWgPeerUpdate(msg websocket.WSMessage) {
|
||||
// If the endpoint changed, trigger holepunch to refresh NAT mappings
|
||||
if updateData.Endpoint != "" && updateData.Endpoint != existingPeer.Endpoint {
|
||||
logger.Info("Endpoint changed for site %d, triggering holepunch to refresh NAT mappings", updateData.SiteId)
|
||||
_ = o.holePunchManager.TriggerHolePunch()
|
||||
o.holePunchManager.ResetServerHolepunchInterval()
|
||||
if o.holePunchManager != nil {
|
||||
_ = o.holePunchManager.TriggerHolePunch()
|
||||
o.holePunchManager.ResetServerHolepunchInterval()
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("Successfully updated peer for site %d", updateData.SiteId)
|
||||
@@ -173,7 +214,8 @@ func (o *Olm) handleWgPeerRelay(msg websocket.WSMessage) {
|
||||
logger.Debug("Received relay-peer message: %v", msg.Data)
|
||||
|
||||
// Check if peerManager is still valid (may be nil during shutdown)
|
||||
if o.peerManager == nil {
|
||||
pm := o.getPeerManager()
|
||||
if pm == nil {
|
||||
logger.Debug("Ignoring relay message: peerManager is nil (shutdown in progress)")
|
||||
return
|
||||
}
|
||||
@@ -193,7 +235,7 @@ func (o *Olm) handleWgPeerRelay(msg websocket.WSMessage) {
|
||||
return
|
||||
}
|
||||
|
||||
if monitor := o.peerManager.GetPeerMonitor(); monitor != nil {
|
||||
if monitor := pm.GetPeerMonitor(); monitor != nil {
|
||||
monitor.CancelRelaySend(relayData.ChainId)
|
||||
}
|
||||
|
||||
@@ -207,14 +249,15 @@ func (o *Olm) handleWgPeerRelay(msg websocket.WSMessage) {
|
||||
// Update HTTP server to mark this peer as using relay
|
||||
o.apiServer.UpdatePeerRelayStatus(relayData.SiteId, relayData.RelayEndpoint, true)
|
||||
|
||||
o.peerManager.RelayPeer(relayData.SiteId, primaryRelay, relayData.RelayPort)
|
||||
pm.RelayPeer(relayData.SiteId, primaryRelay, relayData.RelayPort)
|
||||
}
|
||||
|
||||
func (o *Olm) handleWgPeerUnrelay(msg websocket.WSMessage) {
|
||||
logger.Debug("Received unrelay-peer message: %v", msg.Data)
|
||||
|
||||
// Check if peerManager is still valid (may be nil during shutdown)
|
||||
if o.peerManager == nil {
|
||||
pm := o.getPeerManager()
|
||||
if pm == nil {
|
||||
logger.Debug("Ignoring unrelay message: peerManager is nil (shutdown in progress)")
|
||||
return
|
||||
}
|
||||
@@ -234,7 +277,7 @@ func (o *Olm) handleWgPeerUnrelay(msg websocket.WSMessage) {
|
||||
return
|
||||
}
|
||||
|
||||
if monitor := o.peerManager.GetPeerMonitor(); monitor != nil {
|
||||
if monitor := pm.GetPeerMonitor(); monitor != nil {
|
||||
monitor.CancelRelaySend(relayData.ChainId)
|
||||
}
|
||||
|
||||
@@ -247,7 +290,72 @@ func (o *Olm) handleWgPeerUnrelay(msg websocket.WSMessage) {
|
||||
// Update HTTP server to mark this peer as using relay
|
||||
o.apiServer.UpdatePeerRelayStatus(relayData.SiteId, relayData.Endpoint, false)
|
||||
|
||||
o.peerManager.UnRelayPeer(relayData.SiteId, primaryRelay)
|
||||
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) {
|
||||
@@ -302,7 +410,12 @@ func (o *Olm) handleWgPeerHolepunchAddSite(msg websocket.WSMessage) {
|
||||
}
|
||||
|
||||
// Get existing peer from PeerManager
|
||||
_, exists := o.peerManager.GetPeer(handshakeData.SiteId)
|
||||
pm := o.getPeerManager()
|
||||
if pm == nil {
|
||||
logger.Debug("Ignoring peer-handshake message: peerManager is nil (shutdown in progress)")
|
||||
return
|
||||
}
|
||||
_, exists := pm.GetPeer(handshakeData.SiteId)
|
||||
if exists {
|
||||
logger.Warn("Peer with site ID %d already added", handshakeData.SiteId)
|
||||
return
|
||||
|
||||
+31
-1
@@ -48,6 +48,21 @@ type OlmConfig struct {
|
||||
OnAuthError func(statusCode int, message string) // Called when auth fails (401/403)
|
||||
OnOlmError func(code string, message string) // Called when registration fails
|
||||
OnExit func() // Called when exit is requested via API
|
||||
|
||||
// DNS watchdog (optional). When WatchdogSubcommand is non-empty, the
|
||||
// olm package will spawn an external watchdog subprocess after a DNS
|
||||
// override is installed. The watchdog will reset the system DNS if
|
||||
// this process dies before it can call RestoreDNSOverride.
|
||||
//
|
||||
// The watchdog is launched as:
|
||||
// <WatchdogExecutable> <WatchdogSubcommand...> \
|
||||
// --parent-pid=<pid> --interface=<name> [--socket=<path>]
|
||||
//
|
||||
// When WatchdogExecutable is empty, os.Executable() of the calling
|
||||
// process is used. WatchdogLogFile defaults to /dev/null.
|
||||
WatchdogExecutable string
|
||||
WatchdogSubcommand []string
|
||||
WatchdogLogFile string
|
||||
}
|
||||
|
||||
type TunnelConfig struct {
|
||||
@@ -61,9 +76,16 @@ type TunnelConfig struct {
|
||||
MTU int
|
||||
DNS string
|
||||
UpstreamDNS []string
|
||||
PublicDNS []string
|
||||
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
|
||||
@@ -87,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
|
||||
}
|
||||
|
||||
+416
-37
@@ -6,6 +6,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/newt/bind"
|
||||
"github.com/fosrl/newt/logger"
|
||||
@@ -32,7 +33,7 @@ type PeerManagerConfig struct {
|
||||
SharedBind *bind.SharedBind
|
||||
// WSClient is optional - if nil, relay messages won't be sent
|
||||
WSClient *websocket.Client
|
||||
APIServer *api.API
|
||||
APIServer *api.API
|
||||
PublicDNS []string
|
||||
}
|
||||
|
||||
@@ -51,11 +52,35 @@ type PeerManager struct {
|
||||
// key is the CIDR string, value is a set of siteIds that want this IP
|
||||
allowedIPClaims map[string]map[int]bool
|
||||
APIServer *api.API
|
||||
publicDNS []string
|
||||
publicDNS []string
|
||||
|
||||
PersistentKeepalive int
|
||||
|
||||
routeOptimizerStop chan struct{}
|
||||
optimizerTrigger chan struct{}
|
||||
|
||||
// lastOwnerChange tracks, per allowed-IP CIDR, when ownership was last transferred.
|
||||
// Used to enforce a cooldown so routes don't flap between two similarly-performing sites.
|
||||
lastOwnerChange map[string]time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
// routeSwitchRTTMargin requires a candidate site's RTT to be at least this much
|
||||
// better (as a fraction) than the current owner's before we consider it worth
|
||||
// switching, so two similarly-performing sites don't flap back and forth.
|
||||
routeSwitchRTTMargin = 0.20 // candidate must be >=20% faster
|
||||
|
||||
// routeSwitchMinAbsMargin is a floor on the RTT improvement required, so the
|
||||
// percentage margin above doesn't become meaningless at very low RTTs (e.g. a
|
||||
// 1ms vs 0.8ms "20% improvement" shouldn't trigger a switch).
|
||||
routeSwitchMinAbsMargin = 5 * time.Millisecond
|
||||
|
||||
// routeSwitchCooldown is the minimum time to wait after transferring ownership
|
||||
// of a route before it can be transferred again, unless the current owner's
|
||||
// connection quality degrades (disconnects or falls back to relay).
|
||||
routeSwitchCooldown = 30 * time.Second
|
||||
)
|
||||
|
||||
// NewPeerManager creates a new PeerManager with an internal PeerMonitor
|
||||
func NewPeerManager(config PeerManagerConfig) *PeerManager {
|
||||
pm := &PeerManager{
|
||||
@@ -67,7 +92,8 @@ func NewPeerManager(config PeerManagerConfig) *PeerManager {
|
||||
allowedIPOwners: make(map[string]int),
|
||||
allowedIPClaims: make(map[string]map[int]bool),
|
||||
APIServer: config.APIServer,
|
||||
publicDNS: config.PublicDNS,
|
||||
publicDNS: config.PublicDNS,
|
||||
lastOwnerChange: make(map[string]time.Time),
|
||||
}
|
||||
|
||||
// Create the peer monitor
|
||||
@@ -80,6 +106,10 @@ func NewPeerManager(config PeerManagerConfig) *PeerManager {
|
||||
config.PublicDNS,
|
||||
)
|
||||
|
||||
pm.optimizerTrigger = make(chan struct{}, 1)
|
||||
|
||||
pm.peerMonitor.SetLocalConnectionCallbacks(pm.LocalPeer, pm.UnLocalPeer)
|
||||
|
||||
return pm
|
||||
}
|
||||
|
||||
@@ -97,6 +127,21 @@ func (pm *PeerManager) GetPeerMonitor() *monitor.PeerMonitor {
|
||||
return pm.peerMonitor
|
||||
}
|
||||
|
||||
// SetPublicDNS replaces the DNS servers used to resolve WireGuard peer
|
||||
// endpoints and hole-punch targets. The servers must be in "host:port" format
|
||||
// (e.g. "8.8.8.8:53"). The change takes effect for all future peer
|
||||
// configuration calls; existing WireGuard peers are not re-resolved.
|
||||
func (pm *PeerManager) SetPublicDNS(servers []string) {
|
||||
pm.mu.Lock()
|
||||
pm.publicDNS = servers
|
||||
mon := pm.peerMonitor
|
||||
pm.mu.Unlock()
|
||||
|
||||
if mon != nil {
|
||||
mon.SetPublicDNS(servers)
|
||||
}
|
||||
}
|
||||
|
||||
func (pm *PeerManager) GetAllPeers() []SiteConfig {
|
||||
pm.mu.RLock()
|
||||
defer pm.mu.RUnlock()
|
||||
@@ -110,7 +155,7 @@ func (pm *PeerManager) GetAllPeers() []SiteConfig {
|
||||
func (pm *PeerManager) AddPeer(siteConfig SiteConfig) error {
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
|
||||
|
||||
for _, alias := range siteConfig.Aliases {
|
||||
address := net.ParseIP(alias.AliasAddress)
|
||||
if address == nil {
|
||||
@@ -118,7 +163,7 @@ func (pm *PeerManager) AddPeer(siteConfig SiteConfig) error {
|
||||
}
|
||||
pm.dnsProxy.AddDNSRecord(alias.Alias, address, siteConfig.SiteId)
|
||||
}
|
||||
|
||||
|
||||
if siteConfig.PublicKey == "" {
|
||||
logger.Debug("Skip adding site %d because no pub key", siteConfig.SiteId)
|
||||
return nil
|
||||
@@ -156,11 +201,11 @@ func (pm *PeerManager) AddPeer(siteConfig SiteConfig) error {
|
||||
if err := network.AddRoutes(siteConfig.RemoteSubnets, pm.interfaceName); err != nil {
|
||||
logger.Error("Failed to add routes for remote subnets: %v", err)
|
||||
}
|
||||
|
||||
|
||||
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 {
|
||||
@@ -169,11 +214,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
|
||||
}
|
||||
@@ -183,7 +228,7 @@ func (pm *PeerManager) AddPeer(siteConfig SiteConfig) error {
|
||||
func (pm *PeerManager) UpdateAllPeersPersistentKeepalive(interval int) map[int]error {
|
||||
pm.mu.RLock()
|
||||
defer pm.mu.RUnlock()
|
||||
|
||||
|
||||
pm.PersistentKeepalive = interval
|
||||
|
||||
errors := make(map[int]error)
|
||||
@@ -236,7 +281,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)
|
||||
}
|
||||
}
|
||||
@@ -305,6 +350,33 @@ 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 {
|
||||
address := net.ParseIP(alias.AliasAddress)
|
||||
if address == nil {
|
||||
continue
|
||||
}
|
||||
pm.dnsProxy.RemoveDNSRecord(alias.Alias, address)
|
||||
}
|
||||
// Add new aliases
|
||||
for _, alias := range siteConfig.Aliases {
|
||||
address := net.ParseIP(alias.AliasAddress)
|
||||
if address == nil {
|
||||
continue
|
||||
}
|
||||
pm.dnsProxy.AddDNSRecord(alias.Alias, address, siteConfig.SiteId)
|
||||
}
|
||||
|
||||
if siteConfig.PublicKey == "" {
|
||||
logger.Debug("Skip updating site %d because no pub key", siteConfig.SiteId)
|
||||
return nil
|
||||
}
|
||||
|
||||
// If public key changed, remove old peer first
|
||||
if siteConfig.PublicKey != oldPeer.PublicKey {
|
||||
if err := RemovePeer(pm.device, siteConfig.SiteId, oldPeer.PublicKey); err != nil {
|
||||
@@ -415,7 +487,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)
|
||||
}
|
||||
}
|
||||
@@ -428,25 +500,8 @@ func (pm *PeerManager) UpdatePeer(siteConfig SiteConfig) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Update aliases
|
||||
// Remove old aliases
|
||||
for _, alias := range oldPeer.Aliases {
|
||||
address := net.ParseIP(alias.AliasAddress)
|
||||
if address == nil {
|
||||
continue
|
||||
}
|
||||
pm.dnsProxy.RemoveDNSRecord(alias.Alias, address)
|
||||
}
|
||||
// Add new aliases
|
||||
for _, alias := range siteConfig.Aliases {
|
||||
address := net.ParseIP(alias.AliasAddress)
|
||||
if address == nil {
|
||||
continue
|
||||
}
|
||||
pm.dnsProxy.AddDNSRecord(alias.Alias, address, siteConfig.SiteId)
|
||||
}
|
||||
|
||||
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
|
||||
@@ -482,6 +537,7 @@ func (pm *PeerManager) releaseAllowedIP(siteId int, cidr string) (newOwner int,
|
||||
delete(claims, siteId)
|
||||
if len(claims) == 0 {
|
||||
delete(pm.allowedIPClaims, cidr)
|
||||
delete(pm.lastOwnerChange, cidr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -700,7 +756,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
|
||||
}
|
||||
}
|
||||
@@ -757,7 +813,7 @@ func (pm *PeerManager) RemoveAlias(siteId int, aliasName string) error {
|
||||
if aliasToRemove != nil {
|
||||
address := net.ParseIP(aliasToRemove.AliasAddress)
|
||||
if address != nil {
|
||||
pm.dnsProxy.RemoveDNSRecord(aliasName, address)
|
||||
pm.dnsProxy.RemoveDNSRecordForSite(aliasName, address, siteId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -788,6 +844,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
|
||||
@@ -830,15 +891,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
|
||||
@@ -856,10 +945,12 @@ func (pm *PeerManager) Start() {
|
||||
if pm.peerMonitor != nil {
|
||||
pm.peerMonitor.Start()
|
||||
}
|
||||
pm.startRouteOptimizer()
|
||||
}
|
||||
|
||||
// Stop stops the peer monitor
|
||||
func (pm *PeerManager) Stop() {
|
||||
pm.stopRouteOptimizer()
|
||||
if pm.peerMonitor != nil {
|
||||
pm.peerMonitor.Stop()
|
||||
}
|
||||
@@ -867,6 +958,7 @@ func (pm *PeerManager) Stop() {
|
||||
|
||||
// Close stops the peer monitor and cleans up resources
|
||||
func (pm *PeerManager) Close() {
|
||||
pm.stopRouteOptimizer()
|
||||
if pm.peerMonitor != nil {
|
||||
pm.peerMonitor.Close()
|
||||
pm.peerMonitor = nil
|
||||
@@ -897,6 +989,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
|
||||
@@ -928,3 +1025,285 @@ endpoint=%s`, util.FixKey(peer.PublicKey), endpoint)
|
||||
logger.Info("Switched peer %d back to direct connection at %s", siteId, 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,
|
||||
bConn bool, bRelay bool, bRTT time.Duration) bool {
|
||||
if aConn != bConn {
|
||||
return aConn // connected beats disconnected
|
||||
}
|
||||
if !aConn {
|
||||
return false // both offline, no preference
|
||||
}
|
||||
if aRelay != bRelay {
|
||||
return !aRelay // direct beats relayed
|
||||
}
|
||||
// Same connectivity class: prefer lower RTT
|
||||
if aRTT == 0 {
|
||||
return false // unknown RTT, don't displace
|
||||
}
|
||||
if bRTT == 0 {
|
||||
return true // current has no RTT data, prefer known
|
||||
}
|
||||
return aRTT < bRTT
|
||||
}
|
||||
|
||||
// selectBestOwner returns the siteId of the best site to own the given IP,
|
||||
// based on connection quality. Must be called with pm.mu held.
|
||||
func (pm *PeerManager) selectBestOwner(claims map[int]bool) int {
|
||||
bestSiteId := -1
|
||||
var bestConn, bestRelay bool
|
||||
var bestRTT time.Duration
|
||||
|
||||
for siteId := range claims {
|
||||
conn, relay, rtt := pm.peerMonitor.GetConnectionQuality(siteId)
|
||||
if bestSiteId < 0 || isBetterConnection(conn, relay, rtt, bestConn, bestRelay, bestRTT) {
|
||||
bestSiteId = siteId
|
||||
bestConn = conn
|
||||
bestRelay = relay
|
||||
bestRTT = rtt
|
||||
}
|
||||
}
|
||||
return bestSiteId
|
||||
}
|
||||
|
||||
// shouldSwitchOwner decides whether ownership of cidr should move from the current
|
||||
// owner to the candidate. It applies hysteresis so two sites with roughly equal
|
||||
// performance don't flap back and forth:
|
||||
// - A switch driven by connectivity class (connected vs not, direct vs relayed) is
|
||||
// always allowed immediately - these are correctness issues, not noise.
|
||||
// - A switch driven purely by RTT requires both a minimum improvement margin and
|
||||
// that the cooldown since the last switch of this route has elapsed.
|
||||
//
|
||||
// Must be called with pm.mu held.
|
||||
func (pm *PeerManager) shouldSwitchOwner(cidr string, currentSiteId, candidateSiteId int) bool {
|
||||
curConn, curRelay, curRTT := pm.peerMonitor.GetConnectionQuality(currentSiteId)
|
||||
candConn, candRelay, candRTT := pm.peerMonitor.GetConnectionQuality(candidateSiteId)
|
||||
|
||||
// Connectivity-class differences (up/down, direct/relayed) are not subject to
|
||||
// hysteresis - always act on them so we don't stay stuck on a broken route.
|
||||
if curConn != candConn || curRelay != candRelay {
|
||||
return true
|
||||
}
|
||||
if !curConn {
|
||||
return false // both down, nothing to do
|
||||
}
|
||||
|
||||
// Same connectivity class: only switch on a meaningful, sustained RTT win.
|
||||
if candRTT == 0 || curRTT == 0 {
|
||||
return false
|
||||
}
|
||||
minImprovement := time.Duration(float64(curRTT) * routeSwitchRTTMargin)
|
||||
if minImprovement < routeSwitchMinAbsMargin {
|
||||
minImprovement = routeSwitchMinAbsMargin
|
||||
}
|
||||
if candRTT > curRTT-minImprovement {
|
||||
return false // not enough of an improvement to be worth switching
|
||||
}
|
||||
|
||||
if lastChange, ok := pm.lastOwnerChange[cidr]; ok {
|
||||
if time.Since(lastChange) < routeSwitchCooldown {
|
||||
return false // switched too recently, avoid flapping
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// getWireGuardAllowedIPs returns the full set of IPs that should be in WireGuard
|
||||
// for a peer: server IP /32 plus all shared IPs it currently owns.
|
||||
// Must be called with pm.mu held.
|
||||
func (pm *PeerManager) getWireGuardAllowedIPs(siteId int) []string {
|
||||
peer, exists := pm.peers[siteId]
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
serverIP := strings.Split(peer.ServerIP, "/")[0] + "/32"
|
||||
ips := []string{serverIP}
|
||||
for cidr, owner := range pm.allowedIPOwners {
|
||||
if owner == siteId {
|
||||
ips = append(ips, cidr)
|
||||
}
|
||||
}
|
||||
return ips
|
||||
}
|
||||
|
||||
// transferOwnership moves WireGuard ownership of cidr from fromSiteId to toSiteId.
|
||||
// Must be called with pm.mu held.
|
||||
func (pm *PeerManager) transferOwnership(cidr string, fromSiteId int, toSiteId int) error {
|
||||
// Update owner map first
|
||||
pm.allowedIPOwners[cidr] = toSiteId
|
||||
|
||||
// Remove cidr from old owner's WireGuard allowed IPs
|
||||
if fromPeer, exists := pm.peers[fromSiteId]; exists {
|
||||
remaining := pm.getWireGuardAllowedIPs(fromSiteId) // cidr is no longer in owners, so it won't appear here
|
||||
if err := RemoveAllowedIP(pm.device, fromPeer.PublicKey, remaining); err != nil {
|
||||
// Revert
|
||||
pm.allowedIPOwners[cidr] = fromSiteId
|
||||
return fmt.Errorf("remove IP %s from site %d: %v", cidr, fromSiteId, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Add cidr to new owner's WireGuard allowed IPs
|
||||
if toPeer, exists := pm.peers[toSiteId]; exists {
|
||||
if err := AddAllowedIP(pm.device, toPeer.PublicKey, cidr); err != nil {
|
||||
return fmt.Errorf("add IP %s to site %d: %v", cidr, toSiteId, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// optimizeRoutes evaluates all shared IPs and reassigns ownership to the best site.
|
||||
func (pm *PeerManager) optimizeRoutes() {
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
|
||||
for cidr, claims := range pm.allowedIPClaims {
|
||||
if len(claims) <= 1 {
|
||||
continue // No competition, nothing to optimize
|
||||
}
|
||||
|
||||
currentOwner, hasOwner := pm.allowedIPOwners[cidr]
|
||||
bestOwner := pm.selectBestOwner(claims)
|
||||
|
||||
if bestOwner < 0 {
|
||||
continue
|
||||
}
|
||||
if hasOwner && currentOwner == bestOwner {
|
||||
continue // Already on the best site
|
||||
}
|
||||
|
||||
if !hasOwner {
|
||||
// No current owner, just assign
|
||||
pm.allowedIPOwners[cidr] = bestOwner
|
||||
pm.lastOwnerChange[cidr] = time.Now()
|
||||
if toPeer, exists := pm.peers[bestOwner]; exists {
|
||||
if err := AddAllowedIP(pm.device, toPeer.PublicKey, cidr); err != nil {
|
||||
logger.Error("Failed to assign IP %s to site %d: %v", cidr, bestOwner, err)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if !pm.shouldSwitchOwner(cidr, currentOwner, bestOwner) {
|
||||
continue // Not a big enough or sustained enough improvement, avoid flapping
|
||||
}
|
||||
|
||||
logger.Info("Route optimizer: moving %s from site %d to site %d", cidr, currentOwner, bestOwner)
|
||||
if err := pm.transferOwnership(cidr, currentOwner, bestOwner); err != nil {
|
||||
logger.Error("Failed to transfer ownership of %s from site %d to site %d: %v",
|
||||
cidr, currentOwner, bestOwner, err)
|
||||
} else {
|
||||
pm.lastOwnerChange[cidr] = time.Now()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// startRouteOptimizer registers the status-change callback and launches the optimizer goroutine.
|
||||
func (pm *PeerManager) startRouteOptimizer() {
|
||||
pm.routeOptimizerStop = make(chan struct{})
|
||||
|
||||
// Trigger optimization whenever any peer's connection status changes
|
||||
if pm.peerMonitor != nil {
|
||||
pm.peerMonitor.SetStatusChangeCallback(func(_ int) {
|
||||
select {
|
||||
case pm.optimizerTrigger <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-pm.routeOptimizerStop:
|
||||
return
|
||||
case <-pm.optimizerTrigger:
|
||||
pm.optimizeRoutes()
|
||||
case <-ticker.C:
|
||||
pm.optimizeRoutes()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// stopRouteOptimizer stops the route optimizer goroutine if it is running.
|
||||
func (pm *PeerManager) stopRouteOptimizer() {
|
||||
if pm.routeOptimizerStop != nil {
|
||||
close(pm.routeOptimizerStop)
|
||||
pm.routeOptimizerStop = nil
|
||||
}
|
||||
}
|
||||
|
||||
+447
-9
@@ -36,7 +36,7 @@ type PeerMonitor struct {
|
||||
timeout time.Duration
|
||||
maxAttempts int
|
||||
wsClient *websocket.Client
|
||||
publicDNS []string
|
||||
publicDNS []string
|
||||
|
||||
// Relay sender tracking
|
||||
relaySends map[string]func()
|
||||
@@ -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
|
||||
@@ -85,7 +102,9 @@ type PeerMonitor struct {
|
||||
apiServer *api.API
|
||||
|
||||
// WG connection status tracking
|
||||
wgConnectionStatus map[int]bool // siteID -> WG connected status
|
||||
wgConnectionStatus map[int]bool // siteID -> WG connected status
|
||||
wgConnectionRTT map[int]time.Duration // siteID -> last known RTT
|
||||
statusChangeCallback func(siteId int) // called when any peer's connection status changes
|
||||
}
|
||||
|
||||
// NewPeerMonitor creates a new peer monitor with the given callback
|
||||
@@ -104,7 +123,7 @@ func NewPeerMonitor(wsClient *websocket.Client, middleDev *middleDevice.MiddleDe
|
||||
wsClient: wsClient,
|
||||
middleDev: middleDev,
|
||||
localIP: localIP,
|
||||
publicDNS: publicDNS,
|
||||
publicDNS: publicDNS,
|
||||
activePorts: make(map[uint16]bool),
|
||||
nsCtx: ctx,
|
||||
nsCancel: cancel,
|
||||
@@ -116,12 +135,18 @@ 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
|
||||
rapidTestMaxAttempts: 5, // 5 attempts = ~1-1.5 seconds total
|
||||
apiServer: apiServer,
|
||||
wgConnectionStatus: make(map[int]bool),
|
||||
wgConnectionRTT: make(map[int]time.Duration),
|
||||
// Exponential backoff settings for holepunch monitor
|
||||
defaultHolepunchMinInterval: 2 * time.Second,
|
||||
defaultHolepunchMaxInterval: 30 * time.Second,
|
||||
@@ -145,6 +170,19 @@ func NewPeerMonitor(wsClient *websocket.Client, middleDev *middleDevice.MiddleDe
|
||||
return pm
|
||||
}
|
||||
|
||||
// SetPublicDNS replaces the DNS servers used to resolve peer endpoints and
|
||||
// hole-punch exit nodes. The servers must be in "host:port" format.
|
||||
func (pm *PeerMonitor) SetPublicDNS(servers []string) {
|
||||
pm.mutex.Lock()
|
||||
pm.publicDNS = servers
|
||||
tester := pm.holepunchTester
|
||||
pm.mutex.Unlock()
|
||||
|
||||
if tester != nil {
|
||||
tester.SetPublicDNS(servers)
|
||||
}
|
||||
}
|
||||
|
||||
// SetInterval changes how frequently peers are checked
|
||||
func (pm *PeerMonitor) SetPeerInterval(minInterval, maxInterval time.Duration) {
|
||||
pm.mutex.Lock()
|
||||
@@ -219,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()
|
||||
|
||||
@@ -237,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) {
|
||||
@@ -259,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.
|
||||
@@ -310,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()
|
||||
@@ -343,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) {
|
||||
@@ -392,10 +575,22 @@ func (pm *PeerMonitor) handleConnectionStatusChange(siteID int, status Connectio
|
||||
pm.mutex.Lock()
|
||||
previousStatus, exists := pm.wgConnectionStatus[siteID]
|
||||
pm.wgConnectionStatus[siteID] = status.Connected
|
||||
if status.Connected && status.RTT > 0 {
|
||||
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 {
|
||||
@@ -407,7 +602,12 @@ 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
|
||||
if pm.statusChangeCallback != nil {
|
||||
pm.statusChangeCallback(siteID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,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) {
|
||||
@@ -521,6 +790,25 @@ func (pm *PeerMonitor) IsPeerRelayed(siteID int) bool {
|
||||
return pm.relayedPeers[siteID]
|
||||
}
|
||||
|
||||
// SetStatusChangeCallback registers a callback that is invoked whenever a peer's
|
||||
// WireGuard connection status changes (connected/disconnected). The callback must
|
||||
// be non-blocking (e.g., send to a buffered channel).
|
||||
func (pm *PeerMonitor) SetStatusChangeCallback(cb func(siteId int)) {
|
||||
pm.mutex.Lock()
|
||||
defer pm.mutex.Unlock()
|
||||
pm.statusChangeCallback = cb
|
||||
}
|
||||
|
||||
// GetConnectionQuality returns the current connection quality metrics for a peer.
|
||||
func (pm *PeerMonitor) GetConnectionQuality(siteId int) (connected bool, relayed bool, rtt time.Duration) {
|
||||
pm.mutex.Lock()
|
||||
defer pm.mutex.Unlock()
|
||||
connected = pm.wgConnectionStatus[siteId]
|
||||
relayed = pm.relayedPeers[siteId]
|
||||
rtt = pm.wgConnectionRTT[siteId]
|
||||
return
|
||||
}
|
||||
|
||||
// startHolepunchMonitor starts the holepunch connection monitoring
|
||||
// Note: This function assumes the mutex is already held by the caller (called from Start())
|
||||
func (pm *PeerMonitor) startHolepunchMonitor() error {
|
||||
@@ -585,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 {
|
||||
@@ -607,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 {
|
||||
@@ -618,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
|
||||
@@ -675,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
|
||||
@@ -734,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()
|
||||
|
||||
|
||||
+19
-11
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+22
-10
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user