Compare commits

..

86 Commits
1.4.0 ... 1.7.0

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
Owen Schwartz
8cc854e313 Merge pull request #121 from fosrl/dev
Support JIT sync message
2026-05-13 15:19:06 -07:00
Owen
2cd16e24a1 Handle jit peers with alias 2026-05-13 15:09:45 -07:00
Owen Schwartz
b6b35b4581 Merge pull request #120 from LaurenceJJones/investigate/106-handleWgPeerUpdate-alias-merge
fix(peers): merge Aliases in handleWgPeerUpdate
2026-05-11 10:12:56 -07:00
Laurence
3f64336b0b peers: merge Aliases in handleWgPeerUpdate
WireGuard update-peer messages now copy Aliases from the payload into
the merged SiteConfig so UpdatePeer can refresh alias DNS records.

Fixes fosrl/olm#106

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 10:45:08 +01:00
Owen Schwartz
e94c13f601 Merge pull request #119 from fosrl/dev
Guard add peer with is registered`
2026-05-06 22:08:22 -07:00
Owen
fff806c53d Guard add peer with is registered` 2026-05-06 22:06:03 -07:00
Owen Schwartz
84d7e8d926 Merge pull request #118 from fosrl/dev
Update newt
2026-04-27 20:13:48 -07:00
Owen
fde70dd15b Update newt 2026-04-27 20:13:20 -07:00
Owen
7bf6da1729 Update newt 2026-04-22 12:22:39 -07:00
Owen Schwartz
9f68f171ba Merge pull request #117 from fosrl/dev
1.5.0
2026-04-22 12:13:09 -07:00
Owen
7a8f4ab049 Merge branch 'main' into dev 2026-04-22 12:09:50 -07:00
Owen
334ea156b6 Merge branch 'private-site-ha' into dev 2026-04-21 15:08:11 -07:00
Owen
aa838fec61 Mention the cli 2026-04-19 15:49:08 -07:00
Owen
6eaf8c1475 Basic route selector working 2026-04-13 18:24:11 -07:00
Owen Schwartz
df6a84648b Merge pull request #100 from LaurenceJJones/fix/issue-38-stale-dns-cleanup
feat(DNS): Add static cleanup funcs
2026-04-07 21:26:38 -04:00
Owen
5ef6b21a6e Add CODEOWNERS 2026-04-07 11:34:38 -04:00
Owen
7d83518951 Get peer manager 2026-03-20 17:28:39 -07:00
Owen
964532777a Increase attempts 2026-03-19 17:24:41 -07:00
Owen Schwartz
703fe4fe5d Merge pull request #105 from fosrl/dev
Fix nil pointer deference
2026-03-19 16:16:06 -07:00
Owen
42ef1f5ee3 Fix nil pointer deference 2026-03-19 15:21:50 -07:00
Owen Schwartz
31eed74933 Merge pull request #103 from fosrl/dev
Update dockerfile for new version
2026-03-17 11:29:04 -07:00
Owen
ac5c11dff0 Update dockerfile for new version 2026-03-17 11:27:44 -07:00
Owen Schwartz
4d0c43fc3e Merge pull request #102 from fosrl/dev
Update cicd
2026-03-16 17:53:13 -07:00
Owen
815997d7ce Update cicd 2026-03-16 17:52:40 -07:00
Owen Schwartz
c77c162bae Merge pull request #101 from fosrl/dev
1.4.3
2026-03-16 16:44:08 -07:00
Owen
703c606af5 Handle no chainId case 2026-03-16 14:31:16 -07:00
Owen
4bc0508c7d Remove redundant info 2026-03-16 13:50:21 -07:00
Owen
3de8dc9fc2 Add optional compression 2026-03-12 17:49:12 -07:00
Owen
c2b5ef96a4 Jit of aliases working 2026-03-12 17:26:46 -07:00
Owen
e326da3d3e Merge branch 'dev' into jit 2026-03-12 16:53:16 -07:00
Owen
53def4e2f6 Merge branch 'main' into dev 2026-03-12 16:51:06 -07:00
Owen
e85fd9d71e Bump newt version 2026-03-12 16:50:41 -07:00
Owen
98a24960f5 Remove extra restore function 2026-03-12 16:50:41 -07:00
Owen
e82387d515 Actually pull the upstream from the dns var 2026-03-12 16:50:41 -07:00
Owen
b3cb3e1c92 Add hardcoded public dns 2026-03-12 16:50:41 -07:00
Laurence
f250702177 feat(DNS): Add static cleanup funcs
To aid CLI in cleaning up configuration we expose static functions that know how to handle each provider and platform linked to https://github.com/fosrl/cli/issues/38
2026-03-12 12:26:03 +00:00
Owen
22cd02ae15 Alias jit handler 2026-03-11 15:56:51 -07:00
André Gilerson
3f258d3500 Fix crash when peer has nil publicKey in site config
Skip sites with empty/nil publicKey instead of passing them to the
WireGuard UAPI layer, which expects a valid 64-char hex string. A nil
key occurs when a Newt site has never connected. Previously this caused
all sites to fail with "hex string does not fit the slice".
2026-03-07 20:44:25 -08:00
Owen
e2690bcc03 Store site id 2026-03-06 16:19:00 -08:00
Owen
f2d0e6a14c Merge branch 'dev' into jit 2026-03-06 16:08:24 -08:00
Laurence
ae88766d85 test(dns): add dns test cases for nodata 2026-03-06 16:08:01 -08:00
Laurence
9ae49e36d5 refactor(dns): simplify DNSRecordStore from trie to map
Replace trie-based domain lookup with simple map for O(1) lookups.
  Add exists boolean to GetRecords for proper NODATA vs NXDOMAIN responses.
2026-03-06 16:08:01 -08:00
Laurence
5ca4825800 refactor(dns): trie + unified record set for DNSRecordStore
- Replace four maps (aRecords, aaaaRecords, aWildcards, aaaaWildcards) with a label trie for exact lookups and a single wildcards map
- Store one recordSet (A + AAAA) per domain/pattern instead of separate A and AAAA maps
- Exact lookups O(labels); PTR unchanged (map); API and behaviour unchanged
2026-03-06 16:08:01 -08:00
Owen
809dbe77de Make chainId in relay message bckwd compat 2026-03-06 15:27:03 -08:00
Owen
c67c2a60a1 Handle canceling sends for relay 2026-03-06 15:15:31 -08:00
Owen
051c0fdfd8 Working jit with chain ids 2026-03-04 17:51:48 -08:00
Owen
e7507e0837 Add api endpoints to jit 2026-03-04 17:01:17 -08:00
Laurence
8549dc8746 enhance(dns): expose stale cleanup functionality
When the tunnel is forced close an integration may want to manually call cleanup function to fix stale issues without having the knowledge of which configuration to cleanup
2026-02-26 11:30:12 +00:00
Owen
21b66fbb34 Update iss 2026-02-25 14:57:56 -08:00
Owen
9c0e37eddb Send token 2026-02-24 19:47:30 -08:00
Owen
5527bff671 Merge branch 'dev' 2026-02-06 15:17:21 -08:00
Owen
af973b2440 Support prt records 2026-02-06 15:17:01 -08:00
Owen
dd9bff9a4b Fix peer names clearing 2026-02-02 18:03:29 -08:00
Owen
1be5e454ba Default override dns to true
Ref #59
2026-02-02 10:03:22 -08:00
Owen
4850b1b332 Handle cross platform close
Former-commit-id: 89932bb736c7f4b3eb9bb2384b0cf6bd27872c1c
2026-01-31 17:50:31 -08:00
Owen
1ff74f7173 Dont go unregistered when low power mode
Former-commit-id: f55fc8fb39f8efc9d5438465f655dc2d734223c3
2026-01-31 17:15:30 -08:00
Owen
4a25a0d413 Dont go unregistered when low power mode
Former-commit-id: 0938564038
2026-01-31 16:58:05 -08:00
Owen
7fc3c7088e Lowercase all domains before matching
Former-commit-id: 8f8872aa47
2026-01-30 14:53:25 -08:00
Owen
1869e70894 Merge branch 'dev'
Former-commit-id: 43cc56a961
2026-01-30 10:58:00 -08:00
Owen
79783cc3dc Merge branch 'main' of github.com:fosrl/olm
Former-commit-id: 0b31f4e5d1
2026-01-30 10:57:40 -08:00
Owen
584298e3bd Fix terminate due to inactivity 2026-01-27 20:19:41 -08:00
miloschwartz
f683afa647 improve override-dns and tunnel-dns descriptions 2026-01-27 17:53:34 -08:00
Owen
ba2631d388 Prevent crashing on close before connect
Former-commit-id: ea461e0bfb
2026-01-23 14:47:54 -08:00
51 changed files with 5070 additions and 772 deletions

1
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1 @@
* @oschwartz10612 @miloschwartz

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,8 @@
FROM golang:1.25-alpine AS builder
# FROM golang:1.25-alpine AS builder
FROM public.ecr.aws/docker/library/golang:1.25-alpine AS builder
# Install git and ca-certificates
RUN apk --no-cache add ca-certificates git tzdata
# Set the working directory inside the container
WORKDIR /app
@@ -13,21 +17,16 @@ RUN go mod download
COPY . .
# Build the application
RUN CGO_ENABLED=0 GOOS=linux go build -o /olm
ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.olmVersion=${VERSION}" -o /olm
# Start a new stage from scratch
FROM alpine:3.23 AS runner
FROM public.ecr.aws/docker/library/alpine:3.23 AS runner
RUN apk --no-cache add ca-certificates
RUN apk --no-cache add ca-certificates tzdata iputils
# Copy the pre-built binary file from the previous stage and the entrypoint script
COPY --from=builder /olm /usr/local/bin/
COPY entrypoint.sh /
RUN chmod +x /entrypoint.sh
# Copy the entrypoint script
ENTRYPOINT ["/entrypoint.sh"]
# Command to run the executable
CMD ["olm"]

View File

@@ -2,6 +2,9 @@
all: local
VERSION ?= dev
LDFLAGS = -X main.olmVersion=$(VERSION)
local:
CGO_ENABLED=0 go build -o ./bin/olm
@@ -43,25 +46,25 @@ go-build-release: \
go-build-release-windows-amd64 \
go-build-release-linux-arm64:
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o bin/olm_linux_arm64
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o bin/olm_linux_arm64
go-build-release-linux-arm32-v7:
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -o bin/olm_linux_arm32
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags "$(LDFLAGS)" -o bin/olm_linux_arm32
go-build-release-linux-arm32-v6:
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -o bin/olm_linux_arm32v6
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags "$(LDFLAGS)" -o bin/olm_linux_arm32v6
go-build-release-linux-amd64:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/olm_linux_amd64
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/olm_linux_amd64
go-build-release-linux-riscv64:
CGO_ENABLED=0 GOOS=linux GOARCH=riscv64 go build -o bin/olm_linux_riscv64
CGO_ENABLED=0 GOOS=linux GOARCH=riscv64 go build -ldflags "$(LDFLAGS)" -o bin/olm_linux_riscv64
go-build-release-darwin-arm64:
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o bin/olm_darwin_arm64
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o bin/olm_darwin_arm64
go-build-release-darwin-amd64:
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o bin/olm_darwin_amd64
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/olm_darwin_amd64
go-build-release-windows-amd64:
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o bin/olm_windows_amd64.exe
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/olm_windows_amd64.exe

View File

