Compare commits

...

23 Commits

Author SHA1 Message Date
Owen Schwartz
1319354914 Merge pull request #128 from fosrl/dev
Pull system dns
2026-07-07 20:46:49 -04:00
Owen
b39be4f5b0 Update newt 2026-07-07 20:45:08 -04:00
Owen
929f183ed0 Improve linux to use networkmanager correctly 2026-07-07 20:38:06 -04:00
Owen
0cf5eb2ad0 Adjust darwin to use sysctl 2026-07-07 17:08:35 -04:00
Owen
1920f52699 Also check network manager on linux 2026-07-07 16:41:01 -04:00
Owen
7b07745650 Fix windows only pulling dhcp 2026-07-07 16:23:25 -04:00
Owen
1f301db892 Mod tidy 2026-07-07 15:49:51 -04:00
Owen
4c600bab15 Add some logging 2026-07-06 17:51:49 -04:00
Owen
f356aed39b Add ios stub 2026-07-06 16:56:37 -04:00
Owen
7a88fac395 Test the dns server first then fall back 2026-07-06 16:08:10 -04:00
Owen
efc012b43d Groundwork for monitoring and updating the dns 2026-06-25 20:21:25 -04:00
Owen Schwartz
83678dedcb Merge pull request #127 from fosrl/dev
Update newt
2026-06-25 06:49:55 -07:00
Owen
e8e004e5e1 Update newt 2026-06-25 09:49:30 -04:00
Owen Schwartz
b08994e019 Merge pull request #126 from fosrl/dev
Handle remove on a per site level correctly
2026-06-25 06:38:55 -07:00
Owen
f87b804051 Merge branch 'main' into dev 2026-06-25 09:37:40 -04:00
Owen
f02045d936 Aliases are by site 2026-06-24 18:36:07 -04:00
Owen
55377980b8 Dont remove alias if used by other peers 2026-06-24 16:42:29 -04:00
Owen Schwartz
403e14327d Merge pull request #125 from fosrl/dev
1.6.0
2026-06-10 11:19:52 -07:00
Owen
31f6d699a8 Add dns watchdog and override fixer 2026-06-09 21:17:13 -07:00
Owen
6e41eb99ab Merge branch 'main' into dev 2026-06-09 12:03:59 -07:00
Owen
12c1918e26 Include chainId for warning the user with an error 2026-06-09 12:03:50 -07:00
Owen
0d6503c03f Add comment 2026-06-09 12:03:50 -07:00
miloschwartz
b789c9af75 update issue template 2026-05-25 21:41:23 -07:00
34 changed files with 2053 additions and 59 deletions

View File

@@ -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

View File

@@ -741,6 +741,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 +765,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) {

View File

@@ -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()

View File

@@ -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()

View File

@@ -20,3 +20,9 @@ func CleanupStaleState(interfaceName string) error {
_ = interfaceName
return nil
}
// ForceResetDNS is a no-op on Android.
func ForceResetDNS(interfaceName string) error {
_ = interfaceName
return nil
}

View File

@@ -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 {
@@ -78,3 +85,31 @@ func CleanupStaleState(interfaceName string) error {
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
}

View File

@@ -19,3 +19,9 @@ func CleanupStaleState(interfaceName string) error {
_ = interfaceName
return nil
}
// ForceResetDNS is a no-op on iOS.
func ForceResetDNS(interfaceName string) error {
_ = interfaceName
return nil
}

View File

@@ -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
@@ -144,3 +151,25 @@ func CleanupStaleState(interfaceName string) error {
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
}

View File

@@ -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 {
@@ -77,3 +83,17 @@ func CleanupStaleState(interfaceName string) error {
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)
}

136
dns/override/watchdog.go Normal file
View File

@@ -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
}

View File

@@ -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)
}()
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -422,6 +422,17 @@ func (d *DarwinDNSConfigurator) clearState() error {
// 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
@@ -473,3 +484,59 @@ func CleanupStaleDarwinDNS() error {
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
}

View File

@@ -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()

274
dns/sysresolver.go Normal file
View File

@@ -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
}

View File

@@ -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
}

116
dns/sysresolver_darwin.go Normal file
View File

@@ -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
}

View File

@@ -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
}

18
dns/sysresolver_ios.go Normal file
View File

@@ -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
}

129
dns/sysresolver_linux.go Normal file
View File

@@ -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")
}

23
dns/sysresolver_stub.go Normal file
View File

@@ -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
}

149
dns/sysresolver_test.go Normal file
View File

@@ -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)
}
}

110
dns/sysresolver_windows.go Normal file
View File

@@ -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
}

68
dns_commands.go Normal file
View File

@@ -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
}

16
go.mod
View File

@@ -4,15 +4,15 @@ go 1.25.0
require (
github.com/Microsoft/go-winio v0.6.2
github.com/fosrl/newt v1.12.0
github.com/fosrl/newt v1.14.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.42.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.49.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.33.0 // indirect
golang.org/x/net v0.52.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.42.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

32
go.sum
View File

@@ -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.12.0 h1:IodzVlsprOYkHvKrXwDfDTh2ZMtXV6IG1rhUj6Jhd44=
github.com/fosrl/newt v1.12.0/go.mod h1:IJW2sZ4WKKLRuxMz6oBm8PMyAEVkOxZk6d1OUV5/LPM=
github.com/fosrl/newt v1.14.0 h1:9jpyfCNAtsH7rPojyIGJwqOqnLZdxm8b+njY5ZuY/6c=
github.com/fosrl/newt v1.14.0/go.mod h1:l6kWoZPSaXT+ZRUjiyPgwflRqZWYaXpUj9oQ0sOPh4o=
github.com/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.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
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.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
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.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
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.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
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=

22
main.go
View File

@@ -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)

View File

@@ -150,6 +150,14 @@ func (o *Olm) handleConnect(msg websocket.WSMessage) {
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)
}
@@ -240,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()})
}

View File

@@ -9,6 +9,7 @@ import (
"net/http"
_ "net/http/pprof"
"os"
"os/exec"
"sync"
"time"
@@ -42,12 +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
peerManagerMu sync.RWMutex
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
@@ -68,13 +75,17 @@ 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.
@@ -188,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),
@@ -327,6 +338,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")
@@ -339,11 +398,66 @@ func (o *Olm) StartTunnel(config TunnelConfig) {
o.tunnelRunning = true // Also set it here in case it is called externally
o.tunnelConfig = config
// 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)
}
// 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
@@ -467,7 +581,8 @@ func (o *Olm) StartTunnel(config TunnelConfig) {
"userToken": userToken,
"fingerprint": o.fingerprint,
"postures": o.postures,
}, 2*time.Second, 20)
"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 {
@@ -595,11 +710,23 @@ 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 {
@@ -768,6 +895,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

View File

@@ -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,7 +76,7 @@ type TunnelConfig struct {
MTU int
DNS string
UpstreamDNS []string
PublicDNS []string
PublicDNS []string
InterfaceName string
// Advanced

View File

@@ -103,6 +103,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()
@@ -768,7 +783,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)
}
}

View File

@@ -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()
@@ -85,8 +85,8 @@ type PeerMonitor struct {
apiServer *api.API
// WG connection status tracking
wgConnectionStatus map[int]bool // siteID -> WG connected status
wgConnectionRTT map[int]time.Duration // siteID -> last known RTT
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
}
@@ -106,7 +106,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,
@@ -148,6 +148,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()