From 9513433c07eab2d621aa3b4d39e79e4f25f9ce23 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 15 Jul 2026 15:51:13 -0400 Subject: [PATCH] Add match domains config --- api/api.go | 1 + config.go | 28 +++++++++++ dns/dns_proxy.go | 119 ++++++++++++++++++++++++++++++++++++++++++++++- main.go | 1 + olm/connect.go | 2 +- olm/olm.go | 6 +++ olm/types.go | 7 +++ 7 files changed, 161 insertions(+), 3 deletions(-) diff --git a/api/api.go b/api/api.go index 895140b..4dea66b 100644 --- a/api/api.go +++ b/api/api.go @@ -29,6 +29,7 @@ type ConnectionRequest struct { PingInterval string `json:"pingInterval,omitempty"` PingTimeout string `json:"pingTimeout,omitempty"` OrgID string `json:"orgId,omitempty"` + MatchDomains []string `json:"matchDomains,omitempty"` } // SwitchOrgRequest defines the structure for switching organizations diff --git a/config.go b/config.go index 5959270..dc439d9 100644 --- a/config.go +++ b/config.go @@ -27,6 +27,13 @@ type OlmConfig struct { UpstreamDNS []string `json:"upstreamDNS"` InterfaceName string `json:"interface"` + // MatchDomains lists FQDN wildcard patterns (using * and ? wildcards, e.g. + // "*.proxy.internal") that olm should check against local records / resolve + // via UpstreamDNS. Queries for domains that don't match any pattern are sent + // directly to the host's own system DNS servers instead. Empty means match + // every domain (i.e. the feature is disabled). + MatchDomains []string `json:"matchDomains"` + // Logging LogLevel string `json:"logLevel"` @@ -99,6 +106,7 @@ func DefaultConfig() *OlmConfig { config.sources["mtu"] = string(SourceDefault) config.sources["dns"] = string(SourceDefault) config.sources["upstreamDNS"] = string(SourceDefault) + config.sources["matchDomains"] = string(SourceDefault) config.sources["logLevel"] = string(SourceDefault) config.sources["interface"] = string(SourceDefault) config.sources["enableApi"] = string(SourceDefault) @@ -229,6 +237,10 @@ func loadConfigFromEnv(config *OlmConfig) { config.UpstreamDNS = []string{val} config.sources["upstreamDNS"] = string(SourceEnv) } + if val := os.Getenv("MATCH_DOMAINS"); val != "" { + config.MatchDomains = splitComma(val) + config.sources["matchDomains"] = string(SourceEnv) + } if val := os.Getenv("LOG_LEVEL"); val != "" { config.LogLevel = val config.sources["logLevel"] = string(SourceEnv) @@ -293,6 +305,7 @@ func loadConfigFromCLI(config *OlmConfig, args []string) (bool, bool, error) { "mtu": config.MTU, "dns": config.DNS, "upstreamDNS": fmt.Sprintf("%v", config.UpstreamDNS), + "matchDomains": fmt.Sprintf("%v", config.MatchDomains), "logLevel": config.LogLevel, "interface": config.InterfaceName, "httpAddr": config.HTTPAddr, @@ -317,6 +330,8 @@ func loadConfigFromCLI(config *OlmConfig, args []string) (bool, bool, error) { serviceFlags.StringVar(&config.DNS, "dns", config.DNS, "DNS server to use") var upstreamDNSFlag string serviceFlags.StringVar(&upstreamDNSFlag, "upstream-dns", "", "Upstream DNS server(s) (comma-separated, default: 8.8.8.8:53)") + var matchDomainsFlag string + serviceFlags.StringVar(&matchDomainsFlag, "match-domains", "", "FQDN wildcard patterns (comma-separated, e.g. '*.proxy.internal,*.host-0?.autoco.internal') to check against local records/upstream DNS; queries for non-matching domains are sent directly to the system's DNS servers (default: match all domains)") serviceFlags.StringVar(&config.LogLevel, "log-level", config.LogLevel, "Log level (DEBUG, INFO, WARN, ERROR, FATAL)") serviceFlags.StringVar(&config.InterfaceName, "interface", config.InterfaceName, "Name of the WireGuard interface") serviceFlags.StringVar(&config.HTTPAddr, "http-addr", config.HTTPAddr, "HTTP server address (e.g., ':9452')") @@ -348,6 +363,11 @@ func loadConfigFromCLI(config *OlmConfig, args []string) (bool, bool, error) { } } + // Parse match domains flag if provided + if matchDomainsFlag != "" { + config.MatchDomains = splitComma(matchDomainsFlag) + } + // Track which values were changed by CLI args if config.Endpoint != origValues["endpoint"].(string) { config.sources["endpoint"] = string(SourceCLI) @@ -373,6 +393,9 @@ func loadConfigFromCLI(config *OlmConfig, args []string) (bool, bool, error) { if fmt.Sprintf("%v", config.UpstreamDNS) != origValues["upstreamDNS"].(string) { config.sources["upstreamDNS"] = string(SourceCLI) } + if fmt.Sprintf("%v", config.MatchDomains) != origValues["matchDomains"].(string) { + config.sources["matchDomains"] = string(SourceCLI) + } if config.LogLevel != origValues["logLevel"].(string) { config.sources["logLevel"] = string(SourceCLI) } @@ -481,6 +504,10 @@ func mergeConfigs(dest, src *OlmConfig) { dest.UpstreamDNS = src.UpstreamDNS dest.sources["upstreamDNS"] = string(SourceFile) } + if len(src.MatchDomains) > 0 { + dest.MatchDomains = src.MatchDomains + dest.sources["matchDomains"] = string(SourceFile) + } if src.LogLevel != "" && src.LogLevel != "INFO" { dest.LogLevel = src.LogLevel dest.sources["logLevel"] = string(SourceFile) @@ -598,6 +625,7 @@ func (c *OlmConfig) ShowConfig() { fmt.Printf(" mtu = %d [%s]\n", c.MTU, getSource("mtu")) fmt.Printf(" dns = %s [%s]\n", c.DNS, getSource("dns")) fmt.Printf(" upstream-dns = %v [%s]\n", c.UpstreamDNS, getSource("upstreamDNS")) + fmt.Printf(" match-domains = %v [%s]\n", c.MatchDomains, getSource("matchDomains")) fmt.Printf(" interface = %s [%s]\n", c.InterfaceName, getSource("interface")) // Logging diff --git a/dns/dns_proxy.go b/dns/dns_proxy.go index a78992a..979f42e 100644 --- a/dns/dns_proxy.go +++ b/dns/dns_proxy.go @@ -5,6 +5,7 @@ import ( "fmt" "net" "net/netip" + "strings" "sync" "time" @@ -38,6 +39,20 @@ type DNSProxy struct { middleDevice *device.MiddleDevice // Reference to MiddleDevice for packet filtering and TUN writes recordStore *DNSRecordStore // Local DNS records + // matchDomains lists the FQDN wildcard patterns (using * and ? wildcards, see + // matchWildcard) that this proxy is responsible for. Queries whose name matches + // one of these patterns are checked against local records and, failing that, + // forwarded to upstreamDNS. Queries that match none of the patterns are sent + // directly to localDNS instead, bypassing local records and upstreamDNS + // entirely. An empty matchDomains means "match everything" (i.e. behave as if + // this feature were not configured). + matchDomains []string + // localDNS holds the host's own system DNS servers (as reported by + // SystemDNSMonitor / PublicDNS), used to resolve queries that don't match + // matchDomains rather than sending them upstream or through the tunnel. + localDNS []string + matchMu sync.RWMutex + // Tunnel DNS fields - for sending queries over WireGuard tunnelIP netip.Addr // WireGuard interface IP (source for tunneled queries) tunnelStack *stack.Stack // Separate netstack for outbound tunnel queries @@ -55,8 +70,14 @@ type DNSProxy struct { wg sync.WaitGroup } -// NewDNSProxy creates a new DNS proxy -func NewDNSProxy(middleDevice *device.MiddleDevice, mtu int, utilitySubnet string, upstreamDns []string, tunnelDns bool, tunnelIP string) (*DNSProxy, error) { +// NewDNSProxy creates a new DNS proxy. +// +// matchDomains, if non-empty, restricts local-record lookup and upstream +// forwarding to queries whose name matches one of the given wildcard patterns +// (see matchWildcard). Queries that match none of the patterns are instead +// forwarded directly to localDNS (the host's own system DNS servers). Pass an +// empty matchDomains to match every query, preserving prior behavior. +func NewDNSProxy(middleDevice *device.MiddleDevice, mtu int, utilitySubnet string, upstreamDns []string, tunnelDns bool, tunnelIP string, matchDomains []string, localDNS []string) (*DNSProxy, error) { proxyIP, err := PickIPFromSubnet(utilitySubnet) if err != nil { return nil, fmt.Errorf("failed to pick DNS proxy IP from subnet: %v", err) @@ -76,6 +97,8 @@ func NewDNSProxy(middleDevice *device.MiddleDevice, mtu int, utilitySubnet strin tunnelDNS: tunnelDns, recordStore: NewDNSRecordStore(), tunnelActivePorts: make(map[uint16]bool), + matchDomains: matchDomains, + localDNS: localDNS, ctx: ctx, cancel: cancel, } @@ -383,6 +406,27 @@ func (p *DNSProxy) handleDNSQuery(udpConn *gonet.UDPConn, queryData []byte, clie question := msg.Question[0] logger.Debug("DNS query for %s (type %s)", question.Name, dns.TypeToString[question.Qtype]) + // If matchDomains is configured and this query's name doesn't match any of + // the configured patterns, skip local records and upstream entirely and + // send it straight to the host's own system DNS servers. + if !p.matchesConfiguredDomains(question.Name) { + logger.Debug("Query for %s does not match configured domains, forwarding to local DNS %v", question.Name, p.getLocalDNS()) + response := p.forwardToLocalDNS(msg) + if response == nil { + logger.Error("Failed to get DNS response for %s from local DNS", question.Name) + return + } + responseData, err := response.Pack() + if err != nil { + logger.Error("Failed to pack DNS response: %v", err) + return + } + if _, err := udpConn.WriteTo(responseData, clientAddr); err != nil { + logger.Error("Failed to send DNS response: %v", err) + } + return + } + // Check if we have local records for this query var response *dns.Msg if question.Qtype == dns.TypeA || question.Qtype == dns.TypeAAAA || question.Qtype == dns.TypePTR { @@ -505,6 +549,77 @@ func (p *DNSProxy) checkLocalRecords(query *dns.Msg, question dns.Question) *dns return response } +// matchesConfiguredDomains reports whether name matches one of the configured +// matchDomains wildcard patterns. If matchDomains is empty, every name is +// considered a match (i.e. the feature is disabled). +func (p *DNSProxy) matchesConfiguredDomains(name string) bool { + p.matchMu.RLock() + patterns := p.matchDomains + p.matchMu.RUnlock() + + if len(patterns) == 0 { + return true + } + + name = strings.ToLower(dns.Fqdn(name)) + for _, pattern := range patterns { + pattern = strings.ToLower(dns.Fqdn(pattern)) + if matchWildcard(pattern, name) { + return true + } + } + return false +} + +// getLocalDNS returns the currently configured local (system) DNS servers. +func (p *DNSProxy) getLocalDNS() []string { + p.matchMu.RLock() + defer p.matchMu.RUnlock() + return p.localDNS +} + +// forwardToLocalDNS forwards a DNS query directly to the host's own system DNS +// servers (localDNS), always using host networking regardless of tunnelDNS - +// these queries are for domains the caller has explicitly excluded from +// Pangolin resolution, so they should never traverse the tunnel. +func (p *DNSProxy) forwardToLocalDNS(query *dns.Msg) *dns.Msg { + servers := p.getLocalDNS() + if len(servers) == 0 { + logger.Warn("No local DNS servers configured, dropping query for %s", query.Question[0].Name) + return nil + } + + var lastErr error + for _, server := range servers { + response, err := p.queryUpstreamDirect(server, query, 2*time.Second) + if err == nil { + return response + } + lastErr = err + } + logger.Error("All local DNS servers failed: %v", lastErr) + return nil +} + +// SetMatchDomains replaces the list of wildcard domain patterns (see +// matchWildcard) that this proxy checks against local records / upstream DNS. +// Queries not matching any pattern are sent to localDNS instead. Pass an +// empty slice to match every query (i.e. disable filtering). +func (p *DNSProxy) SetMatchDomains(patterns []string) { + p.matchMu.Lock() + defer p.matchMu.Unlock() + p.matchDomains = patterns +} + +// SetLocalDNS replaces the list of local (host system) DNS servers used to +// resolve queries that don't match matchDomains. Servers must be in +// "host:port" format (e.g. "192.168.1.1:53"). +func (p *DNSProxy) SetLocalDNS(servers []string) { + p.matchMu.Lock() + defer p.matchMu.Unlock() + p.localDNS = servers +} + // forwardToUpstream forwards a DNS query to upstream DNS servers func (p *DNSProxy) forwardToUpstream(query *dns.Msg) *dns.Msg { // Try primary DNS server diff --git a/main.go b/main.go index 05a2cee..ddd4876 100644 --- a/main.go +++ b/main.go @@ -263,6 +263,7 @@ func runOlmMainWithArgs(ctx context.Context, cancel context.CancelFunc, signalCt MTU: config.MTU, DNS: config.DNS, UpstreamDNS: config.UpstreamDNS, + MatchDomains: config.MatchDomains, InterfaceName: config.InterfaceName, Holepunch: !config.DisableHolepunch, TlsClientCert: config.TlsClientCert, diff --git a/olm/connect.go b/olm/connect.go index 6337290..8eac7a4 100644 --- a/olm/connect.go +++ b/olm/connect.go @@ -145,7 +145,7 @@ func (o *Olm) handleConnect(msg websocket.WSMessage) { } // Create and start DNS proxy - o.dnsProxy, err = dns.NewDNSProxy(o.middleDev, o.tunnelConfig.MTU, wgData.UtilitySubnet, o.tunnelConfig.UpstreamDNS, o.tunnelConfig.TunnelDNS, interfaceIP) + o.dnsProxy, err = dns.NewDNSProxy(o.middleDev, o.tunnelConfig.MTU, wgData.UtilitySubnet, o.tunnelConfig.UpstreamDNS, o.tunnelConfig.TunnelDNS, interfaceIP, o.tunnelConfig.MatchDomains, o.tunnelConfig.PublicDNS) if err != nil { logger.Error("Failed to create DNS proxy: %v", err) } diff --git a/olm/olm.go b/olm/olm.go index ef00414..64208eb 100644 --- a/olm/olm.go +++ b/olm/olm.go @@ -231,6 +231,7 @@ func (o *Olm) registerAPICallbacks() { Holepunch: req.Holepunch, TlsClientCert: req.TlsClientCert, OrgID: req.OrgID, + MatchDomains: req.MatchDomains, } var err error @@ -427,6 +428,11 @@ func (o *Olm) StartTunnel(config TunnelConfig) { if pm := o.getPeerManager(); pm != nil { pm.SetPublicDNS(servers) } + // Keep the DNS proxy's local-DNS fallback (used for MatchDomains + // misses) in sync with the host's real system DNS servers. + if o.dnsProxy != nil { + o.dnsProxy.SetLocalDNS(servers) + } // UpstreamDNS is updated only when the caller did not supply an // explicit value; dynamic updates keep the proxy forwarding to the diff --git a/olm/types.go b/olm/types.go index a58d637..56c7aac 100644 --- a/olm/types.go +++ b/olm/types.go @@ -79,6 +79,13 @@ type TunnelConfig struct { PublicDNS []string InterfaceName string + // MatchDomains lists FQDN wildcard patterns (using * and ? wildcards) that + // olm should check against local records / resolve via UpstreamDNS. Queries + // that don't match any pattern are sent directly to the host's own system + // DNS servers (PublicDNS) instead of being handled by the DNS proxy at all. + // An empty MatchDomains matches every query, preserving prior behavior. + MatchDomains []string + // Advanced Holepunch bool TlsClientCert string