@@ -1,4 +1,5 @@
# Olm
Olm is being phased out in favor of the [Pangolin CLI](https://github.com/fosrl/cli) and is only meant for advanced use cases.
Olm is a [WireGuard](https://www.wireguard.com/) tunnel client designed to securely connect your computer to Newt sites running on remote networks.

View File

@@ -78,6 +78,13 @@ type MetadataChangeRequest struct {
Postures map[string]any `json:"postures"`
}
// JITConnectionRequest defines the structure for a dynamic Just-In-Time connection request.
// Either SiteID or ResourceID must be provided (but not necessarily both).
type JITConnectionRequest struct {
Site string `json:"site,omitempty"`
Resource string `json:"resource,omitempty"`
}
// API represents the HTTP server and its state
type API struct {
addr string
@@ -92,6 +99,7 @@ type API struct {
onExit func() error
onRebind func() error
onPowerMode func(PowerModeRequest) error
onJITConnect func(JITConnectionRequest) error
statusMu sync.RWMutex
peerStatuses map[int]*PeerStatus
@@ -143,6 +151,7 @@ func (s *API) SetHandlers(
onExit func() error,
onRebind func() error,
onPowerMode func(PowerModeRequest) error,
onJITConnect func(JITConnectionRequest) error,
) {
s.onConnect = onConnect
s.onSwitchOrg = onSwitchOrg
@@ -151,6 +160,7 @@ func (s *API) SetHandlers(
s.onExit = onExit
s.onRebind = onRebind
s.onPowerMode = onPowerMode
s.onJITConnect = onJITConnect
}
// Start starts the HTTP server
@@ -169,6 +179,7 @@ func (s *API) Start() error {
mux.HandleFunc("/health", s.handleHealth)
mux.HandleFunc("/rebind", s.handleRebind)
mux.HandleFunc("/power-mode", s.handlePowerMode)
mux.HandleFunc("/jit-connect", s.handleJITConnect)
s.server = &http.Server{
Handler: mux,
@@ -272,9 +283,6 @@ func (s *API) SetConnectionStatus(isConnected bool) {
if isConnected {
s.connectedAt = time.Now()
} else {
// Clear peer statuses when disconnected
s.peerStatuses = make(map[int]*PeerStatus)
}
}
@@ -636,6 +644,54 @@ func (s *API) handleRebind(w http.ResponseWriter, r *http.Request) {
})
}
// handleJITConnect handles the /jit-connect endpoint.
// It initiates a dynamic Just-In-Time connection to a site identified by either
// a site or a resource. Exactly one of the two must be provided.
func (s *API) handleJITConnect(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req JITConnectionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, fmt.Sprintf("Invalid request body: %v", err), http.StatusBadRequest)
return
}
// Validate that exactly one of site or resource is provided
if req.Site == "" && req.Resource == "" {
http.Error(w, "Missing required field: either site or resource must be provided", http.StatusBadRequest)
return
}
if req.Site != "" && req.Resource != "" {
http.Error(w, "Ambiguous request: provide either site or resource, not both", http.StatusBadRequest)
return
}
if req.Site != "" {
logger.Info("Received JIT connection request via API: site=%s", req.Site)
} else {
logger.Info("Received JIT connection request via API: resource=%s", req.Resource)
}
if s.onJITConnect != nil {
if err := s.onJITConnect(req); err != nil {
http.Error(w, fmt.Sprintf("JIT connection failed: %v", err), http.StatusInternalServerError)
return
}
} else {
http.Error(w, "JIT connect handler not configured", http.StatusNotImplemented)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
_ = json.NewEncoder(w).Encode(map[string]string{
"status": "JIT connection request accepted",
})
}
// handlePowerMode handles the /power-mode endpoint
// This allows changing the power mode between "normal" and "low"
func (s *API) handlePowerMode(w http.ResponseWriter, r *http.Request) {

View File

@@ -89,6 +89,7 @@ func DefaultConfig() *OlmConfig {
PingInterval: "3s",
PingTimeout: "5s",
DisableHolepunch: false,
OverrideDNS: true,
TunnelDNS: false,
// DoNotCreateNewClient: false,
sources: make(map[string]string),
@@ -324,9 +325,9 @@ func loadConfigFromCLI(config *OlmConfig, args []string) (bool, bool, error) {
serviceFlags.StringVar(&config.PingTimeout, "ping-timeout", config.PingTimeout, "Timeout for each ping")
serviceFlags.BoolVar(&config.EnableAPI, "enable-api", config.EnableAPI, "Enable API server for receiving connection requests")
serviceFlags.BoolVar(&config.DisableHolepunch, "disable-holepunch", config.DisableHolepunch, "Disable hole punching")
serviceFlags.BoolVar(&config.OverrideDNS, "override-dns", config.OverrideDNS, "Override system DNS settings")
serviceFlags.BoolVar(&config.OverrideDNS, "override-dns", config.OverrideDNS, "When enabled, the client uses custom DNS servers to resolve internal resources and aliases. This overrides your system's default DNS settings. Queries that cannot be resolved as a Pangolin resource will be forwarded to your configured Upstream DNS Server. (default false)")
serviceFlags.BoolVar(&config.DisableRelay, "disable-relay", config.DisableRelay, "Disable relay connections")
serviceFlags.BoolVar(&config.TunnelDNS, "tunnel-dns", config.TunnelDNS, "Use tunnel for DNS traffic")
serviceFlags.BoolVar(&config.TunnelDNS, "tunnel-dns", config.TunnelDNS, "When enabled, DNS queries are routed through the tunnel for remote resolution. To ensure queries are tunneled correctly, you must define the DNS server as a Pangolin resource and enter its address as an Upstream DNS Server. (default false)")
// serviceFlags.BoolVar(&config.DoNotCreateNewClient, "do-not-create-new-client", config.DoNotCreateNewClient, "Do not create new client")
version := serviceFlags.Bool("version", false, "Print the version")

View File

@@ -45,6 +45,11 @@ type DNSProxy struct {
tunnelActivePorts map[uint16]bool
tunnelPortsLock sync.Mutex
// jitHandler is called when a local record is resolved for a site that may not be
// connected yet, giving the caller a chance to initiate a JIT connection.
// It is invoked asynchronously so it never blocks DNS resolution.
jitHandler func(siteId int)
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
@@ -380,10 +385,20 @@ func (p *DNSProxy) handleDNSQuery(udpConn *gonet.UDPConn, queryData []byte, clie
// Check if we have local records for this query
var response *dns.Msg
if question.Qtype == dns.TypeA || question.Qtype == dns.TypeAAAA {
if question.Qtype == dns.TypeA || question.Qtype == dns.TypeAAAA || question.Qtype == dns.TypePTR {
response = p.checkLocalRecords(msg, question)
}
// If a local A/AAAA record was found, notify the JIT handler so that the owning
// site can be connected on-demand if it is not yet active.
if response != nil && p.jitHandler != nil &&
(question.Qtype == dns.TypeA || question.Qtype == dns.TypeAAAA) {
if siteId, ok := p.recordStore.GetSiteIdForDomain(question.Name); ok && siteId != 0 {
handler := p.jitHandler
go handler(siteId)
}
}
// If no local records, forward to upstream
if response == nil {
logger.Debug("No local record for %s, forwarding upstream to %v", question.Name, p.upstreamDNS)
@@ -410,6 +425,34 @@ func (p *DNSProxy) handleDNSQuery(udpConn *gonet.UDPConn, queryData []byte, clie
// checkLocalRecords checks if we have local records for the query
func (p *DNSProxy) checkLocalRecords(query *dns.Msg, question dns.Question) *dns.Msg {
// Handle PTR queries
if question.Qtype == dns.TypePTR {
if ptrDomain, ok := p.recordStore.GetPTRRecord(question.Name); ok {
logger.Debug("Found local PTR record for %s -> %s", question.Name, ptrDomain)
// Create response message
response := new(dns.Msg)
response.SetReply(query)
response.Authoritative = true
// Add PTR answer record
rr := &dns.PTR{
Hdr: dns.RR_Header{
Name: question.Name,
Rrtype: dns.TypePTR,
Class: dns.ClassINET,
Ttl: 300, // 5 minutes
},
Ptr: ptrDomain,
}
response.Answer = append(response.Answer, rr)
return response
}
return nil
}
// Handle A and AAAA queries
var recordType RecordType
if question.Qtype == dns.TypeA {
recordType = RecordTypeA
@@ -419,19 +462,20 @@ func (p *DNSProxy) checkLocalRecords(query *dns.Msg, question dns.Question) *dns
return nil
}
ips := p.recordStore.GetRecords(question.Name, recordType)
if len(ips) == 0 {
ips, exists := p.recordStore.GetRecords(question.Name, recordType)
if !exists {
// Domain not found in local records, forward to upstream
return nil
}
logger.Debug("Found %d local record(s) for %s", len(ips), question.Name)
// Create response message
// Create response message (NODATA if no records, otherwise with answers)
response := new(dns.Msg)
response.SetReply(query)
response.Authoritative = true
// Add answer records
// Add answer records (loop is a no-op if ips is empty)
for _, ip := range ips {
var rr dns.RR
if question.Qtype == dns.TypeA {
@@ -689,11 +733,30 @@ func (p *DNSProxy) runPacketSender() {
}
}
// SetJITHandler registers a callback that is invoked whenever a local DNS record is
// resolved for an A or AAAA query. The siteId identifies which site owns the record.
// The handler is called in its own goroutine so it must be safe to call concurrently.
// Pass nil to disable JIT notifications.
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
func (p *DNSProxy) AddDNSRecord(domain string, ip net.IP) error {
return p.recordStore.AddRecord(domain, ip)
func (p *DNSProxy) AddDNSRecord(domain string, ip net.IP, siteId int) error {
logger.Debug("Adding dns record for domain %s with IP %s (siteId=%d)", domain, ip.String(), siteId)
return p.recordStore.AddRecord(domain, ip, siteId)
}
// RemoveDNSRecord removes a DNS record from the local store
@@ -702,8 +765,15 @@ func (p *DNSProxy) RemoveDNSRecord(domain string, ip net.IP) {
p.recordStore.RemoveRecord(domain, ip)
}
// GetDNSRecords returns all IP addresses for a domain and record type
func (p *DNSProxy) GetDNSRecords(domain string, recordType RecordType) []net.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) {
return p.recordStore.GetRecords(domain, recordType)
}

178
dns/dns_proxy_test.go Normal file
View File

@@ -0,0 +1,178 @@
package dns
import (
"net"
"testing"
"github.com/miekg/dns"
)
func TestCheckLocalRecordsNODATAForAAAA(t *testing.T) {
proxy := &DNSProxy{
recordStore: NewDNSRecordStore(),
}
// Add an A record for a domain (no AAAA record)
ip := net.ParseIP("10.0.0.1")
err := proxy.recordStore.AddRecord("myservice.internal", ip, 0)
if err != nil {
t.Fatalf("Failed to add A record: %v", err)
}
// Query AAAA for domain with only A record - should return NODATA
query := new(dns.Msg)
query.SetQuestion("myservice.internal.", dns.TypeAAAA)
response := proxy.checkLocalRecords(query, query.Question[0])
if response == nil {
t.Fatal("Expected NODATA response, got nil (would forward to upstream)")
}
if response.Rcode != dns.RcodeSuccess {
t.Errorf("Expected Rcode NOERROR (0), got %d", response.Rcode)
}
if len(response.Answer) != 0 {
t.Errorf("Expected empty answer section for NODATA, got %d answers", len(response.Answer))
}
if !response.Authoritative {
t.Error("Expected response to be authoritative")
}
// Query A for same domain - should return the record
query = new(dns.Msg)
query.SetQuestion("myservice.internal.", dns.TypeA)
response = proxy.checkLocalRecords(query, query.Question[0])
if response == nil {
t.Fatal("Expected response with A record, got nil")
}
if len(response.Answer) != 1 {
t.Fatalf("Expected 1 answer, got %d", len(response.Answer))
}
aRecord, ok := response.Answer[0].(*dns.A)
if !ok {
t.Fatal("Expected A record in answer")
}
if !aRecord.A.Equal(ip.To4()) {
t.Errorf("Expected IP %v, got %v", ip.To4(), aRecord.A)
}
}
func TestCheckLocalRecordsNODATAForA(t *testing.T) {
proxy := &DNSProxy{
recordStore: NewDNSRecordStore(),
}
// Add an AAAA record for a domain (no A record)
ip := net.ParseIP("2001:db8::1")
err := proxy.recordStore.AddRecord("ipv6only.internal", ip, 0)
if err != nil {
t.Fatalf("Failed to add AAAA record: %v", err)
}
// Query A for domain with only AAAA record - should return NODATA
query := new(dns.Msg)
query.SetQuestion("ipv6only.internal.", dns.TypeA)
response := proxy.checkLocalRecords(query, query.Question[0])
if response == nil {
t.Fatal("Expected NODATA response, got nil")
}
if response.Rcode != dns.RcodeSuccess {
t.Errorf("Expected Rcode NOERROR (0), got %d", response.Rcode)
}
if len(response.Answer) != 0 {
t.Errorf("Expected empty answer section, got %d answers", len(response.Answer))
}
if !response.Authoritative {
t.Error("Expected response to be authoritative")
}
// Query AAAA for same domain - should return the record
query = new(dns.Msg)
query.SetQuestion("ipv6only.internal.", dns.TypeAAAA)
response = proxy.checkLocalRecords(query, query.Question[0])
if response == nil {
t.Fatal("Expected response with AAAA record, got nil")
}
if len(response.Answer) != 1 {
t.Fatalf("Expected 1 answer, got %d", len(response.Answer))
}
aaaaRecord, ok := response.Answer[0].(*dns.AAAA)
if !ok {
t.Fatal("Expected AAAA record in answer")
}
if !aaaaRecord.AAAA.Equal(ip) {
t.Errorf("Expected IP %v, got %v", ip, aaaaRecord.AAAA)
}
}
func TestCheckLocalRecordsNonExistentDomain(t *testing.T) {
proxy := &DNSProxy{
recordStore: NewDNSRecordStore(),
}
// Add a record so the store isn't empty
err := proxy.recordStore.AddRecord("exists.internal", net.ParseIP("10.0.0.1"), 0)
if err != nil {
t.Fatalf("Failed to add record: %v", err)
}
// Query A for non-existent domain - should return nil (forward to upstream)
query := new(dns.Msg)
query.SetQuestion("unknown.internal.", dns.TypeA)
response := proxy.checkLocalRecords(query, query.Question[0])
if response != nil {
t.Error("Expected nil for non-existent domain, got response")
}
// Query AAAA for non-existent domain - should also return nil
query = new(dns.Msg)
query.SetQuestion("unknown.internal.", dns.TypeAAAA)
response = proxy.checkLocalRecords(query, query.Question[0])
if response != nil {
t.Error("Expected nil for non-existent domain, got response")
}
}
func TestCheckLocalRecordsNODATAWildcard(t *testing.T) {
proxy := &DNSProxy{
recordStore: NewDNSRecordStore(),
}
// Add a wildcard A record (no AAAA)
ip := net.ParseIP("10.0.0.1")
err := proxy.recordStore.AddRecord("*.wildcard.internal", ip, 0)
if err != nil {
t.Fatalf("Failed to add wildcard A record: %v", err)
}
// Query AAAA for wildcard-matched domain - should return NODATA
query := new(dns.Msg)
query.SetQuestion("host.wildcard.internal.", dns.TypeAAAA)
response := proxy.checkLocalRecords(query, query.Question[0])
if response == nil {
t.Fatal("Expected NODATA response for wildcard match, got nil")
}
if response.Rcode != dns.RcodeSuccess {
t.Errorf("Expected Rcode NOERROR (0), got %d", response.Rcode)
}
if len(response.Answer) != 0 {
t.Errorf("Expected empty answer section, got %d answers", len(response.Answer))
}
// Query A for wildcard-matched domain - should return the record
query = new(dns.Msg)
query.SetQuestion("host.wildcard.internal.", dns.TypeA)
response = proxy.checkLocalRecords(query, query.Question[0])
if response == nil {
t.Fatal("Expected response with A record, got nil")
}
if len(response.Answer) != 1 {
t.Fatalf("Expected 1 answer, got %d", len(response.Answer))
}
}

View File

@@ -1,6 +1,7 @@
package dns
import (
"fmt"
"net"
"strings"
"sync"
@@ -14,24 +15,32 @@ type RecordType uint16
const (
RecordTypeA RecordType = RecordType(dns.TypeA)
RecordTypeAAAA RecordType = RecordType(dns.TypeAAAA)
RecordTypePTR RecordType = RecordType(dns.TypePTR)
)
// DNSRecordStore manages local DNS records for A and AAAA queries
// recordSet holds A and AAAA records for a single domain or wildcard pattern
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.
// Exact domains are stored in a map; wildcard patterns are in a separate map.
type DNSRecordStore struct {
mu sync.RWMutex
aRecords map[string][]net.IP // domain -> list of IPv4 addresses
aaaaRecords map[string][]net.IP // domain -> list of IPv6 addresses
aWildcards map[string][]net.IP // wildcard pattern -> list of IPv4 addresses
aaaaWildcards map[string][]net.IP // wildcard pattern -> list of IPv6 addresses
mu sync.RWMutex
exact map[string]*recordSet // normalized FQDN -> A/AAAA records
wildcards map[string]*recordSet // wildcard pattern -> A/AAAA records
ptrRecords map[string]string // IP address string -> domain name
}
// NewDNSRecordStore creates a new DNS record store
func NewDNSRecordStore() *DNSRecordStore {
return &DNSRecordStore{
aRecords: make(map[string][]net.IP),
aaaaRecords: make(map[string][]net.IP),
aWildcards: make(map[string][]net.IP),
aaaaWildcards: make(map[string][]net.IP),
exact: make(map[string]*recordSet),
wildcards: make(map[string]*recordSet),
ptrRecords: make(map[string]string),
}
}
@@ -39,7 +48,68 @@ func NewDNSRecordStore() *DNSRecordStore {
// domain should be in FQDN format (e.g., "example.com.")
// domain can contain wildcards: * (0+ chars) and ? (exactly 1 char)
// ip should be a valid IPv4 or IPv6 address
func (s *DNSRecordStore) AddRecord(domain string, ip net.IP) error {
// siteId is the site that owns this alias/domain
// Automatically adds a corresponding PTR record for non-wildcard domains
func (s *DNSRecordStore) AddRecord(domain string, ip net.IP, siteId int) error {
s.mu.Lock()
defer s.mu.Unlock()
if len(domain) == 0 || domain[len(domain)-1] != '.' {
domain = domain + "."
}
domain = strings.ToLower(dns.Fqdn(domain))
isWildcard := strings.ContainsAny(domain, "*?")
isV4 := ip.To4() != nil
if !isV4 && ip.To16() == nil {
return &net.ParseError{Type: "IP address", Text: ip.String()}
}
// Choose the appropriate map based on whether this is a wildcard
m := s.exact
if isWildcard {
m = s.wildcards
}
if m[domain] == nil {
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) {
return nil
}
}
rs.A = append(rs.A, ip)
} else {
for _, existing := range rs.AAAA {
if existing.Equal(ip) {
return nil
}
}
rs.AAAA = append(rs.AAAA, ip)
}
// Add PTR record for non-wildcard domains
if !isWildcard {
s.ptrRecords[ip.String()] = domain
}
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.")
func (s *DNSRecordStore) AddPTRRecord(ip net.IP, domain string) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -48,154 +118,245 @@ func (s *DNSRecordStore) AddRecord(domain string, ip net.IP) error {
domain = domain + "."
}
// Normalize domain to lowercase
domain = dns.Fqdn(domain)
// Normalize domain to lowercase FQDN
domain = strings.ToLower(dns.Fqdn(domain))
// Check if domain contains wildcards
isWildcard := strings.ContainsAny(domain, "*?")
if ip.To4() != nil {
// IPv4 address
if isWildcard {
s.aWildcards[domain] = append(s.aWildcards[domain], ip)
} else {
s.aRecords[domain] = append(s.aRecords[domain], ip)
}
} else if ip.To16() != nil {
// IPv6 address
if isWildcard {
s.aaaaWildcards[domain] = append(s.aaaaWildcards[domain], ip)
} else {
s.aaaaRecords[domain] = append(s.aaaaRecords[domain], ip)
}
} else {
return &net.ParseError{Type: "IP address", Text: ip.String()}
}
// Store PTR record using IP string as key
s.ptrRecords[ip.String()] = domain
return nil
}
// RemoveRecord removes a specific DNS record mapping
// 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()
// Ensure domain ends with a dot (FQDN format)
if len(domain) == 0 || domain[len(domain)-1] != '.' {
domain = domain + "."
}
// Normalize domain to lowercase
domain = dns.Fqdn(domain)
// Check if domain contains wildcards
domain = strings.ToLower(dns.Fqdn(domain))
isWildcard := strings.ContainsAny(domain, "*?")
// Choose the appropriate map
m := s.exact
if isWildcard {
m = s.wildcards
}
rs := m[domain]
if rs == nil {
return
}
if rs.owners == nil {
rs.owners = make(map[string]map[int]bool)
}
if ip == nil {
// Remove all records for this domain
if isWildcard {
delete(s.aWildcards, domain)
delete(s.aaaaWildcards, domain)
} else {
delete(s.aRecords, domain)
delete(s.aaaaRecords, domain)
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 {
if ptrDomain, exists := s.ptrRecords[ipAddr.String()]; exists && ptrDomain == domain {
delete(s.ptrRecords, ipAddr.String())
}
}
for _, ipAddr := range rs.AAAA {
if ptrDomain, exists := s.ptrRecords[ipAddr.String()]; exists && ptrDomain == domain {
delete(s.ptrRecords, ipAddr.String())
}
}
}
delete(m, domain)
return
}
// 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 {
// Remove specific IPv4 address
if isWildcard {
if ips, ok := s.aWildcards[domain]; ok {
s.aWildcards[domain] = removeIP(ips, ip)
if len(s.aWildcards[domain]) == 0 {
delete(s.aWildcards, domain)
}
}
} else {
if ips, ok := s.aRecords[domain]; ok {
s.aRecords[domain] = removeIP(ips, ip)
if len(s.aRecords[domain]) == 0 {
delete(s.aRecords, domain)
}
rs.A = removeIP(rs.A, ip)
if !isWildcard {
if ptrDomain, exists := s.ptrRecords[ip.String()]; exists && ptrDomain == domain {
delete(s.ptrRecords, ip.String())
}
}
} else if ip.To16() != nil {
// Remove specific IPv6 address
if isWildcard {
if ips, ok := s.aaaaWildcards[domain]; ok {
s.aaaaWildcards[domain] = removeIP(ips, ip)
if len(s.aaaaWildcards[domain]) == 0 {
delete(s.aaaaWildcards, domain)
}
}
} else {
if ips, ok := s.aaaaRecords[domain]; ok {
s.aaaaRecords[domain] = removeIP(ips, ip)
if len(s.aaaaRecords[domain]) == 0 {
delete(s.aaaaRecords, domain)
}
} else {
rs.AAAA = removeIP(rs.AAAA, ip)
if !isWildcard {
if ptrDomain, exists := s.ptrRecords[ip.String()]; exists && ptrDomain == domain {
delete(s.ptrRecords, ip.String())
}
}
}
delete(rs.owners, ipKey)
// Clean up empty record sets
if len(rs.A) == 0 && len(rs.AAAA) == 0 {
delete(m, domain)
}
}
// GetRecords returns all IP addresses for a domain and record type
// First checks for exact matches, then checks wildcard patterns
func (s *DNSRecordStore) GetRecords(domain string, recordType RecordType) []net.IP {
s.mu.RLock()
defer s.mu.RUnlock()
// Normalize domain to lowercase FQDN
domain = dns.Fqdn(domain)
var records []net.IP
switch recordType {
case RecordTypeA:
// Check exact match first
if ips, ok := s.aRecords[domain]; ok {
// Return a copy to prevent external modifications
records = make([]net.IP, len(ips))
copy(records, ips)
return records
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
}
// Check wildcard patterns
for pattern, ips := range s.aWildcards {
if matchWildcard(pattern, domain) {
records = append(records, ips...)
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)
}
}
if len(records) > 0 {
// Return a copy
result := make([]net.IP, len(records))
copy(result, records)
return result
}
case RecordTypeAAAA:
// Check exact match first
if ips, ok := s.aaaaRecords[domain]; ok {
// Return a copy to prevent external modifications
records = make([]net.IP, len(ips))
copy(records, ips)
return records
}
// Check wildcard patterns
for pattern, ips := range s.aaaaWildcards {
if matchWildcard(pattern, domain) {
records = append(records, ips...)
}
}
if len(records) > 0 {
// Return a copy
result := make([]net.IP, len(records))
copy(result, records)
return result
}
}
return records
return kept
}
// RemovePTRRecord removes a PTR record for an IP address
func (s *DNSRecordStore) RemovePTRRecord(ip net.IP) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.ptrRecords, ip.String())
}
// GetSiteIdForDomain returns the siteId associated with the given domain.
// It checks exact matches first, then wildcard patterns.
// The second return value is false if the domain is not found in local records.
func (s *DNSRecordStore) GetSiteIdForDomain(domain string) (int, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
domain = strings.ToLower(dns.Fqdn(domain))
// Check exact match first
if rs, exists := s.exact[domain]; exists {
return rs.SiteId, true
}
// Check wildcard matches
for pattern, rs := range s.wildcards {
if matchWildcard(pattern, domain) {
return rs.SiteId, true
}
}
return 0, false
}
// GetRecords returns all IP addresses for a domain and record type.
// The second return value indicates whether the domain exists at all
// (true = domain exists, use NODATA if no records; false = NXDOMAIN).
func (s *DNSRecordStore) GetRecords(domain string, recordType RecordType) ([]net.IP, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
domain = strings.ToLower(dns.Fqdn(domain))
// Check exact match first
if rs, exists := s.exact[domain]; exists {
var ips []net.IP
if recordType == RecordTypeA {
ips = rs.A
} else {
ips = rs.AAAA
}
if len(ips) > 0 {
out := make([]net.IP, len(ips))
copy(out, ips)
return out, true
}
// Domain exists but no records of this type
return nil, true
}
// Check wildcard matches
var records []net.IP
matched := false
for pattern, rs := range s.wildcards {
if !matchWildcard(pattern, domain) {
continue
}
matched = true
if recordType == RecordTypeA {
records = append(records, rs.A...)
} else {
records = append(records, rs.AAAA...)
}
}
if !matched {
return nil, false
}
if len(records) == 0 {
return nil, true
}
out := make([]net.IP, len(records))
copy(out, records)
return out, true
}
// GetPTRRecord returns the domain name for a PTR record query
// domain should be in reverse DNS format (e.g., "1.0.0.127.in-addr.arpa.")
func (s *DNSRecordStore) GetPTRRecord(domain string) (string, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
// Convert reverse DNS format to IP address
ip := reverseDNSToIP(domain)
if ip == nil {
return "", false
}
// Look up the PTR record
if ptrDomain, ok := s.ptrRecords[ip.String()]; ok {
return ptrDomain, true
}
return "", false
}
// HasRecord checks if a domain has any records of the specified type
@@ -204,46 +365,56 @@ func (s *DNSRecordStore) HasRecord(domain string, recordType RecordType) bool {
s.mu.RLock()
defer s.mu.RUnlock()
// Normalize domain to lowercase FQDN
domain = dns.Fqdn(domain)
domain = strings.ToLower(dns.Fqdn(domain))
switch recordType {
case RecordTypeA:
// Check exact match
if _, ok := s.aRecords[domain]; ok {
// Check exact match
if rs, exists := s.exact[domain]; exists {
if recordType == RecordTypeA && len(rs.A) > 0 {
return true
}
// Check wildcard patterns
for pattern := range s.aWildcards {
if matchWildcard(pattern, domain) {
return true
}
}
case RecordTypeAAAA:
// Check exact match
if _, ok := s.aaaaRecords[domain]; ok {
if recordType == RecordTypeAAAA && len(rs.AAAA) > 0 {
return true
}
// Check wildcard patterns
for pattern := range s.aaaaWildcards {
if matchWildcard(pattern, domain) {
return true
}
}
}
// Check wildcard matches
for pattern, rs := range s.wildcards {
if !matchWildcard(pattern, domain) {
continue
}
if recordType == RecordTypeA && len(rs.A) > 0 {
return true
}
if recordType == RecordTypeAAAA && len(rs.AAAA) > 0 {
return true
}
}
return false
}
// HasPTRRecord checks if a PTR record exists for the given reverse DNS domain
func (s *DNSRecordStore) HasPTRRecord(domain string) bool {
s.mu.RLock()
defer s.mu.RUnlock()
// Convert reverse DNS format to IP address
ip := reverseDNSToIP(domain)
if ip == nil {
return false
}
_, ok := s.ptrRecords[ip.String()]
return ok
}
// Clear removes all records from the store
func (s *DNSRecordStore) Clear() {
s.mu.Lock()
defer s.mu.Unlock()
s.aRecords = make(map[string][]net.IP)
s.aaaaRecords = make(map[string][]net.IP)
s.aWildcards = make(map[string][]net.IP)
s.aaaaWildcards = make(map[string][]net.IP)
s.exact = make(map[string]*recordSet)
s.wildcards = make(map[string]*recordSet)
s.ptrRecords = make(map[string]string)
}
// removeIP is a helper function to remove a specific IP from a slice
@@ -323,3 +494,75 @@ func matchWildcardInternal(pattern, domain string, pi, di int) bool {
return matchWildcardInternal(pattern, domain, pi+1, di+1)
}
// reverseDNSToIP converts a reverse DNS query name to an IP address
// Supports both IPv4 (in-addr.arpa) and IPv6 (ip6.arpa) formats
func reverseDNSToIP(domain string) net.IP {
// Normalize to lowercase and ensure FQDN
domain = strings.ToLower(dns.Fqdn(domain))
// Check for IPv4 reverse DNS (in-addr.arpa)
if strings.HasSuffix(domain, ".in-addr.arpa.") {
// Remove the suffix
ipPart := strings.TrimSuffix(domain, ".in-addr.arpa.")
// Split by dots and reverse
parts := strings.Split(ipPart, ".")
if len(parts) != 4 {
return nil
}
// Reverse the octets
reversed := make([]string, 4)
for i := 0; i < 4; i++ {
reversed[i] = parts[3-i]
}
// Parse as IP
return net.ParseIP(strings.Join(reversed, "."))
}
// Check for IPv6 reverse DNS (ip6.arpa)
if strings.HasSuffix(domain, ".ip6.arpa.") {
// Remove the suffix
ipPart := strings.TrimSuffix(domain, ".ip6.arpa.")
// Split by dots and reverse
parts := strings.Split(ipPart, ".")
if len(parts) != 32 {
return nil
}
// Reverse the nibbles and group into 16-bit hex values
reversed := make([]string, 32)
for i := 0; i < 32; i++ {
reversed[i] = parts[31-i]
}
// Join into IPv6 format (groups of 4 nibbles separated by colons)
var ipv6Parts []string
for i := 0; i < 32; i += 4 {
ipv6Parts = append(ipv6Parts, reversed[i]+reversed[i+1]+reversed[i+2]+reversed[i+3])
}
// Parse as IP
return net.ParseIP(strings.Join(ipv6Parts, ":"))
}
return nil
}
// IPToReverseDNS converts an IP address to reverse DNS format
// Returns the domain name for PTR queries (e.g., "1.0.0.127.in-addr.arpa.")
func IPToReverseDNS(ip net.IP) string {
if ip4 := ip.To4(); ip4 != nil {
// IPv4: reverse octets and append .in-addr.arpa.
return dns.Fqdn(fmt.Sprintf("%d.%d.%d.%d.in-addr.arpa",
ip4[3], ip4[2], ip4[1], ip4[0]))
}
if ip6 := ip.To16(); ip6 != nil && ip.To4() == nil {
// IPv6: expand to 32 nibbles, reverse, and append .ip6.arpa.
var nibbles []string
for i := 15; i >= 0; i-- {
nibbles = append(nibbles, fmt.Sprintf("%x", ip6[i]&0x0f))
nibbles = append(nibbles, fmt.Sprintf("%x", ip6[i]>>4))
}
return dns.Fqdn(strings.Join(nibbles, ".") + ".ip6.arpa")
}
return ""
}

View File

@@ -170,38 +170,47 @@ func TestDNSRecordStoreWildcard(t *testing.T) {
// Add wildcard records
wildcardIP := net.ParseIP("10.0.0.1")
err := store.AddRecord("*.autoco.internal", wildcardIP)
err := store.AddRecord("*.autoco.internal", wildcardIP, 0)
if err != nil {
t.Fatalf("Failed to add wildcard record: %v", err)
}
// Add exact record
exactIP := net.ParseIP("10.0.0.2")
err = store.AddRecord("exact.autoco.internal", exactIP)
err = store.AddRecord("exact.autoco.internal", exactIP, 0)
if err != nil {
t.Fatalf("Failed to add exact record: %v", err)
}
// Test exact match takes precedence
ips := store.GetRecords("exact.autoco.internal.", RecordTypeA)
ips, exists := store.GetRecords("exact.autoco.internal.", RecordTypeA)
if !exists {
t.Error("Expected domain to exist")
}
if len(ips) != 1 {
t.Errorf("Expected 1 IP for exact match, got %d", len(ips))
}
if !ips[0].Equal(exactIP) {
if len(ips) > 0 && !ips[0].Equal(exactIP) {
t.Errorf("Expected exact IP %v, got %v", exactIP, ips[0])
}
// Test wildcard match
ips = store.GetRecords("host.autoco.internal.", RecordTypeA)
ips, exists = store.GetRecords("host.autoco.internal.", RecordTypeA)
if !exists {
t.Error("Expected wildcard match to exist")
}
if len(ips) != 1 {
t.Errorf("Expected 1 IP for wildcard match, got %d", len(ips))
}
if !ips[0].Equal(wildcardIP) {
if len(ips) > 0 && !ips[0].Equal(wildcardIP) {
t.Errorf("Expected wildcard IP %v, got %v", wildcardIP, ips[0])
}
// Test non-match (base domain)
ips = store.GetRecords("autoco.internal.", RecordTypeA)
ips, exists = store.GetRecords("autoco.internal.", RecordTypeA)
if exists {
t.Error("Expected base domain to not exist")
}
if len(ips) != 0 {
t.Errorf("Expected 0 IPs for base domain, got %d", len(ips))
}
@@ -212,13 +221,16 @@ func TestDNSRecordStoreComplexWildcard(t *testing.T) {
// Add complex wildcard pattern
ip1 := net.ParseIP("10.0.0.1")
err := store.AddRecord("*.host-0?.autoco.internal", ip1)
err := store.AddRecord("*.host-0?.autoco.internal", ip1, 0)
if err != nil {
t.Fatalf("Failed to add wildcard record: %v", err)
}
// Test matching domain
ips := store.GetRecords("sub.host-01.autoco.internal.", RecordTypeA)
ips, exists := store.GetRecords("sub.host-01.autoco.internal.", RecordTypeA)
if !exists {
t.Error("Expected complex wildcard match to exist")
}
if len(ips) != 1 {
t.Errorf("Expected 1 IP for complex wildcard match, got %d", len(ips))
}
@@ -227,13 +239,19 @@ func TestDNSRecordStoreComplexWildcard(t *testing.T) {
}
// Test non-matching domain (missing prefix)
ips = store.GetRecords("host-01.autoco.internal.", RecordTypeA)
ips, exists = store.GetRecords("host-01.autoco.internal.", RecordTypeA)
if exists {
t.Error("Expected domain without prefix to not exist")
}
if len(ips) != 0 {
t.Errorf("Expected 0 IPs for domain without prefix, got %d", len(ips))
}
// Test non-matching domain (wrong ? position)
ips = store.GetRecords("sub.host-012.autoco.internal.", RecordTypeA)
ips, exists = store.GetRecords("sub.host-012.autoco.internal.", RecordTypeA)
if exists {
t.Error("Expected domain with wrong ? match to not exist")
}
if len(ips) != 0 {
t.Errorf("Expected 0 IPs for domain with wrong ? match, got %d", len(ips))
}
@@ -244,13 +262,16 @@ func TestDNSRecordStoreRemoveWildcard(t *testing.T) {
// Add wildcard record
ip := net.ParseIP("10.0.0.1")
err := store.AddRecord("*.autoco.internal", ip)
err := store.AddRecord("*.autoco.internal", ip, 0)
if err != nil {
t.Fatalf("Failed to add wildcard record: %v", err)
}
// Verify it exists
ips := store.GetRecords("host.autoco.internal.", RecordTypeA)
ips, exists := store.GetRecords("host.autoco.internal.", RecordTypeA)
if !exists {
t.Error("Expected domain to exist before removal")
}
if len(ips) != 1 {
t.Errorf("Expected 1 IP before removal, got %d", len(ips))
}
@@ -259,7 +280,10 @@ func TestDNSRecordStoreRemoveWildcard(t *testing.T) {
store.RemoveRecord("*.autoco.internal", nil)
// Verify it's gone
ips = store.GetRecords("host.autoco.internal.", RecordTypeA)
ips, exists = store.GetRecords("host.autoco.internal.", RecordTypeA)
if exists {
t.Error("Expected domain to not exist after removal")
}
if len(ips) != 0 {
t.Errorf("Expected 0 IPs after removal, got %d", len(ips))
}
@@ -273,36 +297,36 @@ func TestDNSRecordStoreMultipleWildcards(t *testing.T) {
ip2 := net.ParseIP("10.0.0.2")
ip3 := net.ParseIP("10.0.0.3")
err := store.AddRecord("*.prod.autoco.internal", ip1)
err := store.AddRecord("*.prod.autoco.internal", ip1, 0)
if err != nil {
t.Fatalf("Failed to add first wildcard: %v", err)
}
err = store.AddRecord("*.dev.autoco.internal", ip2)
err = store.AddRecord("*.dev.autoco.internal", ip2, 0)
if err != nil {
t.Fatalf("Failed to add second wildcard: %v", err)
}
// Add a broader wildcard that matches both
err = store.AddRecord("*.autoco.internal", ip3)
err = store.AddRecord("*.autoco.internal", ip3, 0)
if err != nil {
t.Fatalf("Failed to add third wildcard: %v", err)
}
// Test domain matching only the prod pattern and the broad pattern
ips := store.GetRecords("host.prod.autoco.internal.", RecordTypeA)
ips, _ := store.GetRecords("host.prod.autoco.internal.", RecordTypeA)
if len(ips) != 2 {
t.Errorf("Expected 2 IPs (prod + broad), got %d", len(ips))
}
// Test domain matching only the dev pattern and the broad pattern
ips = store.GetRecords("service.dev.autoco.internal.", RecordTypeA)
ips, _ = store.GetRecords("service.dev.autoco.internal.", RecordTypeA)
if len(ips) != 2 {
t.Errorf("Expected 2 IPs (dev + broad), got %d", len(ips))
}
// Test domain matching only the broad pattern
ips = store.GetRecords("host.test.autoco.internal.", RecordTypeA)
ips, _ = store.GetRecords("host.test.autoco.internal.", RecordTypeA)
if len(ips) != 1 {
t.Errorf("Expected 1 IP (broad only), got %d", len(ips))
}
@@ -313,13 +337,13 @@ func TestDNSRecordStoreIPv6Wildcard(t *testing.T) {
// Add IPv6 wildcard record
ip := net.ParseIP("2001:db8::1")
err := store.AddRecord("*.autoco.internal", ip)
err := store.AddRecord("*.autoco.internal", ip, 0)
if err != nil {
t.Fatalf("Failed to add IPv6 wildcard record: %v", err)
}
// Test wildcard match for IPv6
ips := store.GetRecords("host.autoco.internal.", RecordTypeAAAA)
ips, _ := store.GetRecords("host.autoco.internal.", RecordTypeAAAA)
if len(ips) != 1 {
t.Errorf("Expected 1 IPv6 for wildcard match, got %d", len(ips))
}
@@ -333,7 +357,7 @@ func TestHasRecordWildcard(t *testing.T) {
// Add wildcard record
ip := net.ParseIP("10.0.0.1")
err := store.AddRecord("*.autoco.internal", ip)
err := store.AddRecord("*.autoco.internal", ip, 0)
if err != nil {
t.Fatalf("Failed to add wildcard record: %v", err)
}
@@ -348,3 +372,548 @@ func TestHasRecordWildcard(t *testing.T) {
t.Error("Expected HasRecord to return false for base domain")
}
}
func TestDNSRecordStoreCaseInsensitive(t *testing.T) {
store := NewDNSRecordStore()
// Add record with mixed case
ip := net.ParseIP("10.0.0.1")
err := store.AddRecord("MyHost.AutoCo.Internal", ip, 0)
if err != nil {
t.Fatalf("Failed to add mixed case record: %v", err)
}
// Test lookup with different cases
testCases := []string{
"myhost.autoco.internal.",
"MYHOST.AUTOCO.INTERNAL.",
"MyHost.AutoCo.Internal.",
"mYhOsT.aUtOcO.iNtErNaL.",
}
for _, domain := range testCases {
ips, _ := store.GetRecords(domain, RecordTypeA)
if len(ips) != 1 {
t.Errorf("Expected 1 IP for domain %q, got %d", domain, len(ips))
}
if len(ips) > 0 && !ips[0].Equal(ip) {
t.Errorf("Expected IP %v for domain %q, got %v", ip, domain, ips[0])
}
}
// Test wildcard with mixed case
wildcardIP := net.ParseIP("10.0.0.2")
err = store.AddRecord("*.Example.Com", wildcardIP, 0)
if err != nil {
t.Fatalf("Failed to add mixed case wildcard: %v", err)
}
wildcardTestCases := []string{
"host.example.com.",
"HOST.EXAMPLE.COM.",
"Host.Example.Com.",
"HoSt.ExAmPlE.CoM.",
}
for _, domain := range wildcardTestCases {
ips, _ := store.GetRecords(domain, RecordTypeA)
if len(ips) != 1 {
t.Errorf("Expected 1 IP for wildcard domain %q, got %d", domain, len(ips))
}
if len(ips) > 0 && !ips[0].Equal(wildcardIP) {
t.Errorf("Expected IP %v for wildcard domain %q, got %v", wildcardIP, domain, ips[0])
}
}
// Test removal with different case
store.RemoveRecord("MYHOST.AUTOCO.INTERNAL", nil)
ips, _ := store.GetRecords("myhost.autoco.internal.", RecordTypeA)
if len(ips) != 0 {
t.Errorf("Expected 0 IPs after removal, got %d", len(ips))
}
// Test HasRecord with different case
if !store.HasRecord("HOST.EXAMPLE.COM.", RecordTypeA) {
t.Error("Expected HasRecord to return true for mixed case wildcard match")
}
}
func TestPTRRecordIPv4(t *testing.T) {
store := NewDNSRecordStore()
// Add PTR record for IPv4
ip := net.ParseIP("192.168.1.1")
domain := "host.example.com."
err := store.AddPTRRecord(ip, domain)
if err != nil {
t.Fatalf("Failed to add PTR record: %v", err)
}
// Test reverse DNS lookup
reverseDomain := "1.1.168.192.in-addr.arpa."
result, ok := store.GetPTRRecord(reverseDomain)
if !ok {
t.Error("Expected PTR record to be found")
}
if result != domain {
t.Errorf("Expected domain %q, got %q", domain, result)
}
// Test HasPTRRecord
if !store.HasPTRRecord(reverseDomain) {
t.Error("Expected HasPTRRecord to return true")
}
// Test non-existent PTR record
_, ok = store.GetPTRRecord("2.1.168.192.in-addr.arpa.")
if ok {
t.Error("Expected PTR record not to be found for different IP")
}
}
func TestPTRRecordIPv6(t *testing.T) {
store := NewDNSRecordStore()
// Add PTR record for IPv6
ip := net.ParseIP("2001:db8::1")
domain := "ipv6host.example.com."
err := store.AddPTRRecord(ip, domain)
if err != nil {
t.Fatalf("Failed to add PTR record: %v", err)
}
// Test reverse DNS lookup
// 2001:db8::1 = 2001:0db8:0000:0000:0000:0000:0000:0001
// Reverse: 1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.
reverseDomain := "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa."
result, ok := store.GetPTRRecord(reverseDomain)
if !ok {
t.Error("Expected IPv6 PTR record to be found")
}
if result != domain {
t.Errorf("Expected domain %q, got %q", domain, result)
}
// Test HasPTRRecord
if !store.HasPTRRecord(reverseDomain) {
t.Error("Expected HasPTRRecord to return true for IPv6")
}
}
func TestRemovePTRRecord(t *testing.T) {
store := NewDNSRecordStore()
// Add PTR record
ip := net.ParseIP("10.0.0.1")
domain := "test.example.com."
err := store.AddPTRRecord(ip, domain)
if err != nil {
t.Fatalf("Failed to add PTR record: %v", err)
}
// Verify it exists
reverseDomain := "1.0.0.10.in-addr.arpa."
_, ok := store.GetPTRRecord(reverseDomain)
if !ok {
t.Error("Expected PTR record to exist before removal")
}
// Remove PTR record
store.RemovePTRRecord(ip)
// Verify it's gone
_, ok = store.GetPTRRecord(reverseDomain)
if ok {
t.Error("Expected PTR record to be removed")
}
// Test HasPTRRecord after removal
if store.HasPTRRecord(reverseDomain) {
t.Error("Expected HasPTRRecord to return false after removal")
}
}
func TestIPToReverseDNS(t *testing.T) {
tests := []struct {
name string
ip string
expected string
}{
{
name: "IPv4 simple",
ip: "192.168.1.1",
expected: "1.1.168.192.in-addr.arpa.",
},
{
name: "IPv4 localhost",
ip: "127.0.0.1",
expected: "1.0.0.127.in-addr.arpa.",
},
{
name: "IPv4 with zeros",
ip: "10.0.0.1",
expected: "1.0.0.10.in-addr.arpa.",
},
{
name: "IPv6 simple",
ip: "2001:db8::1",
expected: "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.",
},
{
name: "IPv6 localhost",
ip: "::1",
expected: "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.ip6.arpa.",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ip := net.ParseIP(tt.ip)
if ip == nil {
t.Fatalf("Failed to parse IP: %s", tt.ip)
}
result := IPToReverseDNS(ip)
if result != tt.expected {
t.Errorf("IPToReverseDNS(%s) = %q, want %q", tt.ip, result, tt.expected)
}
})
}
}
func TestReverseDNSToIP(t *testing.T) {
tests := []struct {
name string
reverseDNS string
expectedIP string
shouldMatch bool
}{
{
name: "IPv4 simple",
reverseDNS: "1.1.168.192.in-addr.arpa.",
expectedIP: "192.168.1.1",
shouldMatch: true,
},
{
name: "IPv4 localhost",
reverseDNS: "1.0.0.127.in-addr.arpa.",
expectedIP: "127.0.0.1",
shouldMatch: true,
},
{
name: "IPv6 simple",
reverseDNS: "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.",
expectedIP: "2001:db8::1",
shouldMatch: true,
},
{
name: "Invalid IPv4 format",
reverseDNS: "1.1.168.in-addr.arpa.",
expectedIP: "",
shouldMatch: false,
},
{
name: "Invalid IPv6 format",
reverseDNS: "1.0.0.0.ip6.arpa.",
expectedIP: "",
shouldMatch: false,
},
{
name: "Not a reverse DNS domain",
reverseDNS: "example.com.",
expectedIP: "",
shouldMatch: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := reverseDNSToIP(tt.reverseDNS)
if tt.shouldMatch {
if result == nil {
t.Errorf("reverseDNSToIP(%q) returned nil, expected IP", tt.reverseDNS)
return
}
expectedIP := net.ParseIP(tt.expectedIP)
if !result.Equal(expectedIP) {
t.Errorf("reverseDNSToIP(%q) = %v, want %v", tt.reverseDNS, result, expectedIP)
}
} else {
if result != nil {
t.Errorf("reverseDNSToIP(%q) = %v, expected nil", tt.reverseDNS, result)
}
}
})
}
}
func TestPTRRecordCaseInsensitive(t *testing.T) {
store := NewDNSRecordStore()
// Add PTR record with mixed case domain
ip := net.ParseIP("192.168.1.1")
domain := "MyHost.Example.Com"
err := store.AddPTRRecord(ip, domain)
if err != nil {
t.Fatalf("Failed to add PTR record: %v", err)
}
// Test lookup with different cases in reverse DNS format
reverseDomain := "1.1.168.192.in-addr.arpa."
result, ok := store.GetPTRRecord(reverseDomain)
if !ok {
t.Error("Expected PTR record to be found")
}
// Domain should be normalized to lowercase
if result != "myhost.example.com." {
t.Errorf("Expected normalized domain %q, got %q", "myhost.example.com.", result)
}
// Test with uppercase reverse DNS
reverseDomainUpper := "1.1.168.192.IN-ADDR.ARPA."
result, ok = store.GetPTRRecord(reverseDomainUpper)
if !ok {
t.Error("Expected PTR record to be found with uppercase reverse DNS")
}
if result != "myhost.example.com." {
t.Errorf("Expected normalized domain %q, got %q", "myhost.example.com.", result)
}
}
func TestClearPTRRecords(t *testing.T) {
store := NewDNSRecordStore()
// Add some PTR records
ip1 := net.ParseIP("192.168.1.1")
ip2 := net.ParseIP("192.168.1.2")
store.AddPTRRecord(ip1, "host1.example.com.")
store.AddPTRRecord(ip2, "host2.example.com.")
// Add some A records too
store.AddRecord("test.example.com.", net.ParseIP("10.0.0.1"), 0)
// Verify PTR records exist
if !store.HasPTRRecord("1.1.168.192.in-addr.arpa.") {
t.Error("Expected PTR record to exist before clear")
}
// Clear all records
store.Clear()
// Verify PTR records are gone
if store.HasPTRRecord("1.1.168.192.in-addr.arpa.") {
t.Error("Expected PTR record to be cleared")
}
if store.HasPTRRecord("2.1.168.192.in-addr.arpa.") {
t.Error("Expected PTR record to be cleared")
}
// Verify A records are also gone
if store.HasRecord("test.example.com.", RecordTypeA) {
t.Error("Expected A record to be cleared")
}
}
func TestAutomaticPTRRecordOnAdd(t *testing.T) {
store := NewDNSRecordStore()
// Add an A record - should automatically add PTR record
domain := "host.example.com."
ip := net.ParseIP("192.168.1.100")
err := store.AddRecord(domain, ip, 0)
if err != nil {
t.Fatalf("Failed to add A record: %v", err)
}
// Verify PTR record was automatically created
reverseDomain := "100.1.168.192.in-addr.arpa."
result, ok := store.GetPTRRecord(reverseDomain)
if !ok {
t.Error("Expected PTR record to be automatically created")
}
if result != domain {
t.Errorf("Expected PTR to point to %q, got %q", domain, result)
}
// Add AAAA record - should also automatically add PTR record
domain6 := "ipv6host.example.com."
ip6 := net.ParseIP("2001:db8::1")
err = store.AddRecord(domain6, ip6, 0)
if err != nil {
t.Fatalf("Failed to add AAAA record: %v", err)
}
// Verify IPv6 PTR record was automatically created
reverseDomain6 := "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa."
result6, ok := store.GetPTRRecord(reverseDomain6)
if !ok {
t.Error("Expected IPv6 PTR record to be automatically created")
}
if result6 != domain6 {
t.Errorf("Expected PTR to point to %q, got %q", domain6, result6)
}
}
func TestAutomaticPTRRecordOnRemove(t *testing.T) {
store := NewDNSRecordStore()
// Add an A record (with automatic PTR)
domain := "host.example.com."
ip := net.ParseIP("192.168.1.100")
store.AddRecord(domain, ip, 0)
// Verify PTR exists
reverseDomain := "100.1.168.192.in-addr.arpa."
if !store.HasPTRRecord(reverseDomain) {
t.Error("Expected PTR record to exist after adding A record")
}
// Remove the A record
store.RemoveRecord(domain, ip)
// Verify PTR was automatically removed
if store.HasPTRRecord(reverseDomain) {
t.Error("Expected PTR record to be automatically removed")
}
// Verify A record is also gone
ips, _ := store.GetRecords(domain, RecordTypeA)
if len(ips) != 0 {
t.Errorf("Expected A record to be removed, got %d records", len(ips))
}
}
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()
// Add multiple IPs for the same domain
domain := "host.example.com."
ip1 := net.ParseIP("192.168.1.100")
ip2 := net.ParseIP("192.168.1.101")
store.AddRecord(domain, ip1, 0)
store.AddRecord(domain, ip2, 0)
// Verify both PTR records exist
reverseDomain1 := "100.1.168.192.in-addr.arpa."
reverseDomain2 := "101.1.168.192.in-addr.arpa."
if !store.HasPTRRecord(reverseDomain1) {
t.Error("Expected first PTR record to exist")
}
if !store.HasPTRRecord(reverseDomain2) {
t.Error("Expected second PTR record to exist")
}
// Remove all records for the domain
store.RemoveRecord(domain, nil)
// Verify both PTR records were removed
if store.HasPTRRecord(reverseDomain1) {
t.Error("Expected first PTR record to be removed")
}
if store.HasPTRRecord(reverseDomain2) {
t.Error("Expected second PTR record to be removed")
}
}
func TestNoPTRForWildcardRecords(t *testing.T) {
store := NewDNSRecordStore()
// Add wildcard record - should NOT create PTR record
domain := "*.example.com."
ip := net.ParseIP("192.168.1.100")
err := store.AddRecord(domain, ip, 0)
if err != nil {
t.Fatalf("Failed to add wildcard record: %v", err)
}
// Verify no PTR record was created
reverseDomain := "100.1.168.192.in-addr.arpa."
_, ok := store.GetPTRRecord(reverseDomain)
if ok {
t.Error("Expected no PTR record for wildcard domain")
}
// Verify wildcard A record exists
if !store.HasRecord("host.example.com.", RecordTypeA) {
t.Error("Expected wildcard A record to exist")
}
}
func TestPTRRecordOverwrite(t *testing.T) {
store := NewDNSRecordStore()
// Add first domain with IP
domain1 := "host1.example.com."
ip := net.ParseIP("192.168.1.100")
store.AddRecord(domain1, ip, 0)
// Verify PTR points to first domain
reverseDomain := "100.1.168.192.in-addr.arpa."
result, ok := store.GetPTRRecord(reverseDomain)
if !ok {
t.Fatal("Expected PTR record to exist")
}
if result != domain1 {
t.Errorf("Expected PTR to point to %q, got %q", domain1, result)
}
// Add second domain with same IP - should overwrite PTR
domain2 := "host2.example.com."
store.AddRecord(domain2, ip, 0)
// Verify PTR now points to second domain (last one added)
result, ok = store.GetPTRRecord(reverseDomain)
if !ok {
t.Fatal("Expected PTR record to still exist")
}
if result != domain2 {
t.Errorf("Expected PTR to point to %q (overwritten), got %q", domain2, result)
}
// Remove first domain - PTR should remain pointing to second domain
store.RemoveRecord(domain1, ip)
result, ok = store.GetPTRRecord(reverseDomain)
if !ok {
t.Error("Expected PTR record to still exist after removing first domain")
}
if result != domain2 {
t.Errorf("Expected PTR to still point to %q, got %q", domain2, result)
}
// Remove second domain - PTR should now be gone
store.RemoveRecord(domain2, ip)
_, ok = store.GetPTRRecord(reverseDomain)
if ok {
t.Error("Expected PTR record to be removed after removing second domain")
}
}

View File

@@ -13,4 +13,16 @@ func SetupDNSOverride(interfaceName string, proxyIp netip.Addr) error {
// RestoreDNSOverride is a no-op on Android
func RestoreDNSOverride() error {
return nil
}
}
// CleanupStaleState is a no-op on Android as DNS configuration is handled by the VpnService API
func CleanupStaleState(interfaceName string) error {
_ = interfaceName
return nil
}
// ForceResetDNS is a no-op on Android.
func ForceResetDNS(interfaceName string) error {
_ = interfaceName
return nil
}

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 {
@@ -61,3 +68,48 @@ func RestoreDNSOverride() error {
logger.Info("DNS configuration restored successfully")
return nil
}
// CleanupStaleState removes any stale DNS configuration left over from a previous
// unclean shutdown (e.g., system crash, power loss while tunnel was active).
// This function should be called early during startup, before any network operations,
// to ensure DNS is working properly.
//
// On macOS, this cleans up any scutil DNS keys that were created but not removed.
func CleanupStaleState(interfaceName string) error {
_ = interfaceName
if err := platform.CleanupStaleDarwinDNS(); err != nil {
logger.Warn("Failed to cleanup stale Darwin DNS config: %v", err)
return fmt.Errorf("Darwin DNS cleanup: %w", err)
}
logger.Info("Stale DNS state cleanup completed successfully")
return nil
}
// ForceResetDNS forcibly clears any DNS override state, whether or not the
// current process installed it. This is intended for the "reset-dns" CLI
// command and for the watchdog process to recover from a stuck override
// left behind by a crashed client.
func ForceResetDNS(interfaceName string) error {
logger.Info("Forcing DNS reset on Darwin (interface=%s)", interfaceName)
// First clean up any persisted state from a previous session.
cleanupErr := CleanupStaleState(interfaceName)
// Then, if the current process happens to hold a live configurator,
// instruct it to restore DNS as well so in-memory state is consistent.
if configurator != nil {
if err := configurator.RestoreDNS(); err != nil {
logger.Warn("ForceResetDNS: in-memory restore failed: %v", err)
}
configurator = nil
}
// As a last-resort defense, sweep any scutil keys matching our naming
// convention even if no state file exists.
if err := platform.SweepOlmScutilKeys(); err != nil {
logger.Warn("ForceResetDNS: scutil sweep failed: %v", err)
}
return cleanupErr
}

View File

@@ -12,4 +12,16 @@ func SetupDNSOverride(interfaceName string, proxyIp netip.Addr) error {
// RestoreDNSOverride is a no-op on iOS as DNS configuration is handled by the system
func RestoreDNSOverride() error {
return nil
}
}
// CleanupStaleState is a no-op on iOS as DNS configuration is handled by the system
func CleanupStaleState(interfaceName string) error {
_ = interfaceName
return nil
}
// ForceResetDNS is a no-op on iOS.
func ForceResetDNS(interfaceName string) error {
_ = interfaceName
return nil
}

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
@@ -98,3 +105,71 @@ func RestoreDNSOverride() error {
logger.Info("DNS configuration restored successfully")
return nil
}
// CleanupStaleState removes any stale DNS configuration left over from a previous
// unclean shutdown (e.g., system crash, power loss while tunnel was active).
// This function should be called early during startup, before any network operations,
// to ensure DNS is working properly.
//
// It checks and cleans up stale state from all supported DNS managers:
// - NetworkManager: removes /etc/NetworkManager/conf.d/olm-dns.conf
// - resolvconf: removes entry for the provided interface
// - File-based: restores /etc/resolv.conf from backup if it exists
//
// This is safe to call even if no stale state exists.
func CleanupStaleState(interfaceName string) error {
var errs []error
// Clean up NetworkManager stale config
if err := platform.CleanupStaleNetworkManagerDNS(); err != nil {
logger.Warn("Failed to cleanup stale NetworkManager DNS config: %v", err)
errs = append(errs, fmt.Errorf("NetworkManager cleanup: %w", err))
} else {
logger.Debug("NetworkManager DNS cleanup completed")
}
// Clean up resolvconf stale entries for the provided interface
if err := platform.CleanupStaleResolvconfDNS(interfaceName); err != nil {
logger.Warn("Failed to cleanup stale resolvconf DNS config: %v", err)
errs = append(errs, fmt.Errorf("resolvconf cleanup: %w", err))
} else {
logger.Debug("resolvconf DNS cleanup completed")
}
// Clean up file-based stale backup
if err := platform.CleanupStaleFileDNS(); err != nil {
logger.Warn("Failed to cleanup stale file-based DNS config: %v", err)
errs = append(errs, fmt.Errorf("file DNS cleanup: %w", err))
} else {
logger.Debug("File-based DNS cleanup completed")
}
if len(errs) > 0 {
return fmt.Errorf("some DNS cleanup operations failed: %v", errs)
}
logger.Info("Stale DNS state cleanup completed successfully")
return nil
}
// ForceResetDNS forcibly clears any DNS override state, whether or not the
// current process installed it. This is intended for the "reset-dns" CLI
// command and for the watchdog process to recover from a stuck override
// left behind by a crashed client.
func ForceResetDNS(interfaceName string) error {
logger.Info("Forcing DNS reset on Linux/FreeBSD (interface=%s)", interfaceName)
// First clean up any persisted state from a previous session.
cleanupErr := CleanupStaleState(interfaceName)
// Then, if the current process happens to hold a live configurator,
// instruct it to restore DNS as well so in-memory state is consistent.
if configurator != nil {
if err := configurator.RestoreDNS(); err != nil {
logger.Warn("ForceResetDNS: in-memory restore failed: %v", err)
}
configurator = nil
}
return cleanupErr
}

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 {
@@ -61,3 +67,33 @@ func RestoreDNSOverride() error {
logger.Info("DNS configuration restored successfully")
return nil
}
// CleanupStaleState removes any stale DNS configuration left over from a previous
// unclean shutdown (e.g., system crash, power loss while tunnel was active).
// This function should be called early during startup, before any network operations,
// to ensure DNS is working properly.
//
// On Windows, DNS configuration is tied to the interface GUID. When the WireGuard
// interface is recreated, it gets a new GUID, so there's no stale state to clean up.
func CleanupStaleState(interfaceName string) error {
// Windows DNS configuration via registry is interface-specific.
// When the WireGuard interface is recreated, it gets a new GUID,
// so there's no leftover state to clean up from previous sessions.
_ = interfaceName
logger.Debug("Windows DNS cleanup: no stale state to clean (interface-specific)")
return nil
}
// ForceResetDNS forcibly clears any DNS override state. On Windows this is
// largely a no-op because the registry override is tied to the interface
// GUID and is reclaimed when the interface is torn down.
func ForceResetDNS(interfaceName string) error {
logger.Info("Forcing DNS reset on Windows (interface=%s)", interfaceName)
if configurator != nil {
if err := configurator.RestoreDNS(); err != nil {
logger.Warn("ForceResetDNS: in-memory restore failed: %v", err)
}
configurator = nil
}
return CleanupStaleState(interfaceName)
}

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

@@ -417,3 +417,126 @@ func (d *DarwinDNSConfigurator) clearState() error {
logger.Debug("Cleared DNS state file")
return nil
}
// CleanupStaleDarwinDNS removes any stale DNS configuration left by the Darwin
// configurator from a previous unclean shutdown. This is a static function that can be
// called without creating a configurator instance, useful for cleanup before network operations.
func CleanupStaleDarwinDNS() error {
// Always sweep orphaned Olm scutil keys regardless of whether a state
// file exists. This protects against cases where the state file was
// lost (e.g., user home wiped, write failed) but DNS keys are still
// installed in the running scutil session.
defer func() {
_ = SweepOlmScutilKeys()
// Flush DNS cache after any sweep so changes take effect.
_ = exec.Command(dscacheutilPath, "-flushcache").Run()
_ = exec.Command("killall", "-HUP", "mDNSResponder").Run()
}()
stateFilePath := getDNSStateFilePath()
// Check if state file exists
data, err := os.ReadFile(stateFilePath)
if err != nil {
if os.IsNotExist(err) {
// No state file, nothing to clean up
return nil
}
return fmt.Errorf("read state file: %w", err)
}
var state DNSPersistentState
if err := json.Unmarshal(data, &state); err != nil {
// Invalid state file, remove it
os.Remove(stateFilePath)
return nil
}
if len(state.CreatedKeys) == 0 {
// No keys to clean up
return nil
}
logger.Info("Found DNS state from previous session, cleaning up %d keys", len(state.CreatedKeys))
// Remove all keys from previous session using scutil directly
for _, key := range state.CreatedKeys {
logger.Debug("Removing leftover DNS key: %s", key)
cmd := fmt.Sprintf("open\nremove %s\nquit\n", key)
scutilCmd := exec.Command(scutilPath)
scutilCmd.Stdin = strings.NewReader(cmd)
if err := scutilCmd.Run(); err != nil {
logger.Warn("Failed to remove DNS key %s: %v", key, err)
}
}
// Clear state file
if err := os.Remove(stateFilePath); err != nil && !os.IsNotExist(err) {
logger.Warn("Failed to clear DNS state file: %v", err)
}
// Flush DNS cache after cleanup
cacheCmd := exec.Command(dscacheutilPath, "-flushcache")
_ = cacheCmd.Run()
killCmd := exec.Command("killall", "-HUP", "mDNSResponder")
_ = killCmd.Run()
return nil
}
// SweepOlmScutilKeys enumerates scutil State:/Network/Service/Olm-* keys and
// removes any that are present. This is a best-effort safety net used when
// state files have been lost or never written.
func SweepOlmScutilKeys() error {
// list scutil keys matching our naming convention
listOutput, err := runScutilOnce("list State:/Network/Service/Olm-.*/DNS\n")
if err != nil {
return fmt.Errorf("scutil list: %w", err)
}
var keys []string
scanner := bufio.NewScanner(bytes.NewReader(listOutput))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// scutil output format: subKey [0] = State:/Network/Service/Olm-Override/DNS
idx := strings.Index(line, "State:/Network/Service/Olm-")
if idx < 0 {
continue
}
key := strings.TrimSpace(line[idx:])
if key != "" {
keys = append(keys, key)
}
}
if len(keys) == 0 {
return nil
}
logger.Info("Sweeping %d orphaned Olm scutil DNS keys", len(keys))
var commands strings.Builder
for _, key := range keys {
commands.WriteString(fmt.Sprintf("remove %s\n", key))
}
if _, err := runScutilOnce(commands.String()); err != nil {
return fmt.Errorf("scutil sweep remove: %w", err)
}
return nil
}
// runScutilOnce runs a one-shot scutil command sequence wrapped with open/quit
// without requiring a configurator instance.
func runScutilOnce(commands string) ([]byte, error) {
wrapped := fmt.Sprintf("open\n%squit\n", commands)
cmd := exec.Command(scutilPath)
cmd.Stdin = strings.NewReader(wrapped)
output, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("scutil command failed: %w, output: %s", err, output)
}
return output, nil
}

View File

@@ -218,3 +218,27 @@ func copyFile(src, dst string) error {
return nil
}
// CleanupStaleFileDNS removes any stale DNS configuration left by the file-based
// configurator from a previous unclean shutdown. This is a static function that can be
// called without creating a configurator instance, useful for cleanup before network operations.
func CleanupStaleFileDNS() error {
// Check if backup file exists from a previous session
if _, err := os.Stat(resolvConfBackupPath); os.IsNotExist(err) {
// No backup file, nothing to clean up
return nil
}
// A backup exists, which means we crashed while DNS was configured
// Restore the original resolv.conf
if err := copyFile(resolvConfBackupPath, resolvConfPath); err != nil {
return fmt.Errorf("restore from backup during cleanup: %w", err)
}
// Remove backup file
if err := os.Remove(resolvConfBackupPath); err != nil {
return fmt.Errorf("remove backup file during cleanup: %w", err)
}
return nil
}

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()
@@ -323,3 +551,41 @@ func GetNetworkManagerVersion() (string, error) {
return version, nil
}
// CleanupStaleNetworkManagerDNS removes any stale DNS configuration left by NetworkManager
// configurator from a previous unclean shutdown. This is a static function that can be called
// without creating a configurator instance, useful for cleanup before network operations.
func CleanupStaleNetworkManagerDNS() error {
confPath := networkManagerConfDir + "/" + networkManagerDNSConfFile
// Check if our config file exists from a previous session
if _, err := os.Stat(confPath); os.IsNotExist(err) {
// No config file, nothing to clean up
return nil
}
// Remove the stale configuration file
if err := os.Remove(confPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove stale DNS config file: %w", err)
}
// Try to reload NetworkManager if it's available
if IsNetworkManagerAvailable() {
conn, err := dbus.SystemBus()
if err != nil {
return fmt.Errorf("connect to system bus for reload: %w", err)
}
defer conn.Close()
obj := conn.Object(networkManagerDest, networkManagerDbusObjectNode)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := obj.CallWithContext(ctx, networkManagerDest+".Reload", 0, uint32(0)).Store(); err != nil {
return fmt.Errorf("reload NetworkManager after cleanup: %w", err)
}
}
return nil
}

View File

@@ -219,3 +219,37 @@ func IsResolvconfAvailable() bool {
cmd := exec.Command(resolvconfCommand, "--version")
return cmd.Run() == nil
}
// CleanupStaleResolvconfDNS removes any stale DNS configuration left by the resolvconf
// configurator from a previous unclean shutdown. This is a static function that can be
// called without creating a configurator instance, useful for cleanup before network operations.
// The interfaceName parameter specifies which interface entry to clean up (typically "olm").
func CleanupStaleResolvconfDNS(interfaceName string) error {
if !IsResolvconfAvailable() {
// resolvconf not available, nothing to clean up
return nil
}
// Detect resolvconf implementation type
implType, err := detectResolvconfType()
if err != nil {
// Can't detect type, try default
implType = "resolvconf"
}
// Try to delete any existing entry for this interface
// This is idempotent - if no entry exists, resolvconf will just return success
var cmd *exec.Cmd
switch implType {
case "openresolv":
cmd = exec.Command(resolvconfCommand, "-f", "-d", interfaceName)
default:
cmd = exec.Command(resolvconfCommand, "-d", interfaceName)
}
// Ignore errors - the entry may not exist, which is fine
_ = cmd.Run()
return nil
}

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
}

20
go.mod
View File

@@ -1,18 +1,18 @@
module github.com/fosrl/olm
go 1.25
go 1.25.0
require (
github.com/Microsoft/go-winio v0.6.2
github.com/fosrl/newt v1.9.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.40.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.46.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.31.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/time v0.12.0 // indirect
golang.org/x/tools v0.40.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

36
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.9.0 h1:66eJMo6fA+YcBTbddxTfNJXNQo1WWKzmn6zPRP5kSDE=
github.com/fosrl/newt v1.9.0/go.mod h1:d1+yYMnKqg4oLqAM9zdbjthjj2FQEVouiACjqU468ck=
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.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
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.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
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=

25
main.go
View File

@@ -13,6 +13,8 @@ import (
olmpkg "github.com/fosrl/olm/olm"
)
var olmVersion = "version_replaceme"
func main() {
// Check if we're running as a Windows service
if isWindowsService() {
@@ -162,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:])
}
@@ -190,7 +212,6 @@ func runOlmMainWithArgs(ctx context.Context, cancel context.CancelFunc, signalCt
os.Exit(0)
}
olmVersion := "version_replaceme"
if showVersion {
fmt.Println("Olm version " + olmVersion)
os.Exit(0)
@@ -220,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)

14
olm.iss
View File

@@ -32,7 +32,7 @@ DefaultGroupName={#MyAppName}
DisableProgramGroupPage=yes
; Uncomment the following line to run in non administrative install mode (install for current user only).
;PrivilegesRequired=lowest
OutputBaseFilename=mysetup
OutputBaseFilename=olm_windows_installer
SolidCompression=yes
WizardStyle=modern
; Add this to ensure PATH changes are applied and the system is prompted for a restart if needed
@@ -78,7 +78,7 @@ begin
Result := True;
exit;
end;
// Perform a case-insensitive check to see if the path is already present.
// We add semicolons to prevent partial matches (e.g., matching C:\App in C:\App2).
if Pos(';' + UpperCase(Path) + ';', ';' + UpperCase(OrigPath) + ';') > 0 then
@@ -109,7 +109,7 @@ begin
PathList.Delimiter := ';';
PathList.StrictDelimiter := True;
PathList.DelimitedText := OrigPath;
// Find and remove the matching entry (case-insensitive)
for I := PathList.Count - 1 downto 0 do
begin
@@ -119,10 +119,10 @@ begin
PathList.Delete(I);
end;
end;
// Reconstruct the PATH
NewPath := PathList.DelimitedText;
// Write the new PATH back to the registry
if RegWriteExpandStringValue(HKEY_LOCAL_MACHINE,
'SYSTEM\CurrentControlSet\Control\Session Manager\Environment',
@@ -145,8 +145,8 @@ begin
// Get the application installation path
AppPath := ExpandConstant('{app}');
Log('Removing PATH entry for: ' + AppPath);
// Remove only our path entry from the system PATH
RemovePathEntry(AppPath);
end;
end;
end;

View File

@@ -7,6 +7,7 @@ import (
"runtime"
"strconv"
"strings"
"time"
"github.com/fosrl/newt/logger"
"github.com/fosrl/newt/network"
@@ -28,9 +29,15 @@ type OlmErrorData struct {
func (o *Olm) handleConnect(msg websocket.WSMessage) {
logger.Debug("Received message: %v", msg.Data)
// Check if tunnel is still running
if !o.tunnelRunning {
logger.Debug("Tunnel stopped, ignoring connect message")
return
}
var wgData WgData
if o.connected {
if o.registered {
logger.Info("Already connected. Ignoring new connection request.")
return
}
@@ -143,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)
}
@@ -162,20 +177,25 @@ func (o *Olm) handleConnect(msg websocket.WSMessage) {
SharedBind: o.sharedBind,
WSClient: o.websocket,
APIServer: o.apiServer,
PublicDNS: o.tunnelConfig.PublicDNS,
})
for i := range wgData.Sites {
site := wgData.Sites[i]
var siteEndpoint string
// here we are going to take the relay endpoint if it exists which means we requested a relay for this peer
if site.RelayEndpoint != "" {
siteEndpoint = site.RelayEndpoint
} else {
siteEndpoint = site.Endpoint
if site.PublicKey != "" {
var siteEndpoint string
// here we are going to take the relay endpoint if it exists which means we requested a relay for this peer
if site.RelayEndpoint != "" {
siteEndpoint = site.RelayEndpoint
} else {
siteEndpoint = site.Endpoint
}
o.apiServer.AddPeerStatus(site.SiteId, site.Name, false, 0, siteEndpoint, false)
}
o.apiServer.AddPeerStatus(site.SiteId, site.Name, false, 0, siteEndpoint, false)
// we still call this to add the aliases for jit lookup but we just do that then pass inside. need to skip the above so we dont add to the api
if err := o.peerManager.AddPeer(site); err != nil {
logger.Error("Failed to add peer: %v", err)
return
@@ -190,6 +210,37 @@ func (o *Olm) handleConnect(msg websocket.WSMessage) {
logger.Error("Failed to start DNS proxy: %v", err)
}
// Register JIT handler: when the DNS proxy resolves a local record, check whether
// the owning site is already connected and, if not, initiate a JIT connection.
o.dnsProxy.SetJITHandler(func(siteId int) {
pm := o.getPeerManager()
if pm == nil || o.websocket == nil {
return
}
// Site already has an active peer connection - nothing to do.
if _, exists := pm.GetPeer(siteId); exists {
return
}
o.peerSendMu.Lock()
defer o.peerSendMu.Unlock()
// A JIT request for this site is already in-flight - avoid duplicate sends.
if _, pending := o.jitPendingSites[siteId]; pending {
return
}
chainId := generateChainId()
logger.Info("DNS-triggered JIT connect for site %d (chainId=%s)", siteId, chainId)
stopFunc, _ := o.websocket.SendMessageInterval("olm/wg/server/peer/init", map[string]interface{}{
"siteId": siteId,
"chainId": chainId,
}, 2*time.Second, 10)
o.stopPeerInits[chainId] = stopFunc
o.jitPendingSites[siteId] = chainId
})
if o.tunnelConfig.OverrideDNS {
// Set up DNS override to use our DNS proxy
if err := dnsOverride.SetupDNSOverride(o.tunnelConfig.InterfaceName, o.dnsProxy.GetProxyIP()); err != nil {
@@ -197,12 +248,18 @@ 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()})
}
o.apiServer.SetRegistered(true)
o.connected = true
o.registered = true
// Start ping monitor now that we are registered and connected
o.websocket.StartPingMonitor()
@@ -218,6 +275,12 @@ func (o *Olm) handleConnect(msg websocket.WSMessage) {
func (o *Olm) handleOlmError(msg websocket.WSMessage) {
logger.Debug("Received olm error message: %v", msg.Data)
// Check if tunnel is still running
if !o.tunnelRunning {
logger.Debug("Tunnel stopped, ignoring olm error message")
return
}
var errorData OlmErrorData
jsonData, err := json.Marshal(msg.Data)
@@ -245,6 +308,12 @@ func (o *Olm) handleOlmError(msg websocket.WSMessage) {
func (o *Olm) handleTerminate(msg websocket.WSMessage) {
logger.Info("Received terminate message")
// Check if tunnel is still running
if !o.tunnelRunning {
logger.Debug("Tunnel stopped, ignoring terminate message")
return
}
var errorData OlmErrorData
jsonData, err := json.Marshal(msg.Data)
@@ -255,6 +324,12 @@ func (o *Olm) handleTerminate(msg websocket.WSMessage) {
logger.Error("Error unmarshaling terminate error data: %v", err)
} else {
logger.Info("Terminate reason (code: %s): %s", errorData.Code, errorData.Message)
if errorData.Code == "TERMINATED_INACTIVITY" {
logger.Info("Ignoring...")
return
}
// Set the olm error in the API server so it can be exposed via status
o.apiServer.SetOlmError(errorData.Code, errorData.Message)
}

View File

@@ -2,6 +2,7 @@ package olm
import (
"encoding/json"
"fmt"
"time"
"github.com/fosrl/newt/holepunch"
@@ -13,6 +14,12 @@ import (
func (o *Olm) handleWgPeerAddData(msg websocket.WSMessage) {
logger.Debug("Received add-remote-subnets-aliases message: %v", msg.Data)
// Check if tunnel is still running
if !o.tunnelRunning {
logger.Debug("Tunnel stopped, ignoring add-remote-subnets-aliases message")
return
}
jsonData, err := json.Marshal(msg.Data)
if err != nil {
logger.Error("Error marshaling data: %v", err)
@@ -25,21 +32,27 @@ func (o *Olm) handleWgPeerAddData(msg websocket.WSMessage) {
return
}
if _, exists := o.peerManager.GetPeer(addSubnetsData.SiteId); !exists {
pm := o.getPeerManager()
if pm == nil {
logger.Debug("Ignoring add-remote-subnets-aliases message: peerManager is nil (shutdown in progress)")
return
}
if _, exists := pm.GetPeer(addSubnetsData.SiteId); !exists {
logger.Debug("Peer %d not found for removing remote subnets and aliases", addSubnetsData.SiteId)
return
}
// Add new subnets
for _, subnet := range addSubnetsData.RemoteSubnets {
if err := o.peerManager.AddRemoteSubnet(addSubnetsData.SiteId, subnet); err != nil {
if err := pm.AddRemoteSubnet(addSubnetsData.SiteId, subnet); err != nil {
logger.Error("Failed to add allowed IP %s: %v", subnet, err)
}
}
// Add new aliases
for _, alias := range addSubnetsData.Aliases {
if err := o.peerManager.AddAlias(addSubnetsData.SiteId, alias); err != nil {
if err := pm.AddAlias(addSubnetsData.SiteId, alias); err != nil {
logger.Error("Failed to add alias %s: %v", alias.Alias, err)
}
}
@@ -48,6 +61,12 @@ func (o *Olm) handleWgPeerAddData(msg websocket.WSMessage) {
func (o *Olm) handleWgPeerRemoveData(msg websocket.WSMessage) {
logger.Debug("Received remove-remote-subnets-aliases message: %v", msg.Data)
// Check if tunnel is still running
if !o.tunnelRunning {
logger.Debug("Tunnel stopped, ignoring remove-remote-subnets-aliases message")
return
}
jsonData, err := json.Marshal(msg.Data)
if err != nil {
logger.Error("Error marshaling data: %v", err)
@@ -60,21 +79,27 @@ func (o *Olm) handleWgPeerRemoveData(msg websocket.WSMessage) {
return
}
if _, exists := o.peerManager.GetPeer(removeSubnetsData.SiteId); !exists {
pm := o.getPeerManager()
if pm == nil {
logger.Debug("Ignoring remove-remote-subnets-aliases message: peerManager is nil (shutdown in progress)")
return
}
if _, exists := pm.GetPeer(removeSubnetsData.SiteId); !exists {
logger.Debug("Peer %d not found for removing remote subnets and aliases", removeSubnetsData.SiteId)
return
}
// Remove subnets
for _, subnet := range removeSubnetsData.RemoteSubnets {
if err := o.peerManager.RemoveRemoteSubnet(removeSubnetsData.SiteId, subnet); err != nil {
if err := pm.RemoveRemoteSubnet(removeSubnetsData.SiteId, subnet); err != nil {
logger.Error("Failed to remove allowed IP %s: %v", subnet, err)
}
}
// Remove aliases
for _, alias := range removeSubnetsData.Aliases {
if err := o.peerManager.RemoveAlias(removeSubnetsData.SiteId, alias.Alias); err != nil {
if err := pm.RemoveAlias(removeSubnetsData.SiteId, alias.Alias); err != nil {
logger.Error("Failed to remove alias %s: %v", alias.Alias, err)
}
}
@@ -83,6 +108,12 @@ func (o *Olm) handleWgPeerRemoveData(msg websocket.WSMessage) {
func (o *Olm) handleWgPeerUpdateData(msg websocket.WSMessage) {
logger.Debug("Received update-remote-subnets-aliases message: %v", msg.Data)
// Check if tunnel is still running
if !o.tunnelRunning {
logger.Debug("Tunnel stopped, ignoring update-remote-subnets-aliases message")
return
}
jsonData, err := json.Marshal(msg.Data)
if err != nil {
logger.Error("Error marshaling data: %v", err)
@@ -95,7 +126,13 @@ func (o *Olm) handleWgPeerUpdateData(msg websocket.WSMessage) {
return
}
if _, exists := o.peerManager.GetPeer(updateSubnetsData.SiteId); !exists {
pm := o.getPeerManager()
if pm == nil {
logger.Debug("Ignoring update-remote-subnets-aliases message: peerManager is nil (shutdown in progress)")
return
}
if _, exists := pm.GetPeer(updateSubnetsData.SiteId); !exists {
logger.Debug("Peer %d not found for updating remote subnets and aliases", updateSubnetsData.SiteId)
return
}
@@ -104,14 +141,14 @@ func (o *Olm) handleWgPeerUpdateData(msg websocket.WSMessage) {
// This ensures that if an old and new subnet are the same on different peers,
// the route won't be temporarily removed
for _, subnet := range updateSubnetsData.NewRemoteSubnets {
if err := o.peerManager.AddRemoteSubnet(updateSubnetsData.SiteId, subnet); err != nil {
if err := pm.AddRemoteSubnet(updateSubnetsData.SiteId, subnet); err != nil {
logger.Error("Failed to add allowed IP %s: %v", subnet, err)
}
}
// Remove old subnets after new ones are added
for _, subnet := range updateSubnetsData.OldRemoteSubnets {
if err := o.peerManager.RemoveRemoteSubnet(updateSubnetsData.SiteId, subnet); err != nil {
if err := pm.RemoveRemoteSubnet(updateSubnetsData.SiteId, subnet); err != nil {
logger.Error("Failed to remove allowed IP %s: %v", subnet, err)
}
}
@@ -120,14 +157,14 @@ func (o *Olm) handleWgPeerUpdateData(msg websocket.WSMessage) {
// This ensures that if an old and new alias share the same IP, the IP won't be
// temporarily removed from the allowed IPs list
for _, alias := range updateSubnetsData.NewAliases {
if err := o.peerManager.AddAlias(updateSubnetsData.SiteId, alias); err != nil {
if err := pm.AddAlias(updateSubnetsData.SiteId, alias); err != nil {
logger.Error("Failed to add alias %s: %v", alias.Alias, err)
}
}
// Remove old aliases after new ones are added
for _, alias := range updateSubnetsData.OldAliases {
if err := o.peerManager.RemoveAlias(updateSubnetsData.SiteId, alias.Alias); err != nil {
if err := pm.RemoveAlias(updateSubnetsData.SiteId, alias.Alias); err != nil {
logger.Error("Failed to remove alias %s: %v", alias.Alias, err)
}
}
@@ -139,12 +176,13 @@ func (o *Olm) handleWgPeerUpdateData(msg websocket.WSMessage) {
func (o *Olm) handleSync(msg websocket.WSMessage) {
logger.Debug("Received sync message: %v", msg.Data)
if !o.connected {
if !o.registered {
logger.Warn("Not connected, ignoring sync request")
return
}
if o.peerManager == nil {
pm := o.getPeerManager()
if pm == nil {
logger.Warn("Peer manager not initialized, ignoring sync request")
return
}
@@ -171,7 +209,7 @@ func (o *Olm) handleSync(msg websocket.WSMessage) {
}
// Get all current peers
currentPeers := o.peerManager.GetAllPeers()
currentPeers := pm.GetAllPeers()
currentPeerMap := make(map[int]peers.SiteConfig)
for _, peer := range currentPeers {
currentPeerMap[peer.SiteId] = peer
@@ -181,7 +219,7 @@ func (o *Olm) handleSync(msg websocket.WSMessage) {
for siteId := range currentPeerMap {
if _, exists := expectedPeers[siteId]; !exists {
logger.Info("Sync: Removing peer for site %d (no longer in expected config)", siteId)
if err := o.peerManager.RemovePeer(siteId); err != nil {
if err := pm.RemovePeer(siteId); err != nil {
logger.Error("Sync: Failed to remove peer %d: %v", siteId, err)
} else {
// Remove any exit nodes associated with this peer from hole punching
@@ -198,23 +236,35 @@ func (o *Olm) handleSync(msg websocket.WSMessage) {
// Find peers to add (in expected but not in current) and peers to update
for siteId, expectedSite := range expectedPeers {
if _, exists := currentPeerMap[siteId]; !exists {
// Only trigger add if this is NOT a JIT-only config (i.e., has more than just siteId and aliases)
jitOnly := expectedSite.PublicKey == ""
if jitOnly {
logger.Debug("Sync: Registering aliases for JIT-only site %d", siteId)
if err := pm.AddPeer(expectedSite); err != nil {
logger.Error("Sync: Failed to register aliases for JIT site %d: %v", siteId, err)
}
continue
}
// New peer - add it using the add flow (with holepunch)
logger.Info("Sync: Adding new peer for site %d", siteId)
o.holePunchManager.TriggerHolePunch()
// // TODO: do we need to send the message to the cloud to add the peer that way?
// if err := o.peerManager.AddPeer(expectedSite); err != nil {
// logger.Error("Sync: Failed to add peer %d: %v", siteId, err)
// } else {
// logger.Info("Sync: Successfully added peer for site %d", siteId)
// }
o.holePunchManager.ResetServerHolepunchInterval() // start sending immediately again so we fill in the endpoint on the cloud
// add the peer via the server
// this is important because newt needs to get triggered as well to add the peer once the hp is complete
o.stopPeerSend, _ = o.websocket.SendMessageInterval("olm/wg/server/peer/add", map[string]interface{}{
"siteId": expectedSite.SiteId,
}, 1*time.Second, 10)
chainId := fmt.Sprintf("sync-%d", expectedSite.SiteId)
o.peerSendMu.Lock()
if stop, ok := o.stopPeerSends[chainId]; ok {
stop()
}
stopFunc, _ := o.websocket.SendMessageInterval("olm/wg/server/peer/add", map[string]interface{}{
"siteId": expectedSite.SiteId,
"chainId": chainId,
}, 2*time.Second, 10)
o.stopPeerSends[chainId] = stopFunc
o.peerSendMu.Unlock()
} else {
// Existing peer - check if update is needed
@@ -273,7 +323,7 @@ func (o *Olm) handleSync(msg websocket.WSMessage) {
siteConfig.Aliases = expectedSite.Aliases
}
if err := o.peerManager.UpdatePeer(siteConfig); err != nil {
if err := pm.UpdatePeer(siteConfig); err != nil {
logger.Error("Sync: Failed to update peer %d: %v", siteId, err)
} else {
// If the endpoint changed, trigger holepunch to refresh NAT mappings

View File

@@ -2,11 +2,14 @@ package olm
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"net"
"net/http"
_ "net/http/pprof"
"os"
"os/exec"
"sync"
"time"
@@ -31,7 +34,7 @@ type Olm struct {
privateKey wgtypes.Key
logFile *os.File
connected bool
registered bool
tunnelRunning bool
uapiListener net.Listener
@@ -40,11 +43,18 @@ type Olm struct {
middleDev *olmDevice.MiddleDevice
sharedBind *bind.SharedBind
dnsProxy *dns.DNSProxy
apiServer *api.API
websocket *websocket.Client
holePunchManager *holepunch.Manager
peerManager *peers.PeerManager
dnsProxy *dns.DNSProxy
dnsMonitor *dns.SystemDNSMonitor
// pendingSystemDNS holds a SetSystemDNS report received before dnsMonitor exists
// (e.g. Android/iOS push a value while the tunnel is still starting up), so it
// isn't silently dropped. Drained into dnsMonitor as soon as StartTunnel creates it.
pendingSystemDNSMu sync.Mutex
pendingSystemDNS []string
apiServer *api.API
websocket *websocket.Client
holePunchManager *holepunch.Manager
peerManager *peers.PeerManager
peerManagerMu sync.RWMutex
// Power mode management
currentPowerMode string
powerModeMu sync.Mutex
@@ -65,7 +75,26 @@ type Olm struct {
stopRegister func()
updateRegister func(newData any)
stopPeerSend func()
stopPeerSends map[string]func()
stopPeerInits map[string]func()
jitPendingSites map[int]string // siteId -> chainId for in-flight JIT requests
peerSendMu sync.Mutex
// WaitGroup to track tunnel lifecycle
tunnelWg sync.WaitGroup
// External DNS watchdog process (spawned after DNS override is installed).
// nil when no watchdog is running.
dnsWatchdogCmd *exec.Cmd
}
// getPeerManager safely returns the current peerManager under a read-lock.
// Callers must check the returned value for nil before using it.
func (o *Olm) getPeerManager() *peers.PeerManager {
o.peerManagerMu.RLock()
pm := o.peerManager
o.peerManagerMu.RUnlock()
return pm
}
// initTunnelInfo creates the shared UDP socket and holepunch manager.
@@ -108,11 +137,18 @@ func (o *Olm) initTunnelInfo(clientID string) error {
logger.Info("Created shared UDP socket on port %d (refcount: %d)", sourcePort, sharedBind.GetRefCount())
// Create the holepunch manager
o.holePunchManager = holepunch.NewManager(sharedBind, clientID, "olm", privateKey.PublicKey().String())
o.holePunchManager = holepunch.NewManager(sharedBind, clientID, "olm", privateKey.PublicKey().String(), o.tunnelConfig.PublicDNS)
return nil
}
// generateChainId generates a random chain ID for tracking peer sender lifecycles.
func generateChainId() string {
b := make([]byte, 8)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
func Init(ctx context.Context, config OlmConfig) (*Olm, error) {
logger.GetLogger().SetLevel(util.ParseLogLevel(config.LogLevel))
@@ -163,10 +199,13 @@ 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),
}
newOlm.registerAPICallbacks()
@@ -219,7 +258,7 @@ func (o *Olm) registerAPICallbacks() {
tunnelConfig.MTU = 1420
}
if req.DNS == "" {
tunnelConfig.DNS = "9.9.9.9"
tunnelConfig.DNS = "8.8.8.8"
}
// DNSProxyIP has no default - it must be provided if DNS proxy is desired
// UpstreamDNS defaults to 8.8.8.8 if not provided
@@ -281,24 +320,149 @@ func (o *Olm) registerAPICallbacks() {
logger.Info("Processing power mode change request via API: mode=%s", req.Mode)
return o.SetPowerMode(req.Mode)
},
func(req api.JITConnectionRequest) error {
logger.Info("Processing JIT connect request via API: site=%s resource=%s", req.Site, req.Resource)
chainId := generateChainId()
o.peerSendMu.Lock()
stopFunc, _ := o.websocket.SendMessageInterval("olm/wg/server/peer/init", map[string]interface{}{
"siteId": req.Site,
"resourceId": req.Resource,
"chainId": chainId,
}, 2*time.Second, 10)
o.stopPeerInits[chainId] = stopFunc
o.peerSendMu.Unlock()
return nil
},
)
}
// 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")
return
}
// debug print out the whole config
logger.Debug("Starting tunnel with config: %+v", config)
o.tunnelRunning = true // Also set it here in case it is called externally
o.tunnelConfig = config
// 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
o.apiServer.SetTerminated(false)
fingerprint := config.InitialFingerprint
if fingerprint == nil {
fingerprint = make(map[string]any)
@@ -310,7 +474,7 @@ func (o *Olm) StartTunnel(config TunnelConfig) {
}
o.SetFingerprint(fingerprint)
o.SetPostures(postures)
o.SetPostures(postures)
// Create a cancellable context for this tunnel process
tunnelCtx, cancel := context.WithCancel(o.olmCtx)
@@ -335,7 +499,6 @@ func (o *Olm) StartTunnel(config TunnelConfig) {
config.OrgID,
config.Endpoint,
30*time.Second, // 30 seconds
config.PingTimeoutDuration,
websocket.WithPingDataProvider(func() map[string]any {
o.metaMu.Lock()
defer o.metaMu.Unlock()
@@ -375,6 +538,7 @@ func (o *Olm) StartTunnel(config TunnelConfig) {
// Handler for peer handshake - adds exit node to holepunch rotation and notifies server
o.websocket.RegisterHandler("olm/wg/peer/holepunch/site/add", o.handleWgPeerHolepunchAddSite)
o.websocket.RegisterHandler("olm/wg/peer/chain/cancel", o.handleCancelChain)
o.websocket.RegisterHandler("olm/sync", o.handleSync)
o.websocket.OnConnect(func() error {
@@ -382,10 +546,16 @@ func (o *Olm) StartTunnel(config TunnelConfig) {
o.apiServer.SetConnectionStatus(true)
if o.connected {
if o.registered {
o.websocket.StartPingMonitor()
logger.Debug("Already connected, skipping registration")
logger.Debug("Already registered, skipping registration")
return nil
}
// Check if tunnel is still running before starting registration
if !o.tunnelRunning {
logger.Debug("Tunnel is no longer running, skipping registration")
return nil
}
@@ -394,6 +564,12 @@ func (o *Olm) StartTunnel(config TunnelConfig) {
// delay for 500ms to allow for time for the hp to get processed
time.Sleep(500 * time.Millisecond)
// Check again after sleep in case tunnel was stopped
if !o.tunnelRunning {
logger.Debug("Tunnel stopped during delay, skipping registration")
return nil
}
if o.stopRegister == nil {
logger.Debug("Sending registration message to server with public key: %s and relay: %v", publicKey, !config.Holepunch)
o.stopRegister, o.updateRegister = o.websocket.SendMessageInterval("olm/wg/register", map[string]any{
@@ -405,7 +581,8 @@ func (o *Olm) StartTunnel(config TunnelConfig) {
"userToken": userToken,
"fingerprint": o.fingerprint,
"postures": o.postures,
}, 1*time.Second, 10)
"chainId": generateChainId(), // use a random chainId for registration updates - it won't be used for cancellation since registration is a one-time message but for tracking the session
}, 2*time.Second, 20) // after 18 tries on the server side we send the error so dont change this without changing that
// Invoke onRegistered callback if configured
if o.olmConfig.OnRegistered != nil {
@@ -417,6 +594,12 @@ func (o *Olm) StartTunnel(config TunnelConfig) {
})
o.websocket.OnTokenUpdate(func(token string, exitNodes []websocket.ExitNode) {
// Check if tunnel is still running and hole punch manager exists
if !o.tunnelRunning || o.holePunchManager == nil {
logger.Debug("Tunnel stopped or hole punch manager nil, ignoring token update")
return
}
o.holePunchManager.SetToken(token)
logger.Debug("Got exit nodes for hole punching: %v", exitNodes)
@@ -447,6 +630,12 @@ func (o *Olm) StartTunnel(config TunnelConfig) {
})
o.websocket.OnAuthError(func(statusCode int, message string) {
// Check if tunnel is still running
if !o.tunnelRunning {
logger.Debug("Tunnel stopped, ignoring auth error")
return
}
logger.Error("Authentication error (status %d): %s. Terminating tunnel.", statusCode, message)
o.apiServer.SetTerminated(true)
o.apiServer.SetConnectionStatus(false)
@@ -466,6 +655,10 @@ func (o *Olm) StartTunnel(config TunnelConfig) {
}
})
// Indicate that tunnel is starting
o.tunnelWg.Add(1)
defer o.tunnelWg.Done()
// Connect to the WebSocket server
if err := o.websocket.Connect(); err != nil {
logger.Error("Failed to connect to server: %v", err)
@@ -479,6 +672,30 @@ func (o *Olm) StartTunnel(config TunnelConfig) {
}
func (o *Olm) Close() {
// Stop registration first to prevent it from trying to use closed websocket
if o.stopRegister != nil {
logger.Debug("Stopping registration interval")
o.stopRegister()
o.stopRegister = nil
}
// Stop all pending peer init and send senders before closing websocket
o.peerSendMu.Lock()
for _, stop := range o.stopPeerInits {
if stop != nil {
stop()
}
}
o.stopPeerInits = make(map[string]func())
for _, stop := range o.stopPeerSends {
if stop != nil {
stop()
}
}
o.stopPeerSends = make(map[string]func())
o.jitPendingSites = make(map[int]string)
o.peerSendMu.Unlock()
// send a disconnect message to the cloud to show disconnected
if o.websocket != nil {
o.websocket.SendMessage("olm/disconnecting", map[string]any{})
@@ -493,21 +710,30 @@ func (o *Olm) Close() {
logger.Error("Failed to restore DNS: %v", err)
}
// Stop the watchdog *after* a successful DNS restore so that if we
// somehow crash mid-restore the watchdog still has a chance to clean
// up. The watchdog itself is a no-op if it was never spawned.
o.stopDNSWatchdog()
if o.holePunchManager != nil {
o.holePunchManager.Stop()
o.holePunchManager = nil
}
if o.stopRegister != nil {
o.stopRegister()
o.stopRegister = nil
// Stop the system DNS monitor after hole punch is stopped (it feeds
// publicDNS into the hole punch manager).
if o.dnsMonitor != nil {
o.dnsMonitor.Stop()
o.dnsMonitor = nil
}
// Close() also calls Stop() internally
o.peerManagerMu.Lock()
if o.peerManager != nil {
o.peerManager.Close()
o.peerManager = nil
}
o.peerManagerMu.Unlock()
if o.uapiListener != nil {
_ = o.uapiListener.Close()
@@ -533,6 +759,21 @@ func (o *Olm) Close() {
logger.Debug("Closing MiddleDevice")
_ = o.middleDev.Close()
o.middleDev = nil
} else if o.tdev != nil {
// If middleDev was never created but tdev exists, close it directly
logger.Debug("Closing TUN device directly (no MiddleDevice)")
_ = o.tdev.Close()
o.tdev = nil
} else if o.tunnelConfig.FileDescriptorTun != 0 {
// If we never created a device from the FD, close it explicitly
// This can happen if tunnel is stopped during registration before handleConnect
logger.Debug("Closing unused TUN file descriptor %d", o.tunnelConfig.FileDescriptorTun)
if err := closeFD(o.tunnelConfig.FileDescriptorTun); err != nil {
logger.Error("Failed to close TUN file descriptor: %v", err)
} else {
logger.Info("Closed unused TUN file descriptor")
}
o.tunnelConfig.FileDescriptorTun = 0
}
// Now close WireGuard device - its TUN reader should have exited by now
@@ -565,20 +806,24 @@ func (o *Olm) StopTunnel() error {
return nil
}
// Reset the running state BEFORE cleanup to prevent callbacks from accessing nil pointers
o.registered = false
o.tunnelRunning = false
// Cancel the tunnel context if it exists
if o.tunnelCancel != nil {
logger.Debug("Cancelling tunnel context")
o.tunnelCancel()
// Give it a moment to clean up
time.Sleep(200 * time.Millisecond)
}
// Wait for the tunnel goroutine to complete
logger.Debug("Waiting for tunnel goroutine to finish")
o.tunnelWg.Wait()
logger.Debug("Tunnel goroutine finished")
// Close() will handle sending disconnect message and closing websocket
o.Close()
// Reset the connected state
o.connected = false
o.tunnelRunning = false
// Update API server status
o.apiServer.SetConnectionStatus(false)
o.apiServer.SetRegistered(false)
@@ -650,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
@@ -686,9 +963,6 @@ func (o *Olm) SetPowerMode(mode string) error {
logger.Info("Switching to low power mode")
// Mark as disconnected so we re-register on reconnect
o.connected = false
// Update API server connection status
if o.apiServer != nil {
o.apiServer.SetConnectionStatus(false)
@@ -703,14 +977,14 @@ func (o *Olm) SetPowerMode(mode string) error {
lowPowerInterval := 10 * time.Minute
if o.peerManager != nil {
peerMonitor := o.peerManager.GetPeerMonitor()
if pm := o.getPeerManager(); pm != nil {
peerMonitor := pm.GetPeerMonitor()
if peerMonitor != nil {
peerMonitor.SetPeerInterval(lowPowerInterval, lowPowerInterval)
peerMonitor.SetPeerHolepunchInterval(lowPowerInterval, lowPowerInterval)
logger.Info("Set monitoring intervals to 10 minutes for low power mode")
}
o.peerManager.UpdateAllPeersPersistentKeepalive(0) // disable
pm.UpdateAllPeersPersistentKeepalive(0) // disable
}
if o.holePunchManager != nil {
@@ -755,14 +1029,14 @@ func (o *Olm) SetPowerMode(mode string) error {
}
// Restore intervals and reconnect websocket
if o.peerManager != nil {
peerMonitor := o.peerManager.GetPeerMonitor()
if pm := o.getPeerManager(); pm != nil {
peerMonitor := pm.GetPeerMonitor()
if peerMonitor != nil {
peerMonitor.ResetPeerHolepunchInterval()
peerMonitor.ResetPeerInterval()
}
o.peerManager.UpdateAllPeersPersistentKeepalive(5)
pm.UpdateAllPeersPersistentKeepalive(5)
}
if o.holePunchManager != nil {

10
olm/olm_unix.go Normal file
View File

@@ -0,0 +1,10 @@
//go:build !windows
package olm
import "syscall"
// closeFD closes a file descriptor in a platform-specific way
func closeFD(fd uint32) error {
return syscall.Close(int(fd))
}

10
olm/olm_windows.go Normal file
View File

@@ -0,0 +1,10 @@
//go:build windows
package olm
import "syscall"
// closeFD closes a file descriptor in a platform-specific way
func closeFD(fd uint32) error {
return syscall.Close(syscall.Handle(fd))
}

View File

@@ -14,9 +14,22 @@ import (
func (o *Olm) handleWgPeerAdd(msg websocket.WSMessage) {
logger.Debug("Received add-peer message: %v", msg.Data)
if o.stopPeerSend != nil {
o.stopPeerSend()
o.stopPeerSend = nil
// Check if tunnel is still running
if !o.tunnelRunning {
logger.Debug("Tunnel stopped, ignoring add-peer message")
return
}
// Check if connection setup is complete
if !o.registered {
logger.Warn("Not connected, ignoring add-peer message")
return
}
pm := o.getPeerManager()
if pm == nil {
logger.Debug("Ignoring add-peer message: peerManager is nil (shutdown in progress)")
return
}
jsonData, err := json.Marshal(msg.Data)
@@ -25,25 +38,68 @@ func (o *Olm) handleWgPeerAdd(msg websocket.WSMessage) {
return
}
var siteConfig peers.SiteConfig
if err := json.Unmarshal(jsonData, &siteConfig); err != nil {
var siteConfigMsg struct {
peers.SiteConfig
ChainId string `json:"chainId"`
}
if err := json.Unmarshal(jsonData, &siteConfigMsg); err != nil {
logger.Error("Error unmarshaling add data: %v", err)
return
}
if siteConfigMsg.ChainId != "" {
o.peerSendMu.Lock()
if stop, ok := o.stopPeerSends[siteConfigMsg.ChainId]; ok {
stop()
delete(o.stopPeerSends, siteConfigMsg.ChainId)
}
o.peerSendMu.Unlock()
} else {
// stop all of the stopPeerSends
o.peerSendMu.Lock()
for _, stop := range o.stopPeerSends {
stop()
}
o.stopPeerSends = make(map[string]func())
o.peerSendMu.Unlock()
}
if siteConfigMsg.PublicKey == "" {
logger.Warn("Skipping add-peer for site %d (%s): no public key available (site may not be connected)", siteConfigMsg.SiteId, siteConfigMsg.Name)
return
}
_ = o.holePunchManager.TriggerHolePunch() // Trigger immediate hole punch attempt so that if the peer decides to relay we have already punched close to when we need it
if err := o.peerManager.AddPeer(siteConfig); err != nil {
if err := pm.AddPeer(siteConfigMsg.SiteConfig); err != nil {
logger.Error("Failed to add peer: %v", err)
return
}
logger.Info("Successfully added peer for site %d", siteConfig.SiteId)
logger.Info("Successfully added peer for site %d", siteConfigMsg.SiteId)
}
func (o *Olm) handleWgPeerRemove(msg websocket.WSMessage) {
logger.Debug("Received remove-peer message: %v", msg.Data)
// Check if tunnel is still running
if !o.tunnelRunning {
logger.Debug("Tunnel stopped, ignoring remove-peer message")
return
}
// Check if connection setup is complete
if !o.registered {
logger.Warn("Not connected, ignoring remove-peer message")
return
}
pm := o.getPeerManager()
if pm == nil {
logger.Debug("Ignoring remove-peer message: peerManager is nil (shutdown in progress)")
return
}
jsonData, err := json.Marshal(msg.Data)
if err != nil {
logger.Error("Error marshaling data: %v", err)
@@ -56,7 +112,7 @@ func (o *Olm) handleWgPeerRemove(msg websocket.WSMessage) {
return
}
if err := o.peerManager.RemovePeer(removeData.SiteId); err != nil {
if err := pm.RemovePeer(removeData.SiteId); err != nil {
logger.Error("Failed to remove peer: %v", err)
return
}
@@ -75,6 +131,24 @@ func (o *Olm) handleWgPeerRemove(msg websocket.WSMessage) {
func (o *Olm) handleWgPeerUpdate(msg websocket.WSMessage) {
logger.Debug("Received update-peer message: %v", msg.Data)
// Check if tunnel is still running
if !o.tunnelRunning {
logger.Debug("Tunnel stopped, ignoring update-peer message")
return
}
// Check if connection setup is complete
if !o.registered {
logger.Warn("Not connected, ignoring update-peer message")
return
}
pm := o.getPeerManager()
if pm == nil {
logger.Debug("Ignoring update-peer message: peerManager is nil (shutdown in progress)")
return
}
jsonData, err := json.Marshal(msg.Data)
if err != nil {
logger.Error("Error marshaling data: %v", err)
@@ -88,7 +162,7 @@ func (o *Olm) handleWgPeerUpdate(msg websocket.WSMessage) {
}
// Get existing peer from PeerManager
existingPeer, exists := o.peerManager.GetPeer(updateData.SiteId)
existingPeer, exists := pm.GetPeer(updateData.SiteId)
if !exists {
logger.Warn("Peer with site ID %d not found", updateData.SiteId)
return
@@ -115,8 +189,11 @@ func (o *Olm) handleWgPeerUpdate(msg websocket.WSMessage) {
if updateData.RemoteSubnets != nil {
siteConfig.RemoteSubnets = updateData.RemoteSubnets
}
if updateData.Aliases != nil {
siteConfig.Aliases = updateData.Aliases
}
if err := o.peerManager.UpdatePeer(siteConfig); err != nil {
if err := pm.UpdatePeer(siteConfig); err != nil {
logger.Error("Failed to update peer: %v", err)
return
}
@@ -124,8 +201,10 @@ func (o *Olm) handleWgPeerUpdate(msg websocket.WSMessage) {
// If the endpoint changed, trigger holepunch to refresh NAT mappings
if updateData.Endpoint != "" && updateData.Endpoint != existingPeer.Endpoint {
logger.Info("Endpoint changed for site %d, triggering holepunch to refresh NAT mappings", updateData.SiteId)
_ = o.holePunchManager.TriggerHolePunch()
o.holePunchManager.ResetServerHolepunchInterval()
if o.holePunchManager != nil {
_ = o.holePunchManager.TriggerHolePunch()
o.holePunchManager.ResetServerHolepunchInterval()
}
}
logger.Info("Successfully updated peer for site %d", updateData.SiteId)
@@ -135,7 +214,8 @@ func (o *Olm) handleWgPeerRelay(msg websocket.WSMessage) {
logger.Debug("Received relay-peer message: %v", msg.Data)
// Check if peerManager is still valid (may be nil during shutdown)
if o.peerManager == nil {
pm := o.getPeerManager()
if pm == nil {
logger.Debug("Ignoring relay message: peerManager is nil (shutdown in progress)")
return
}
@@ -146,13 +226,21 @@ func (o *Olm) handleWgPeerRelay(msg websocket.WSMessage) {
return
}
var relayData peers.RelayPeerData
var relayData struct {
peers.RelayPeerData
ChainId string `json:"chainId"`
}
if err := json.Unmarshal(jsonData, &relayData); err != nil {
logger.Error("Error unmarshaling relay data: %v", err)
return
}
primaryRelay, err := util.ResolveDomain(relayData.RelayEndpoint)
if monitor := pm.GetPeerMonitor(); monitor != nil {
monitor.CancelRelaySend(relayData.ChainId)
}
primaryRelay, err := util.ResolveDomainUpstream(relayData.RelayEndpoint, o.tunnelConfig.PublicDNS)
if err != nil {
logger.Error("Failed to resolve primary relay endpoint: %v", err)
return
@@ -161,14 +249,15 @@ func (o *Olm) handleWgPeerRelay(msg websocket.WSMessage) {
// Update HTTP server to mark this peer as using relay
o.apiServer.UpdatePeerRelayStatus(relayData.SiteId, relayData.RelayEndpoint, true)
o.peerManager.RelayPeer(relayData.SiteId, primaryRelay, relayData.RelayPort)
pm.RelayPeer(relayData.SiteId, primaryRelay, relayData.RelayPort)
}
func (o *Olm) handleWgPeerUnrelay(msg websocket.WSMessage) {
logger.Debug("Received unrelay-peer message: %v", msg.Data)
// Check if peerManager is still valid (may be nil during shutdown)
if o.peerManager == nil {
pm := o.getPeerManager()
if pm == nil {
logger.Debug("Ignoring unrelay message: peerManager is nil (shutdown in progress)")
return
}
@@ -179,13 +268,21 @@ func (o *Olm) handleWgPeerUnrelay(msg websocket.WSMessage) {
return
}
var relayData peers.UnRelayPeerData
var relayData struct {
peers.UnRelayPeerData
ChainId string `json:"chainId"`
}
if err := json.Unmarshal(jsonData, &relayData); err != nil {
logger.Error("Error unmarshaling relay data: %v", err)
return
}
primaryRelay, err := util.ResolveDomain(relayData.Endpoint)
if monitor := pm.GetPeerMonitor(); monitor != nil {
monitor.CancelRelaySend(relayData.ChainId)
}
primaryRelay, err := util.ResolveDomainUpstream(relayData.Endpoint, o.tunnelConfig.PublicDNS)
if err != nil {
logger.Warn("Failed to resolve primary relay endpoint: %v", err)
}
@@ -193,12 +290,18 @@ func (o *Olm) handleWgPeerUnrelay(msg websocket.WSMessage) {
// Update HTTP server to mark this peer as using relay
o.apiServer.UpdatePeerRelayStatus(relayData.SiteId, relayData.Endpoint, false)
o.peerManager.UnRelayPeer(relayData.SiteId, primaryRelay)
pm.UnRelayPeer(relayData.SiteId, primaryRelay)
}
func (o *Olm) handleWgPeerHolepunchAddSite(msg websocket.WSMessage) {
logger.Debug("Received peer-handshake message: %v", msg.Data)
// Check if tunnel is still running
if !o.tunnelRunning {
logger.Debug("Tunnel stopped, ignoring peer-handshake message")
return
}
jsonData, err := json.Marshal(msg.Data)
if err != nil {
logger.Error("Error marshaling handshake data: %v", err)
@@ -206,7 +309,8 @@ func (o *Olm) handleWgPeerHolepunchAddSite(msg websocket.WSMessage) {
}
var handshakeData struct {
SiteId int `json:"siteId"`
SiteId int `json:"siteId"`
ChainId string `json:"chainId"`
ExitNode struct {
PublicKey string `json:"publicKey"`
Endpoint string `json:"endpoint"`
@@ -219,8 +323,34 @@ func (o *Olm) handleWgPeerHolepunchAddSite(msg websocket.WSMessage) {
return
}
// Stop the peer init sender for this chain, if any
if handshakeData.ChainId != "" {
o.peerSendMu.Lock()
if stop, ok := o.stopPeerInits[handshakeData.ChainId]; ok {
stop()
delete(o.stopPeerInits, handshakeData.ChainId)
}
// If this chain was initiated by a DNS-triggered JIT request, clear the
// pending entry so the site can be re-triggered if needed in the future.
delete(o.jitPendingSites, handshakeData.SiteId)
o.peerSendMu.Unlock()
} else {
// Stop all of the stopPeerInits
o.peerSendMu.Lock()
for _, stop := range o.stopPeerInits {
stop()
}
o.stopPeerInits = make(map[string]func())
o.peerSendMu.Unlock()
}
// Get existing peer from PeerManager
_, exists := o.peerManager.GetPeer(handshakeData.SiteId)
pm := o.getPeerManager()
if pm == nil {
logger.Debug("Ignoring peer-handshake message: peerManager is nil (shutdown in progress)")
return
}
_, exists := pm.GetPeer(handshakeData.SiteId)
if exists {
logger.Warn("Peer with site ID %d already added", handshakeData.SiteId)
return
@@ -249,10 +379,72 @@ func (o *Olm) handleWgPeerHolepunchAddSite(msg websocket.WSMessage) {
o.holePunchManager.TriggerHolePunch() // Trigger immediate hole punch attempt
o.holePunchManager.ResetServerHolepunchInterval() // start sending immediately again so we fill in the endpoint on the cloud
// Send handshake acknowledgment back to server with retry
o.stopPeerSend, _ = o.websocket.SendMessageInterval("olm/wg/server/peer/add", map[string]interface{}{
"siteId": handshakeData.SiteId,
}, 1*time.Second, 10)
// Send handshake acknowledgment back to server with retry, keyed by chainId
chainId := handshakeData.ChainId
if chainId == "" {
chainId = generateChainId()
}
o.peerSendMu.Lock()
stopFunc, _ := o.websocket.SendMessageInterval("olm/wg/server/peer/add", map[string]interface{}{
"siteId": handshakeData.SiteId,
"chainId": chainId,
}, 2*time.Second, 10)
o.stopPeerSends[chainId] = stopFunc
o.peerSendMu.Unlock()
logger.Info("Initiated handshake for site %d with exit node %s", handshakeData.SiteId, handshakeData.ExitNode.Endpoint)
}
func (o *Olm) handleCancelChain(msg websocket.WSMessage) {
logger.Debug("Received cancel-chain message: %v", msg.Data)
jsonData, err := json.Marshal(msg.Data)
if err != nil {
logger.Error("Error marshaling cancel-chain data: %v", err)
return
}
var cancelData struct {
ChainId string `json:"chainId"`
}
if err := json.Unmarshal(jsonData, &cancelData); err != nil {
logger.Error("Error unmarshaling cancel-chain data: %v", err)
return
}
if cancelData.ChainId == "" {
logger.Warn("Received cancel-chain message with no chainId")
return
}
o.peerSendMu.Lock()
defer o.peerSendMu.Unlock()
found := false
if stop, ok := o.stopPeerInits[cancelData.ChainId]; ok {
stop()
delete(o.stopPeerInits, cancelData.ChainId)
found = true
}
// If this chain was a DNS-triggered JIT request, clear the pending entry so
// the site can be re-triggered on the next DNS lookup.
for siteId, chainId := range o.jitPendingSites {
if chainId == cancelData.ChainId {
delete(o.jitPendingSites, siteId)
break
}
}
if stop, ok := o.stopPeerSends[cancelData.ChainId]; ok {
stop()
delete(o.stopPeerSends, cancelData.ChainId)
found = true
}
if found {
logger.Info("Cancelled chain %s", cancelData.ChainId)
} else {
logger.Warn("Cancel-chain: no active sender found for chain %s", cancelData.ChainId)
}
}

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

View File

@@ -6,6 +6,7 @@ import (
"strconv"
"strings"
"sync"
"time"
"github.com/fosrl/newt/bind"
"github.com/fosrl/newt/logger"
@@ -33,6 +34,7 @@ type PeerManagerConfig struct {
// WSClient is optional - if nil, relay messages won't be sent
WSClient *websocket.Client
APIServer *api.API
PublicDNS []string
}
type PeerManager struct {
@@ -50,8 +52,12 @@ type PeerManager struct {
// key is the CIDR string, value is a set of siteIds that want this IP
allowedIPClaims map[string]map[int]bool
APIServer *api.API
publicDNS []string
PersistentKeepalive int
routeOptimizerStop chan struct{}
optimizerTrigger chan struct{}
}
// NewPeerManager creates a new PeerManager with an internal PeerMonitor
@@ -65,6 +71,7 @@ func NewPeerManager(config PeerManagerConfig) *PeerManager {
allowedIPOwners: make(map[string]int),
allowedIPClaims: make(map[string]map[int]bool),
APIServer: config.APIServer,
publicDNS: config.PublicDNS,
}
// Create the peer monitor
@@ -74,8 +81,11 @@ func NewPeerManager(config PeerManagerConfig) *PeerManager {
config.LocalIP,
config.SharedBind,
config.APIServer,
config.PublicDNS,
)
pm.optimizerTrigger = make(chan struct{}, 1)
return pm
}
@@ -93,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()
@@ -107,6 +132,19 @@ func (pm *PeerManager) AddPeer(siteConfig SiteConfig) error {
pm.mu.Lock()
defer pm.mu.Unlock()
for _, alias := range siteConfig.Aliases {
address := net.ParseIP(alias.AliasAddress)
if address == nil {
continue
}
pm.dnsProxy.AddDNSRecord(alias.Alias, address, siteConfig.SiteId)
}
if siteConfig.PublicKey == "" {
logger.Debug("Skip adding site %d because no pub key", siteConfig.SiteId)
return nil
}
// build the allowed IPs list from the remote subnets and aliases and add them to the peer
allowedIPs := make([]string, 0, len(siteConfig.RemoteSubnets)+len(siteConfig.Aliases))
allowedIPs = append(allowedIPs, siteConfig.RemoteSubnets...)
@@ -129,7 +167,7 @@ func (pm *PeerManager) AddPeer(siteConfig SiteConfig) error {
wgConfig := siteConfig
wgConfig.AllowedIps = ownedIPs
if err := ConfigurePeer(pm.device, wgConfig, pm.privateKey, pm.peerMonitor.IsPeerRelayed(siteConfig.SiteId), pm.PersistentKeepalive); err != nil {
if err := ConfigurePeer(pm.device, wgConfig, pm.privateKey, pm.peerMonitor.IsPeerRelayed(siteConfig.SiteId), pm.PersistentKeepalive, pm.publicDNS); err != nil {
return err
}
@@ -139,13 +177,6 @@ func (pm *PeerManager) AddPeer(siteConfig SiteConfig) error {
if err := network.AddRoutes(siteConfig.RemoteSubnets, pm.interfaceName); err != nil {
logger.Error("Failed to add routes for remote subnets: %v", err)
}
for _, alias := range siteConfig.Aliases {
address := net.ParseIP(alias.AliasAddress)
if address == nil {
continue
}
pm.dnsProxy.AddDNSRecord(alias.Alias, address)
}
monitorAddress := strings.Split(siteConfig.ServerIP, "/")[0]
monitorPeer := net.JoinHostPort(monitorAddress, strconv.Itoa(int(siteConfig.ServerPort+1))) // +1 for the monitor port
@@ -173,7 +204,7 @@ func (pm *PeerManager) AddPeer(siteConfig SiteConfig) error {
func (pm *PeerManager) UpdateAllPeersPersistentKeepalive(interval int) map[int]error {
pm.mu.RLock()
defer pm.mu.RUnlock()
pm.PersistentKeepalive = interval
errors := make(map[int]error)
@@ -270,7 +301,7 @@ func (pm *PeerManager) RemovePeer(siteId int) error {
ownedIPs := pm.getOwnedAllowedIPs(promotedPeerId)
wgConfig := promotedPeer
wgConfig.AllowedIps = ownedIPs
if err := ConfigurePeer(pm.device, wgConfig, pm.privateKey, pm.peerMonitor.IsPeerRelayed(promotedPeerId), pm.PersistentKeepalive); err != nil {
if err := ConfigurePeer(pm.device, wgConfig, pm.privateKey, pm.peerMonitor.IsPeerRelayed(promotedPeerId), pm.PersistentKeepalive, pm.publicDNS); err != nil {
logger.Error("Failed to update promoted peer %d: %v", promotedPeerId, err)
}
}
@@ -295,6 +326,29 @@ func (pm *PeerManager) UpdatePeer(siteConfig SiteConfig) error {
return fmt.Errorf("peer with site ID %d not found", siteConfig.SiteId)
}
// Update aliases
// Remove old aliases
for _, alias := range oldPeer.Aliases {
address := net.ParseIP(alias.AliasAddress)
if address == nil {
continue
}
pm.dnsProxy.RemoveDNSRecord(alias.Alias, address)
}
// Add new aliases
for _, alias := range siteConfig.Aliases {
address := net.ParseIP(alias.AliasAddress)
if address == nil {
continue
}
pm.dnsProxy.AddDNSRecord(alias.Alias, address, siteConfig.SiteId)
}
if siteConfig.PublicKey == "" {
logger.Debug("Skip updating site %d because no pub key", siteConfig.SiteId)
return nil
}
// If public key changed, remove old peer first
if siteConfig.PublicKey != oldPeer.PublicKey {
if err := RemovePeer(pm.device, siteConfig.SiteId, oldPeer.PublicKey); err != nil {
@@ -346,7 +400,7 @@ func (pm *PeerManager) UpdatePeer(siteConfig SiteConfig) error {
wgConfig := siteConfig
wgConfig.AllowedIps = ownedIPs
if err := ConfigurePeer(pm.device, wgConfig, pm.privateKey, pm.peerMonitor.IsPeerRelayed(siteConfig.SiteId), pm.PersistentKeepalive); err != nil {
if err := ConfigurePeer(pm.device, wgConfig, pm.privateKey, pm.peerMonitor.IsPeerRelayed(siteConfig.SiteId), pm.PersistentKeepalive, pm.publicDNS); err != nil {
return err
}
@@ -356,7 +410,7 @@ func (pm *PeerManager) UpdatePeer(siteConfig SiteConfig) error {
promotedOwnedIPs := pm.getOwnedAllowedIPs(promotedPeerId)
promotedWgConfig := promotedPeer
promotedWgConfig.AllowedIps = promotedOwnedIPs
if err := ConfigurePeer(pm.device, promotedWgConfig, pm.privateKey, pm.peerMonitor.IsPeerRelayed(promotedPeerId), pm.PersistentKeepalive); err != nil {
if err := ConfigurePeer(pm.device, promotedWgConfig, pm.privateKey, pm.peerMonitor.IsPeerRelayed(promotedPeerId), pm.PersistentKeepalive, pm.publicDNS); err != nil {
logger.Error("Failed to update promoted peer %d: %v", promotedPeerId, err)
}
}
@@ -418,24 +472,6 @@ func (pm *PeerManager) UpdatePeer(siteConfig SiteConfig) error {
}
}
// Update aliases
// Remove old aliases
for _, alias := range oldPeer.Aliases {
address := net.ParseIP(alias.AliasAddress)
if address == nil {
continue
}
pm.dnsProxy.RemoveDNSRecord(alias.Alias, address)
}
// Add new aliases
for _, alias := range siteConfig.Aliases {
address := net.ParseIP(alias.AliasAddress)
if address == nil {
continue
}
pm.dnsProxy.AddDNSRecord(alias.Alias, address)
}
pm.peerMonitor.UpdateHolepunchEndpoint(siteConfig.SiteId, siteConfig.Endpoint)
monitorAddress := strings.Split(siteConfig.ServerIP, "/")[0]
@@ -713,7 +749,7 @@ func (pm *PeerManager) AddAlias(siteId int, alias Alias) error {
address := net.ParseIP(alias.AliasAddress)
if address != nil {
pm.dnsProxy.AddDNSRecord(alias.Alias, address)
pm.dnsProxy.AddDNSRecord(alias.Alias, address, siteId)
}
// Add an allowed IP for the alias
@@ -747,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)
}
}
@@ -846,10 +882,12 @@ func (pm *PeerManager) Start() {
if pm.peerMonitor != nil {
pm.peerMonitor.Start()
}
pm.startRouteOptimizer()
}
// Stop stops the peer monitor
func (pm *PeerManager) Stop() {
pm.stopRouteOptimizer()
if pm.peerMonitor != nil {
pm.peerMonitor.Stop()
}
@@ -857,6 +895,7 @@ func (pm *PeerManager) Stop() {
// Close stops the peer monitor and cleans up resources
func (pm *PeerManager) Close() {
pm.stopRouteOptimizer()
if pm.peerMonitor != nil {
pm.peerMonitor.Close()
pm.peerMonitor = nil
@@ -918,3 +957,166 @@ endpoint=%s`, util.FixKey(peer.PublicKey), endpoint)
logger.Info("Switched peer %d back to direct connection at %s", siteId, endpoint)
return nil
}
// isBetterConnection returns true if connection quality (a) is better than (b).
// Priority: connected > disconnected, then direct > relayed, then lower RTT.
func isBetterConnection(aConn bool, aRelay bool, aRTT time.Duration,
bConn bool, bRelay bool, bRTT time.Duration) bool {
if aConn != bConn {
return aConn // connected beats disconnected
}
if !aConn {
return false // both offline, no preference
}
if aRelay != bRelay {
return !aRelay // direct beats relayed
}
// Same connectivity class: prefer lower RTT
if aRTT == 0 {
return false // unknown RTT, don't displace
}
if bRTT == 0 {
return true // current has no RTT data, prefer known
}
return aRTT < bRTT
}
// selectBestOwner returns the siteId of the best site to own the given IP,
// based on connection quality. Must be called with pm.mu held.
func (pm *PeerManager) selectBestOwner(claims map[int]bool) int {
bestSiteId := -1
var bestConn, bestRelay bool
var bestRTT time.Duration
for siteId := range claims {
conn, relay, rtt := pm.peerMonitor.GetConnectionQuality(siteId)
if bestSiteId < 0 || isBetterConnection(conn, relay, rtt, bestConn, bestRelay, bestRTT) {
bestSiteId = siteId
bestConn = conn
bestRelay = relay
bestRTT = rtt
}
}
return bestSiteId
}
// getWireGuardAllowedIPs returns the full set of IPs that should be in WireGuard
// for a peer: server IP /32 plus all shared IPs it currently owns.
// Must be called with pm.mu held.
func (pm *PeerManager) getWireGuardAllowedIPs(siteId int) []string {
peer, exists := pm.peers[siteId]
if !exists {
return nil
}
serverIP := strings.Split(peer.ServerIP, "/")[0] + "/32"
ips := []string{serverIP}
for cidr, owner := range pm.allowedIPOwners {
if owner == siteId {
ips = append(ips, cidr)
}
}
return ips
}
// transferOwnership moves WireGuard ownership of cidr from fromSiteId to toSiteId.
// Must be called with pm.mu held.
func (pm *PeerManager) transferOwnership(cidr string, fromSiteId int, toSiteId int) error {
// Update owner map first
pm.allowedIPOwners[cidr] = toSiteId
// Remove cidr from old owner's WireGuard allowed IPs
if fromPeer, exists := pm.peers[fromSiteId]; exists {
remaining := pm.getWireGuardAllowedIPs(fromSiteId) // cidr is no longer in owners, so it won't appear here
if err := RemoveAllowedIP(pm.device, fromPeer.PublicKey, remaining); err != nil {
// Revert
pm.allowedIPOwners[cidr] = fromSiteId
return fmt.Errorf("remove IP %s from site %d: %v", cidr, fromSiteId, err)
}
}
// Add cidr to new owner's WireGuard allowed IPs
if toPeer, exists := pm.peers[toSiteId]; exists {
if err := AddAllowedIP(pm.device, toPeer.PublicKey, cidr); err != nil {
return fmt.Errorf("add IP %s to site %d: %v", cidr, toSiteId, err)
}
}
return nil
}
// optimizeRoutes evaluates all shared IPs and reassigns ownership to the best site.
func (pm *PeerManager) optimizeRoutes() {
pm.mu.Lock()
defer pm.mu.Unlock()
for cidr, claims := range pm.allowedIPClaims {
if len(claims) <= 1 {
continue // No competition, nothing to optimize
}
currentOwner, hasOwner := pm.allowedIPOwners[cidr]
bestOwner := pm.selectBestOwner(claims)
if bestOwner < 0 {
continue
}
if hasOwner && currentOwner == bestOwner {
continue // Already on the best site
}
if !hasOwner {
// No current owner, just assign
pm.allowedIPOwners[cidr] = bestOwner
if toPeer, exists := pm.peers[bestOwner]; exists {
if err := AddAllowedIP(pm.device, toPeer.PublicKey, cidr); err != nil {
logger.Error("Failed to assign IP %s to site %d: %v", cidr, bestOwner, err)
}
}
continue
}
logger.Info("Route optimizer: moving %s from site %d to site %d", cidr, currentOwner, bestOwner)
if err := pm.transferOwnership(cidr, currentOwner, bestOwner); err != nil {
logger.Error("Failed to transfer ownership of %s from site %d to site %d: %v",
cidr, currentOwner, bestOwner, err)
}
}
}
// startRouteOptimizer registers the status-change callback and launches the optimizer goroutine.
func (pm *PeerManager) startRouteOptimizer() {
pm.routeOptimizerStop = make(chan struct{})
// Trigger optimization whenever any peer's connection status changes
if pm.peerMonitor != nil {
pm.peerMonitor.SetStatusChangeCallback(func(_ int) {
select {
case pm.optimizerTrigger <- struct{}{}:
default:
}
})
}
go func() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-pm.routeOptimizerStop:
return
case <-pm.optimizerTrigger:
pm.optimizeRoutes()
case <-ticker.C:
pm.optimizeRoutes()
}
}
}()
}
// stopRouteOptimizer stops the route optimizer goroutine if it is running.
func (pm *PeerManager) stopRouteOptimizer() {
if pm.routeOptimizerStop != nil {
close(pm.routeOptimizerStop)
pm.routeOptimizerStop = nil
}
}

View File

@@ -2,6 +2,8 @@ package monitor
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"net"
"net/netip"
@@ -31,9 +33,14 @@ type PeerMonitor struct {
monitors map[int]*Client
mutex sync.Mutex
running bool
timeout time.Duration
timeout time.Duration
maxAttempts int
wsClient *websocket.Client
publicDNS []string
// Relay sender tracking
relaySends map[string]func()
relaySendMu sync.Mutex
// Netstack fields
middleDev *middleDevice.MiddleDevice
@@ -47,13 +54,13 @@ type PeerMonitor struct {
nsWg sync.WaitGroup
// Holepunch testing fields
sharedBind *bind.SharedBind
holepunchTester *holepunch.HolepunchTester
holepunchTimeout time.Duration
holepunchEndpoints map[int]string // siteID -> endpoint for holepunch testing
holepunchStatus map[int]bool // siteID -> connected status
holepunchStopChan chan struct{}
holepunchUpdateChan chan struct{}
sharedBind *bind.SharedBind
holepunchTester *holepunch.HolepunchTester
holepunchTimeout time.Duration
holepunchEndpoints map[int]string // siteID -> endpoint for holepunch testing
holepunchStatus map[int]bool // siteID -> connected status
holepunchStopChan chan struct{}
holepunchUpdateChan chan struct{}
// Relay tracking fields
relayedPeers map[int]bool // siteID -> whether the peer is currently relayed
@@ -78,11 +85,19 @@ type PeerMonitor struct {
apiServer *api.API
// WG connection status tracking
wgConnectionStatus map[int]bool // siteID -> WG connected status
wgConnectionStatus map[int]bool // siteID -> WG connected status
wgConnectionRTT map[int]time.Duration // siteID -> last known RTT
statusChangeCallback func(siteId int) // called when any peer's connection status changes
}
// NewPeerMonitor creates a new peer monitor with the given callback
func NewPeerMonitor(wsClient *websocket.Client, middleDev *middleDevice.MiddleDevice, localIP string, sharedBind *bind.SharedBind, apiServer *api.API) *PeerMonitor {
func generateChainId() string {
b := make([]byte, 8)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
func NewPeerMonitor(wsClient *websocket.Client, middleDev *middleDevice.MiddleDevice, localIP string, sharedBind *bind.SharedBind, apiServer *api.API, publicDNS []string) *PeerMonitor {
ctx, cancel := context.WithCancel(context.Background())
pm := &PeerMonitor{
monitors: make(map[int]*Client),
@@ -91,6 +106,7 @@ func NewPeerMonitor(wsClient *websocket.Client, middleDev *middleDevice.MiddleDe
wsClient: wsClient,
middleDev: middleDev,
localIP: localIP,
publicDNS: publicDNS,
activePorts: make(map[uint16]bool),
nsCtx: ctx,
nsCancel: cancel,
@@ -99,6 +115,7 @@ func NewPeerMonitor(wsClient *websocket.Client, middleDev *middleDevice.MiddleDe
holepunchEndpoints: make(map[int]string),
holepunchStatus: make(map[int]bool),
relayedPeers: make(map[int]bool),
relaySends: make(map[string]func()),
holepunchMaxAttempts: 3, // Trigger relay after 3 consecutive failures
holepunchFailures: make(map[int]int),
// Rapid initial test settings: complete within ~1.5 seconds
@@ -107,6 +124,7 @@ func NewPeerMonitor(wsClient *websocket.Client, middleDev *middleDevice.MiddleDe
rapidTestMaxAttempts: 5, // 5 attempts = ~1-1.5 seconds total
apiServer: apiServer,
wgConnectionStatus: make(map[int]bool),
wgConnectionRTT: make(map[int]time.Duration),
// Exponential backoff settings for holepunch monitor
defaultHolepunchMinInterval: 2 * time.Second,
defaultHolepunchMaxInterval: 30 * time.Second,
@@ -124,12 +142,25 @@ func NewPeerMonitor(wsClient *websocket.Client, middleDev *middleDevice.MiddleDe
// Initialize holepunch tester if sharedBind is available
if sharedBind != nil {
pm.holepunchTester = holepunch.NewHolepunchTester(sharedBind)
pm.holepunchTester = holepunch.NewHolepunchTester(sharedBind, publicDNS)
}
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()
@@ -377,6 +408,9 @@ func (pm *PeerMonitor) handleConnectionStatusChange(siteID int, status Connectio
pm.mutex.Lock()
previousStatus, exists := pm.wgConnectionStatus[siteID]
pm.wgConnectionStatus[siteID] = status.Connected
if status.Connected && status.RTT > 0 {
pm.wgConnectionRTT[siteID] = status.RTT
}
isRelayed := pm.relayedPeers[siteID]
endpoint := pm.holepunchEndpoints[siteID]
pm.mutex.Unlock()
@@ -394,22 +428,30 @@ func (pm *PeerMonitor) handleConnectionStatusChange(siteID int, status Connectio
if pm.apiServer != nil {
pm.apiServer.UpdatePeerStatus(siteID, status.Connected, status.RTT, endpoint, isRelayed)
}
// Notify route optimizer of status change
if pm.statusChangeCallback != nil {
pm.statusChangeCallback(siteID)
}
}
// sendRelay sends a relay message to the server
// sendRelay sends a relay message to the server with retry, keyed by chainId
func (pm *PeerMonitor) sendRelay(siteID int) error {
if pm.wsClient == nil {
return fmt.Errorf("websocket client is nil")
}
err := pm.wsClient.SendMessage("olm/wg/relay", map[string]interface{}{
"siteId": siteID,
})
if err != nil {
logger.Error("Failed to send registration message: %v", err)
return err
}
logger.Info("Sent relay message")
chainId := generateChainId()
stopFunc, _ := pm.wsClient.SendMessageInterval("olm/wg/relay", map[string]interface{}{
"siteId": siteID,
"chainId": chainId,
}, 2*time.Second, 10)
pm.relaySendMu.Lock()
pm.relaySends[chainId] = stopFunc
pm.relaySendMu.Unlock()
logger.Info("Sent relay message for site %d (chain %s)", siteID, chainId)
return nil
}
@@ -419,23 +461,52 @@ func (pm *PeerMonitor) RequestRelay(siteID int) error {
return pm.sendRelay(siteID)
}
// sendUnRelay sends an unrelay message to the server
// sendUnRelay sends an unrelay message to the server with retry, keyed by chainId
func (pm *PeerMonitor) sendUnRelay(siteID int) error {
if pm.wsClient == nil {
return fmt.Errorf("websocket client is nil")
}
err := pm.wsClient.SendMessage("olm/wg/unrelay", map[string]interface{}{
"siteId": siteID,
})
if err != nil {
logger.Error("Failed to send registration message: %v", err)
return err
}
logger.Info("Sent unrelay message")
chainId := generateChainId()
stopFunc, _ := pm.wsClient.SendMessageInterval("olm/wg/unrelay", map[string]interface{}{
"siteId": siteID,
"chainId": chainId,
}, 2*time.Second, 10)
pm.relaySendMu.Lock()
pm.relaySends[chainId] = stopFunc
pm.relaySendMu.Unlock()
logger.Info("Sent unrelay message for site %d (chain %s)", siteID, chainId)
return nil
}
// CancelRelaySend stops the interval sender for the given chainId, if one exists.
// If chainId is empty, all active relay senders are stopped.
func (pm *PeerMonitor) CancelRelaySend(chainId string) {
pm.relaySendMu.Lock()
defer pm.relaySendMu.Unlock()
if chainId == "" {
for id, stop := range pm.relaySends {
if stop != nil {
stop()
}
delete(pm.relaySends, id)
}
logger.Info("Cancelled all relay senders")
return
}
if stop, ok := pm.relaySends[chainId]; ok {
stop()
delete(pm.relaySends, chainId)
logger.Info("Cancelled relay sender for chain %s", chainId)
} else {
logger.Warn("CancelRelaySend: no active sender for chain %s", chainId)
}
}
// Stop stops monitoring all peers
func (pm *PeerMonitor) Stop() {
// Stop holepunch monitor first (outside of mutex to avoid deadlock)
@@ -474,6 +545,25 @@ func (pm *PeerMonitor) IsPeerRelayed(siteID int) bool {
return pm.relayedPeers[siteID]
}
// SetStatusChangeCallback registers a callback that is invoked whenever a peer's
// WireGuard connection status changes (connected/disconnected). The callback must
// be non-blocking (e.g., send to a buffered channel).
func (pm *PeerMonitor) SetStatusChangeCallback(cb func(siteId int)) {
pm.mutex.Lock()
defer pm.mutex.Unlock()
pm.statusChangeCallback = cb
}
// GetConnectionQuality returns the current connection quality metrics for a peer.
func (pm *PeerMonitor) GetConnectionQuality(siteId int) (connected bool, relayed bool, rtt time.Duration) {
pm.mutex.Lock()
defer pm.mutex.Unlock()
connected = pm.wgConnectionStatus[siteId]
relayed = pm.relayedPeers[siteId]
rtt = pm.wgConnectionRTT[siteId]
return
}
// startHolepunchMonitor starts the holepunch connection monitoring
// Note: This function assumes the mutex is already held by the caller (called from Start())
func (pm *PeerMonitor) startHolepunchMonitor() error {
@@ -534,7 +624,7 @@ func (pm *PeerMonitor) runHolepunchMonitor() {
pm.holepunchCurrentInterval = pm.holepunchMinInterval
currentInterval := pm.holepunchCurrentInterval
pm.mutex.Unlock()
timer.Reset(currentInterval)
logger.Debug("Holepunch monitor interval updated, reset to %v", currentInterval)
case <-timer.C:
@@ -677,6 +767,16 @@ func (pm *PeerMonitor) Close() {
// Stop holepunch monitor first (outside of mutex to avoid deadlock)
pm.stopHolepunchMonitor()
// Stop all pending relay senders
pm.relaySendMu.Lock()
for chainId, stop := range pm.relaySends {
if stop != nil {
stop()
}
delete(pm.relaySends, chainId)
}
pm.relaySendMu.Unlock()
pm.mutex.Lock()
defer pm.mutex.Unlock()

View File

@@ -11,14 +11,14 @@ import (
)
// ConfigurePeer sets up or updates a peer within the WireGuard device
func ConfigurePeer(dev *device.Device, siteConfig SiteConfig, privateKey wgtypes.Key, relay bool, persistentKeepalive int) error {
func ConfigurePeer(dev *device.Device, siteConfig SiteConfig, privateKey wgtypes.Key, relay bool, persistentKeepalive int, publicDNS []string) error {
var endpoint string
if relay && siteConfig.RelayEndpoint != "" {
endpoint = formatEndpoint(siteConfig.RelayEndpoint)
} else {
endpoint = formatEndpoint(siteConfig.Endpoint)
}
siteHost, err := util.ResolveDomain(endpoint)
siteHost, err := util.ResolveDomainUpstream(endpoint, publicDNS)
if err != nil {
return fmt.Errorf("failed to resolve endpoint for site %d: %v", siteConfig.SiteId, err)
}

View File

@@ -2,6 +2,7 @@ package websocket
import (
"bytes"
"compress/gzip"
"crypto/tls"
"crypto/x509"
"encoding/json"
@@ -82,7 +83,6 @@ type Client struct {
isDisconnected bool // Flag to track if client is intentionally disconnected
reconnectMux sync.RWMutex
pingInterval time.Duration
pingTimeout time.Duration
onConnect func() error
onTokenUpdate func(token string, exitNodes []ExitNode)
onAuthError func(statusCode int, message string) // Callback for auth errors
@@ -158,7 +158,7 @@ func (c *Client) OnAuthError(callback func(statusCode int, message string)) {
}
// NewClient creates a new websocket client
func NewClient(ID, secret, userToken, orgId, endpoint string, pingInterval time.Duration, pingTimeout time.Duration, opts ...ClientOption) (*Client, error) {
func NewClient(ID, secret, userToken, orgId, endpoint string, pingInterval time.Duration, opts ...ClientOption) (*Client, error) {
config := &Config{
ID: ID,
Secret: secret,
@@ -175,7 +175,6 @@ func NewClient(ID, secret, userToken, orgId, endpoint string, pingInterval time.
reconnectInterval: 3 * time.Second,
isConnected: false,
pingInterval: pingInterval,
pingTimeout: pingTimeout,
clientType: "olm",
pingDone: make(chan struct{}),
}
@@ -388,6 +387,7 @@ func (c *Client) getToken() (string, []ExitNode, error) {
tokenData := map[string]interface{}{
"olmId": c.config.ID,
"secret": c.config.Secret,
"userToken": c.config.UserToken,
"orgId": c.config.OrgID,
}
jsonData, err := json.Marshal(tokenData)
@@ -802,8 +802,7 @@ func (c *Client) readPumpWithDisconnectDetection() {
case <-c.done:
return
default:
var msg WSMessage
err := c.conn.ReadJSON(&msg)
messageType, p, err := c.conn.ReadMessage()
if err != nil {
// Check if we're shutting down or explicitly disconnected before logging error
select {
@@ -828,6 +827,30 @@ func (c *Client) readPumpWithDisconnectDetection() {
}
}
// Decompress binary frames (gzip-compressed JSON)
var data []byte
if messageType == websocket.BinaryMessage {
gr, gzErr := gzip.NewReader(bytes.NewReader(p))
if gzErr != nil {
logger.Error("websocket: failed to create gzip reader: %v", gzErr)
continue
}
data, gzErr = io.ReadAll(gr)
gr.Close()
if gzErr != nil {
logger.Error("websocket: failed to decompress message: %v", gzErr)
continue
}
} else {
data = p
}
var msg WSMessage
if err = json.Unmarshal(data, &msg); err != nil {
logger.Error("websocket: failed to parse message: %v", err)
continue
}
// Update config version from incoming message
c.setConfigVersion(msg.ConfigVersion)