mirror of
https://github.com/fosrl/olm.git
synced 2026-08-04 02:35:54 -05:00
Compare commits
162
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96e1d0f98c | ||
|
|
29663cdb81 | ||
|
|
23597835d8 | ||
|
|
8ef735185f | ||
|
|
99d249db2d | ||
|
|
9c3c04c728 | ||
|
|
39aaaafa17 | ||
|
|
3d88b321e8 | ||
|
|
e1214e21cd | ||
|
|
a2f0f64c2f | ||
|
|
0e06fb0152 | ||
|
|
7929ce9cf9 | ||
|
|
9513433c07 | ||
|
|
1319354914 | ||
|
|
b39be4f5b0 | ||
|
|
929f183ed0 | ||
|
|
0cf5eb2ad0 | ||
|
|
1920f52699 | ||
|
|
7b07745650 | ||
|
|
1f301db892 | ||
|
|
4c600bab15 | ||
|
|
f356aed39b | ||
|
|
7a88fac395 | ||
|
|
efc012b43d | ||
|
|
83678dedcb | ||
|
|
e8e004e5e1 | ||
|
|
b08994e019 | ||
|
|
f87b804051 | ||
|
|
f02045d936 | ||
|
|
55377980b8 | ||
|
|
403e14327d | ||
|
|
31f6d699a8 | ||
|
|
6e41eb99ab | ||
|
|
12c1918e26 | ||
|
|
0d6503c03f | ||
|
|
b789c9af75 | ||
|
|
8cc854e313 | ||
|
|
2cd16e24a1 | ||
|
|
b6b35b4581 | ||
|
|
3f64336b0b | ||
|
|
e94c13f601 | ||
|
|
fff806c53d | ||
|
|
84d7e8d926 | ||
|
|
fde70dd15b | ||
|
|
7bf6da1729 | ||
|
|
9f68f171ba | ||
|
|
7a8f4ab049 | ||
|
|
334ea156b6 | ||
|
|
aa838fec61 | ||
|
|
6eaf8c1475 | ||
|
|
df6a84648b | ||
|
|
5ef6b21a6e | ||
|
|
7d83518951 | ||
|
|
964532777a | ||
|
|
703fe4fe5d | ||
|
|
42ef1f5ee3 | ||
|
|
31eed74933 | ||
|
|
ac5c11dff0 | ||
|
|
4d0c43fc3e | ||
|
|
815997d7ce | ||
|
|
c77c162bae | ||
|
|
703c606af5 | ||
|
|
4bc0508c7d | ||
|
|
3de8dc9fc2 | ||
|
|
c2b5ef96a4 | ||
|
|
e326da3d3e | ||
|
|
53def4e2f6 | ||
|
|
e85fd9d71e | ||
|
|
98a24960f5 | ||
|
|
e82387d515 | ||
|
|
b3cb3e1c92 | ||
|
|
f250702177 | ||
|
|
22cd02ae15 | ||
|
|
3f258d3500 | ||
|
|
e2690bcc03 | ||
|
|
f2d0e6a14c | ||
|
|
ae88766d85 | ||
|
|
9ae49e36d5 | ||
|
|
5ca4825800 | ||
|
|
809dbe77de | ||
|
|
c67c2a60a1 | ||
|
|
051c0fdfd8 | ||
|
|
e7507e0837 | ||
|
|
8549dc8746 | ||
|
|
21b66fbb34 | ||
|
|
9c0e37eddb | ||
|
|
5527bff671 | ||
|
|
af973b2440 | ||
|
|
dd9bff9a4b | ||
|
|
1be5e454ba | ||
|
|
4850b1b332 | ||
|
|
1ff74f7173 | ||
|
|
4a25a0d413 | ||
|
|
7fc3c7088e | ||
|
|
1869e70894 | ||
|
|
79783cc3dc | ||
|
|
584298e3bd | ||
|
|
f683afa647 | ||
|
|
ba2631d388 | ||
|
|
6ae4e2b691 | ||
|
|
51eee9dcf5 | ||
|
|
660e9e0e35 | ||
|
|
4ef6089053 | ||
|
|
c4e297cc96 | ||
|
|
79e8a4a8bb | ||
|
|
f2e81c024a | ||
|
|
6d10650e70 | ||
|
|
a81c683c66 | ||
|
|
25cb50901e | ||
|
|
a8e0844758 | ||
|
|
8b9ee6f26a | ||
|
|
82e8fcc3a7 | ||
|
|
e2b7777ba7 | ||
|
|
4e4d1a39f6 | ||
|
|
17dc1b0be1 | ||
|
|
a06436eeab | ||
|
|
a13010c4af | ||
|
|
cfac3cdd53 | ||
|
|
5ecba61718 | ||
|
|
2ea12ce258 | ||
|
|
0b46289136 | ||
|
|
71044165d0 | ||
|
|
eafd816159 | ||
|
|
e1a687407e | ||
|
|
bd8031651e | ||
|
|
a63439543d | ||
|
|
90cd6e7f6e | ||
|
|
ea4a63c9b3 | ||
|
|
e047330ffd | ||
|
|
9dcc0796a6 | ||
|
|
4b6999e06a | ||
|
|
69952ee5c5 | ||
|
|
3710880ce0 | ||
|
|
17b75bf58f | ||
|
|
3ba1714524 | ||
|
|
3470da76fc | ||
|
|
c86df2c041 | ||
|
|
0e8315b149 | ||
|
|
2ab9790588 | ||
|
|
1ecb97306f | ||
|
|
15e96a779c | ||
|
|
dada0cc124 | ||
|
|
9c0b4fcd5f | ||
|
|
8a788ef238 | ||
|
|
20e0c18845 | ||
|
|
5b637bb4ca | ||
|
|
c565a46a6f | ||
|
|
7b7eae617a | ||
|
|
1ed27fec1a | ||
|
|
83edde3449 | ||
|
|
1b43f029a9 | ||
|
|
aeb908b68c | ||
|
|
f08b17c7bd | ||
|
|
cce8742490 | ||
|
|
c56696bab1 | ||
|
|
28910ce188 | ||
|
|
f8dc134210 | ||
|
|
148f5fde23 | ||
|
|
b76259bc31 | ||
|
|
0b05497c25 | ||
|
|
dde79bb2dc | ||
|
|
3822b1a065 |
@@ -0,0 +1 @@
|
||||
* @oschwartz10612 @miloschwartz
|
||||
@@ -14,12 +14,13 @@ body:
|
||||
label: Environment
|
||||
description: Please fill out the relevant details below for your environment.
|
||||
value: |
|
||||
- OS Type & Version: (e.g., Ubuntu 22.04)
|
||||
- OS Type & Version:
|
||||
- Pangolin Version:
|
||||
- Edition (Community or Enterprise):
|
||||
- Gerbil Version:
|
||||
- Traefik Version:
|
||||
- Newt Version:
|
||||
- Olm Version: (if applicable)
|
||||
- Client Version:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
+671
-350
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,7 @@ jobs:
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
|
||||
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0
|
||||
with:
|
||||
go-version: 1.25
|
||||
|
||||
|
||||
@@ -46,7 +46,18 @@ Initiates a new connection request to a Pangolin server.
|
||||
"tlsClientCert": "string",
|
||||
"pingInterval": "3s",
|
||||
"pingTimeout": "5s",
|
||||
"orgId": "string"
|
||||
"orgId": "string",
|
||||
"fingerprint": {
|
||||
"username": "string",
|
||||
"hostname": "string",
|
||||
"platform": "string",
|
||||
"osVersion": "string",
|
||||
"kernelVersion": "string",
|
||||
"arch": "string",
|
||||
"deviceModel": "string",
|
||||
"serialNumber": "string"
|
||||
},
|
||||
"postures": {}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -67,6 +78,16 @@ Initiates a new connection request to a Pangolin server.
|
||||
- `pingInterval`: Interval for pinging the server (default: 3s)
|
||||
- `pingTimeout`: Timeout for each ping (default: 5s)
|
||||
- `orgId`: Organization ID to connect to
|
||||
- `fingerprint`: Device fingerprinting information (should be set before connecting)
|
||||
- `username`: Current username on the device
|
||||
- `hostname`: Device hostname
|
||||
- `platform`: Operating system platform (macos, windows, linux, ios, android, unknown)
|
||||
- `osVersion`: Operating system version
|
||||
- `kernelVersion`: Kernel version
|
||||
- `arch`: System architecture (e.g., amd64, arm64)
|
||||
- `deviceModel`: Device model identifier
|
||||
- `serialNumber`: Device serial number
|
||||
- `postures`: Device posture/security information
|
||||
|
||||
**Response:**
|
||||
- **Status Code:** `202 Accepted`
|
||||
@@ -205,6 +226,56 @@ Switches to a different organization while maintaining the connection.
|
||||
|
||||
---
|
||||
|
||||
### PUT /metadata
|
||||
Updates device fingerprinting and posture information. This endpoint can be called at any time to update metadata, but it's recommended to provide this information in the initial `/connect` request or immediately before connecting.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"fingerprint": {
|
||||
"username": "string",
|
||||
"hostname": "string",
|
||||
"platform": "string",
|
||||
"osVersion": "string",
|
||||
"kernelVersion": "string",
|
||||
"arch": "string",
|
||||
"deviceModel": "string",
|
||||
"serialNumber": "string"
|
||||
},
|
||||
"postures": {}
|
||||
}
|
||||
```
|
||||
|
||||
**Optional Fields:**
|
||||
- `fingerprint`: Device fingerprinting information
|
||||
- `username`: Current username on the device
|
||||
- `hostname`: Device hostname
|
||||
- `platform`: Operating system platform (macos, windows, linux, ios, android, unknown)
|
||||
- `osVersion`: Operating system version
|
||||
- `kernelVersion`: Kernel version
|
||||
- `arch`: System architecture (e.g., amd64, arm64)
|
||||
- `deviceModel`: Device model identifier
|
||||
- `serialNumber`: Device serial number
|
||||
- `postures`: Device posture/security information (object with arbitrary key-value pairs)
|
||||
|
||||
**Response:**
|
||||
- **Status Code:** `200 OK`
|
||||
- **Content-Type:** `application/json`
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "metadata updated"
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
- `405 Method Not Allowed` - Non-PUT requests
|
||||
- `400 Bad Request` - Invalid JSON
|
||||
|
||||
**Note:** It's recommended to call this endpoint BEFORE `/connect` to ensure fingerprinting information is available during the initial connection handshake.
|
||||
|
||||
---
|
||||
|
||||
### POST /exit
|
||||
Initiates a graceful shutdown of the Olm process.
|
||||
|
||||
@@ -247,6 +318,22 @@ Simple health check endpoint to verify the API server is running.
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Update metadata before connecting (recommended)
|
||||
```bash
|
||||
curl -X PUT http://localhost:9452/metadata \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"fingerprint": {
|
||||
"username": "john",
|
||||
"hostname": "johns-laptop",
|
||||
"platform": "macos",
|
||||
"osVersion": "14.2.1",
|
||||
"arch": "arm64",
|
||||
"deviceModel": "MacBookPro18,3"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### Connect to a peer
|
||||
```bash
|
||||
curl -X POST http://localhost:9452/connect \
|
||||
|
||||
+9
-10
@@ -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"]
|
||||
@@ -2,9 +2,15 @@
|
||||
|
||||
all: local
|
||||
|
||||
VERSION ?= dev
|
||||
LDFLAGS = -X main.olmVersion=$(VERSION)
|
||||
|
||||
local:
|
||||
CGO_ENABLED=0 go build -o ./bin/olm
|
||||
|
||||
docker-build:
|
||||
docker build -t fosrl/olm:latest .
|
||||
|
||||
docker-build-release:
|
||||
@if [ -z "$(tag)" ]; then \
|
||||
echo "Error: tag is required. Usage: make docker-build-release tag=<tag>"; \
|
||||
@@ -40,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
|
||||
@@ -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.
|
||||
|
||||
|
||||
+286
-26
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -28,11 +29,17 @@ type ConnectionRequest struct {
|
||||
PingInterval string `json:"pingInterval,omitempty"`
|
||||
PingTimeout string `json:"pingTimeout,omitempty"`
|
||||
OrgID string `json:"orgId,omitempty"`
|
||||
MatchDomains []string `json:"matchDomains,omitempty"`
|
||||
}
|
||||
|
||||
// SwitchOrgRequest defines the structure for switching organizations
|
||||
type SwitchOrgRequest struct {
|
||||
OrgID string `json:"orgId"`
|
||||
OrgID string `json:"org_id"`
|
||||
}
|
||||
|
||||
// PowerModeRequest represents a request to change power mode
|
||||
type PowerModeRequest struct {
|
||||
Mode string `json:"mode"` // "normal" or "low"
|
||||
}
|
||||
|
||||
// PeerStatus represents the status of a peer connection
|
||||
@@ -44,15 +51,23 @@ type PeerStatus struct {
|
||||
LastSeen time.Time `json:"lastSeen"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
IsRelay bool `json:"isRelay"`
|
||||
IsLocal bool `json:"isLocal"` // true when connected via a local network endpoint, bypassing both the public endpoint and relay
|
||||
PeerIP string `json:"peerAddress,omitempty"`
|
||||
HolepunchConnected bool `json:"holepunchConnected"`
|
||||
}
|
||||
|
||||
// OlmError holds error information from registration failures
|
||||
type OlmError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// StatusResponse is returned by the status endpoint
|
||||
type StatusResponse struct {
|
||||
Connected bool `json:"connected"`
|
||||
Registered bool `json:"registered"`
|
||||
Terminated bool `json:"terminated"`
|
||||
OlmError *OlmError `json:"error,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Agent string `json:"agent,omitempty"`
|
||||
OrgID string `json:"orgId,omitempty"`
|
||||
@@ -60,25 +75,45 @@ type StatusResponse struct {
|
||||
NetworkSettings network.NetworkSettings `json:"networkSettings,omitempty"`
|
||||
}
|
||||
|
||||
type MetadataChangeRequest struct {
|
||||
Fingerprint map[string]any `json:"fingerprint"`
|
||||
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
|
||||
socketPath string
|
||||
listener net.Listener
|
||||
server *http.Server
|
||||
onConnect func(ConnectionRequest) error
|
||||
onSwitchOrg func(SwitchOrgRequest) error
|
||||
onDisconnect func() error
|
||||
onExit func() error
|
||||
addr string
|
||||
socketPath string
|
||||
listener net.Listener
|
||||
server *http.Server
|
||||
|
||||
onConnect func(ConnectionRequest) error
|
||||
onSwitchOrg func(SwitchOrgRequest) error
|
||||
onMetadataChange func(MetadataChangeRequest) error
|
||||
onDisconnect func() error
|
||||
onExit func() error
|
||||
onRebind func() error
|
||||
onPowerMode func(PowerModeRequest) error
|
||||
onJITConnect func(JITConnectionRequest) error
|
||||
|
||||
statusMu sync.RWMutex
|
||||
peerStatuses map[int]*PeerStatus
|
||||
connectedAt time.Time
|
||||
isConnected bool
|
||||
isRegistered bool
|
||||
isTerminated bool
|
||||
version string
|
||||
agent string
|
||||
orgID string
|
||||
olmError *OlmError
|
||||
|
||||
version string
|
||||
agent string
|
||||
orgID string
|
||||
}
|
||||
|
||||
// NewAPI creates a new HTTP server that listens on a TCP address
|
||||
@@ -101,28 +136,52 @@ func NewAPISocket(socketPath string) *API {
|
||||
return s
|
||||
}
|
||||
|
||||
func NewAPIStub() *API {
|
||||
s := &API{
|
||||
peerStatuses: make(map[int]*PeerStatus),
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// SetHandlers sets the callback functions for handling API requests
|
||||
func (s *API) SetHandlers(
|
||||
onConnect func(ConnectionRequest) error,
|
||||
onSwitchOrg func(SwitchOrgRequest) error,
|
||||
onMetadataChange func(MetadataChangeRequest) error,
|
||||
onDisconnect func() error,
|
||||
onExit func() error,
|
||||
onRebind func() error,
|
||||
onPowerMode func(PowerModeRequest) error,
|
||||
onJITConnect func(JITConnectionRequest) error,
|
||||
) {
|
||||
s.onConnect = onConnect
|
||||
s.onSwitchOrg = onSwitchOrg
|
||||
s.onMetadataChange = onMetadataChange
|
||||
s.onDisconnect = onDisconnect
|
||||
s.onExit = onExit
|
||||
s.onRebind = onRebind
|
||||
s.onPowerMode = onPowerMode
|
||||
s.onJITConnect = onJITConnect
|
||||
}
|
||||
|
||||
// Start starts the HTTP server
|
||||
func (s *API) Start() error {
|
||||
if s.socketPath == "" && s.addr == "" {
|
||||
return fmt.Errorf("either socketPath or addr must be provided to start the API server")
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/connect", s.handleConnect)
|
||||
mux.HandleFunc("/status", s.handleStatus)
|
||||
mux.HandleFunc("/switch-org", s.handleSwitchOrg)
|
||||
mux.HandleFunc("/metadata", s.handleMetadataChange)
|
||||
mux.HandleFunc("/disconnect", s.handleDisconnect)
|
||||
mux.HandleFunc("/exit", s.handleExit)
|
||||
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,
|
||||
@@ -160,7 +219,7 @@ func (s *API) Stop() error {
|
||||
|
||||
// Close the server first, which will also close the listener gracefully
|
||||
if s.server != nil {
|
||||
s.server.Close()
|
||||
_ = s.server.Close()
|
||||
}
|
||||
|
||||
// Clean up socket file if using Unix socket
|
||||
@@ -171,7 +230,7 @@ func (s *API) Stop() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *API) AddPeerStatus(siteID int, siteName string, connected bool, rtt time.Duration, endpoint string, isRelay bool) {
|
||||
func (s *API) AddPeerStatus(siteID int, siteName string, connected bool, rtt time.Duration, endpoint string, isRelay bool, isLocal bool) {
|
||||
s.statusMu.Lock()
|
||||
defer s.statusMu.Unlock()
|
||||
|
||||
@@ -189,10 +248,11 @@ func (s *API) AddPeerStatus(siteID int, siteName string, connected bool, rtt tim
|
||||
status.LastSeen = time.Now()
|
||||
status.Endpoint = endpoint
|
||||
status.IsRelay = isRelay
|
||||
status.IsLocal = isLocal
|
||||
}
|
||||
|
||||
// UpdatePeerStatus updates the status of a peer including endpoint and relay info
|
||||
func (s *API) UpdatePeerStatus(siteID int, connected bool, rtt time.Duration, endpoint string, isRelay bool) {
|
||||
// UpdatePeerStatus updates the status of a peer including endpoint, relay, and local info
|
||||
func (s *API) UpdatePeerStatus(siteID int, connected bool, rtt time.Duration, endpoint string, isRelay bool, isLocal bool) {
|
||||
s.statusMu.Lock()
|
||||
defer s.statusMu.Unlock()
|
||||
|
||||
@@ -209,6 +269,7 @@ func (s *API) UpdatePeerStatus(siteID int, connected bool, rtt time.Duration, en
|
||||
status.LastSeen = time.Now()
|
||||
status.Endpoint = endpoint
|
||||
status.IsRelay = isRelay
|
||||
status.IsLocal = isLocal
|
||||
}
|
||||
|
||||
func (s *API) RemovePeerStatus(siteID int) { // remove the peer from the status map
|
||||
@@ -226,9 +287,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,6 +294,27 @@ func (s *API) SetRegistered(registered bool) {
|
||||
s.statusMu.Lock()
|
||||
defer s.statusMu.Unlock()
|
||||
s.isRegistered = registered
|
||||
// Clear any registration error when successfully registered
|
||||
if registered {
|
||||
s.olmError = nil
|
||||
}
|
||||
}
|
||||
|
||||
// SetOlmError sets the registration error
|
||||
func (s *API) SetOlmError(code string, message string) {
|
||||
s.statusMu.Lock()
|
||||
defer s.statusMu.Unlock()
|
||||
s.olmError = &OlmError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
}
|
||||
}
|
||||
|
||||
// ClearOlmError clears any registration error
|
||||
func (s *API) ClearOlmError() {
|
||||
s.statusMu.Lock()
|
||||
defer s.statusMu.Unlock()
|
||||
s.olmError = nil
|
||||
}
|
||||
|
||||
func (s *API) SetTerminated(terminated bool) {
|
||||
@@ -287,6 +366,31 @@ func (s *API) UpdatePeerRelayStatus(siteID int, endpoint string, isRelay bool) {
|
||||
|
||||
status.Endpoint = endpoint
|
||||
status.IsRelay = isRelay
|
||||
if isRelay {
|
||||
// Relay and local are mutually exclusive; local always wins when viable.
|
||||
status.IsLocal = false
|
||||
}
|
||||
}
|
||||
|
||||
// UpdatePeerLocalStatus updates only the local-connection status of a peer. A peer using a
|
||||
// local connection is never simultaneously relayed.
|
||||
func (s *API) UpdatePeerLocalStatus(siteID int, endpoint string, isLocal bool) {
|
||||
s.statusMu.Lock()
|
||||
defer s.statusMu.Unlock()
|
||||
|
||||
status, exists := s.peerStatuses[siteID]
|
||||
if !exists {
|
||||
status = &PeerStatus{
|
||||
SiteID: siteID,
|
||||
}
|
||||
s.peerStatuses[siteID] = status
|
||||
}
|
||||
|
||||
status.Endpoint = endpoint
|
||||
status.IsLocal = isLocal
|
||||
if isLocal {
|
||||
status.IsRelay = false
|
||||
}
|
||||
}
|
||||
|
||||
// UpdatePeerHolepunchStatus updates the holepunch connection status of a peer
|
||||
@@ -345,7 +449,7 @@ func (s *API) handleConnect(w http.ResponseWriter, r *http.Request) {
|
||||
// Return a success response
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "connection request accepted",
|
||||
})
|
||||
}
|
||||
@@ -358,12 +462,12 @@ func (s *API) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
s.statusMu.RLock()
|
||||
defer s.statusMu.RUnlock()
|
||||
|
||||
resp := StatusResponse{
|
||||
Connected: s.isConnected,
|
||||
Registered: s.isRegistered,
|
||||
Terminated: s.isTerminated,
|
||||
OlmError: s.olmError,
|
||||
Version: s.version,
|
||||
Agent: s.agent,
|
||||
OrgID: s.orgID,
|
||||
@@ -371,8 +475,18 @@ func (s *API) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
NetworkSettings: network.GetSettings(),
|
||||
}
|
||||
|
||||
s.statusMu.RUnlock()
|
||||
|
||||
data, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// handleHealth handles the /health endpoint
|
||||
@@ -384,7 +498,7 @@ func (s *API) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "ok",
|
||||
})
|
||||
}
|
||||
@@ -401,7 +515,7 @@ func (s *API) handleExit(w http.ResponseWriter, r *http.Request) {
|
||||
// Return a success response first
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "shutdown initiated",
|
||||
})
|
||||
|
||||
@@ -450,7 +564,7 @@ func (s *API) handleSwitchOrg(w http.ResponseWriter, r *http.Request) {
|
||||
// Return a success response
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "org switch request accepted",
|
||||
})
|
||||
}
|
||||
@@ -484,16 +598,43 @@ func (s *API) handleDisconnect(w http.ResponseWriter, r *http.Request) {
|
||||
// Return a success response
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "disconnect initiated",
|
||||
})
|
||||
}
|
||||
|
||||
// handleMetadataChange handles the /metadata endpoint
|
||||
func (s *API) handleMetadataChange(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req MetadataChangeRequest
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
if err := decoder.Decode(&req); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Invalid request: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("Received metadata change request via API: %v", req)
|
||||
|
||||
_ = s.onMetadataChange(req)
|
||||
|
||||
// Return a success response
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "metadata updated",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *API) GetStatus() StatusResponse {
|
||||
return StatusResponse{
|
||||
Connected: s.isConnected,
|
||||
Registered: s.isRegistered,
|
||||
Terminated: s.isTerminated,
|
||||
OlmError: s.olmError,
|
||||
Version: s.version,
|
||||
Agent: s.agent,
|
||||
OrgID: s.orgID,
|
||||
@@ -501,3 +642,122 @@ func (s *API) GetStatus() StatusResponse {
|
||||
NetworkSettings: network.GetSettings(),
|
||||
}
|
||||
}
|
||||
|
||||
// handleRebind handles the /rebind endpoint
|
||||
// This triggers a socket rebind, which is necessary when network connectivity changes
|
||||
// (e.g., WiFi to cellular transition on macOS/iOS) and the old socket becomes stale.
|
||||
func (s *API) handleRebind(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("Received rebind request via API")
|
||||
|
||||
// Call the rebind handler if set
|
||||
if s.onRebind != nil {
|
||||
if err := s.onRebind(); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Rebind failed: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
http.Error(w, "Rebind handler not configured", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
|
||||
// Return a success response
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "socket rebound successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req PowerModeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Invalid request body: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate power mode
|
||||
if req.Mode != "normal" && req.Mode != "low" {
|
||||
http.Error(w, "Invalid power mode: must be 'normal' or 'low'", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("Received power mode change request via API: mode=%s", req.Mode)
|
||||
|
||||
// Call the power mode handler if set
|
||||
if s.onPowerMode != nil {
|
||||
if err := s.onPowerMode(req); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Power mode change failed: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
http.Error(w, "Power mode handler not configured", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
|
||||
// Return a success response
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": fmt.Sprintf("power mode changed to %s successfully", req.Mode),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -27,6 +27,13 @@ type OlmConfig struct {
|
||||
UpstreamDNS []string `json:"upstreamDNS"`
|
||||
InterfaceName string `json:"interface"`
|
||||
|
||||
// MatchDomains lists FQDN wildcard patterns (using * and ? wildcards, e.g.
|
||||
// "*.proxy.internal") that olm should check against local records / resolve
|
||||
// via UpstreamDNS. Queries for domains that don't match any pattern are sent
|
||||
// directly to the host's own system DNS servers instead. Empty means match
|
||||
// every domain (i.e. the feature is disabled).
|
||||
MatchDomains []string `json:"matchDomainsDNS"`
|
||||
|
||||
// Logging
|
||||
LogLevel string `json:"logLevel"`
|
||||
|
||||
@@ -40,11 +47,12 @@ type OlmConfig struct {
|
||||
PingTimeout string `json:"pingTimeout"`
|
||||
|
||||
// Advanced
|
||||
DisableHolepunch bool `json:"disableHolepunch"`
|
||||
TlsClientCert string `json:"tlsClientCert"`
|
||||
OverrideDNS bool `json:"overrideDNS"`
|
||||
TunnelDNS bool `json:"tunnelDNS"`
|
||||
DisableRelay bool `json:"disableRelay"`
|
||||
DisableHolepunch bool `json:"disableHolepunch"`
|
||||
TlsClientCert string `json:"tlsClientCert"`
|
||||
OverrideDNS bool `json:"overrideDNS"`
|
||||
TunnelDNS bool `json:"tunnelDNS"`
|
||||
DisableRelay bool `json:"disableRelay"`
|
||||
PreferLocalRoutes bool `json:"preferLocalRoutes"`
|
||||
// DoNotCreateNewClient bool `json:"doNotCreateNewClient"`
|
||||
|
||||
// Parsed values (not in JSON)
|
||||
@@ -89,6 +97,7 @@ func DefaultConfig() *OlmConfig {
|
||||
PingInterval: "3s",
|
||||
PingTimeout: "5s",
|
||||
DisableHolepunch: false,
|
||||
OverrideDNS: true,
|
||||
TunnelDNS: false,
|
||||
// DoNotCreateNewClient: false,
|
||||
sources: make(map[string]string),
|
||||
@@ -98,6 +107,7 @@ func DefaultConfig() *OlmConfig {
|
||||
config.sources["mtu"] = string(SourceDefault)
|
||||
config.sources["dns"] = string(SourceDefault)
|
||||
config.sources["upstreamDNS"] = string(SourceDefault)
|
||||
config.sources["matchDomains"] = string(SourceDefault)
|
||||
config.sources["logLevel"] = string(SourceDefault)
|
||||
config.sources["interface"] = string(SourceDefault)
|
||||
config.sources["enableApi"] = string(SourceDefault)
|
||||
@@ -109,6 +119,7 @@ func DefaultConfig() *OlmConfig {
|
||||
config.sources["overrideDNS"] = string(SourceDefault)
|
||||
config.sources["tunnelDNS"] = string(SourceDefault)
|
||||
config.sources["disableRelay"] = string(SourceDefault)
|
||||
config.sources["preferLocalRoutes"] = string(SourceDefault)
|
||||
// config.sources["doNotCreateNewClient"] = string(SourceDefault)
|
||||
|
||||
return config
|
||||
@@ -228,6 +239,10 @@ func loadConfigFromEnv(config *OlmConfig) {
|
||||
config.UpstreamDNS = []string{val}
|
||||
config.sources["upstreamDNS"] = string(SourceEnv)
|
||||
}
|
||||
if val := os.Getenv("MATCH_DOMAINS_DNS"); val != "" {
|
||||
config.MatchDomains = splitComma(val)
|
||||
config.sources["matchDomains"] = string(SourceEnv)
|
||||
}
|
||||
if val := os.Getenv("LOG_LEVEL"); val != "" {
|
||||
config.LogLevel = val
|
||||
config.sources["logLevel"] = string(SourceEnv)
|
||||
@@ -268,6 +283,10 @@ func loadConfigFromEnv(config *OlmConfig) {
|
||||
config.DisableRelay = true
|
||||
config.sources["disableRelay"] = string(SourceEnv)
|
||||
}
|
||||
if val := os.Getenv("PREFER_LOCAL_ROUTES"); val == "true" {
|
||||
config.PreferLocalRoutes = true
|
||||
config.sources["preferLocalRoutes"] = string(SourceEnv)
|
||||
}
|
||||
if val := os.Getenv("TUNNEL_DNS"); val == "true" {
|
||||
config.TunnelDNS = true
|
||||
config.sources["tunnelDNS"] = string(SourceEnv)
|
||||
@@ -284,25 +303,27 @@ func loadConfigFromCLI(config *OlmConfig, args []string) (bool, bool, error) {
|
||||
|
||||
// Store original values to detect changes
|
||||
origValues := map[string]interface{}{
|
||||
"endpoint": config.Endpoint,
|
||||
"id": config.ID,
|
||||
"secret": config.Secret,
|
||||
"org": config.OrgID,
|
||||
"userToken": config.UserToken,
|
||||
"mtu": config.MTU,
|
||||
"dns": config.DNS,
|
||||
"upstreamDNS": fmt.Sprintf("%v", config.UpstreamDNS),
|
||||
"logLevel": config.LogLevel,
|
||||
"interface": config.InterfaceName,
|
||||
"httpAddr": config.HTTPAddr,
|
||||
"socketPath": config.SocketPath,
|
||||
"pingInterval": config.PingInterval,
|
||||
"pingTimeout": config.PingTimeout,
|
||||
"enableApi": config.EnableAPI,
|
||||
"disableHolepunch": config.DisableHolepunch,
|
||||
"overrideDNS": config.OverrideDNS,
|
||||
"disableRelay": config.DisableRelay,
|
||||
"tunnelDNS": config.TunnelDNS,
|
||||
"endpoint": config.Endpoint,
|
||||
"id": config.ID,
|
||||
"secret": config.Secret,
|
||||
"org": config.OrgID,
|
||||
"userToken": config.UserToken,
|
||||
"mtu": config.MTU,
|
||||
"dns": config.DNS,
|
||||
"upstreamDNS": fmt.Sprintf("%v", config.UpstreamDNS),
|
||||
"matchDomains": fmt.Sprintf("%v", config.MatchDomains),
|
||||
"logLevel": config.LogLevel,
|
||||
"interface": config.InterfaceName,
|
||||
"httpAddr": config.HTTPAddr,
|
||||
"socketPath": config.SocketPath,
|
||||
"pingInterval": config.PingInterval,
|
||||
"pingTimeout": config.PingTimeout,
|
||||
"enableApi": config.EnableAPI,
|
||||
"disableHolepunch": config.DisableHolepunch,
|
||||
"overrideDNS": config.OverrideDNS,
|
||||
"disableRelay": config.DisableRelay,
|
||||
"preferLocalRoutes": config.PreferLocalRoutes,
|
||||
"tunnelDNS": config.TunnelDNS,
|
||||
// "doNotCreateNewClient": config.DoNotCreateNewClient,
|
||||
}
|
||||
|
||||
@@ -316,6 +337,8 @@ func loadConfigFromCLI(config *OlmConfig, args []string) (bool, bool, error) {
|
||||
serviceFlags.StringVar(&config.DNS, "dns", config.DNS, "DNS server to use")
|
||||
var upstreamDNSFlag string
|
||||
serviceFlags.StringVar(&upstreamDNSFlag, "upstream-dns", "", "Upstream DNS server(s) (comma-separated, default: 8.8.8.8:53)")
|
||||
var matchDomainsFlag string
|
||||
serviceFlags.StringVar(&matchDomainsFlag, "match-domains-dns", "", "FQDN wildcard patterns (comma-separated, e.g. '*.proxy.internal,*.host-0?.autoco.internal') to check against local records/upstream DNS; queries for non-matching domains are sent directly to the system's DNS servers (default: match all domains)")
|
||||
serviceFlags.StringVar(&config.LogLevel, "log-level", config.LogLevel, "Log level (DEBUG, INFO, WARN, ERROR, FATAL)")
|
||||
serviceFlags.StringVar(&config.InterfaceName, "interface", config.InterfaceName, "Name of the WireGuard interface")
|
||||
serviceFlags.StringVar(&config.HTTPAddr, "http-addr", config.HTTPAddr, "HTTP server address (e.g., ':9452')")
|
||||
@@ -324,9 +347,10 @@ 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.PreferLocalRoutes, "prefer-local-routes", config.PreferLocalRoutes, "Add tunnel routes with a high metric so overlapping local/connected routes take precedence (default false)")
|
||||
serviceFlags.BoolVar(&config.TunnelDNS, "tunnel-dns", config.TunnelDNS, "When enabled, DNS queries are routed through the tunnel for remote resolution. To ensure queries are tunneled correctly, you must define the DNS server as a Pangolin resource and enter its address as an Upstream DNS Server. (default false)")
|
||||
// serviceFlags.BoolVar(&config.DoNotCreateNewClient, "do-not-create-new-client", config.DoNotCreateNewClient, "Do not create new client")
|
||||
|
||||
version := serviceFlags.Bool("version", false, "Print the version")
|
||||
@@ -347,6 +371,11 @@ func loadConfigFromCLI(config *OlmConfig, args []string) (bool, bool, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Parse match domains flag if provided
|
||||
if matchDomainsFlag != "" {
|
||||
config.MatchDomains = splitComma(matchDomainsFlag)
|
||||
}
|
||||
|
||||
// Track which values were changed by CLI args
|
||||
if config.Endpoint != origValues["endpoint"].(string) {
|
||||
config.sources["endpoint"] = string(SourceCLI)
|
||||
@@ -372,6 +401,9 @@ func loadConfigFromCLI(config *OlmConfig, args []string) (bool, bool, error) {
|
||||
if fmt.Sprintf("%v", config.UpstreamDNS) != origValues["upstreamDNS"].(string) {
|
||||
config.sources["upstreamDNS"] = string(SourceCLI)
|
||||
}
|
||||
if fmt.Sprintf("%v", config.MatchDomains) != origValues["matchDomains"].(string) {
|
||||
config.sources["matchDomains"] = string(SourceCLI)
|
||||
}
|
||||
if config.LogLevel != origValues["logLevel"].(string) {
|
||||
config.sources["logLevel"] = string(SourceCLI)
|
||||
}
|
||||
@@ -402,6 +434,9 @@ func loadConfigFromCLI(config *OlmConfig, args []string) (bool, bool, error) {
|
||||
if config.DisableRelay != origValues["disableRelay"].(bool) {
|
||||
config.sources["disableRelay"] = string(SourceCLI)
|
||||
}
|
||||
if config.PreferLocalRoutes != origValues["preferLocalRoutes"].(bool) {
|
||||
config.sources["preferLocalRoutes"] = string(SourceCLI)
|
||||
}
|
||||
if config.TunnelDNS != origValues["tunnelDNS"].(bool) {
|
||||
config.sources["tunnelDNS"] = string(SourceCLI)
|
||||
}
|
||||
@@ -480,6 +515,10 @@ func mergeConfigs(dest, src *OlmConfig) {
|
||||
dest.UpstreamDNS = src.UpstreamDNS
|
||||
dest.sources["upstreamDNS"] = string(SourceFile)
|
||||
}
|
||||
if len(src.MatchDomains) > 0 {
|
||||
dest.MatchDomains = src.MatchDomains
|
||||
dest.sources["matchDomains"] = string(SourceFile)
|
||||
}
|
||||
if src.LogLevel != "" && src.LogLevel != "INFO" {
|
||||
dest.LogLevel = src.LogLevel
|
||||
dest.sources["logLevel"] = string(SourceFile)
|
||||
@@ -529,6 +568,10 @@ func mergeConfigs(dest, src *OlmConfig) {
|
||||
dest.DisableRelay = src.DisableRelay
|
||||
dest.sources["disableRelay"] = string(SourceFile)
|
||||
}
|
||||
if src.PreferLocalRoutes {
|
||||
dest.PreferLocalRoutes = src.PreferLocalRoutes
|
||||
dest.sources["preferLocalRoutes"] = string(SourceFile)
|
||||
}
|
||||
// if src.DoNotCreateNewClient {
|
||||
// dest.DoNotCreateNewClient = src.DoNotCreateNewClient
|
||||
// dest.sources["doNotCreateNewClient"] = string(SourceFile)
|
||||
@@ -597,6 +640,7 @@ func (c *OlmConfig) ShowConfig() {
|
||||
fmt.Printf(" mtu = %d [%s]\n", c.MTU, getSource("mtu"))
|
||||
fmt.Printf(" dns = %s [%s]\n", c.DNS, getSource("dns"))
|
||||
fmt.Printf(" upstream-dns = %v [%s]\n", c.UpstreamDNS, getSource("upstreamDNS"))
|
||||
fmt.Printf(" match-domains-dns = %v [%s]\n", c.MatchDomains, getSource("matchDomains"))
|
||||
fmt.Printf(" interface = %s [%s]\n", c.InterfaceName, getSource("interface"))
|
||||
|
||||
// Logging
|
||||
@@ -620,6 +664,7 @@ func (c *OlmConfig) ShowConfig() {
|
||||
fmt.Printf(" override-dns = %v [%s]\n", c.OverrideDNS, getSource("overrideDNS"))
|
||||
fmt.Printf(" tunnel-dns = %v [%s]\n", c.TunnelDNS, getSource("tunnelDNS"))
|
||||
fmt.Printf(" disable-relay = %v [%s]\n", c.DisableRelay, getSource("disableRelay"))
|
||||
fmt.Printf(" prefer-local-routes = %v [%s]\n", c.PreferLocalRoutes, getSource("preferLocalRoutes"))
|
||||
// fmt.Printf(" do-not-create-new-client = %v [%s]\n", c.DoNotCreateNewClient, getSource("doNotCreateNewClient"))
|
||||
if c.TlsClientCert != "" {
|
||||
fmt.Printf(" tls-cert = %s [%s]\n", c.TlsClientCert, getSource("tlsClientCert"))
|
||||
|
||||
+503
-171
@@ -1,9 +1,12 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/netip"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
"golang.zx2c4.com/wireguard/tun"
|
||||
@@ -18,14 +21,68 @@ type FilterRule struct {
|
||||
Handler PacketHandler
|
||||
}
|
||||
|
||||
// MiddleDevice wraps a TUN device with packet filtering capabilities
|
||||
type MiddleDevice struct {
|
||||
// closeAwareDevice wraps a tun.Device along with a flag
|
||||
// indicating whether its Close method was called.
|
||||
type closeAwareDevice struct {
|
||||
isClosed atomic.Bool
|
||||
tun.Device
|
||||
rules []FilterRule
|
||||
mutex sync.RWMutex
|
||||
readCh chan readResult
|
||||
injectCh chan []byte
|
||||
closed chan struct{}
|
||||
closeEventCh chan struct{}
|
||||
wg sync.WaitGroup
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
func newCloseAwareDevice(tunDevice tun.Device) *closeAwareDevice {
|
||||
return &closeAwareDevice{
|
||||
Device: tunDevice,
|
||||
isClosed: atomic.Bool{},
|
||||
closeEventCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// redirectEvents redirects the Events() method of the underlying tun.Device
|
||||
// to the given channel.
|
||||
func (c *closeAwareDevice) redirectEvents(out chan tun.Event) {
|
||||
c.wg.Add(1)
|
||||
go func() {
|
||||
defer c.wg.Done()
|
||||
for {
|
||||
select {
|
||||
case ev, ok := <-c.Device.Events():
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if ev == tun.EventDown {
|
||||
continue
|
||||
}
|
||||
|
||||
select {
|
||||
case out <- ev:
|
||||
case <-c.closeEventCh:
|
||||
return
|
||||
}
|
||||
case <-c.closeEventCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Close calls the underlying Device's Close method
|
||||
// after setting isClosed to true.
|
||||
func (c *closeAwareDevice) Close() (err error) {
|
||||
c.closeOnce.Do(func() {
|
||||
c.isClosed.Store(true)
|
||||
close(c.closeEventCh)
|
||||
err = c.Device.Close()
|
||||
c.wg.Wait()
|
||||
})
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *closeAwareDevice) IsClosed() bool {
|
||||
return c.isClosed.Load()
|
||||
}
|
||||
|
||||
type readResult struct {
|
||||
@@ -36,58 +93,136 @@ type readResult struct {
|
||||
err error
|
||||
}
|
||||
|
||||
// MiddleDevice wraps a TUN device with packet filtering capabilities
|
||||
// and supports swapping the underlying device.
|
||||
type MiddleDevice struct {
|
||||
devices []*closeAwareDevice
|
||||
mu sync.Mutex
|
||||
cond *sync.Cond
|
||||
rules []FilterRule
|
||||
rulesMutex sync.RWMutex
|
||||
readCh chan readResult
|
||||
injectCh chan []byte
|
||||
closed atomic.Bool
|
||||
events chan tun.Event
|
||||
}
|
||||
|
||||
// NewMiddleDevice creates a new filtered TUN device wrapper
|
||||
func NewMiddleDevice(device tun.Device) *MiddleDevice {
|
||||
d := &MiddleDevice{
|
||||
Device: device,
|
||||
devices: make([]*closeAwareDevice, 0),
|
||||
rules: make([]FilterRule, 0),
|
||||
readCh: make(chan readResult),
|
||||
readCh: make(chan readResult, 16),
|
||||
injectCh: make(chan []byte, 100),
|
||||
closed: make(chan struct{}),
|
||||
events: make(chan tun.Event, 16),
|
||||
}
|
||||
go d.pump()
|
||||
d.cond = sync.NewCond(&d.mu)
|
||||
|
||||
if device != nil {
|
||||
d.AddDevice(device)
|
||||
}
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
func (d *MiddleDevice) pump() {
|
||||
// AddDevice adds a new underlying TUN device, closing any previous one
|
||||
func (d *MiddleDevice) AddDevice(device tun.Device) {
|
||||
d.mu.Lock()
|
||||
if d.closed.Load() {
|
||||
d.mu.Unlock()
|
||||
_ = device.Close()
|
||||
return
|
||||
}
|
||||
|
||||
var toClose *closeAwareDevice
|
||||
if len(d.devices) > 0 {
|
||||
toClose = d.devices[len(d.devices)-1]
|
||||
}
|
||||
|
||||
cad := newCloseAwareDevice(device)
|
||||
cad.redirectEvents(d.events)
|
||||
|
||||
d.devices = []*closeAwareDevice{cad}
|
||||
|
||||
// Start pump for the new device
|
||||
go d.pump(cad)
|
||||
|
||||
d.cond.Broadcast()
|
||||
d.mu.Unlock()
|
||||
|
||||
if toClose != nil {
|
||||
logger.Debug("MiddleDevice: Closing previous device")
|
||||
if err := toClose.Close(); err != nil {
|
||||
logger.Debug("MiddleDevice: Error closing previous device: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *MiddleDevice) pump(dev *closeAwareDevice) {
|
||||
const defaultOffset = 16
|
||||
batchSize := d.Device.BatchSize()
|
||||
logger.Debug("MiddleDevice: pump started")
|
||||
batchSize := dev.BatchSize()
|
||||
logger.Debug("MiddleDevice: pump started for device")
|
||||
|
||||
// Recover from panic if readCh is closed while we're trying to send
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logger.Debug("MiddleDevice: pump recovered from panic (channel closed)")
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
// Check closed first with priority
|
||||
select {
|
||||
case <-d.closed:
|
||||
logger.Debug("MiddleDevice: pump exiting due to closed channel")
|
||||
// Check if this device is closed
|
||||
if dev.IsClosed() {
|
||||
logger.Debug("MiddleDevice: pump exiting, device is closed")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if MiddleDevice itself is closed
|
||||
if d.closed.Load() {
|
||||
logger.Debug("MiddleDevice: pump exiting, MiddleDevice is closed")
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
// Allocate buffers for reading
|
||||
// We allocate new buffers for each read to avoid race conditions
|
||||
// since we pass them to the channel
|
||||
bufs := make([][]byte, batchSize)
|
||||
sizes := make([]int, batchSize)
|
||||
for i := range bufs {
|
||||
bufs[i] = make([]byte, 2048) // Standard MTU + headroom
|
||||
}
|
||||
|
||||
n, err := d.Device.Read(bufs, sizes, defaultOffset)
|
||||
n, err := dev.Read(bufs, sizes, defaultOffset)
|
||||
|
||||
// Check closed again after read returns
|
||||
select {
|
||||
case <-d.closed:
|
||||
logger.Debug("MiddleDevice: pump exiting due to closed channel (after read)")
|
||||
// Check if device was closed during read
|
||||
if dev.IsClosed() {
|
||||
logger.Debug("MiddleDevice: pump exiting, device closed during read")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if MiddleDevice was closed during read
|
||||
if d.closed.Load() {
|
||||
logger.Debug("MiddleDevice: pump exiting, MiddleDevice closed during read")
|
||||
return
|
||||
}
|
||||
|
||||
// Try to send the result - check closed state first to avoid sending on closed channel
|
||||
if d.closed.Load() {
|
||||
logger.Debug("MiddleDevice: pump exiting, device closed before send")
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
// Now try to send the result
|
||||
select {
|
||||
case d.readCh <- readResult{bufs: bufs, sizes: sizes, offset: defaultOffset, n: n, err: err}:
|
||||
case <-d.closed:
|
||||
logger.Debug("MiddleDevice: pump exiting due to closed channel (during send)")
|
||||
return
|
||||
default:
|
||||
// Channel full, check if we should exit
|
||||
if dev.IsClosed() || d.closed.Load() {
|
||||
return
|
||||
}
|
||||
// Try again with blocking
|
||||
select {
|
||||
case d.readCh <- readResult{bufs: bufs, sizes: sizes, offset: defaultOffset, n: n, err: err}:
|
||||
case <-dev.closeEventCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
@@ -99,16 +234,28 @@ func (d *MiddleDevice) pump() {
|
||||
|
||||
// InjectOutbound injects a packet to be read by WireGuard (as if it came from TUN)
|
||||
func (d *MiddleDevice) InjectOutbound(packet []byte) {
|
||||
if d.closed.Load() {
|
||||
return
|
||||
}
|
||||
// Use defer/recover to handle panic from sending on closed channel
|
||||
// This can happen during shutdown race conditions
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logger.Debug("MiddleDevice: InjectOutbound recovered from panic (channel closed)")
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case d.injectCh <- packet:
|
||||
case <-d.closed:
|
||||
default:
|
||||
// Channel full, drop packet
|
||||
logger.Debug("MiddleDevice: InjectOutbound dropping packet, channel full")
|
||||
}
|
||||
}
|
||||
|
||||
// AddRule adds a packet filtering rule
|
||||
func (d *MiddleDevice) AddRule(destIP netip.Addr, handler PacketHandler) {
|
||||
d.mutex.Lock()
|
||||
defer d.mutex.Unlock()
|
||||
d.rulesMutex.Lock()
|
||||
defer d.rulesMutex.Unlock()
|
||||
d.rules = append(d.rules, FilterRule{
|
||||
DestIP: destIP,
|
||||
Handler: handler,
|
||||
@@ -117,8 +264,8 @@ func (d *MiddleDevice) AddRule(destIP netip.Addr, handler PacketHandler) {
|
||||
|
||||
// RemoveRule removes all rules for a given destination IP
|
||||
func (d *MiddleDevice) RemoveRule(destIP netip.Addr) {
|
||||
d.mutex.Lock()
|
||||
defer d.mutex.Unlock()
|
||||
d.rulesMutex.Lock()
|
||||
defer d.rulesMutex.Unlock()
|
||||
newRules := make([]FilterRule, 0, len(d.rules))
|
||||
for _, rule := range d.rules {
|
||||
if rule.DestIP != destIP {
|
||||
@@ -130,18 +277,120 @@ func (d *MiddleDevice) RemoveRule(destIP netip.Addr) {
|
||||
|
||||
// Close stops the device
|
||||
func (d *MiddleDevice) Close() error {
|
||||
select {
|
||||
case <-d.closed:
|
||||
// Already closed
|
||||
return nil
|
||||
default:
|
||||
logger.Debug("MiddleDevice: Closing, signaling closed channel")
|
||||
close(d.closed)
|
||||
if !d.closed.CompareAndSwap(false, true) {
|
||||
return nil // already closed
|
||||
}
|
||||
logger.Debug("MiddleDevice: Closing underlying TUN device")
|
||||
err := d.Device.Close()
|
||||
logger.Debug("MiddleDevice: Underlying TUN device closed, err=%v", err)
|
||||
return err
|
||||
|
||||
d.mu.Lock()
|
||||
devices := d.devices
|
||||
d.devices = nil
|
||||
d.cond.Broadcast()
|
||||
d.mu.Unlock()
|
||||
|
||||
// Close underlying devices first - this causes the pump goroutines to exit
|
||||
// when their read operations return errors
|
||||
var lastErr error
|
||||
logger.Debug("MiddleDevice: Closing %d devices", len(devices))
|
||||
for _, device := range devices {
|
||||
if err := device.Close(); err != nil {
|
||||
logger.Debug("MiddleDevice: Error closing device: %v", err)
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
|
||||
// Now close channels to unblock any remaining readers
|
||||
// The pump should have exited by now, but close channels to be safe
|
||||
close(d.readCh)
|
||||
close(d.injectCh)
|
||||
close(d.events)
|
||||
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// Events returns the events channel
|
||||
func (d *MiddleDevice) Events() <-chan tun.Event {
|
||||
return d.events
|
||||
}
|
||||
|
||||
// File returns the underlying file descriptor
|
||||
func (d *MiddleDevice) File() *os.File {
|
||||
for {
|
||||
dev := d.peekLast()
|
||||
if dev == nil {
|
||||
if !d.waitForDevice() {
|
||||
return nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
file := dev.File()
|
||||
|
||||
if dev.IsClosed() {
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
|
||||
return file
|
||||
}
|
||||
}
|
||||
|
||||
// MTU returns the MTU of the underlying device
|
||||
func (d *MiddleDevice) MTU() (int, error) {
|
||||
for {
|
||||
dev := d.peekLast()
|
||||
if dev == nil {
|
||||
if !d.waitForDevice() {
|
||||
return 0, io.EOF
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
mtu, err := dev.MTU()
|
||||
if err == nil {
|
||||
return mtu, nil
|
||||
}
|
||||
|
||||
if dev.IsClosed() {
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
// Name returns the name of the underlying device
|
||||
func (d *MiddleDevice) Name() (string, error) {
|
||||
for {
|
||||
dev := d.peekLast()
|
||||
if dev == nil {
|
||||
if !d.waitForDevice() {
|
||||
return "", io.EOF
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
name, err := dev.Name()
|
||||
if err == nil {
|
||||
return name, nil
|
||||
}
|
||||
|
||||
if dev.IsClosed() {
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
// BatchSize returns the batch size
|
||||
func (d *MiddleDevice) BatchSize() int {
|
||||
dev := d.peekLast()
|
||||
if dev == nil {
|
||||
return 1
|
||||
}
|
||||
return dev.BatchSize()
|
||||
}
|
||||
|
||||
// extractDestIP extracts destination IP from packet (fast path)
|
||||
@@ -176,156 +425,239 @@ func extractDestIP(packet []byte) (netip.Addr, bool) {
|
||||
|
||||
// Read intercepts packets going UP from the TUN device (towards WireGuard)
|
||||
func (d *MiddleDevice) Read(bufs [][]byte, sizes []int, offset int) (n int, err error) {
|
||||
// Check if already closed first (non-blocking)
|
||||
select {
|
||||
case <-d.closed:
|
||||
logger.Debug("MiddleDevice: Read returning os.ErrClosed (pre-check)")
|
||||
return 0, os.ErrClosed
|
||||
default:
|
||||
}
|
||||
|
||||
// Now block waiting for data
|
||||
select {
|
||||
case res := <-d.readCh:
|
||||
if res.err != nil {
|
||||
logger.Debug("MiddleDevice: Read returning error from pump: %v", res.err)
|
||||
return 0, res.err
|
||||
for {
|
||||
if d.closed.Load() {
|
||||
logger.Debug("MiddleDevice: Read returning io.EOF, device closed")
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
// Copy packets from result to provided buffers
|
||||
count := 0
|
||||
for i := 0; i < res.n && i < len(bufs); i++ {
|
||||
// Handle offset mismatch if necessary
|
||||
// We assume the pump used defaultOffset (16)
|
||||
// If caller asks for different offset, we need to shift
|
||||
src := res.bufs[i]
|
||||
srcOffset := res.offset
|
||||
srcSize := res.sizes[i]
|
||||
|
||||
// Calculate where the packet data starts and ends in src
|
||||
pktData := src[srcOffset : srcOffset+srcSize]
|
||||
|
||||
// Ensure dest buffer is large enough
|
||||
if len(bufs[i]) < offset+len(pktData) {
|
||||
continue // Skip if buffer too small
|
||||
// Wait for a device to be available
|
||||
dev := d.peekLast()
|
||||
if dev == nil {
|
||||
if !d.waitForDevice() {
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
copy(bufs[i][offset:], pktData)
|
||||
sizes[i] = len(pktData)
|
||||
count++
|
||||
}
|
||||
n = count
|
||||
|
||||
case pkt := <-d.injectCh:
|
||||
if len(bufs) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if len(bufs[0]) < offset+len(pkt) {
|
||||
return 0, nil // Buffer too small
|
||||
}
|
||||
copy(bufs[0][offset:], pkt)
|
||||
sizes[0] = len(pkt)
|
||||
n = 1
|
||||
|
||||
case <-d.closed:
|
||||
logger.Debug("MiddleDevice: Read returning os.ErrClosed")
|
||||
return 0, os.ErrClosed // Signal that device is closed
|
||||
}
|
||||
|
||||
d.mutex.RLock()
|
||||
rules := d.rules
|
||||
d.mutex.RUnlock()
|
||||
|
||||
if len(rules) == 0 {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Process packets and filter out handled ones
|
||||
writeIdx := 0
|
||||
for readIdx := 0; readIdx < n; readIdx++ {
|
||||
packet := bufs[readIdx][offset : offset+sizes[readIdx]]
|
||||
|
||||
destIP, ok := extractDestIP(packet)
|
||||
if !ok {
|
||||
// Can't parse, keep packet
|
||||
if writeIdx != readIdx {
|
||||
bufs[writeIdx] = bufs[readIdx]
|
||||
sizes[writeIdx] = sizes[readIdx]
|
||||
}
|
||||
writeIdx++
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if packet matches any rule
|
||||
handled := false
|
||||
for _, rule := range rules {
|
||||
if rule.DestIP == destIP {
|
||||
if rule.Handler(packet) {
|
||||
// Packet was handled and should be dropped
|
||||
handled = true
|
||||
break
|
||||
// Now block waiting for data from readCh or injectCh
|
||||
select {
|
||||
case res, ok := <-d.readCh:
|
||||
if !ok {
|
||||
// Channel closed, device is shutting down
|
||||
return 0, io.EOF
|
||||
}
|
||||
if res.err != nil {
|
||||
// Check if device was swapped
|
||||
if dev.IsClosed() {
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
logger.Debug("MiddleDevice: Read returning error from pump: %v", res.err)
|
||||
return 0, res.err
|
||||
}
|
||||
|
||||
// Copy packets from result to provided buffers
|
||||
count := 0
|
||||
for i := 0; i < res.n && i < len(bufs); i++ {
|
||||
src := res.bufs[i]
|
||||
srcOffset := res.offset
|
||||
srcSize := res.sizes[i]
|
||||
|
||||
pktData := src[srcOffset : srcOffset+srcSize]
|
||||
|
||||
if len(bufs[i]) < offset+len(pktData) {
|
||||
continue
|
||||
}
|
||||
|
||||
copy(bufs[i][offset:], pktData)
|
||||
sizes[i] = len(pktData)
|
||||
count++
|
||||
}
|
||||
n = count
|
||||
|
||||
case pkt, ok := <-d.injectCh:
|
||||
if !ok {
|
||||
// Channel closed, device is shutting down
|
||||
return 0, io.EOF
|
||||
}
|
||||
if len(bufs) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if len(bufs[0]) < offset+len(pkt) {
|
||||
return 0, nil
|
||||
}
|
||||
copy(bufs[0][offset:], pkt)
|
||||
sizes[0] = len(pkt)
|
||||
n = 1
|
||||
}
|
||||
|
||||
// Apply filtering rules
|
||||
d.rulesMutex.RLock()
|
||||
rules := d.rules
|
||||
d.rulesMutex.RUnlock()
|
||||
|
||||
if len(rules) == 0 {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Process packets and filter out handled ones
|
||||
writeIdx := 0
|
||||
for readIdx := 0; readIdx < n; readIdx++ {
|
||||
packet := bufs[readIdx][offset : offset+sizes[readIdx]]
|
||||
|
||||
destIP, ok := extractDestIP(packet)
|
||||
if !ok {
|
||||
if writeIdx != readIdx {
|
||||
bufs[writeIdx] = bufs[readIdx]
|
||||
sizes[writeIdx] = sizes[readIdx]
|
||||
}
|
||||
writeIdx++
|
||||
continue
|
||||
}
|
||||
|
||||
handled := false
|
||||
for _, rule := range rules {
|
||||
if rule.DestIP == destIP {
|
||||
if rule.Handler(packet) {
|
||||
handled = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !handled {
|
||||
// Keep packet
|
||||
if writeIdx != readIdx {
|
||||
bufs[writeIdx] = bufs[readIdx]
|
||||
sizes[writeIdx] = sizes[readIdx]
|
||||
if !handled {
|
||||
if writeIdx != readIdx {
|
||||
bufs[writeIdx] = bufs[readIdx]
|
||||
sizes[writeIdx] = sizes[readIdx]
|
||||
}
|
||||
writeIdx++
|
||||
}
|
||||
writeIdx++
|
||||
}
|
||||
}
|
||||
|
||||
return writeIdx, err
|
||||
return writeIdx, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Write intercepts packets going DOWN to the TUN device (from WireGuard)
|
||||
func (d *MiddleDevice) Write(bufs [][]byte, offset int) (int, error) {
|
||||
d.mutex.RLock()
|
||||
rules := d.rules
|
||||
d.mutex.RUnlock()
|
||||
for {
|
||||
if d.closed.Load() {
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
if len(rules) == 0 {
|
||||
return d.Device.Write(bufs, offset)
|
||||
}
|
||||
|
||||
// Filter packets going down
|
||||
filteredBufs := make([][]byte, 0, len(bufs))
|
||||
for _, buf := range bufs {
|
||||
if len(buf) <= offset {
|
||||
dev := d.peekLast()
|
||||
if dev == nil {
|
||||
if !d.waitForDevice() {
|
||||
return 0, io.EOF
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
packet := buf[offset:]
|
||||
destIP, ok := extractDestIP(packet)
|
||||
if !ok {
|
||||
// Can't parse, keep packet
|
||||
filteredBufs = append(filteredBufs, buf)
|
||||
continue
|
||||
}
|
||||
d.rulesMutex.RLock()
|
||||
rules := d.rules
|
||||
d.rulesMutex.RUnlock()
|
||||
|
||||
// Check if packet matches any rule
|
||||
handled := false
|
||||
for _, rule := range rules {
|
||||
if rule.DestIP == destIP {
|
||||
if rule.Handler(packet) {
|
||||
// Packet was handled and should be dropped
|
||||
handled = true
|
||||
break
|
||||
var filteredBufs [][]byte
|
||||
if len(rules) == 0 {
|
||||
filteredBufs = bufs
|
||||
} else {
|
||||
filteredBufs = make([][]byte, 0, len(bufs))
|
||||
for _, buf := range bufs {
|
||||
if len(buf) <= offset {
|
||||
continue
|
||||
}
|
||||
|
||||
packet := buf[offset:]
|
||||
destIP, ok := extractDestIP(packet)
|
||||
if !ok {
|
||||
filteredBufs = append(filteredBufs, buf)
|
||||
continue
|
||||
}
|
||||
|
||||
handled := false
|
||||
for _, rule := range rules {
|
||||
if rule.DestIP == destIP {
|
||||
if rule.Handler(packet) {
|
||||
handled = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !handled {
|
||||
filteredBufs = append(filteredBufs, buf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !handled {
|
||||
filteredBufs = append(filteredBufs, buf)
|
||||
if len(filteredBufs) == 0 {
|
||||
return len(bufs), nil
|
||||
}
|
||||
}
|
||||
|
||||
if len(filteredBufs) == 0 {
|
||||
return len(bufs), nil // All packets were handled
|
||||
}
|
||||
n, err := dev.Write(filteredBufs, offset)
|
||||
if err == nil {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
return d.Device.Write(filteredBufs, offset)
|
||||
if dev.IsClosed() {
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
|
||||
return n, err
|
||||
}
|
||||
}
|
||||
|
||||
func (d *MiddleDevice) waitForDevice() bool {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
for len(d.devices) == 0 && !d.closed.Load() {
|
||||
d.cond.Wait()
|
||||
}
|
||||
return !d.closed.Load()
|
||||
}
|
||||
|
||||
func (d *MiddleDevice) peekLast() *closeAwareDevice {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
if len(d.devices) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return d.devices[len(d.devices)-1]
|
||||
}
|
||||
|
||||
// WriteToTun writes packets directly to the underlying TUN device,
|
||||
// bypassing WireGuard. This is useful for sending packets that should
|
||||
// appear to come from the TUN interface (e.g., DNS responses from a proxy).
|
||||
// Unlike Write(), this does not go through packet filtering rules.
|
||||
func (d *MiddleDevice) WriteToTun(bufs [][]byte, offset int) (int, error) {
|
||||
for {
|
||||
if d.closed.Load() {
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
dev := d.peekLast()
|
||||
if dev == nil {
|
||||
if !d.waitForDevice() {
|
||||
return 0, io.EOF
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
n, err := dev.Write(bufs, offset)
|
||||
if err == nil {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
if dev.IsClosed() {
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
|
||||
return n, err
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !windows
|
||||
//go:build darwin
|
||||
|
||||
package device
|
||||
|
||||
@@ -26,7 +26,7 @@ func CreateTUNFromFD(tunFd uint32, mtuInt int) (tun.Device, error) {
|
||||
}
|
||||
|
||||
file := os.NewFile(uintptr(dupTunFd), "/dev/tun")
|
||||
device, err := tun.CreateTUNFromFile(file, mtuInt)
|
||||
device, err := tun.CreateTUNFromFile(file, 0)
|
||||
if err != nil {
|
||||
file.Close()
|
||||
return nil, err
|
||||
@@ -0,0 +1,50 @@
|
||||
//go:build linux
|
||||
|
||||
package device
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
"golang.org/x/sys/unix"
|
||||
"golang.zx2c4.com/wireguard/ipc"
|
||||
"golang.zx2c4.com/wireguard/tun"
|
||||
)
|
||||
|
||||
func CreateTUNFromFD(tunFd uint32, mtuInt int) (tun.Device, error) {
|
||||
if runtime.GOOS == "android" { // otherwise we get a permission denied
|
||||
theTun, _, err := tun.CreateUnmonitoredTUNFromFD(int(tunFd))
|
||||
return theTun, err
|
||||
}
|
||||
|
||||
dupTunFd, err := unix.Dup(int(tunFd))
|
||||
if err != nil {
|
||||
logger.Error("Unable to dup tun fd: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = unix.SetNonblock(dupTunFd, true)
|
||||
if err != nil {
|
||||
unix.Close(dupTunFd)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
file := os.NewFile(uintptr(dupTunFd), "/dev/tun")
|
||||
device, err := tun.CreateTUNFromFile(file, mtuInt)
|
||||
if err != nil {
|
||||
file.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return device, nil
|
||||
}
|
||||
|
||||
func UapiOpen(interfaceName string) (*os.File, error) {
|
||||
return ipc.UAPIOpen(interfaceName)
|
||||
}
|
||||
|
||||
func UapiListen(interfaceName string, fileUAPI *os.File) (net.Listener, error) {
|
||||
return ipc.UAPIListen(interfaceName, fileUAPI)
|
||||
}
|
||||
+235
-67
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -12,7 +13,6 @@ import (
|
||||
"github.com/fosrl/newt/util"
|
||||
"github.com/fosrl/olm/device"
|
||||
"github.com/miekg/dns"
|
||||
"golang.zx2c4.com/wireguard/tun"
|
||||
"gvisor.dev/gvisor/pkg/buffer"
|
||||
"gvisor.dev/gvisor/pkg/tcpip"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
|
||||
@@ -34,26 +34,50 @@ type DNSProxy struct {
|
||||
ep *channel.Endpoint
|
||||
proxyIP netip.Addr
|
||||
upstreamDNS []string
|
||||
tunnelDNS bool // Whether to tunnel DNS queries over WireGuard or to spit them out locally
|
||||
tunnelDNS bool // Whether to tunnel DNS queries over WireGuard or to spit them out locally
|
||||
mtu int
|
||||
tunDevice tun.Device // Direct reference to underlying TUN device for responses
|
||||
middleDevice *device.MiddleDevice // Reference to MiddleDevice for packet filtering
|
||||
middleDevice *device.MiddleDevice // Reference to MiddleDevice for packet filtering and TUN writes
|
||||
recordStore *DNSRecordStore // Local DNS records
|
||||
|
||||
// matchDomains lists the FQDN wildcard patterns (using * and ? wildcards, see
|
||||
// matchWildcard) that this proxy is responsible for. Queries whose name matches
|
||||
// one of these patterns are checked against local records and, failing that,
|
||||
// forwarded to upstreamDNS. Queries that match none of the patterns are sent
|
||||
// directly to localDNS instead, bypassing local records and upstreamDNS
|
||||
// entirely. An empty matchDomains means "match everything" (i.e. behave as if
|
||||
// this feature were not configured).
|
||||
matchDomains []string
|
||||
// localDNS holds the host's own system DNS servers (as reported by
|
||||
// SystemDNSMonitor / PublicDNS), used to resolve queries that don't match
|
||||
// matchDomains rather than sending them upstream or through the tunnel.
|
||||
localDNS []string
|
||||
matchMu sync.RWMutex
|
||||
|
||||
// Tunnel DNS fields - for sending queries over WireGuard
|
||||
tunnelIP netip.Addr // WireGuard interface IP (source for tunneled queries)
|
||||
tunnelStack *stack.Stack // Separate netstack for outbound tunnel queries
|
||||
tunnelEp *channel.Endpoint
|
||||
tunnelIP netip.Addr // WireGuard interface IP (source for tunneled queries)
|
||||
tunnelStack *stack.Stack // Separate netstack for outbound tunnel queries
|
||||
tunnelEp *channel.Endpoint
|
||||
tunnelActivePorts map[uint16]bool
|
||||
tunnelPortsLock sync.Mutex
|
||||
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
|
||||
}
|
||||
|
||||
// NewDNSProxy creates a new DNS proxy
|
||||
func NewDNSProxy(tunDevice tun.Device, middleDevice *device.MiddleDevice, mtu int, utilitySubnet string, upstreamDns []string, tunnelDns bool, tunnelIP string) (*DNSProxy, error) {
|
||||
// NewDNSProxy creates a new DNS proxy.
|
||||
//
|
||||
// matchDomains, if non-empty, restricts local-record lookup and upstream
|
||||
// forwarding to queries whose name matches one of the given wildcard patterns
|
||||
// (see matchWildcard). Queries that match none of the patterns are instead
|
||||
// forwarded directly to localDNS (the host's own system DNS servers). Pass an
|
||||
// empty matchDomains to match every query, preserving prior behavior.
|
||||
func NewDNSProxy(middleDevice *device.MiddleDevice, mtu int, utilitySubnet string, upstreamDns []string, tunnelDns bool, tunnelIP string, matchDomains []string, localDNS []string) (*DNSProxy, error) {
|
||||
proxyIP, err := PickIPFromSubnet(utilitySubnet)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to pick DNS proxy IP from subnet: %v", err)
|
||||
@@ -68,12 +92,13 @@ func NewDNSProxy(tunDevice tun.Device, middleDevice *device.MiddleDevice, mtu in
|
||||
proxy := &DNSProxy{
|
||||
proxyIP: proxyIP,
|
||||
mtu: mtu,
|
||||
tunDevice: tunDevice,
|
||||
middleDevice: middleDevice,
|
||||
upstreamDNS: upstreamDns,
|
||||
tunnelDNS: tunnelDns,
|
||||
recordStore: NewDNSRecordStore(),
|
||||
tunnelActivePorts: make(map[uint16]bool),
|
||||
matchDomains: matchDomains,
|
||||
localDNS: localDNS,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
@@ -381,12 +406,43 @@ func (p *DNSProxy) handleDNSQuery(udpConn *gonet.UDPConn, queryData []byte, clie
|
||||
question := msg.Question[0]
|
||||
logger.Debug("DNS query for %s (type %s)", question.Name, dns.TypeToString[question.Qtype])
|
||||
|
||||
// If matchDomains is configured and this query's name doesn't match any of
|
||||
// the configured patterns, skip local records and upstream entirely and
|
||||
// send it straight to the host's own system DNS servers.
|
||||
if !p.matchesConfiguredDomains(question.Name) {
|
||||
logger.Debug("Query for %s does not match configured domains, forwarding to local DNS %v", question.Name, p.getLocalDNS())
|
||||
response := p.forwardToLocalDNS(msg)
|
||||
if response == nil {
|
||||
logger.Error("Failed to get DNS response for %s from local DNS", question.Name)
|
||||
return
|
||||
}
|
||||
responseData, err := response.Pack()
|
||||
if err != nil {
|
||||
logger.Error("Failed to pack DNS response: %v", err)
|
||||
return
|
||||
}
|
||||
if _, err := udpConn.WriteTo(responseData, clientAddr); err != nil {
|
||||
logger.Error("Failed to send DNS response: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we have local records for this query
|
||||
var response *dns.Msg
|
||||
if question.Qtype == dns.TypeA || question.Qtype == dns.TypeAAAA {
|
||||
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)
|
||||
@@ -413,6 +469,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
|
||||
@@ -422,19 +506,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 {
|
||||
@@ -464,6 +549,77 @@ func (p *DNSProxy) checkLocalRecords(query *dns.Msg, question dns.Question) *dns
|
||||
return response
|
||||
}
|
||||
|
||||
// matchesConfiguredDomains reports whether name matches one of the configured
|
||||
// matchDomains wildcard patterns. If matchDomains is empty, every name is
|
||||
// considered a match (i.e. the feature is disabled).
|
||||
func (p *DNSProxy) matchesConfiguredDomains(name string) bool {
|
||||
p.matchMu.RLock()
|
||||
patterns := p.matchDomains
|
||||
p.matchMu.RUnlock()
|
||||
|
||||
if len(patterns) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
name = strings.ToLower(dns.Fqdn(name))
|
||||
for _, pattern := range patterns {
|
||||
pattern = strings.ToLower(dns.Fqdn(pattern))
|
||||
if matchWildcard(pattern, name) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// getLocalDNS returns the currently configured local (system) DNS servers.
|
||||
func (p *DNSProxy) getLocalDNS() []string {
|
||||
p.matchMu.RLock()
|
||||
defer p.matchMu.RUnlock()
|
||||
return p.localDNS
|
||||
}
|
||||
|
||||
// forwardToLocalDNS forwards a DNS query directly to the host's own system DNS
|
||||
// servers (localDNS), always using host networking regardless of tunnelDNS -
|
||||
// these queries are for domains the caller has explicitly excluded from
|
||||
// Pangolin resolution, so they should never traverse the tunnel.
|
||||
func (p *DNSProxy) forwardToLocalDNS(query *dns.Msg) *dns.Msg {
|
||||
servers := p.getLocalDNS()
|
||||
if len(servers) == 0 {
|
||||
logger.Warn("No local DNS servers configured, dropping query for %s", query.Question[0].Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for _, server := range servers {
|
||||
response, err := p.queryUpstreamDirect(server, query, 2*time.Second)
|
||||
if err == nil {
|
||||
return response
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
logger.Error("All local DNS servers failed: %v", lastErr)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetMatchDomains replaces the list of wildcard domain patterns (see
|
||||
// matchWildcard) that this proxy checks against local records / upstream DNS.
|
||||
// Queries not matching any pattern are sent to localDNS instead. Pass an
|
||||
// empty slice to match every query (i.e. disable filtering).
|
||||
func (p *DNSProxy) SetMatchDomains(patterns []string) {
|
||||
p.matchMu.Lock()
|
||||
defer p.matchMu.Unlock()
|
||||
p.matchDomains = patterns
|
||||
}
|
||||
|
||||
// SetLocalDNS replaces the list of local (host system) DNS servers used to
|
||||
// resolve queries that don't match matchDomains. Servers must be in
|
||||
// "host:port" format (e.g. "192.168.1.1:53").
|
||||
func (p *DNSProxy) SetLocalDNS(servers []string) {
|
||||
p.matchMu.Lock()
|
||||
defer p.matchMu.Unlock()
|
||||
p.localDNS = servers
|
||||
}
|
||||
|
||||
// forwardToUpstream forwards a DNS query to upstream DNS servers
|
||||
func (p *DNSProxy) forwardToUpstream(query *dns.Msg) *dns.Msg {
|
||||
// Try primary DNS server
|
||||
@@ -602,12 +758,12 @@ func (p *DNSProxy) runTunnelPacketSender() {
|
||||
defer p.wg.Done()
|
||||
logger.Debug("DNS tunnel packet sender goroutine started")
|
||||
|
||||
ticker := time.NewTicker(1 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-p.ctx.Done():
|
||||
// Use blocking ReadContext instead of polling - much more CPU efficient
|
||||
// This will block until a packet is available or context is cancelled
|
||||
pkt := p.tunnelEp.ReadContext(p.ctx)
|
||||
if pkt == nil {
|
||||
// Context was cancelled or endpoint closed
|
||||
logger.Debug("DNS tunnel packet sender exiting")
|
||||
// Drain any remaining packets
|
||||
for {
|
||||
@@ -618,36 +774,28 @@ func (p *DNSProxy) runTunnelPacketSender() {
|
||||
pkt.DecRef()
|
||||
}
|
||||
return
|
||||
case <-ticker.C:
|
||||
// Try to read packets
|
||||
for i := 0; i < 10; i++ {
|
||||
pkt := p.tunnelEp.Read()
|
||||
if pkt == nil {
|
||||
break
|
||||
}
|
||||
|
||||
// Extract packet data
|
||||
slices := pkt.AsSlices()
|
||||
if len(slices) > 0 {
|
||||
var totalSize int
|
||||
for _, slice := range slices {
|
||||
totalSize += len(slice)
|
||||
}
|
||||
|
||||
buf := make([]byte, totalSize)
|
||||
pos := 0
|
||||
for _, slice := range slices {
|
||||
copy(buf[pos:], slice)
|
||||
pos += len(slice)
|
||||
}
|
||||
|
||||
// Inject into MiddleDevice (outbound to WG)
|
||||
p.middleDevice.InjectOutbound(buf)
|
||||
}
|
||||
|
||||
pkt.DecRef()
|
||||
}
|
||||
}
|
||||
|
||||
// Extract packet data
|
||||
slices := pkt.AsSlices()
|
||||
if len(slices) > 0 {
|
||||
var totalSize int
|
||||
for _, slice := range slices {
|
||||
totalSize += len(slice)
|
||||
}
|
||||
|
||||
buf := make([]byte, totalSize)
|
||||
pos := 0
|
||||
for _, slice := range slices {
|
||||
copy(buf[pos:], slice)
|
||||
pos += len(slice)
|
||||
}
|
||||
|
||||
// Inject into MiddleDevice (outbound to WG)
|
||||
p.middleDevice.InjectOutbound(buf)
|
||||
}
|
||||
|
||||
pkt.DecRef()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -660,18 +808,12 @@ func (p *DNSProxy) runPacketSender() {
|
||||
const offset = 16
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-p.ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
// Read packets from netstack endpoint
|
||||
pkt := p.ep.Read()
|
||||
// Use blocking ReadContext instead of polling - much more CPU efficient
|
||||
// This will block until a packet is available or context is cancelled
|
||||
pkt := p.ep.ReadContext(p.ctx)
|
||||
if pkt == nil {
|
||||
// No packet available, small sleep to avoid busy loop
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
continue
|
||||
// Context was cancelled or endpoint closed
|
||||
return
|
||||
}
|
||||
|
||||
// Extract packet data as slices
|
||||
@@ -694,9 +836,9 @@ func (p *DNSProxy) runPacketSender() {
|
||||
pos += len(slice)
|
||||
}
|
||||
|
||||
// Write packet to TUN device
|
||||
// Write packet to TUN device via MiddleDevice
|
||||
// offset=16 indicates packet data starts at position 16 in the buffer
|
||||
_, err := p.tunDevice.Write([][]byte{buf}, offset)
|
||||
_, err := p.middleDevice.WriteToTun([][]byte{buf}, offset)
|
||||
if err != nil {
|
||||
logger.Error("Failed to write DNS response to TUN: %v", err)
|
||||
}
|
||||
@@ -706,11 +848,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
|
||||
@@ -719,8 +880,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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
+394
-151
@@ -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
|
||||
@@ -322,4 +493,76 @@ 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 ""
|
||||
}
|
||||
|
||||
+626
-57
@@ -37,7 +37,7 @@ func TestWildcardMatching(t *testing.T) {
|
||||
domain: "autoco.internal.",
|
||||
expected: false,
|
||||
},
|
||||
|
||||
|
||||
// Question mark wildcard tests
|
||||
{
|
||||
name: "host-0?.autoco.internal matches host-01.autoco.internal",
|
||||
@@ -63,7 +63,7 @@ func TestWildcardMatching(t *testing.T) {
|
||||
domain: "host-012.autoco.internal.",
|
||||
expected: false,
|
||||
},
|
||||
|
||||
|
||||
// Combined wildcard tests
|
||||
{
|
||||
name: "*.host-0?.autoco.internal matches sub.host-01.autoco.internal",
|
||||
@@ -83,7 +83,7 @@ func TestWildcardMatching(t *testing.T) {
|
||||
domain: "host-01.autoco.internal.",
|
||||
expected: false,
|
||||
},
|
||||
|
||||
|
||||
// Multiple asterisks
|
||||
{
|
||||
name: "*.*. autoco.internal matches any.thing.autoco.internal",
|
||||
@@ -97,7 +97,7 @@ func TestWildcardMatching(t *testing.T) {
|
||||
domain: "single.autoco.internal.",
|
||||
expected: false,
|
||||
},
|
||||
|
||||
|
||||
// Asterisk in middle
|
||||
{
|
||||
name: "host-*.autoco.internal matches host-anything.autoco.internal",
|
||||
@@ -111,7 +111,7 @@ func TestWildcardMatching(t *testing.T) {
|
||||
domain: "host-.autoco.internal.",
|
||||
expected: true,
|
||||
},
|
||||
|
||||
|
||||
// Multiple question marks
|
||||
{
|
||||
name: "host-??.autoco.internal matches host-01.autoco.internal",
|
||||
@@ -125,7 +125,7 @@ func TestWildcardMatching(t *testing.T) {
|
||||
domain: "host-1.autoco.internal.",
|
||||
expected: false,
|
||||
},
|
||||
|
||||
|
||||
// Exact match (no wildcards)
|
||||
{
|
||||
name: "exact.autoco.internal matches exact.autoco.internal",
|
||||
@@ -139,7 +139,7 @@ func TestWildcardMatching(t *testing.T) {
|
||||
domain: "other.autoco.internal.",
|
||||
expected: false,
|
||||
},
|
||||
|
||||
|
||||
// Edge cases
|
||||
{
|
||||
name: "* matches anything",
|
||||
@@ -154,7 +154,7 @@ func TestWildcardMatching(t *testing.T) {
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := matchWildcard(tt.pattern, tt.domain)
|
||||
@@ -167,41 +167,50 @@ func TestWildcardMatching(t *testing.T) {
|
||||
|
||||
func TestDNSRecordStoreWildcard(t *testing.T) {
|
||||
store := NewDNSRecordStore()
|
||||
|
||||
|
||||
// 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))
|
||||
}
|
||||
@@ -209,31 +218,40 @@ func TestDNSRecordStoreWildcard(t *testing.T) {
|
||||
|
||||
func TestDNSRecordStoreComplexWildcard(t *testing.T) {
|
||||
store := NewDNSRecordStore()
|
||||
|
||||
|
||||
// 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))
|
||||
}
|
||||
if len(ips) > 0 && !ips[0].Equal(ip1) {
|
||||
t.Errorf("Expected IP %v, got %v", ip1, ips[0])
|
||||
}
|
||||
|
||||
|
||||
// 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))
|
||||
}
|
||||
@@ -241,25 +259,31 @@ func TestDNSRecordStoreComplexWildcard(t *testing.T) {
|
||||
|
||||
func TestDNSRecordStoreRemoveWildcard(t *testing.T) {
|
||||
store := NewDNSRecordStore()
|
||||
|
||||
|
||||
// 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))
|
||||
}
|
||||
|
||||
|
||||
// Remove wildcard record
|
||||
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))
|
||||
}
|
||||
@@ -267,42 +291,42 @@ func TestDNSRecordStoreRemoveWildcard(t *testing.T) {
|
||||
|
||||
func TestDNSRecordStoreMultipleWildcards(t *testing.T) {
|
||||
store := NewDNSRecordStore()
|
||||
|
||||
|
||||
// Add multiple wildcard patterns that don't overlap
|
||||
ip1 := net.ParseIP("10.0.0.1")
|
||||
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))
|
||||
}
|
||||
@@ -310,16 +334,16 @@ func TestDNSRecordStoreMultipleWildcards(t *testing.T) {
|
||||
|
||||
func TestDNSRecordStoreIPv6Wildcard(t *testing.T) {
|
||||
store := NewDNSRecordStore()
|
||||
|
||||
|
||||
// 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))
|
||||
}
|
||||
@@ -330,21 +354,566 @@ func TestDNSRecordStoreIPv6Wildcard(t *testing.T) {
|
||||
|
||||
func TestHasRecordWildcard(t *testing.T) {
|
||||
store := NewDNSRecordStore()
|
||||
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
|
||||
// Test HasRecord with wildcard match
|
||||
if !store.HasRecord("host.autoco.internal.", RecordTypeA) {
|
||||
t.Error("Expected HasRecord to return true for wildcard match")
|
||||
}
|
||||
|
||||
|
||||
// Test HasRecord with non-match
|
||||
if store.HasRecord("autoco.internal.", RecordTypeA) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
//go:build android
|
||||
|
||||
package olm
|
||||
|
||||
import "net/netip"
|
||||
|
||||
// SetupDNSOverride is a no-op on Android
|
||||
// Android handles DNS through the VpnService API at the Java/Kotlin layer
|
||||
func SetupDNSOverride(interfaceName string, proxyIp netip.Addr) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"net/netip"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
"github.com/fosrl/olm/dns"
|
||||
platform "github.com/fosrl/olm/dns/platform"
|
||||
)
|
||||
|
||||
@@ -15,9 +14,12 @@ 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, dnsProxy *dns.DNSProxy) error {
|
||||
if dnsProxy == nil {
|
||||
return fmt.Errorf("DNS proxy is nil")
|
||||
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
|
||||
@@ -38,7 +40,7 @@ func SetupDNSOverride(interfaceName string, dnsProxy *dns.DNSProxy) error {
|
||||
|
||||
// Set new DNS servers to point to our proxy
|
||||
newDNS := []netip.Addr{
|
||||
dnsProxy.GetProxyIP(),
|
||||
proxyIp,
|
||||
}
|
||||
|
||||
logger.Info("Setting DNS servers to: %v", newDNS)
|
||||
@@ -66,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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
//go:build ios
|
||||
|
||||
package olm
|
||||
|
||||
import "net/netip"
|
||||
|
||||
// SetupDNSOverride is a no-op on iOS as DNS configuration is handled by the system
|
||||
func SetupDNSOverride(interfaceName string, proxyIp netip.Addr) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"net/netip"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
"github.com/fosrl/olm/dns"
|
||||
platform "github.com/fosrl/olm/dns/platform"
|
||||
)
|
||||
|
||||
@@ -15,9 +14,12 @@ 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, dnsProxy *dns.DNSProxy) error {
|
||||
if dnsProxy == nil {
|
||||
return fmt.Errorf("DNS proxy is nil")
|
||||
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
|
||||
@@ -32,7 +34,7 @@ func SetupDNSOverride(interfaceName string, dnsProxy *dns.DNSProxy) error {
|
||||
configurator, err = platform.NewSystemdResolvedDNSConfigurator(interfaceName)
|
||||
if err == nil {
|
||||
logger.Info("Using systemd-resolved DNS configurator")
|
||||
return setDNS(dnsProxy, configurator)
|
||||
return setDNS(proxyIp, configurator)
|
||||
}
|
||||
logger.Warn("Failed to create systemd-resolved configurator: %v, falling back", err)
|
||||
|
||||
@@ -40,7 +42,7 @@ func SetupDNSOverride(interfaceName string, dnsProxy *dns.DNSProxy) error {
|
||||
configurator, err = platform.NewNetworkManagerDNSConfigurator(interfaceName)
|
||||
if err == nil {
|
||||
logger.Info("Using NetworkManager DNS configurator")
|
||||
return setDNS(dnsProxy, configurator)
|
||||
return setDNS(proxyIp, configurator)
|
||||
}
|
||||
logger.Warn("Failed to create NetworkManager configurator: %v, falling back", err)
|
||||
|
||||
@@ -48,7 +50,7 @@ func SetupDNSOverride(interfaceName string, dnsProxy *dns.DNSProxy) error {
|
||||
configurator, err = platform.NewResolvconfDNSConfigurator(interfaceName)
|
||||
if err == nil {
|
||||
logger.Info("Using resolvconf DNS configurator")
|
||||
return setDNS(dnsProxy, configurator)
|
||||
return setDNS(proxyIp, configurator)
|
||||
}
|
||||
logger.Warn("Failed to create resolvconf configurator: %v, falling back", err)
|
||||
}
|
||||
@@ -60,11 +62,11 @@ func SetupDNSOverride(interfaceName string, dnsProxy *dns.DNSProxy) error {
|
||||
}
|
||||
|
||||
logger.Info("Using file-based DNS configurator")
|
||||
return setDNS(dnsProxy, configurator)
|
||||
return setDNS(proxyIp, configurator)
|
||||
}
|
||||
|
||||
// setDNS is a helper function to set DNS and log the results
|
||||
func setDNS(dnsProxy *dns.DNSProxy, conf platform.DNSConfigurator) error {
|
||||
func setDNS(proxyIp netip.Addr, conf platform.DNSConfigurator) error {
|
||||
// Get current DNS servers before changing
|
||||
currentDNS, err := conf.GetCurrentDNS()
|
||||
if err != nil {
|
||||
@@ -75,7 +77,7 @@ func setDNS(dnsProxy *dns.DNSProxy, conf platform.DNSConfigurator) error {
|
||||
|
||||
// Set new DNS servers to point to our proxy
|
||||
newDNS := []netip.Addr{
|
||||
dnsProxy.GetProxyIP(),
|
||||
proxyIp,
|
||||
}
|
||||
|
||||
logger.Info("Setting DNS servers to: %v", newDNS)
|
||||
@@ -103,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
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"net/netip"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
"github.com/fosrl/olm/dns"
|
||||
platform "github.com/fosrl/olm/dns/platform"
|
||||
)
|
||||
|
||||
@@ -15,9 +14,11 @@ 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, dnsProxy *dns.DNSProxy) error {
|
||||
if dnsProxy == nil {
|
||||
return fmt.Errorf("DNS proxy is nil")
|
||||
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
|
||||
@@ -38,7 +39,7 @@ func SetupDNSOverride(interfaceName string, dnsProxy *dns.DNSProxy) error {
|
||||
|
||||
// Set new DNS servers to point to our proxy
|
||||
newDNS := []netip.Addr{
|
||||
dnsProxy.GetProxyIP(),
|
||||
proxyIp,
|
||||
}
|
||||
|
||||
logger.Info("Setting DNS servers to: %v", newDNS)
|
||||
@@ -66,3 +67,33 @@ func RestoreDNSOverride() error {
|
||||
logger.Info("DNS configuration restored successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupStaleState removes any stale DNS configuration left over from a previous
|
||||
// unclean shutdown (e.g., system crash, power loss while tunnel was active).
|
||||
// This function should be called early during startup, before any network operations,
|
||||
// to ensure DNS is working properly.
|
||||
//
|
||||
// On Windows, DNS configuration is tied to the interface GUID. When the WireGuard
|
||||
// interface is recreated, it gets a new GUID, so there's no stale state to clean up.
|
||||
func CleanupStaleState(interfaceName string) error {
|
||||
// Windows DNS configuration via registry is interface-specific.
|
||||
// When the WireGuard interface is recreated, it gets a new GUID,
|
||||
// so there's no leftover state to clean up from previous sessions.
|
||||
_ = interfaceName
|
||||
logger.Debug("Windows DNS cleanup: no stale state to clean (interface-specific)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ForceResetDNS forcibly clears any DNS override state. On Windows this is
|
||||
// largely a no-op because the registry override is tied to the interface
|
||||
// GUID and is reclaimed when the interface is torn down.
|
||||
func ForceResetDNS(interfaceName string) error {
|
||||
logger.Info("Forcing DNS reset on Windows (interface=%s)", interfaceName)
|
||||
if configurator != nil {
|
||||
if err := configurator.RestoreDNS(); err != nil {
|
||||
logger.Warn("ForceResetDNS: in-memory restore failed: %v", err)
|
||||
}
|
||||
configurator = nil
|
||||
}
|
||||
return CleanupStaleState(interfaceName)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package olm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
)
|
||||
|
||||
// WatchdogConfig configures the DNS override watchdog. The watchdog runs as
|
||||
// an external process (spawned via SpawnWatchdog) and monitors a parent olm
|
||||
// process. When the parent appears to have died without restoring DNS, the
|
||||
// watchdog forcibly resets the system DNS configuration.
|
||||
type WatchdogConfig struct {
|
||||
// ParentPID is the PID of the olm process that installed the DNS
|
||||
// override. The watchdog exits when this PID is no longer alive.
|
||||
ParentPID int
|
||||
|
||||
// SocketPath is the path to the olm Unix domain socket (or named pipe
|
||||
// on Windows). The watchdog uses it as a secondary liveness signal.
|
||||
// May be empty if no socket-based API is enabled.
|
||||
SocketPath string
|
||||
|
||||
// InterfaceName is the name of the WireGuard interface whose DNS
|
||||
// override should be reset on parent death.
|
||||
InterfaceName string
|
||||
|
||||
// CheckInterval is how often to poll the parent's liveness.
|
||||
// Defaults to 5 seconds when zero.
|
||||
CheckInterval time.Duration
|
||||
|
||||
// FailureThreshold is the number of consecutive failed liveness checks
|
||||
// before the watchdog declares the parent dead and resets DNS.
|
||||
// Defaults to 3 when zero.
|
||||
FailureThreshold int
|
||||
}
|
||||
|
||||
// RunWatchdog runs the watchdog loop in the current process until either
|
||||
// (a) the parent dies and DNS is reset, or (b) ctx is cancelled.
|
||||
func RunWatchdog(ctx context.Context, cfg WatchdogConfig) error {
|
||||
if cfg.ParentPID <= 0 {
|
||||
return fmt.Errorf("watchdog: invalid parent PID %d", cfg.ParentPID)
|
||||
}
|
||||
if cfg.CheckInterval <= 0 {
|
||||
cfg.CheckInterval = 5 * time.Second
|
||||
}
|
||||
if cfg.FailureThreshold <= 0 {
|
||||
cfg.FailureThreshold = 3
|
||||
}
|
||||
|
||||
logger.Info("DNS watchdog started: parent=%d interval=%s threshold=%d socket=%q interface=%q",
|
||||
cfg.ParentPID, cfg.CheckInterval, cfg.FailureThreshold, cfg.SocketPath, cfg.InterfaceName)
|
||||
|
||||
ticker := time.NewTicker(cfg.CheckInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
consecutiveFailures := 0
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logger.Info("DNS watchdog context cancelled, exiting cleanly")
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
}
|
||||
|
||||
alive := isParentAlive(cfg.ParentPID, cfg.SocketPath)
|
||||
if alive {
|
||||
if consecutiveFailures > 0 {
|
||||
logger.Debug("DNS watchdog: parent recovered after %d failures", consecutiveFailures)
|
||||
}
|
||||
consecutiveFailures = 0
|
||||
continue
|
||||
}
|
||||
|
||||
consecutiveFailures++
|
||||
logger.Warn("DNS watchdog: parent liveness check failed (%d/%d)",
|
||||
consecutiveFailures, cfg.FailureThreshold)
|
||||
|
||||
if consecutiveFailures >= cfg.FailureThreshold {
|
||||
logger.Warn("DNS watchdog: parent declared dead, forcing DNS reset")
|
||||
if err := ForceResetDNS(cfg.InterfaceName); err != nil {
|
||||
logger.Error("DNS watchdog: ForceResetDNS failed: %v", err)
|
||||
return err
|
||||
}
|
||||
logger.Info("DNS watchdog: DNS reset complete, exiting")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isParentAlive returns true if the parent process appears to be alive. It
|
||||
// considers the parent alive if EITHER the PID is still running OR the
|
||||
// socket-based health endpoint responds. This dual check avoids false
|
||||
// positives where one signal is flaky (e.g., socket blocked but process
|
||||
// still recovering).
|
||||
func isParentAlive(pid int, socketPath string) bool {
|
||||
if pidAlive(pid) {
|
||||
return true
|
||||
}
|
||||
// Process is gone; double-check via socket to avoid races where PID
|
||||
// recycling or signal-0 quirks lie to us. Socket should already be
|
||||
// gone too.
|
||||
if socketPath != "" && socketHealthy(socketPath) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// socketHealthy attempts a fast /health request over the unix socket.
|
||||
func socketHealthy(socketPath string) bool {
|
||||
if _, err := os.Stat(socketPath); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 2 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
d := net.Dialer{Timeout: 2 * time.Second}
|
||||
return d.DialContext(ctx, "unix", socketPath)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := client.Get("http://localhost/health")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return resp.StatusCode == http.StatusOK
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//go:build !windows
|
||||
|
||||
package olm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"syscall"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
)
|
||||
|
||||
// SpawnWatchdogConfig captures the inputs needed to launch the external
|
||||
// watchdog subprocess that monitors the calling olm process and forces a
|
||||
// DNS reset if the parent dies before restoring DNS.
|
||||
type SpawnWatchdogConfig struct {
|
||||
// Executable is the path to the binary that will host the watchdog
|
||||
// (typically os.Executable()). The binary must understand the
|
||||
// watchdog subcommand layout described below.
|
||||
Executable string
|
||||
|
||||
// Subcommand is the argv prefix the binary uses to enter watchdog
|
||||
// mode (e.g., []string{"watchdog"} or []string{"dns", "watchdog"}).
|
||||
Subcommand []string
|
||||
|
||||
// InterfaceName is the WireGuard interface whose DNS override should
|
||||
// be reset if the parent dies.
|
||||
InterfaceName string
|
||||
|
||||
// SocketPath is the parent's olm API socket path (may be empty).
|
||||
SocketPath string
|
||||
|
||||
// LogFile, if non-empty, is the path the watchdog writes its stdout
|
||||
// and stderr to. If empty, /dev/null is used.
|
||||
LogFile string
|
||||
}
|
||||
|
||||
// SpawnWatchdog launches the watchdog subprocess in a detached process group
|
||||
// so that it survives the death of the parent. The returned *exec.Cmd is the
|
||||
// handle the parent should call StopWatchdog on during clean shutdown.
|
||||
//
|
||||
// The spawned process is invoked as:
|
||||
//
|
||||
// <Executable> <Subcommand...> --parent-pid=<ppid> \
|
||||
// --interface=<InterfaceName> [--socket=<SocketPath>]
|
||||
//
|
||||
// Both pangolin (cli) and olm should map their watchdog subcommand to
|
||||
// RunWatchdog.
|
||||
func SpawnWatchdog(cfg SpawnWatchdogConfig) (*exec.Cmd, error) {
|
||||
if cfg.Executable == "" {
|
||||
return nil, fmt.Errorf("watchdog: executable is required")
|
||||
}
|
||||
if len(cfg.Subcommand) == 0 {
|
||||
return nil, fmt.Errorf("watchdog: subcommand is required")
|
||||
}
|
||||
|
||||
args := append([]string{}, cfg.Subcommand...)
|
||||
args = append(args,
|
||||
"--parent-pid="+strconv.Itoa(os.Getpid()),
|
||||
"--interface="+cfg.InterfaceName,
|
||||
)
|
||||
if cfg.SocketPath != "" {
|
||||
args = append(args, "--socket="+cfg.SocketPath)
|
||||
}
|
||||
|
||||
cmd := exec.Command(cfg.Executable, args...)
|
||||
|
||||
// Detach: new session so the watchdog is not killed by a signal
|
||||
// delivered to the parent's process group.
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Setsid: true,
|
||||
}
|
||||
|
||||
// Direct watchdog output to a log file or /dev/null so it doesn't
|
||||
// share file descriptors with the parent's TTY.
|
||||
logTarget := cfg.LogFile
|
||||
if logTarget == "" {
|
||||
logTarget = os.DevNull
|
||||
}
|
||||
logFile, err := os.OpenFile(logTarget, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("watchdog: open log file: %w", err)
|
||||
}
|
||||
cmd.Stdin = nil
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
_ = logFile.Close()
|
||||
return nil, fmt.Errorf("watchdog: start: %w", err)
|
||||
}
|
||||
|
||||
// We don't need our handle on the log file after the subprocess
|
||||
// inherits it.
|
||||
_ = logFile.Close()
|
||||
|
||||
logger.Info("DNS watchdog spawned (pid=%d, exe=%s)", cmd.Process.Pid, cfg.Executable)
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
// StopWatchdog asks the watchdog to exit cleanly via SIGTERM and reaps it.
|
||||
// Safe to call with a nil cmd.
|
||||
func StopWatchdog(cmd *exec.Cmd) {
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
return
|
||||
}
|
||||
pid := cmd.Process.Pid
|
||||
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
|
||||
logger.Debug("DNS watchdog stop signal failed (pid=%d): %v", pid, err)
|
||||
}
|
||||
// Reap in the background; we don't want to block shutdown if the
|
||||
// watchdog is wedged.
|
||||
go func() {
|
||||
_ = cmd.Wait()
|
||||
logger.Debug("DNS watchdog (pid=%d) reaped", pid)
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//go:build windows
|
||||
|
||||
package olm
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// SpawnWatchdogConfig is provided on Windows for API symmetry but the
|
||||
// watchdog itself is effectively a no-op there (see watchdog_windows.go).
|
||||
type SpawnWatchdogConfig struct {
|
||||
Executable string
|
||||
Subcommand []string
|
||||
InterfaceName string
|
||||
SocketPath string
|
||||
LogFile string
|
||||
}
|
||||
|
||||
// SpawnWatchdog is a no-op on Windows; DNS overrides are interface-GUID
|
||||
// scoped and reclaimed when the interface is removed.
|
||||
func SpawnWatchdog(cfg SpawnWatchdogConfig) (*exec.Cmd, error) {
|
||||
_ = cfg
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// StopWatchdog is a no-op on Windows.
|
||||
func StopWatchdog(cmd *exec.Cmd) {
|
||||
_ = cmd
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//go:build !windows
|
||||
|
||||
package olm
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// pidAlive returns true if the process with the given PID is still alive.
|
||||
// On Unix-like systems we use signal 0, which performs error checking but
|
||||
// does not deliver an actual signal.
|
||||
func pidAlive(pid int) bool {
|
||||
if pid <= 0 {
|
||||
return false
|
||||
}
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if err := proc.Signal(syscall.Signal(0)); err != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//go:build windows
|
||||
|
||||
package olm
|
||||
|
||||
// pidAlive on Windows. Reliable PID probing on Windows requires syscall
|
||||
// OpenProcess with PROCESS_QUERY_LIMITED_INFORMATION followed by
|
||||
// GetExitCodeProcess, which is non-trivial. Since DNS override on Windows
|
||||
// is interface-GUID-scoped and is naturally cleaned up when the WireGuard
|
||||
// interface goes away, the watchdog is effectively a no-op on Windows.
|
||||
// We always report the parent as alive so the watchdog never tears down
|
||||
// DNS based on PID checks.
|
||||
func pidAlive(pid int) bool {
|
||||
_ = pid
|
||||
return true
|
||||
}
|
||||
+124
-1
@@ -416,4 +416,127 @@ func (d *DarwinDNSConfigurator) clearState() error {
|
||||
|
||||
logger.Debug("Cleared DNS state file")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// CleanupStaleDarwinDNS removes any stale DNS configuration left by the Darwin
|
||||
// configurator from a previous unclean shutdown. This is a static function that can be
|
||||
// called without creating a configurator instance, useful for cleanup before network operations.
|
||||
func CleanupStaleDarwinDNS() error {
|
||||
// Always sweep orphaned Olm scutil keys regardless of whether a state
|
||||
// file exists. This protects against cases where the state file was
|
||||
// lost (e.g., user home wiped, write failed) but DNS keys are still
|
||||
// installed in the running scutil session.
|
||||
defer func() {
|
||||
_ = SweepOlmScutilKeys()
|
||||
// Flush DNS cache after any sweep so changes take effect.
|
||||
_ = exec.Command(dscacheutilPath, "-flushcache").Run()
|
||||
_ = exec.Command("killall", "-HUP", "mDNSResponder").Run()
|
||||
}()
|
||||
|
||||
stateFilePath := getDNSStateFilePath()
|
||||
|
||||
// Check if state file exists
|
||||
data, err := os.ReadFile(stateFilePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// No state file, nothing to clean up
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("read state file: %w", err)
|
||||
}
|
||||
|
||||
var state DNSPersistentState
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
// Invalid state file, remove it
|
||||
os.Remove(stateFilePath)
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(state.CreatedKeys) == 0 {
|
||||
// No keys to clean up
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Info("Found DNS state from previous session, cleaning up %d keys", len(state.CreatedKeys))
|
||||
|
||||
// Remove all keys from previous session using scutil directly
|
||||
for _, key := range state.CreatedKeys {
|
||||
logger.Debug("Removing leftover DNS key: %s", key)
|
||||
cmd := fmt.Sprintf("open\nremove %s\nquit\n", key)
|
||||
scutilCmd := exec.Command(scutilPath)
|
||||
scutilCmd.Stdin = strings.NewReader(cmd)
|
||||
if err := scutilCmd.Run(); err != nil {
|
||||
logger.Warn("Failed to remove DNS key %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear state file
|
||||
if err := os.Remove(stateFilePath); err != nil && !os.IsNotExist(err) {
|
||||
logger.Warn("Failed to clear DNS state file: %v", err)
|
||||
}
|
||||
|
||||
// Flush DNS cache after cleanup
|
||||
cacheCmd := exec.Command(dscacheutilPath, "-flushcache")
|
||||
_ = cacheCmd.Run()
|
||||
|
||||
killCmd := exec.Command("killall", "-HUP", "mDNSResponder")
|
||||
_ = killCmd.Run()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SweepOlmScutilKeys enumerates scutil State:/Network/Service/Olm-* keys and
|
||||
// removes any that are present. This is a best-effort safety net used when
|
||||
// state files have been lost or never written.
|
||||
func SweepOlmScutilKeys() error {
|
||||
// list scutil keys matching our naming convention
|
||||
listOutput, err := runScutilOnce("list State:/Network/Service/Olm-.*/DNS\n")
|
||||
if err != nil {
|
||||
return fmt.Errorf("scutil list: %w", err)
|
||||
}
|
||||
|
||||
var keys []string
|
||||
scanner := bufio.NewScanner(bytes.NewReader(listOutput))
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
// scutil output format: subKey [0] = State:/Network/Service/Olm-Override/DNS
|
||||
idx := strings.Index(line, "State:/Network/Service/Olm-")
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(line[idx:])
|
||||
if key != "" {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Info("Sweeping %d orphaned Olm scutil DNS keys", len(keys))
|
||||
|
||||
var commands strings.Builder
|
||||
for _, key := range keys {
|
||||
commands.WriteString(fmt.Sprintf("remove %s\n", key))
|
||||
}
|
||||
|
||||
if _, err := runScutilOnce(commands.String()); err != nil {
|
||||
return fmt.Errorf("scutil sweep remove: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runScutilOnce runs a one-shot scutil command sequence wrapped with open/quit
|
||||
// without requiring a configurator instance.
|
||||
func runScutilOnce(commands string) ([]byte, error) {
|
||||
wrapped := fmt.Sprintf("open\n%squit\n", commands)
|
||||
cmd := exec.Command(scutilPath)
|
||||
cmd.Stdin = strings.NewReader(wrapped)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scutil command failed: %w, output: %s", err, output)
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
@@ -218,3 +218,27 @@ func copyFile(src, dst string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupStaleFileDNS removes any stale DNS configuration left by the file-based
|
||||
// configurator from a previous unclean shutdown. This is a static function that can be
|
||||
// called without creating a configurator instance, useful for cleanup before network operations.
|
||||
func CleanupStaleFileDNS() error {
|
||||
// Check if backup file exists from a previous session
|
||||
if _, err := os.Stat(resolvConfBackupPath); os.IsNotExist(err) {
|
||||
// No backup file, nothing to clean up
|
||||
return nil
|
||||
}
|
||||
|
||||
// A backup exists, which means we crashed while DNS was configured
|
||||
// Restore the original resolv.conf
|
||||
if err := copyFile(resolvConfBackupPath, resolvConfPath); err != nil {
|
||||
return fmt.Errorf("restore from backup during cleanup: %w", err)
|
||||
}
|
||||
|
||||
// Remove backup file
|
||||
if err := os.Remove(resolvConfBackupPath); err != nil {
|
||||
return fmt.Errorf("remove backup file during cleanup: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ package dns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
@@ -16,12 +17,25 @@ import (
|
||||
|
||||
const (
|
||||
// NetworkManager D-Bus constants
|
||||
networkManagerDest = "org.freedesktop.NetworkManager"
|
||||
networkManagerDbusObjectNode = "/org/freedesktop/NetworkManager"
|
||||
networkManagerDbusDNSManagerInterface = "org.freedesktop.NetworkManager.DnsManager"
|
||||
networkManagerDbusDNSManagerObjectNode = networkManagerDbusObjectNode + "/DnsManager"
|
||||
networkManagerDbusDNSManagerModeProperty = networkManagerDbusDNSManagerInterface + ".Mode"
|
||||
networkManagerDbusVersionProperty = "org.freedesktop.NetworkManager.Version"
|
||||
networkManagerDest = "org.freedesktop.NetworkManager"
|
||||
networkManagerDbusObjectNode = "/org/freedesktop/NetworkManager"
|
||||
networkManagerDbusDNSManagerInterface = "org.freedesktop.NetworkManager.DnsManager"
|
||||
networkManagerDbusDNSManagerObjectNode = networkManagerDbusObjectNode + "/DnsManager"
|
||||
networkManagerDbusDNSManagerModeProperty = networkManagerDbusDNSManagerInterface + ".Mode"
|
||||
networkManagerDbusVersionProperty = "org.freedesktop.NetworkManager.Version"
|
||||
networkManagerDbusActiveConnsProperty = networkManagerDest + ".ActiveConnections"
|
||||
networkManagerDbusActiveInterface = "org.freedesktop.NetworkManager.Connection.Active"
|
||||
networkManagerDbusActiveIP4ConfigProperty = networkManagerDbusActiveInterface + ".Ip4Config"
|
||||
networkManagerDbusActiveIP6ConfigProperty = networkManagerDbusActiveInterface + ".Ip6Config"
|
||||
networkManagerDbusActiveDevicesProperty = networkManagerDbusActiveInterface + ".Devices"
|
||||
networkManagerDbusIP4ConfigInterface = "org.freedesktop.NetworkManager.IP4Config"
|
||||
networkManagerDbusIP6ConfigInterface = "org.freedesktop.NetworkManager.IP6Config"
|
||||
networkManagerDbusDeviceInterface = "org.freedesktop.NetworkManager.Device"
|
||||
networkManagerDbusDeviceDhcp4ConfigProp = networkManagerDbusDeviceInterface + ".Dhcp4Config"
|
||||
networkManagerDbusDeviceDhcp6ConfigProp = networkManagerDbusDeviceInterface + ".Dhcp6Config"
|
||||
networkManagerDbusDhcp4ConfigInterface = "org.freedesktop.NetworkManager.DHCP4Config"
|
||||
networkManagerDbusDhcp6ConfigInterface = "org.freedesktop.NetworkManager.DHCP6Config"
|
||||
networkManagerDbusGetAppliedConnMethod = networkManagerDbusDeviceInterface + ".GetAppliedConnection"
|
||||
|
||||
// NetworkManager dispatcher script path
|
||||
networkManagerDispatcherDir = "/etc/NetworkManager/dispatcher.d"
|
||||
@@ -301,6 +315,220 @@ func GetNetworkManagerDNSMode() (string, error) {
|
||||
return mode, nil
|
||||
}
|
||||
|
||||
// GetNetworkManagerNameservers returns the DNS servers NetworkManager knows
|
||||
// about for every active connection, read live via D-Bus.
|
||||
//
|
||||
// olm's own NetworkManager DNS override (see NetworkManagerDNSConfigurator)
|
||||
// works by writing a [global-dns-domain-*] section to
|
||||
// /etc/NetworkManager/conf.d/olm-dns.conf and reloading NetworkManager. That
|
||||
// is NetworkManager's global DNS override mechanism: it replaces the DNS
|
||||
// servers NetworkManager's DnsManager computes as "effective" system-wide,
|
||||
// for every connection - not just what gets written to /etc/resolv.conf. So
|
||||
// once olm's override is active, even each connection's merged
|
||||
// IP4Config/IP6Config.NameserverData (the previous, sole source used here)
|
||||
// can end up reporting olm's own proxy address instead of the real network
|
||||
// DNS.
|
||||
//
|
||||
// To recover the real DNS regardless, this also reads two further sources
|
||||
// that NetworkManager's DNS merging - and therefore olm's global-dns override
|
||||
// - never touches, since both are populated independently of it:
|
||||
// - Dhcp4Config/Dhcp6Config.Options["*name_servers"]: the raw nameserver
|
||||
// list straight from the DHCP lease.
|
||||
// - Device.GetAppliedConnection()'s ipv4.dns/ipv6.dns: the DNS servers
|
||||
// explicitly configured on the connection profile itself, e.g. a static
|
||||
// DNS override set by the user directly in NetworkManager (the
|
||||
// NetworkManager equivalent of a manually-set Windows adapter DNS).
|
||||
//
|
||||
// IP4Config/IP6Config.NameserverData is still queried too, as a fallback for
|
||||
// setups the other two don't cover. Any of olm's own address that leaks
|
||||
// through any of these sources is expected to be dropped by the caller via
|
||||
// SystemDNSMonitor.SetExcludeIP.
|
||||
func GetNetworkManagerNameservers() ([]netip.Addr, error) {
|
||||
conn, err := dbus.SystemBus()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect to system bus: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
nm := conn.Object(networkManagerDest, networkManagerDbusObjectNode)
|
||||
|
||||
activeVariant, err := nm.GetProperty(networkManagerDbusActiveConnsProperty)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get active connections: %w", err)
|
||||
}
|
||||
activePaths, ok := activeVariant.Value().([]dbus.ObjectPath)
|
||||
if !ok {
|
||||
return nil, errors.New("ActiveConnections is not a list of object paths")
|
||||
}
|
||||
|
||||
ipConfigSources := []struct {
|
||||
activeProperty string
|
||||
configIface string
|
||||
}{
|
||||
{networkManagerDbusActiveIP4ConfigProperty, networkManagerDbusIP4ConfigInterface},
|
||||
{networkManagerDbusActiveIP6ConfigProperty, networkManagerDbusIP6ConfigInterface},
|
||||
}
|
||||
|
||||
seen := make(map[netip.Addr]bool)
|
||||
var servers []netip.Addr
|
||||
add := func(addr netip.Addr) {
|
||||
addr = addr.Unmap()
|
||||
if !addr.IsValid() || addr.IsLoopback() || addr.IsLinkLocalUnicast() {
|
||||
return
|
||||
}
|
||||
if !seen[addr] {
|
||||
seen[addr] = true
|
||||
servers = append(servers, addr)
|
||||
}
|
||||
}
|
||||
|
||||
for _, activePath := range activePaths {
|
||||
active := conn.Object(networkManagerDest, activePath)
|
||||
|
||||
for _, src := range ipConfigSources {
|
||||
cfgVariant, err := active.GetProperty(src.activeProperty)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
cfgPath, ok := cfgVariant.Value().(dbus.ObjectPath)
|
||||
if !ok || cfgPath == "" || cfgPath == "/" {
|
||||
continue
|
||||
}
|
||||
|
||||
nsVariant, err := conn.Object(networkManagerDest, cfgPath).GetProperty(src.configIface + ".NameserverData")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
entries, ok := nsVariant.Value().([]map[string]dbus.Variant)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, entry := range entries {
|
||||
addrVariant, ok := entry["address"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
addrStr, ok := addrVariant.Value().(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if addr, err := netip.ParseAddr(addrStr); err == nil {
|
||||
add(addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
devicesVariant, err := active.GetProperty(networkManagerDbusActiveDevicesProperty)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
devicePaths, ok := devicesVariant.Value().([]dbus.ObjectPath)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, devicePath := range devicePaths {
|
||||
device := conn.Object(networkManagerDest, devicePath)
|
||||
|
||||
for _, addr := range dhcpLeaseNameservers(conn, device, networkManagerDbusDeviceDhcp4ConfigProp, networkManagerDbusDhcp4ConfigInterface, "domain_name_servers") {
|
||||
add(addr)
|
||||
}
|
||||
for _, addr := range dhcpLeaseNameservers(conn, device, networkManagerDbusDeviceDhcp6ConfigProp, networkManagerDbusDhcp6ConfigInterface, "dhcp6_name_servers") {
|
||||
add(addr)
|
||||
}
|
||||
for _, addr := range appliedConnectionNameservers(device) {
|
||||
add(addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return servers, nil
|
||||
}
|
||||
|
||||
// dhcpLeaseNameservers reads a space-separated nameserver list out of a
|
||||
// device's Dhcp4Config/Dhcp6Config Options, straight from the DHCP lease -
|
||||
// data NetworkManager's DNS merging (and therefore olm's own global-dns
|
||||
// override) never touches.
|
||||
func dhcpLeaseNameservers(conn *dbus.Conn, device dbus.BusObject, configProperty, configIface, optionsKey string) []netip.Addr {
|
||||
cfgVariant, err := device.GetProperty(configProperty)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
cfgPath, ok := cfgVariant.Value().(dbus.ObjectPath)
|
||||
if !ok || cfgPath == "" || cfgPath == "/" {
|
||||
return nil
|
||||
}
|
||||
|
||||
optsVariant, err := conn.Object(networkManagerDest, cfgPath).GetProperty(configIface + ".Options")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
opts, ok := optsVariant.Value().(map[string]dbus.Variant)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
raw, ok := opts[optionsKey]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
str, ok := raw.Value().(string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
var addrs []netip.Addr
|
||||
for _, field := range strings.Fields(str) {
|
||||
if addr, err := netip.ParseAddr(field); err == nil {
|
||||
addrs = append(addrs, addr)
|
||||
}
|
||||
}
|
||||
return addrs
|
||||
}
|
||||
|
||||
// appliedConnectionNameservers reads the ipv4.dns/ipv6.dns servers configured
|
||||
// on the device's currently-applied connection profile - e.g. a static DNS
|
||||
// override set by the user directly in NetworkManager - independent of DHCP
|
||||
// and of olm's own global-dns override.
|
||||
func appliedConnectionNameservers(device dbus.BusObject) []netip.Addr {
|
||||
var settings map[string]map[string]dbus.Variant
|
||||
var versionID uint64
|
||||
if err := device.Call(networkManagerDbusGetAppliedConnMethod, 0, uint32(0)).Store(&settings, &versionID); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var addrs []netip.Addr
|
||||
|
||||
if ipv4, ok := settings["ipv4"]; ok {
|
||||
if dnsVariant, ok := ipv4["dns"]; ok {
|
||||
if raw, ok := dnsVariant.Value().([]uint32); ok {
|
||||
for _, v := range raw {
|
||||
var b [4]byte
|
||||
// NetworkManager encodes IPv4 addresses in this setting as
|
||||
// network-byte-order bytes reinterpreted as a native uint32.
|
||||
binary.LittleEndian.PutUint32(b[:], v)
|
||||
addrs = append(addrs, netip.AddrFrom4(b))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ipv6, ok := settings["ipv6"]; ok {
|
||||
if dnsVariant, ok := ipv6["dns"]; ok {
|
||||
if raw, ok := dnsVariant.Value().([][]byte); ok {
|
||||
for _, b := range raw {
|
||||
if len(b) == 16 {
|
||||
var arr [16]byte
|
||||
copy(arr[:], b)
|
||||
addrs = append(addrs, netip.AddrFrom16(arr))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return addrs
|
||||
}
|
||||
|
||||
// GetNetworkManagerVersion returns the version of NetworkManager
|
||||
func GetNetworkManagerVersion() (string, error) {
|
||||
conn, err := dbus.SystemBus()
|
||||
@@ -323,3 +551,41 @@ func GetNetworkManagerVersion() (string, error) {
|
||||
|
||||
return version, nil
|
||||
}
|
||||
|
||||
// CleanupStaleNetworkManagerDNS removes any stale DNS configuration left by NetworkManager
|
||||
// configurator from a previous unclean shutdown. This is a static function that can be called
|
||||
// without creating a configurator instance, useful for cleanup before network operations.
|
||||
func CleanupStaleNetworkManagerDNS() error {
|
||||
confPath := networkManagerConfDir + "/" + networkManagerDNSConfFile
|
||||
|
||||
// Check if our config file exists from a previous session
|
||||
if _, err := os.Stat(confPath); os.IsNotExist(err) {
|
||||
// No config file, nothing to clean up
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove the stale configuration file
|
||||
if err := os.Remove(confPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove stale DNS config file: %w", err)
|
||||
}
|
||||
|
||||
// Try to reload NetworkManager if it's available
|
||||
if IsNetworkManagerAvailable() {
|
||||
conn, err := dbus.SystemBus()
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to system bus for reload: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
obj := conn.Object(networkManagerDest, networkManagerDbusObjectNode)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := obj.CallWithContext(ctx, networkManagerDest+".Reload", 0, uint32(0)).Store(); err != nil {
|
||||
return fmt.Errorf("reload NetworkManager after cleanup: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -219,3 +219,37 @@ func IsResolvconfAvailable() bool {
|
||||
cmd := exec.Command(resolvconfCommand, "--version")
|
||||
return cmd.Run() == nil
|
||||
}
|
||||
|
||||
// CleanupStaleResolvconfDNS removes any stale DNS configuration left by the resolvconf
|
||||
// configurator from a previous unclean shutdown. This is a static function that can be
|
||||
// called without creating a configurator instance, useful for cleanup before network operations.
|
||||
// The interfaceName parameter specifies which interface entry to clean up (typically "olm").
|
||||
func CleanupStaleResolvconfDNS(interfaceName string) error {
|
||||
if !IsResolvconfAvailable() {
|
||||
// resolvconf not available, nothing to clean up
|
||||
return nil
|
||||
}
|
||||
|
||||
// Detect resolvconf implementation type
|
||||
implType, err := detectResolvconfType()
|
||||
if err != nil {
|
||||
// Can't detect type, try default
|
||||
implType = "resolvconf"
|
||||
}
|
||||
|
||||
// Try to delete any existing entry for this interface
|
||||
// This is idempotent - if no entry exists, resolvconf will just return success
|
||||
var cmd *exec.Cmd
|
||||
|
||||
switch implType {
|
||||
case "openresolv":
|
||||
cmd = exec.Command(resolvconfCommand, "-f", "-d", interfaceName)
|
||||
default:
|
||||
cmd = exec.Command(resolvconfCommand, "-d", interfaceName)
|
||||
}
|
||||
|
||||
// Ignore errors - the entry may not exist, which is fine
|
||||
_ = cmd.Run()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
package dns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
const defaultPollInterval = 30 * time.Second
|
||||
|
||||
// dnsHealthCheckTimeout bounds how long we wait for a candidate DNS server to
|
||||
// answer a health-check query before considering it unusable.
|
||||
const dnsHealthCheckTimeout = 2 * time.Second
|
||||
|
||||
// SystemDNSMonitor monitors the host system's DNS configuration and notifies
|
||||
// callers when it changes. The reported servers are in "host:port" format
|
||||
// (e.g. "8.8.8.8:53") and can be used directly as UpstreamDNS and PublicDNS.
|
||||
//
|
||||
// Platform behaviour:
|
||||
// - Linux: reads /run/systemd/resolve/resolv.conf when present (updated by
|
||||
// systemd-resolved on every DHCP change), then falls back to
|
||||
// /etc/resolv.conf.olm.backup (written before olm overrides DNS), and
|
||||
// finally /etc/resolv.conf.
|
||||
// - macOS: reads the unscoped resolvers from `scutil --dns`, falling back
|
||||
// to /etc/resolv.conf if scutil is unavailable. This includes olm's own
|
||||
// supplemental scutil DNS override entry, which is expected to be
|
||||
// filtered out via SetExcludeIP.
|
||||
// - Windows: enumerates every network adapter's effective DNS servers
|
||||
// (static if set, else DHCP-assigned) from the registry.
|
||||
// - Other platforms: returns an empty list (no-op monitor).
|
||||
type SystemDNSMonitor struct {
|
||||
mu sync.RWMutex
|
||||
current []string // last health-checked, applied server list
|
||||
lastRaw []string // last raw (exclude-filtered but unvalidated) candidate list seen
|
||||
onChange func(servers []string)
|
||||
interval time.Duration
|
||||
stopCh chan struct{}
|
||||
excludeMu sync.RWMutex
|
||||
excludeIPs map[netip.Addr]bool
|
||||
}
|
||||
|
||||
// NewSystemDNSMonitor creates a new monitor. onChange is called with the new
|
||||
// server list whenever a change is detected; it is also called once from Start
|
||||
// with the initial values. A zero interval uses the 30-second default.
|
||||
func NewSystemDNSMonitor(interval time.Duration, onChange func(servers []string)) *SystemDNSMonitor {
|
||||
if interval <= 0 {
|
||||
interval = defaultPollInterval
|
||||
}
|
||||
return &SystemDNSMonitor{
|
||||
interval: interval,
|
||||
onChange: onChange,
|
||||
stopCh: make(chan struct{}),
|
||||
excludeIPs: make(map[netip.Addr]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// SetExcludeIP registers an IP address that must never appear in the reported
|
||||
// DNS server list. Call this after olm's DNS proxy is created to prevent the
|
||||
// proxy's own IP from being returned as an upstream server when the OS DNS has
|
||||
// been overridden to point at the proxy.
|
||||
func (m *SystemDNSMonitor) SetExcludeIP(ip netip.Addr) {
|
||||
m.excludeMu.Lock()
|
||||
m.excludeIPs[ip.Unmap()] = true
|
||||
m.excludeMu.Unlock()
|
||||
}
|
||||
|
||||
// Start reads the current system DNS immediately, fires onChange, then polls
|
||||
// in the background until Stop is called or ctx is cancelled.
|
||||
func (m *SystemDNSMonitor) Start(ctx context.Context) {
|
||||
m.applyCandidates(m.readFiltered())
|
||||
go m.run(ctx)
|
||||
}
|
||||
|
||||
// Stop halts the background polling goroutine.
|
||||
func (m *SystemDNSMonitor) Stop() {
|
||||
select {
|
||||
case <-m.stopCh:
|
||||
default:
|
||||
close(m.stopCh)
|
||||
}
|
||||
}
|
||||
|
||||
// Current returns the most recently observed system DNS servers.
|
||||
func (m *SystemDNSMonitor) Current() []string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
out := make([]string, len(m.current))
|
||||
copy(out, m.current)
|
||||
return out
|
||||
}
|
||||
|
||||
// readFiltered calls the platform-specific readSystemDNS and removes any
|
||||
// addresses that have been excluded via SetExcludeIP. If all addresses are
|
||||
// excluded the function returns nil so the caller can retain the last
|
||||
// known-good value.
|
||||
func (m *SystemDNSMonitor) readFiltered() []string {
|
||||
return m.filterExcluded(readSystemDNS())
|
||||
}
|
||||
|
||||
// filterExcluded removes any addresses that have been excluded via
|
||||
// SetExcludeIP from servers. Used both for the internally-polled server list
|
||||
// (readFiltered) and for server lists reported externally (ReportExternal) by
|
||||
// platforms - Android, iOS - where olm cannot read the OS's DNS configuration
|
||||
// itself.
|
||||
func (m *SystemDNSMonitor) filterExcluded(servers []string) []string {
|
||||
m.excludeMu.RLock()
|
||||
excludeIPs := m.excludeIPs
|
||||
m.excludeMu.RUnlock()
|
||||
|
||||
if len(excludeIPs) == 0 {
|
||||
return servers
|
||||
}
|
||||
|
||||
var filtered []string
|
||||
for _, s := range servers {
|
||||
host, _, err := net.SplitHostPort(s)
|
||||
if err != nil {
|
||||
filtered = append(filtered, s)
|
||||
continue
|
||||
}
|
||||
addr, err := netip.ParseAddr(host)
|
||||
if err != nil || excludeIPs[addr.Unmap()] {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, s)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// ReportExternal applies an externally-observed DNS server list (e.g. from
|
||||
// Android's ConnectivityManager or iOS's SCDynamicStore, where the platform
|
||||
// itself - not olm - must detect the OS's real DNS configuration) through the
|
||||
// same exclude-IP filtering, health-check validation, and change-detection as
|
||||
// the internal poll loop, firing onChange if the result differs from the last
|
||||
// known value.
|
||||
func (m *SystemDNSMonitor) ReportExternal(servers []string) {
|
||||
m.applyCandidates(m.filterExcluded(servers))
|
||||
}
|
||||
|
||||
func (m *SystemDNSMonitor) run(ctx context.Context) {
|
||||
ticker := time.NewTicker(m.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-m.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
m.applyCandidates(m.readFiltered())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// applyCandidates takes an exclude-filtered (but not yet health-checked) list
|
||||
// of candidate DNS servers - from either the internal poll loop or
|
||||
// ReportExternal - and, only if it differs from the last raw list seen (to
|
||||
// avoid re-running network health checks on every 30-second poll tick when
|
||||
// nothing has actually changed), health-checks it via filterUnreachable and
|
||||
// applies whatever passes, firing onChange if the result changed.
|
||||
//
|
||||
// If none of the candidates pass the health check, the previous known-good
|
||||
// value is retained rather than clobbered - this is what protects against
|
||||
// e.g. a carrier reporting a DNS server (such as T-Mobile's internal ULA
|
||||
// DNS64 resolvers) that is technically "the system DNS" but not actually
|
||||
// reachable/usable from wherever queries are sent.
|
||||
func (m *SystemDNSMonitor) applyCandidates(raw []string) {
|
||||
if len(raw) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
if dnsSlicesEqual(m.lastRaw, raw) {
|
||||
m.mu.Unlock()
|
||||
logger.Debug("System DNS candidates unchanged, skipping health check: %v", raw)
|
||||
return
|
||||
}
|
||||
m.lastRaw = raw
|
||||
m.mu.Unlock()
|
||||
|
||||
logger.Debug("System DNS candidates changed, health-checking: %v", raw)
|
||||
validated := filterUnreachable(raw)
|
||||
if len(validated) == 0 {
|
||||
logger.Warn("None of the detected DNS servers answered a health-check query, keeping previous value: %v", raw)
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
changed := !dnsSlicesEqual(m.current, validated)
|
||||
if changed {
|
||||
m.current = validated
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
if changed && m.onChange != nil {
|
||||
logger.Info("System DNS changed: %v", validated)
|
||||
m.onChange(validated)
|
||||
}
|
||||
}
|
||||
|
||||
// dnsServerReachable is a seam for tests; production code always uses probeDNSServerErr.
|
||||
var dnsServerReachable = probeDNSServerErr
|
||||
|
||||
// filterUnreachable validates that each candidate server actually answers a
|
||||
// DNS query before it's trusted, rather than statically guessing from the
|
||||
// address (e.g. rejecting all private/ULA addresses, which would also reject
|
||||
// a perfectly valid home router forwarding to a real resolver). Checks run
|
||||
// concurrently so multiple candidates don't serialize the timeout.
|
||||
func filterUnreachable(servers []string) []string {
|
||||
if len(servers) == 0 {
|
||||
return servers
|
||||
}
|
||||
|
||||
reachable := make([]bool, len(servers))
|
||||
errs := make([]error, len(servers))
|
||||
var wg sync.WaitGroup
|
||||
for i, server := range servers {
|
||||
wg.Add(1)
|
||||
go func(i int, server string) {
|
||||
defer wg.Done()
|
||||
reachable[i], errs[i] = dnsServerReachable(server)
|
||||
}(i, server)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
var result []string
|
||||
for i, server := range servers {
|
||||
if reachable[i] {
|
||||
result = append(result, server)
|
||||
} else {
|
||||
logger.Debug("Discarding DNS server %s: failed health check: %v", server, errs[i])
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// probeDNSServerErr sends a minimal root NS query to confirm a candidate server
|
||||
// actually answers, without depending on any specific external hostname being
|
||||
// reachable (which could itself be blocked/filtered independently of whether
|
||||
// the resolver works). The returned error is kept (rather than just a bool) so
|
||||
// callers can log why a candidate was rejected (unreachable route, timeout, etc.).
|
||||
func probeDNSServerErr(server string) (bool, error) {
|
||||
client := &dns.Client{Timeout: dnsHealthCheckTimeout}
|
||||
msg := new(dns.Msg)
|
||||
msg.SetQuestion(".", dns.TypeNS)
|
||||
_, _, err := client.Exchange(msg, server)
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
// dnsSlicesEqual reports whether two server lists are equal regardless of order.
|
||||
func dnsSlicesEqual(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
ac := make([]string, len(a))
|
||||
bc := make([]string, len(b))
|
||||
copy(ac, a)
|
||||
copy(bc, b)
|
||||
sort.Strings(ac)
|
||||
sort.Strings(bc)
|
||||
for i := range ac {
|
||||
if ac[i] != bc[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//go:build android
|
||||
|
||||
package dns
|
||||
|
||||
// readSystemDNS returns nil on Android: olm cannot read the OS's DNS
|
||||
// configuration itself here, so the app detects it (via ConnectivityManager)
|
||||
// and pushes it in through Olm.SetSystemDNS instead (see SystemDnsMonitor.java).
|
||||
//
|
||||
// This is a dedicated file (rather than falling through the general
|
||||
// sysresolver_stub.go catch-all) because wireguard-android's build passes
|
||||
// "-tags linux" to share Linux netlink code with Android, and that custom tag
|
||||
// makes "!linux" evaluate to false even on a real GOOS=android build, which
|
||||
// would otherwise make sysresolver_stub.go stop applying and leave
|
||||
// readSystemDNS undefined. An explicit "android" constraint isn't affected by
|
||||
// that, since nothing passes a conflicting "-tags android".
|
||||
func readSystemDNS() []string {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//go:build darwin && !ios && !nosysresolver
|
||||
|
||||
package dns
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// scutilPath is the well-known location of scutil on macOS.
|
||||
const scutilPath = "/usr/sbin/scutil"
|
||||
|
||||
// readSystemDNS returns the current system DNS servers in "host:53" format.
|
||||
//
|
||||
// olm's own DNS override is itself a scutil supplemental resolver (see
|
||||
// dns/platform/darwin.go), and macOS gives supplemental resolvers priority
|
||||
// over the primary network service's resolver when generating the merged
|
||||
// configuration - which is also what gets mirrored into /etc/resolv.conf. So
|
||||
// once olm's override is active, /etc/resolv.conf (and a naive read of just
|
||||
// the top of "scutil --dns") reflects olm's own proxy address, not the
|
||||
// physical network's real DNS.
|
||||
//
|
||||
// Instead this reads every resolver in the unscoped "DNS configuration"
|
||||
// section of `scutil --dns` (the "(for scoped queries)" section that follows
|
||||
// only duplicates per-interface resolvers and is skipped), which includes
|
||||
// both the real physical-network resolver and olm's own supplemental one.
|
||||
// olm's own address is expected to be filtered out by the caller via
|
||||
// SystemDNSMonitor.SetExcludeIP, the same mechanism used on Windows to drop
|
||||
// olm's own adapter DNS entry.
|
||||
//
|
||||
// /etc/resolv.conf is kept as a fallback for when scutil is unavailable.
|
||||
func readSystemDNS() []string {
|
||||
if out, err := exec.Command(scutilPath, "--dns").Output(); err == nil {
|
||||
if servers := parseScutilDNS(string(out)); len(servers) > 0 {
|
||||
return servers
|
||||
}
|
||||
}
|
||||
return parseMacResolvConf("/etc/resolv.conf")
|
||||
}
|
||||
|
||||
// parseScutilDNS extracts nameserver addresses from the unscoped "DNS
|
||||
// configuration" section at the top of `scutil --dns` output, stopping at
|
||||
// the "DNS configuration (for scoped queries)" section that follows it.
|
||||
func parseScutilDNS(output string) []string {
|
||||
var result []string
|
||||
seen := make(map[string]bool)
|
||||
|
||||
scanner := bufio.NewScanner(strings.NewReader(output))
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if strings.HasPrefix(line, "DNS configuration (for scoped queries)") {
|
||||
break
|
||||
}
|
||||
if !strings.HasPrefix(line, "nameserver[") {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
addr, err := netip.ParseAddr(strings.TrimSpace(parts[1]))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if addr.IsLoopback() || addr.IsLinkLocalUnicast() {
|
||||
continue
|
||||
}
|
||||
hp := net.JoinHostPort(addr.String(), "53")
|
||||
if !seen[hp] {
|
||||
seen[hp] = true
|
||||
result = append(result, hp)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parseMacResolvConf(path string) []string {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var result []string
|
||||
seen := make(map[string]bool)
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if !strings.HasPrefix(line, "nameserver") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
addr, err := netip.ParseAddr(fields[1])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if addr.IsLoopback() || addr.IsLinkLocalUnicast() {
|
||||
continue
|
||||
}
|
||||
s := net.JoinHostPort(addr.String(), "53")
|
||||
if !seen[s] {
|
||||
seen[s] = true
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build darwin && !ios && nosysresolver
|
||||
|
||||
package dns
|
||||
|
||||
// readSystemDNS is disabled by the nosysresolver build tag. This is used for
|
||||
// the macOS app build: unlike the CLI, the app's PacketTunnel system
|
||||
// extension additionally applies NEDNSSettings (see apple/PacketTunnel),
|
||||
// which can become the system's primary resolver and make /etc/resolv.conf
|
||||
// reflect olm's own proxy IP instead of the real upstream DNS. Rather than
|
||||
// have olm poll a value that may be self-referential, the app pushes the
|
||||
// real DNS servers in via SetSystemDNS (detected in Swift via
|
||||
// SCDynamicStore) exactly like Android and iOS. The CLI keeps the real
|
||||
// implementation in sysresolver_darwin.go, since it has no such override
|
||||
// mechanism and /etc/resolv.conf always reflects the physical network there.
|
||||
func readSystemDNS() []string {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//go:build ios
|
||||
|
||||
package dns
|
||||
|
||||
// readSystemDNS returns nil on iOS: olm cannot read the OS's DNS
|
||||
// configuration itself here, so the app must detect it and push it in
|
||||
// through the equivalent of Olm.SetSystemDNS instead.
|
||||
//
|
||||
// This is a dedicated file (rather than falling through the general
|
||||
// sysresolver_stub.go catch-all) because Go's build constraint evaluator
|
||||
// treats GOOS=ios as implicitly satisfying the "darwin" tag as well as
|
||||
// "ios". sysresolver_stub.go excludes with "!darwin", which is false for an
|
||||
// iOS build, so the stub silently stops applying and would leave
|
||||
// readSystemDNS undefined. An explicit "ios" constraint isn't affected by
|
||||
// that ambiguity.
|
||||
func readSystemDNS() []string {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
//go:build linux && !android
|
||||
|
||||
package dns
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
platform "github.com/fosrl/olm/dns/platform"
|
||||
)
|
||||
|
||||
// readSystemDNS returns the current system DNS servers in "host:53" format.
|
||||
//
|
||||
// Resolution order:
|
||||
// 1. /run/systemd/resolve/resolv.conf, but only when systemd-resolved is
|
||||
// actually running (checked live via D-Bus) — the file lives in /run and
|
||||
// can linger there, frozen at whatever it last contained, long after the
|
||||
// service that maintained it has stopped (e.g. it ran earlier in the
|
||||
// boot and was since disabled). Trusting its mere existence would report
|
||||
// that stale snapshot forever instead of falling through to a live
|
||||
// source. When the service is actually up the file is maintained with
|
||||
// the real per-link DNS servers, updated on every DHCP change and never
|
||||
// touched by olm's D-Bus DNS override.
|
||||
// 2. NetworkManager, queried live over D-Bus — NetworkManager's own view of
|
||||
// each active connection's DNS servers, independent of what is currently
|
||||
// written to /etc/resolv.conf. This covers NetworkManager's "dnsmasq" and
|
||||
// "unbound" DNS modes, where /etc/resolv.conf only contains a loopback
|
||||
// stub address, and stays accurate even if olm's own override has
|
||||
// directly overwritten /etc/resolv.conf, without going stale the way a
|
||||
// one-time backup snapshot would if the real DNS changes mid-override
|
||||
// (e.g. the user switches WiFi networks). olm's own NetworkManager
|
||||
// override is itself a NetworkManager-level global DNS override (see
|
||||
// platform.GetNetworkManagerNameservers), so this also reads each
|
||||
// device's raw DHCP lease and applied-connection settings, which that
|
||||
// override does not touch, to recover the real servers.
|
||||
// 3. /etc/resolv.conf.olm.backup — written by olm before it overrides
|
||||
// /etc/resolv.conf on non-systemd systems, for when NetworkManager isn't
|
||||
// in use at all.
|
||||
// 4. /etc/resolv.conf — plain fallback.
|
||||
//
|
||||
// Loopback and link-local addresses (e.g. 127.0.0.53, ::1) are excluded
|
||||
// because they are stub resolver addresses, not real upstream servers.
|
||||
func readSystemDNS() []string {
|
||||
// Prefer systemd-resolved's resolved (non-stub) resolv.conf, but only if
|
||||
// systemd-resolved is actually alive right now - see resolution order
|
||||
// note above on why the file's existence alone isn't enough.
|
||||
if platform.IsSystemdResolvedAvailable() {
|
||||
if servers := parseResolvConf("/run/systemd/resolve/resolv.conf"); len(servers) > 0 {
|
||||
return servers
|
||||
}
|
||||
}
|
||||
|
||||
if servers := readNetworkManagerDNS(); len(servers) > 0 {
|
||||
return servers
|
||||
}
|
||||
|
||||
// If olm has already overridden /etc/resolv.conf the backup holds the
|
||||
// original pre-override DNS servers.
|
||||
if _, err := os.Stat("/etc/resolv.conf.olm.backup"); err == nil {
|
||||
if servers := parseResolvConf("/etc/resolv.conf.olm.backup"); len(servers) > 0 {
|
||||
return servers
|
||||
}
|
||||
}
|
||||
|
||||
return parseResolvConf("/etc/resolv.conf")
|
||||
}
|
||||
|
||||
// readNetworkManagerDNS returns the DNS servers NetworkManager reports over
|
||||
// D-Bus for its active connections, in "host:53" format. Returns nil if
|
||||
// NetworkManager isn't running or reports nothing usable.
|
||||
func readNetworkManagerDNS() []string {
|
||||
addrs, err := platform.GetNetworkManagerNameservers()
|
||||
if err != nil || len(addrs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(addrs))
|
||||
for _, addr := range addrs {
|
||||
result = append(result, addrToHostPort(addr))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// parseResolvConf reads nameserver lines from a resolv.conf-style file,
|
||||
// skipping loopback and link-local addresses.
|
||||
func parseResolvConf(path string) []string {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var result []string
|
||||
seen := make(map[string]bool)
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if !strings.HasPrefix(line, "nameserver") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
addr, err := netip.ParseAddr(fields[1])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if addr.IsLoopback() || addr.IsLinkLocalUnicast() {
|
||||
continue
|
||||
}
|
||||
s := addrToHostPort(addr)
|
||||
if !seen[s] {
|
||||
seen[s] = true
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// addrToHostPort converts a netip.Addr to "addr:53" format, wrapping IPv6
|
||||
// addresses in brackets as required by net.JoinHostPort.
|
||||
func addrToHostPort(addr netip.Addr) string {
|
||||
return net.JoinHostPort(addr.String(), "53")
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//go:build !linux && !darwin && !windows && !android
|
||||
|
||||
package dns
|
||||
|
||||
// readSystemDNS returns nil on platforms where automatic DNS discovery is not
|
||||
// implemented (freebsd, etc.). Callers should fall back to a statically
|
||||
// configured DNS server.
|
||||
//
|
||||
// android and ios are excluded from this constraint (and have their own
|
||||
// sysresolver_android.go / sysresolver_ios.go with an explicit "android" /
|
||||
// "ios" tag) rather than falling through the "!linux" / "!darwin" catch-all
|
||||
// here:
|
||||
// - wireguard-android's build passes "-tags linux" to share Linux netlink code
|
||||
// (a deliberate, long-standing convention, since Android's kernel is Linux), and Go's
|
||||
// build constraint evaluator can't distinguish a custom "-tags linux" from the real
|
||||
// GOOS=linux - so with that tag set, "!linux" is false even though GOOS is actually
|
||||
// android, and this file would silently stop applying, leaving readSystemDNS undefined.
|
||||
// - Go's build constraint evaluator treats GOOS=ios as implicitly satisfying the
|
||||
// "darwin" tag as well as "ios", so "!darwin" is false on an iOS build too, which
|
||||
// would otherwise leave readSystemDNS undefined there as well.
|
||||
func readSystemDNS() []string {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package dns
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// stubReachable overrides dnsServerReachable for the duration of the test so
|
||||
// tests don't depend on real network access, restoring the original on
|
||||
// cleanup.
|
||||
func stubReachable(t *testing.T, fn func(server string) bool) {
|
||||
t.Helper()
|
||||
orig := dnsServerReachable
|
||||
dnsServerReachable = func(server string) (bool, error) { return fn(server), nil }
|
||||
t.Cleanup(func() { dnsServerReachable = orig })
|
||||
}
|
||||
|
||||
func allReachable(t *testing.T) {
|
||||
stubReachable(t, func(string) bool { return true })
|
||||
}
|
||||
|
||||
func TestReportExternalFiltersExcludedIP(t *testing.T) {
|
||||
allReachable(t)
|
||||
|
||||
var got []string
|
||||
m := &SystemDNSMonitor{
|
||||
excludeIPs: make(map[netip.Addr]bool),
|
||||
onChange: func(servers []string) {
|
||||
got = servers
|
||||
},
|
||||
}
|
||||
m.SetExcludeIP(netip.MustParseAddr("10.0.0.1"))
|
||||
|
||||
m.ReportExternal([]string{"10.0.0.1:53", "8.8.8.8:53"})
|
||||
|
||||
want := []string{"8.8.8.8:53"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("onChange servers = %v, want %v", got, want)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Current(), want) {
|
||||
t.Fatalf("Current() = %v, want %v", m.Current(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportExternalAllExcludedIsNoop(t *testing.T) {
|
||||
allReachable(t)
|
||||
|
||||
called := false
|
||||
m := &SystemDNSMonitor{
|
||||
excludeIPs: make(map[netip.Addr]bool),
|
||||
current: []string{"1.1.1.1:53"},
|
||||
onChange: func(servers []string) {
|
||||
called = true
|
||||
},
|
||||
}
|
||||
m.SetExcludeIP(netip.MustParseAddr("10.0.0.1"))
|
||||
|
||||
m.ReportExternal([]string{"10.0.0.1:53"})
|
||||
|
||||
if called {
|
||||
t.Fatal("onChange should not fire when all reported servers are excluded")
|
||||
}
|
||||
want := []string{"1.1.1.1:53"}
|
||||
if !reflect.DeepEqual(m.Current(), want) {
|
||||
t.Fatalf("Current() = %v, want unchanged %v", m.Current(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportExternalOnlyFiresOnChange(t *testing.T) {
|
||||
allReachable(t)
|
||||
|
||||
calls := 0
|
||||
m := &SystemDNSMonitor{
|
||||
excludeIPs: make(map[netip.Addr]bool),
|
||||
onChange: func(servers []string) {
|
||||
calls++
|
||||
},
|
||||
}
|
||||
|
||||
m.ReportExternal([]string{"8.8.8.8:53"})
|
||||
m.ReportExternal([]string{"8.8.8.8:53"})
|
||||
|
||||
if calls != 1 {
|
||||
t.Fatalf("onChange fired %d times, want 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportExternalDropsUnreachableServer(t *testing.T) {
|
||||
// Simulates e.g. T-Mobile's private ULA DNS64 resolver: technically "the
|
||||
// system DNS" per the OS, but doesn't actually answer queries.
|
||||
stubReachable(t, func(server string) bool {
|
||||
return server != "[fd00:976a::9]:53"
|
||||
})
|
||||
|
||||
var got []string
|
||||
m := &SystemDNSMonitor{
|
||||
excludeIPs: make(map[netip.Addr]bool),
|
||||
onChange: func(servers []string) {
|
||||
got = servers
|
||||
},
|
||||
}
|
||||
|
||||
m.ReportExternal([]string{"[fd00:976a::9]:53", "8.8.8.8:53"})
|
||||
|
||||
want := []string{"8.8.8.8:53"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("onChange servers = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportExternalKeepsPreviousValueWhenAllUnreachable(t *testing.T) {
|
||||
stubReachable(t, func(string) bool { return true })
|
||||
|
||||
called := false
|
||||
m := &SystemDNSMonitor{
|
||||
excludeIPs: make(map[netip.Addr]bool),
|
||||
current: []string{"1.1.1.1:53"},
|
||||
onChange: func(servers []string) {
|
||||
called = true
|
||||
},
|
||||
}
|
||||
|
||||
// Now simulate every candidate failing the health check (e.g. moved to a
|
||||
// network where none of the reported servers actually respond).
|
||||
stubReachable(t, func(string) bool { return false })
|
||||
|
||||
m.ReportExternal([]string{"[fd00:976a::9]:53", "[fd00:976a::10]:53"})
|
||||
|
||||
if called {
|
||||
t.Fatal("onChange should not fire when no candidate passes the health check")
|
||||
}
|
||||
want := []string{"1.1.1.1:53"}
|
||||
if !reflect.DeepEqual(m.Current(), want) {
|
||||
t.Fatalf("Current() = %v, want unchanged %v", m.Current(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterUnreachable(t *testing.T) {
|
||||
stubReachable(t, func(server string) bool {
|
||||
return server == "8.8.8.8:53"
|
||||
})
|
||||
|
||||
got := filterUnreachable([]string{"10.0.0.1:53", "8.8.8.8:53", "9.9.9.9:53"})
|
||||
want := []string{"8.8.8.8:53"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("filterUnreachable() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//go:build windows
|
||||
|
||||
package dns
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
|
||||
"golang.org/x/sys/windows/registry"
|
||||
)
|
||||
|
||||
const (
|
||||
tcpipInterfacesPath = `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces`
|
||||
dhcpNameServerKey = "DhcpNameServer"
|
||||
staticNameServerKey = "NameServer"
|
||||
)
|
||||
|
||||
// readSystemDNS returns the current system DNS servers in "host:53" format by
|
||||
// enumerating every network adapter in the Windows registry.
|
||||
//
|
||||
// For each adapter olm reads the effective DNS servers: static (NameServer)
|
||||
// if set, since a static entry overrides DHCP for that adapter and is what
|
||||
// the OS resolver actually uses, otherwise falling back to the DHCP-assigned
|
||||
// servers (DhcpNameServer). This also picks up olm's own WireGuard adapter,
|
||||
// which olm points at its local DNS proxy via a static NameServer entry; that
|
||||
// address is expected to be filtered out by the caller via
|
||||
// SystemDNSMonitor.SetExcludeIP. Loopback and link-local addresses are
|
||||
// excluded.
|
||||
func readSystemDNS() []string {
|
||||
key, err := registry.OpenKey(registry.LOCAL_MACHINE, tcpipInterfacesPath, registry.ENUMERATE_SUB_KEYS)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer key.Close()
|
||||
|
||||
subkeys, err := key.ReadSubKeyNames(-1)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
var result []string
|
||||
|
||||
for _, guid := range subkeys {
|
||||
path := fmt.Sprintf(`%s\%s`, tcpipInterfacesPath, guid)
|
||||
iKey, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
servers, _, err := iKey.GetStringValue(staticNameServerKey)
|
||||
if err != nil || servers == "" {
|
||||
servers, _, err = iKey.GetStringValue(dhcpNameServerKey)
|
||||
}
|
||||
iKey.Close()
|
||||
if err != nil || servers == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, s := range splitWinDNSList(servers) {
|
||||
addr, err := netip.ParseAddr(s)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if addr.IsLoopback() || addr.IsLinkLocalUnicast() {
|
||||
continue
|
||||
}
|
||||
hp := net.JoinHostPort(addr.String(), "53")
|
||||
if !seen[hp] {
|
||||
seen[hp] = true
|
||||
result = append(result, hp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// splitWinDNSList splits a Windows DNS server list that may be comma- or
|
||||
// space-separated.
|
||||
func splitWinDNSList(s string) []string {
|
||||
var out []string
|
||||
for _, part := range splitByRunes(s, []rune{',', ' '}) {
|
||||
if part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func splitByRunes(s string, delims []rune) []string {
|
||||
var result []string
|
||||
start := 0
|
||||
for i, r := range s {
|
||||
for _, d := range delims {
|
||||
if r == d {
|
||||
if i > start {
|
||||
result = append(result, s[start:i])
|
||||
}
|
||||
start = i + len(string(r))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if start < len(s) {
|
||||
result = append(result, s[start:])
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
dnsOverride "github.com/fosrl/olm/dns/override"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultWatchdogInterval = 5 * time.Second
|
||||
defaultWatchdogThreshold = 3
|
||||
)
|
||||
|
||||
// runDNSWatchdogCommand handles the `olm watchdog` subcommand. The watchdog
|
||||
// is meant to be spawned by an olm process after it installs a DNS
|
||||
// override, and forcibly resets the system DNS if that parent dies before
|
||||
// restoring it.
|
||||
func runDNSWatchdogCommand(ctx context.Context, args []string) error {
|
||||
fs := flag.NewFlagSet("watchdog", flag.ContinueOnError)
|
||||
parentPID := fs.Int("parent-pid", 0, "PID of the olm process to monitor")
|
||||
socketPath := fs.String("socket", "", "Path to the olm API unix socket (optional)")
|
||||
interfaceName := fs.String("interface", "", "WireGuard interface name (used for cleanup)")
|
||||
interval := fs.Duration("interval", defaultWatchdogInterval, "Liveness check interval")
|
||||
threshold := fs.Int("threshold", defaultWatchdogThreshold, "Consecutive failures before DNS reset")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if *parentPID <= 0 {
|
||||
return fmt.Errorf("--parent-pid is required and must be positive")
|
||||
}
|
||||
|
||||
// Ensure logger is initialised for the watchdog process.
|
||||
logger.Init(nil)
|
||||
|
||||
return dnsOverride.RunWatchdog(ctx, dnsOverride.WatchdogConfig{
|
||||
ParentPID: *parentPID,
|
||||
SocketPath: *socketPath,
|
||||
InterfaceName: *interfaceName,
|
||||
CheckInterval: *interval,
|
||||
FailureThreshold: *threshold,
|
||||
})
|
||||
}
|
||||
|
||||
// runResetDNSCommand handles the `olm reset-dns` subcommand. It forcibly
|
||||
// removes any DNS override state left behind on the system.
|
||||
func runResetDNSCommand(args []string) error {
|
||||
fs := flag.NewFlagSet("reset-dns", flag.ContinueOnError)
|
||||
interfaceName := fs.String("interface", "olm", "WireGuard interface name")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Init(nil)
|
||||
|
||||
if err := dnsOverride.ForceResetDNS(*interfaceName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("DNS reset complete")
|
||||
return nil
|
||||
}
|
||||
@@ -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.8.1
|
||||
github.com/fosrl/newt v1.15.0
|
||||
github.com/godbus/dbus/v5 v5.2.2
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/miekg/dns v1.1.70
|
||||
golang.org/x/sys v0.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,13 +20,16 @@ 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
|
||||
// replace github.com/fosrl/newt => ../newt
|
||||
|
||||
@@ -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.8.1 h1:oP3xBEISoO/TENsHccqqs6LXpoOWCt6aiP75CfIWpvk=
|
||||
github.com/fosrl/newt v1.8.1/go.mod h1:pol958CEs0nQmo/35Ltv0CGksheIKCS2hoNvdTVLEcI=
|
||||
github.com/fosrl/newt v1.15.0 h1:WpL0whZM1FMjUe2Vy5jSH1bgbxm1O9k1qCyF/mqZT+s=
|
||||
github.com/fosrl/newt v1.15.0/go.mod h1:l6kWoZPSaXT+ZRUjiyPgwflRqZWYaXpUj9oQ0sOPh4o=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
|
||||
@@ -16,33 +16,33 @@ github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW
|
||||
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
|
||||
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
|
||||
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
||||
golang.org/x/crypto v0.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=
|
||||
|
||||
@@ -10,9 +10,11 @@ import (
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
"github.com/fosrl/newt/updates"
|
||||
"github.com/fosrl/olm/olm"
|
||||
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)
|
||||
@@ -210,7 +231,7 @@ func runOlmMainWithArgs(ctx context.Context, cancel context.CancelFunc, signalCt
|
||||
}
|
||||
|
||||
// Create a new olm.Config struct and copy values from the main config
|
||||
olmConfig := olm.GlobalConfig{
|
||||
olmConfig := olmpkg.OlmConfig{
|
||||
LogLevel: config.LogLevel,
|
||||
EnableAPI: config.EnableAPI,
|
||||
HTTPAddr: config.HTTPAddr,
|
||||
@@ -219,15 +240,22 @@ func runOlmMainWithArgs(ctx context.Context, cancel context.CancelFunc, signalCt
|
||||
Agent: "Olm CLI",
|
||||
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)
|
||||
if err != nil {
|
||||
logger.Fatal("Failed to initialize olm: %v", err)
|
||||
}
|
||||
|
||||
olm.Init(ctx, olmConfig)
|
||||
if err := olm.StartApi(); err != nil {
|
||||
logger.Fatal("Failed to start API server: %v", err)
|
||||
}
|
||||
|
||||
if config.ID != "" && config.Secret != "" && config.Endpoint != "" {
|
||||
tunnelConfig := olm.TunnelConfig{
|
||||
tunnelConfig := olmpkg.TunnelConfig{
|
||||
Endpoint: config.Endpoint,
|
||||
ID: config.ID,
|
||||
Secret: config.Secret,
|
||||
@@ -235,6 +263,7 @@ func runOlmMainWithArgs(ctx context.Context, cancel context.CancelFunc, signalCt
|
||||
MTU: config.MTU,
|
||||
DNS: config.DNS,
|
||||
UpstreamDNS: config.UpstreamDNS,
|
||||
MatchDomains: config.MatchDomains,
|
||||
InterfaceName: config.InterfaceName,
|
||||
Holepunch: !config.DisableHolepunch,
|
||||
TlsClientCert: config.TlsClientCert,
|
||||
@@ -243,6 +272,7 @@ func runOlmMainWithArgs(ctx context.Context, cancel context.CancelFunc, signalCt
|
||||
OrgID: config.OrgID,
|
||||
OverrideDNS: config.OverrideDNS,
|
||||
DisableRelay: config.DisableRelay,
|
||||
PreferLocalRoutes: config.PreferLocalRoutes,
|
||||
EnableUAPI: true,
|
||||
}
|
||||
go olm.StartTunnel(tunnelConfig)
|
||||
|
||||
@@ -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;
|
||||
+350
@@ -0,0 +1,350 @@
|
||||
package olm
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
"github.com/fosrl/newt/network"
|
||||
olmDevice "github.com/fosrl/olm/device"
|
||||
"github.com/fosrl/olm/dns"
|
||||
dnsOverride "github.com/fosrl/olm/dns/override"
|
||||
"github.com/fosrl/olm/peers"
|
||||
"github.com/fosrl/olm/websocket"
|
||||
"golang.zx2c4.com/wireguard/device"
|
||||
"golang.zx2c4.com/wireguard/tun"
|
||||
)
|
||||
|
||||
// OlmErrorData represents the error data sent from the server
|
||||
type OlmErrorData struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
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.registered {
|
||||
logger.Info("Already connected. Ignoring new connection request.")
|
||||
return
|
||||
}
|
||||
|
||||
if o.stopRegister != nil {
|
||||
o.stopRegister()
|
||||
o.stopRegister = nil
|
||||
}
|
||||
|
||||
if o.updateRegister != nil {
|
||||
o.updateRegister = nil
|
||||
}
|
||||
|
||||
// if there is an existing tunnel then close it
|
||||
if o.dev != nil {
|
||||
logger.Info("Got new message. Closing existing tunnel!")
|
||||
o.dev.Close()
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(msg.Data)
|
||||
if err != nil {
|
||||
logger.Info("Error marshaling data: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(jsonData, &wgData); err != nil {
|
||||
logger.Info("Error unmarshaling target data: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
o.tdev, err = func() (tun.Device, error) {
|
||||
if o.tunnelConfig.FileDescriptorTun != 0 {
|
||||
return olmDevice.CreateTUNFromFD(o.tunnelConfig.FileDescriptorTun, o.tunnelConfig.MTU)
|
||||
}
|
||||
ifName := o.tunnelConfig.InterfaceName
|
||||
if runtime.GOOS == "darwin" { // this is if we dont pass a fd
|
||||
ifName, err = network.FindUnusedUTUN()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return tun.CreateTUN(ifName, o.tunnelConfig.MTU)
|
||||
}()
|
||||
if err != nil {
|
||||
logger.Error("Failed to create TUN device: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// if config.FileDescriptorTun == 0 {
|
||||
if realInterfaceName, err2 := o.tdev.Name(); err2 == nil { // if the interface is defined then this should not really do anything?
|
||||
o.tunnelConfig.InterfaceName = realInterfaceName
|
||||
}
|
||||
// }
|
||||
|
||||
// Wrap TUN device with packet filter for DNS proxy
|
||||
o.middleDev = olmDevice.NewMiddleDevice(o.tdev)
|
||||
|
||||
wgLogger := logger.GetLogger().GetWireGuardLogger("wireguard: ")
|
||||
// Use filtered device instead of raw TUN device
|
||||
o.dev = device.NewDevice(o.middleDev, o.sharedBind, (*device.Logger)(wgLogger))
|
||||
|
||||
if o.tunnelConfig.EnableUAPI {
|
||||
fileUAPI, err := func() (*os.File, error) {
|
||||
if o.tunnelConfig.FileDescriptorUAPI != 0 {
|
||||
fd, err := strconv.ParseUint(fmt.Sprintf("%d", o.tunnelConfig.FileDescriptorUAPI), 10, 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid UAPI file descriptor: %v", err)
|
||||
}
|
||||
return os.NewFile(uintptr(fd), ""), nil
|
||||
}
|
||||
return olmDevice.UapiOpen(o.tunnelConfig.InterfaceName)
|
||||
}()
|
||||
if err != nil {
|
||||
logger.Error("UAPI listen error: %v", err)
|
||||
os.Exit(1)
|
||||
return
|
||||
}
|
||||
|
||||
o.uapiListener, err = olmDevice.UapiListen(o.tunnelConfig.InterfaceName, fileUAPI)
|
||||
if err != nil {
|
||||
logger.Error("Failed to listen on uapi socket: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
conn, err := o.uapiListener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go o.dev.IpcHandle(conn)
|
||||
}
|
||||
}()
|
||||
logger.Info("UAPI listener started")
|
||||
}
|
||||
|
||||
if err = o.dev.Up(); err != nil {
|
||||
logger.Error("Failed to bring up WireGuard device: %v", err)
|
||||
}
|
||||
|
||||
// Extract interface IP (strip CIDR notation if present)
|
||||
interfaceIP := wgData.TunnelIP
|
||||
if strings.Contains(interfaceIP, "/") {
|
||||
interfaceIP = strings.Split(interfaceIP, "/")[0]
|
||||
}
|
||||
|
||||
// Create and start DNS proxy
|
||||
o.dnsProxy, err = dns.NewDNSProxy(o.middleDev, o.tunnelConfig.MTU, wgData.UtilitySubnet, o.tunnelConfig.UpstreamDNS, o.tunnelConfig.TunnelDNS, interfaceIP, o.tunnelConfig.MatchDomains, o.tunnelConfig.PublicDNS)
|
||||
if err != nil {
|
||||
logger.Error("Failed to create DNS proxy: %v", err)
|
||||
}
|
||||
|
||||
// Tell the system DNS monitor to exclude the proxy IP so that subsequent
|
||||
// polls never mistake the proxy for a real upstream server (on Linux the OS
|
||||
// DNS is overridden to point at this IP, which would otherwise feed back
|
||||
// into UpstreamDNS or PublicDNS on the next poll).
|
||||
if o.dnsMonitor != nil && o.dnsProxy != nil {
|
||||
o.dnsMonitor.SetExcludeIP(o.dnsProxy.GetProxyIP())
|
||||
}
|
||||
|
||||
if err = network.ConfigureInterface(o.tunnelConfig.InterfaceName, wgData.TunnelIP, o.tunnelConfig.MTU); err != nil {
|
||||
logger.Error("Failed to o.tunnelConfigure interface: %v", err)
|
||||
}
|
||||
|
||||
if network.AddRoutes([]string{wgData.UtilitySubnet}, o.tunnelConfig.InterfaceName); err != nil { // also route the utility subnet
|
||||
logger.Error("Failed to add route for utility subnet: %v", err)
|
||||
}
|
||||
|
||||
// Create peer manager with integrated peer monitoring
|
||||
o.peerManager = peers.NewPeerManager(peers.PeerManagerConfig{
|
||||
Device: o.dev,
|
||||
DNSProxy: o.dnsProxy,
|
||||
InterfaceName: o.tunnelConfig.InterfaceName,
|
||||
PrivateKey: o.privateKey,
|
||||
MiddleDev: o.middleDev,
|
||||
LocalIP: interfaceIP,
|
||||
SharedBind: o.sharedBind,
|
||||
WSClient: o.websocket,
|
||||
APIServer: o.apiServer,
|
||||
PublicDNS: o.tunnelConfig.PublicDNS,
|
||||
})
|
||||
|
||||
for i := range wgData.Sites {
|
||||
site := wgData.Sites[i]
|
||||
|
||||
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, 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
|
||||
}
|
||||
|
||||
logger.Info("Configured peer %s", site.PublicKey)
|
||||
}
|
||||
|
||||
o.peerManager.Start()
|
||||
|
||||
if err := o.dnsProxy.Start(); err != nil { // start DNS proxy first so there is no downtime
|
||||
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 {
|
||||
logger.Error("Failed to setup DNS override: %v", err)
|
||||
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.registered = true
|
||||
|
||||
// Start ping monitor now that we are registered and connected
|
||||
o.websocket.StartPingMonitor()
|
||||
|
||||
// Invoke onConnected callback if configured
|
||||
if o.olmConfig.OnConnected != nil {
|
||||
go o.olmConfig.OnConnected()
|
||||
}
|
||||
|
||||
logger.Info("WireGuard device created.")
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
logger.Error("Error marshaling olm error data: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(jsonData, &errorData); err != nil {
|
||||
logger.Error("Error unmarshaling olm error data: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Error("Olm error (code: %s): %s", errorData.Code, errorData.Message)
|
||||
|
||||
// Set the olm error in the API server so it can be exposed via status
|
||||
o.apiServer.SetOlmError(errorData.Code, errorData.Message)
|
||||
|
||||
// Invoke onOlmError callback if configured
|
||||
if o.olmConfig.OnOlmError != nil {
|
||||
go o.olmConfig.OnOlmError(errorData.Code, errorData.Message)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
logger.Error("Error marshaling terminate error data: %v", err)
|
||||
} else {
|
||||
if err := json.Unmarshal(jsonData, &errorData); err != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
o.apiServer.SetTerminated(true)
|
||||
o.apiServer.SetConnectionStatus(false)
|
||||
o.apiServer.SetRegistered(false)
|
||||
o.apiServer.ClearPeerStatuses()
|
||||
|
||||
network.ClearNetworkSettings()
|
||||
|
||||
o.Close()
|
||||
|
||||
if o.olmConfig.OnTerminated != nil {
|
||||
go o.olmConfig.OnTerminated()
|
||||
}
|
||||
}
|
||||
+397
@@ -0,0 +1,397 @@
|
||||
package olm
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/newt/holepunch"
|
||||
"github.com/fosrl/newt/logger"
|
||||
"github.com/fosrl/olm/peers"
|
||||
"github.com/fosrl/olm/websocket"
|
||||
)
|
||||
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
var addSubnetsData peers.PeerAdd
|
||||
if err := json.Unmarshal(jsonData, &addSubnetsData); err != nil {
|
||||
logger.Error("Error unmarshaling add-remote-subnets data: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
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 := 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 := pm.AddAlias(addSubnetsData.SiteId, alias); err != nil {
|
||||
logger.Error("Failed to add alias %s: %v", alias.Alias, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
var removeSubnetsData peers.RemovePeerData
|
||||
if err := json.Unmarshal(jsonData, &removeSubnetsData); err != nil {
|
||||
logger.Error("Error unmarshaling remove-remote-subnets data: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
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 := 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 := pm.RemoveAlias(removeSubnetsData.SiteId, alias.Alias); err != nil {
|
||||
logger.Error("Failed to remove alias %s: %v", alias.Alias, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
var updateSubnetsData peers.UpdatePeerData
|
||||
if err := json.Unmarshal(jsonData, &updateSubnetsData); err != nil {
|
||||
logger.Error("Error unmarshaling update-remote-subnets data: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Add new subnets BEFORE removing old ones to preserve shared subnets
|
||||
// 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 := 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 := pm.RemoveRemoteSubnet(updateSubnetsData.SiteId, subnet); err != nil {
|
||||
logger.Error("Failed to remove allowed IP %s: %v", subnet, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Add new aliases BEFORE removing old ones to preserve shared IP addresses
|
||||
// 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 := 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 := pm.RemoveAlias(updateSubnetsData.SiteId, alias.Alias); err != nil {
|
||||
logger.Error("Failed to remove alias %s: %v", alias.Alias, err)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("Successfully updated remote subnets and aliases for peer %d", updateSubnetsData.SiteId)
|
||||
}
|
||||
|
||||
// Handler for syncing peer configuration - reconciles expected state with actual state
|
||||
func (o *Olm) handleSync(msg websocket.WSMessage) {
|
||||
logger.Debug("Received sync message: %v", msg.Data)
|
||||
|
||||
if !o.registered {
|
||||
logger.Warn("Not connected, ignoring sync request")
|
||||
return
|
||||
}
|
||||
|
||||
pm := o.getPeerManager()
|
||||
if pm == nil {
|
||||
logger.Warn("Peer manager not initialized, ignoring sync request")
|
||||
return
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(msg.Data)
|
||||
if err != nil {
|
||||
logger.Error("Error marshaling sync data: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var syncData SyncData
|
||||
if err := json.Unmarshal(jsonData, &syncData); err != nil {
|
||||
logger.Error("Error unmarshaling sync data: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Sync exit nodes for hole punching
|
||||
o.syncExitNodes(syncData.ExitNodes)
|
||||
|
||||
// Build a map of expected peers from the incoming data
|
||||
expectedPeers := make(map[int]peers.SiteConfig)
|
||||
for _, site := range syncData.Sites {
|
||||
expectedPeers[site.SiteId] = site
|
||||
}
|
||||
|
||||
// Get all current peers
|
||||
currentPeers := pm.GetAllPeers()
|
||||
currentPeerMap := make(map[int]peers.SiteConfig)
|
||||
for _, peer := range currentPeers {
|
||||
currentPeerMap[peer.SiteId] = peer
|
||||
}
|
||||
|
||||
// Find peers to remove (in current but not in expected)
|
||||
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 := 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
|
||||
if o.holePunchManager != nil {
|
||||
removed := o.holePunchManager.RemoveExitNodesByPeer(siteId)
|
||||
if removed > 0 {
|
||||
logger.Info("Sync: Removed %d exit nodes associated with peer %d from hole punch rotation", removed, siteId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find peers to add (in expected but not in current) and peers to update
|
||||
for siteId, expectedSite := range expectedPeers {
|
||||
if _, exists := currentPeerMap[siteId]; !exists {
|
||||
// Only trigger add if this is NOT a JIT-only config (i.e., has more than just siteId and aliases)
|
||||
jitOnly := expectedSite.PublicKey == ""
|
||||
if jitOnly {
|
||||
logger.Debug("Sync: Registering aliases for JIT-only site %d", siteId)
|
||||
if err := pm.AddPeer(expectedSite); err != nil {
|
||||
logger.Error("Sync: Failed to register aliases for JIT site %d: %v", siteId, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// New peer - add it using the add flow (with holepunch)
|
||||
logger.Info("Sync: Adding new peer for site %d", siteId)
|
||||
|
||||
o.holePunchManager.TriggerHolePunch()
|
||||
o.holePunchManager.ResetServerHolepunchInterval() // start sending immediately again so we fill in the endpoint on the cloud
|
||||
|
||||
// add the peer via the server
|
||||
// this is important because newt needs to get triggered as well to add the peer once the hp is complete
|
||||
chainId := fmt.Sprintf("sync-%d", expectedSite.SiteId)
|
||||
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
|
||||
currentSite := currentPeerMap[siteId]
|
||||
needsUpdate := false
|
||||
|
||||
// Check if any fields have changed
|
||||
if expectedSite.Endpoint != "" && expectedSite.Endpoint != currentSite.Endpoint {
|
||||
needsUpdate = true
|
||||
}
|
||||
if expectedSite.RelayEndpoint != "" && expectedSite.RelayEndpoint != currentSite.RelayEndpoint {
|
||||
needsUpdate = true
|
||||
}
|
||||
if expectedSite.PublicKey != "" && expectedSite.PublicKey != currentSite.PublicKey {
|
||||
needsUpdate = true
|
||||
}
|
||||
if expectedSite.ServerIP != "" && expectedSite.ServerIP != currentSite.ServerIP {
|
||||
needsUpdate = true
|
||||
}
|
||||
if expectedSite.ServerPort != 0 && expectedSite.ServerPort != currentSite.ServerPort {
|
||||
needsUpdate = true
|
||||
}
|
||||
// Check remote subnets
|
||||
if expectedSite.RemoteSubnets != nil && !slicesEqual(expectedSite.RemoteSubnets, currentSite.RemoteSubnets) {
|
||||
needsUpdate = true
|
||||
}
|
||||
// Check aliases
|
||||
if expectedSite.Aliases != nil && !aliasesEqual(expectedSite.Aliases, currentSite.Aliases) {
|
||||
needsUpdate = true
|
||||
}
|
||||
|
||||
if needsUpdate {
|
||||
logger.Info("Sync: Updating peer for site %d", siteId)
|
||||
|
||||
// Merge expected data with current data
|
||||
siteConfig := currentSite
|
||||
if expectedSite.Endpoint != "" {
|
||||
siteConfig.Endpoint = expectedSite.Endpoint
|
||||
}
|
||||
if expectedSite.RelayEndpoint != "" {
|
||||
siteConfig.RelayEndpoint = expectedSite.RelayEndpoint
|
||||
}
|
||||
if expectedSite.PublicKey != "" {
|
||||
siteConfig.PublicKey = expectedSite.PublicKey
|
||||
}
|
||||
if expectedSite.ServerIP != "" {
|
||||
siteConfig.ServerIP = expectedSite.ServerIP
|
||||
}
|
||||
if expectedSite.ServerPort != 0 {
|
||||
siteConfig.ServerPort = expectedSite.ServerPort
|
||||
}
|
||||
if expectedSite.RemoteSubnets != nil {
|
||||
siteConfig.RemoteSubnets = expectedSite.RemoteSubnets
|
||||
}
|
||||
if expectedSite.Aliases != nil {
|
||||
siteConfig.Aliases = expectedSite.Aliases
|
||||
}
|
||||
|
||||
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
|
||||
if expectedSite.Endpoint != "" && expectedSite.Endpoint != currentSite.Endpoint {
|
||||
logger.Info("Sync: Endpoint changed for site %d, triggering holepunch to refresh NAT mappings", siteId)
|
||||
o.holePunchManager.TriggerHolePunch()
|
||||
o.holePunchManager.ResetServerHolepunchInterval()
|
||||
}
|
||||
logger.Info("Sync: Successfully updated peer for site %d", siteId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("Sync completed: processed %d expected peers, had %d current peers", len(expectedPeers), len(currentPeers))
|
||||
}
|
||||
|
||||
// syncExitNodes reconciles the expected exit nodes with the current ones in the hole punch manager
|
||||
func (o *Olm) syncExitNodes(expectedExitNodes []SyncExitNode) {
|
||||
if o.holePunchManager == nil {
|
||||
logger.Warn("Hole punch manager not initialized, skipping exit node sync")
|
||||
return
|
||||
}
|
||||
|
||||
// Build a map of expected exit nodes by endpoint
|
||||
expectedExitNodeMap := make(map[string]SyncExitNode)
|
||||
for _, exitNode := range expectedExitNodes {
|
||||
expectedExitNodeMap[exitNode.Endpoint] = exitNode
|
||||
}
|
||||
|
||||
// Get current exit nodes from hole punch manager
|
||||
currentExitNodes := o.holePunchManager.GetExitNodes()
|
||||
currentExitNodeMap := make(map[string]holepunch.ExitNode)
|
||||
for _, exitNode := range currentExitNodes {
|
||||
currentExitNodeMap[exitNode.Endpoint] = exitNode
|
||||
}
|
||||
|
||||
// Find exit nodes to remove (in current but not in expected)
|
||||
for endpoint := range currentExitNodeMap {
|
||||
if _, exists := expectedExitNodeMap[endpoint]; !exists {
|
||||
logger.Info("Sync: Removing exit node %s (no longer in expected config)", endpoint)
|
||||
o.holePunchManager.RemoveExitNode(endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
// Find exit nodes to add (in expected but not in current)
|
||||
for endpoint, expectedExitNode := range expectedExitNodeMap {
|
||||
if _, exists := currentExitNodeMap[endpoint]; !exists {
|
||||
logger.Info("Sync: Adding new exit node %s", endpoint)
|
||||
|
||||
relayPort := expectedExitNode.RelayPort
|
||||
if relayPort == 0 {
|
||||
relayPort = 21820 // default relay port
|
||||
}
|
||||
|
||||
hpExitNode := holepunch.ExitNode{
|
||||
Endpoint: expectedExitNode.Endpoint,
|
||||
RelayPort: relayPort,
|
||||
PublicKey: expectedExitNode.PublicKey,
|
||||
SiteIds: expectedExitNode.SiteIds,
|
||||
}
|
||||
|
||||
if o.holePunchManager.AddExitNode(hpExitNode) {
|
||||
logger.Info("Sync: Successfully added exit node %s", endpoint)
|
||||
}
|
||||
o.holePunchManager.TriggerHolePunch()
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("Sync exit nodes completed: processed %d expected exit nodes, had %d current exit nodes", len(expectedExitNodeMap), len(currentExitNodeMap))
|
||||
}
|
||||
+851
-784
File diff suppressed because it is too large
Load Diff
@@ -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))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
+515
@@ -0,0 +1,515 @@
|
||||
package olm
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/newt/holepunch"
|
||||
"github.com/fosrl/newt/logger"
|
||||
"github.com/fosrl/newt/util"
|
||||
"github.com/fosrl/olm/peers"
|
||||
"github.com/fosrl/olm/websocket"
|
||||
)
|
||||
|
||||
func (o *Olm) handleWgPeerAdd(msg websocket.WSMessage) {
|
||||
logger.Debug("Received add-peer message: %v", msg.Data)
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
logger.Error("Error marshaling data: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
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 := pm.AddPeer(siteConfigMsg.SiteConfig); err != nil {
|
||||
logger.Error("Failed to add peer: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
var removeData peers.PeerRemove
|
||||
if err := json.Unmarshal(jsonData, &removeData); err != nil {
|
||||
logger.Error("Error unmarshaling remove data: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := pm.RemovePeer(removeData.SiteId); err != nil {
|
||||
logger.Error("Failed to remove peer: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Remove any exit nodes associated with this peer from hole punching
|
||||
if o.holePunchManager != nil {
|
||||
removed := o.holePunchManager.RemoveExitNodesByPeer(removeData.SiteId)
|
||||
if removed > 0 {
|
||||
logger.Info("Removed %d exit nodes associated with peer %d from hole punch rotation", removed, removeData.SiteId)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("Successfully removed peer for site %d", removeData.SiteId)
|
||||
}
|
||||
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
var updateData peers.SiteConfig
|
||||
if err := json.Unmarshal(jsonData, &updateData); err != nil {
|
||||
logger.Error("Error unmarshaling update data: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Get existing peer from PeerManager
|
||||
existingPeer, exists := pm.GetPeer(updateData.SiteId)
|
||||
if !exists {
|
||||
logger.Warn("Peer with site ID %d not found", updateData.SiteId)
|
||||
return
|
||||
}
|
||||
|
||||
// Create updated site config by merging with existing data
|
||||
siteConfig := existingPeer
|
||||
|
||||
if updateData.Endpoint != "" {
|
||||
siteConfig.Endpoint = updateData.Endpoint
|
||||
}
|
||||
if updateData.RelayEndpoint != "" {
|
||||
siteConfig.RelayEndpoint = updateData.RelayEndpoint
|
||||
}
|
||||
if updateData.PublicKey != "" {
|
||||
siteConfig.PublicKey = updateData.PublicKey
|
||||
}
|
||||
if updateData.ServerIP != "" {
|
||||
siteConfig.ServerIP = updateData.ServerIP
|
||||
}
|
||||
if updateData.ServerPort != 0 {
|
||||
siteConfig.ServerPort = updateData.ServerPort
|
||||
}
|
||||
if updateData.RemoteSubnets != nil {
|
||||
siteConfig.RemoteSubnets = updateData.RemoteSubnets
|
||||
}
|
||||
if updateData.Aliases != nil {
|
||||
siteConfig.Aliases = updateData.Aliases
|
||||
}
|
||||
|
||||
if err := pm.UpdatePeer(siteConfig); err != nil {
|
||||
logger.Error("Failed to update peer: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 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)
|
||||
if o.holePunchManager != nil {
|
||||
_ = o.holePunchManager.TriggerHolePunch()
|
||||
o.holePunchManager.ResetServerHolepunchInterval()
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("Successfully updated peer for site %d", updateData.SiteId)
|
||||
}
|
||||
|
||||
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)
|
||||
pm := o.getPeerManager()
|
||||
if pm == nil {
|
||||
logger.Debug("Ignoring relay message: peerManager is nil (shutdown in progress)")
|
||||
return
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(msg.Data)
|
||||
if err != nil {
|
||||
logger.Error("Error marshaling data: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Update HTTP server to mark this peer as using relay
|
||||
o.apiServer.UpdatePeerRelayStatus(relayData.SiteId, relayData.RelayEndpoint, true)
|
||||
|
||||
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)
|
||||
pm := o.getPeerManager()
|
||||
if pm == nil {
|
||||
logger.Debug("Ignoring unrelay message: peerManager is nil (shutdown in progress)")
|
||||
return
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(msg.Data)
|
||||
if err != nil {
|
||||
logger.Error("Error marshaling data: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var 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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// Update HTTP server to mark this peer as using relay
|
||||
o.apiServer.UpdatePeerRelayStatus(relayData.SiteId, relayData.Endpoint, false)
|
||||
|
||||
pm.UnRelayPeer(relayData.SiteId, primaryRelay)
|
||||
}
|
||||
|
||||
// handleWgPeerLocal handles the server's acknowledgement of an "olm/wg/local" message.
|
||||
// olm already switched the peer to the local endpoint before sending that message (it
|
||||
// doesn't wait for permission, unlike relay), so all this needs to do is stop the retry
|
||||
// sender for the given chain.
|
||||
func (o *Olm) handleWgPeerLocal(msg websocket.WSMessage) {
|
||||
logger.Debug("Received local-peer ack message: %v", msg.Data)
|
||||
|
||||
pm := o.getPeerManager()
|
||||
if pm == nil {
|
||||
logger.Debug("Ignoring local ack message: peerManager is nil (shutdown in progress)")
|
||||
return
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(msg.Data)
|
||||
if err != nil {
|
||||
logger.Error("Error marshaling data: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var localData struct {
|
||||
peers.LocalPeerAckData
|
||||
ChainId string `json:"chainId"`
|
||||
}
|
||||
if err := json.Unmarshal(jsonData, &localData); err != nil {
|
||||
logger.Error("Error unmarshaling local ack data: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if monitor := pm.GetPeerMonitor(); monitor != nil {
|
||||
monitor.CancelLocalSend(localData.ChainId)
|
||||
}
|
||||
}
|
||||
|
||||
// handleWgPeerUnlocal handles the server's acknowledgement of an "olm/wg/unlocal" message.
|
||||
// Same as handleWgPeerLocal, olm has already fallen back from the local endpoint by the time
|
||||
// it sends the notification, so this just stops the retry sender.
|
||||
func (o *Olm) handleWgPeerUnlocal(msg websocket.WSMessage) {
|
||||
logger.Debug("Received unlocal-peer ack message: %v", msg.Data)
|
||||
|
||||
pm := o.getPeerManager()
|
||||
if pm == nil {
|
||||
logger.Debug("Ignoring unlocal ack message: peerManager is nil (shutdown in progress)")
|
||||
return
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(msg.Data)
|
||||
if err != nil {
|
||||
logger.Error("Error marshaling data: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var localData struct {
|
||||
peers.LocalPeerAckData
|
||||
ChainId string `json:"chainId"`
|
||||
}
|
||||
if err := json.Unmarshal(jsonData, &localData); err != nil {
|
||||
logger.Error("Error unmarshaling unlocal ack data: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if monitor := pm.GetPeerMonitor(); monitor != nil {
|
||||
monitor.CancelLocalSend(localData.ChainId)
|
||||
}
|
||||
}
|
||||
|
||||
func (o *Olm) handleWgPeerHolepunchAddSite(msg websocket.WSMessage) {
|
||||
logger.Debug("Received peer-handshake message: %v", msg.Data)
|
||||
|
||||
// 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)
|
||||
return
|
||||
}
|
||||
|
||||
var handshakeData struct {
|
||||
SiteId int `json:"siteId"`
|
||||
ChainId string `json:"chainId"`
|
||||
ExitNode struct {
|
||||
PublicKey string `json:"publicKey"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
RelayPort uint16 `json:"relayPort"`
|
||||
} `json:"exitNode"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(jsonData, &handshakeData); err != nil {
|
||||
logger.Error("Error unmarshaling handshake data: %v", err)
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
relayPort := handshakeData.ExitNode.RelayPort
|
||||
if relayPort == 0 {
|
||||
relayPort = 21820 // default relay port
|
||||
}
|
||||
|
||||
siteId := handshakeData.SiteId
|
||||
exitNode := holepunch.ExitNode{
|
||||
Endpoint: handshakeData.ExitNode.Endpoint,
|
||||
RelayPort: relayPort,
|
||||
PublicKey: handshakeData.ExitNode.PublicKey,
|
||||
SiteIds: []int{siteId},
|
||||
}
|
||||
|
||||
added := o.holePunchManager.AddExitNode(exitNode)
|
||||
if added {
|
||||
logger.Info("Added exit node %s to holepunch rotation for handshake", exitNode.Endpoint)
|
||||
} else {
|
||||
logger.Debug("Exit node %s already in holepunch rotation", exitNode.Endpoint)
|
||||
}
|
||||
|
||||
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, 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)
|
||||
}
|
||||
}
|
||||
+55
-2
@@ -12,9 +12,22 @@ type WgData struct {
|
||||
UtilitySubnet string `json:"utilitySubnet"` // this is for things like the DNS server, and alias addresses
|
||||
}
|
||||
|
||||
type GlobalConfig struct {
|
||||
type SyncData struct {
|
||||
Sites []peers.SiteConfig `json:"sites"`
|
||||
ExitNodes []SyncExitNode `json:"exitNodes"`
|
||||
}
|
||||
|
||||
type SyncExitNode struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
RelayPort uint16 `json:"relayPort"`
|
||||
PublicKey string `json:"publicKey"`
|
||||
SiteIds []int `json:"siteIds"`
|
||||
}
|
||||
|
||||
type OlmConfig struct {
|
||||
// Logging
|
||||
LogLevel string
|
||||
LogLevel string
|
||||
LogFilePath string
|
||||
|
||||
// HTTP server
|
||||
EnableAPI bool
|
||||
@@ -23,12 +36,33 @@ type GlobalConfig struct {
|
||||
Version string
|
||||
Agent string
|
||||
|
||||
WakeUpDebounce time.Duration
|
||||
|
||||
// Debugging
|
||||
PprofAddr string // Address to serve pprof on (e.g., "localhost:6060")
|
||||
|
||||
// Callbacks
|
||||
OnRegistered func()
|
||||
OnConnected func()
|
||||
OnTerminated func()
|
||||
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 {
|
||||
@@ -42,8 +76,16 @@ type TunnelConfig struct {
|
||||
MTU int
|
||||
DNS string
|
||||
UpstreamDNS []string
|
||||
PublicDNS []string
|
||||
InterfaceName string
|
||||
|
||||
// MatchDomains lists FQDN wildcard patterns (using * and ? wildcards) that
|
||||
// olm should check against local records / resolve via UpstreamDNS. Queries
|
||||
// that don't match any pattern are sent directly to the host's own system
|
||||
// DNS servers (PublicDNS) instead of being handled by the DNS proxy at all.
|
||||
// An empty MatchDomains matches every query, preserving prior behavior.
|
||||
MatchDomains []string
|
||||
|
||||
// Advanced
|
||||
Holepunch bool
|
||||
TlsClientCert string
|
||||
@@ -63,5 +105,16 @@ type TunnelConfig struct {
|
||||
OverrideDNS bool
|
||||
TunnelDNS bool
|
||||
|
||||
InitialFingerprint map[string]any
|
||||
InitialPostures map[string]any
|
||||
|
||||
DisableRelay bool
|
||||
|
||||
// PreferLocalRoutes, when enabled, adds tunnel routes with an explicit
|
||||
// high metric/priority so that an overlapping local/connected route to
|
||||
// the same destination always takes precedence over the VPN route,
|
||||
// rather than the two racing based on insertion order. Defaults to
|
||||
// false, preserving the routing behavior from before this option was
|
||||
// introduced.
|
||||
PreferLocalRoutes bool
|
||||
}
|
||||
|
||||
+35
-43
@@ -1,55 +1,47 @@
|
||||
package olm
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
"github.com/fosrl/newt/network"
|
||||
"github.com/fosrl/olm/websocket"
|
||||
"github.com/fosrl/olm/peers"
|
||||
)
|
||||
|
||||
func sendPing(olm *websocket.Client) error {
|
||||
err := olm.SendMessage("olm/ping", map[string]interface{}{
|
||||
"timestamp": time.Now().Unix(),
|
||||
"userToken": olm.GetConfig().UserToken,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("Failed to send ping message: %v", err)
|
||||
return err
|
||||
// slicesEqual compares two string slices for equality (order-independent)
|
||||
func slicesEqual(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
logger.Debug("Sent ping message")
|
||||
return nil
|
||||
}
|
||||
|
||||
func keepSendingPing(olm *websocket.Client) {
|
||||
// Send ping immediately on startup
|
||||
if err := sendPing(olm); err != nil {
|
||||
logger.Error("Failed to send initial ping: %v", err)
|
||||
} else {
|
||||
logger.Info("Sent initial ping message")
|
||||
// Create a map to count occurrences in slice a
|
||||
counts := make(map[string]int)
|
||||
for _, v := range a {
|
||||
counts[v]++
|
||||
}
|
||||
|
||||
// Set up ticker for one minute intervals
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-stopPing:
|
||||
logger.Info("Stopping ping messages")
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := sendPing(olm); err != nil {
|
||||
logger.Error("Failed to send periodic ping: %v", err)
|
||||
}
|
||||
// Check if slice b has the same elements
|
||||
for _, v := range b {
|
||||
counts[v]--
|
||||
if counts[v] < 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func GetNetworkSettingsJSON() (string, error) {
|
||||
return network.GetJSON()
|
||||
}
|
||||
|
||||
func GetNetworkSettingsIncrementor() int {
|
||||
return network.GetIncrementor()
|
||||
// aliasesEqual compares two Alias slices for equality (order-independent)
|
||||
func aliasesEqual(a, b []peers.Alias) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
// Create a map to count occurrences in slice a (using alias+address as key)
|
||||
counts := make(map[string]int)
|
||||
for _, v := range a {
|
||||
key := v.Alias + "|" + v.AliasAddress
|
||||
counts[key]++
|
||||
}
|
||||
// Check if slice b has the same elements
|
||||
for _, v := range b {
|
||||
key := v.Alias + "|" + v.AliasAddress
|
||||
counts[key]--
|
||||
if counts[key] < 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
+463
-42
@@ -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,35 @@ type PeerManager struct {
|
||||
// key is the CIDR string, value is a set of siteIds that want this IP
|
||||
allowedIPClaims map[string]map[int]bool
|
||||
APIServer *api.API
|
||||
publicDNS []string
|
||||
|
||||
PersistentKeepalive int
|
||||
|
||||
routeOptimizerStop chan struct{}
|
||||
optimizerTrigger chan struct{}
|
||||
|
||||
// lastOwnerChange tracks, per allowed-IP CIDR, when ownership was last transferred.
|
||||
// Used to enforce a cooldown so routes don't flap between two similarly-performing sites.
|
||||
lastOwnerChange map[string]time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
// routeSwitchRTTMargin requires a candidate site's RTT to be at least this much
|
||||
// better (as a fraction) than the current owner's before we consider it worth
|
||||
// switching, so two similarly-performing sites don't flap back and forth.
|
||||
routeSwitchRTTMargin = 0.20 // candidate must be >=20% faster
|
||||
|
||||
// routeSwitchMinAbsMargin is a floor on the RTT improvement required, so the
|
||||
// percentage margin above doesn't become meaningless at very low RTTs (e.g. a
|
||||
// 1ms vs 0.8ms "20% improvement" shouldn't trigger a switch).
|
||||
routeSwitchMinAbsMargin = 5 * time.Millisecond
|
||||
|
||||
// routeSwitchCooldown is the minimum time to wait after transferring ownership
|
||||
// of a route before it can be transferred again, unless the current owner's
|
||||
// connection quality degrades (disconnects or falls back to relay).
|
||||
routeSwitchCooldown = 30 * time.Second
|
||||
)
|
||||
|
||||
// NewPeerManager creates a new PeerManager with an internal PeerMonitor
|
||||
func NewPeerManager(config PeerManagerConfig) *PeerManager {
|
||||
pm := &PeerManager{
|
||||
@@ -63,6 +92,8 @@ func NewPeerManager(config PeerManagerConfig) *PeerManager {
|
||||
allowedIPOwners: make(map[string]int),
|
||||
allowedIPClaims: make(map[string]map[int]bool),
|
||||
APIServer: config.APIServer,
|
||||
publicDNS: config.PublicDNS,
|
||||
lastOwnerChange: make(map[string]time.Time),
|
||||
}
|
||||
|
||||
// Create the peer monitor
|
||||
@@ -72,8 +103,13 @@ func NewPeerManager(config PeerManagerConfig) *PeerManager {
|
||||
config.LocalIP,
|
||||
config.SharedBind,
|
||||
config.APIServer,
|
||||
config.PublicDNS,
|
||||
)
|
||||
|
||||
pm.optimizerTrigger = make(chan struct{}, 1)
|
||||
|
||||
pm.peerMonitor.SetLocalConnectionCallbacks(pm.LocalPeer, pm.UnLocalPeer)
|
||||
|
||||
return pm
|
||||
}
|
||||
|
||||
@@ -84,6 +120,28 @@ func (pm *PeerManager) GetPeer(siteId int) (SiteConfig, bool) {
|
||||
return peer, ok
|
||||
}
|
||||
|
||||
// GetPeerMonitor returns the internal peer monitor instance
|
||||
func (pm *PeerManager) GetPeerMonitor() *monitor.PeerMonitor {
|
||||
pm.mu.RLock()
|
||||
defer pm.mu.RUnlock()
|
||||
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()
|
||||
@@ -98,6 +156,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...)
|
||||
@@ -120,7 +191,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)); err != nil {
|
||||
if err := ConfigurePeer(pm.device, wgConfig, pm.privateKey, pm.peerMonitor.IsPeerRelayed(siteConfig.SiteId), pm.PersistentKeepalive, pm.publicDNS); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -130,18 +201,11 @@ func (pm *PeerManager) AddPeer(siteConfig SiteConfig) error {
|
||||
if err := network.AddRoutes(siteConfig.RemoteSubnets, pm.interfaceName); err != nil {
|
||||
logger.Error("Failed to add routes for remote subnets: %v", err)
|
||||
}
|
||||
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
|
||||
|
||||
err := pm.peerMonitor.AddPeer(siteConfig.SiteId, monitorPeer, siteConfig.Endpoint) // always use the real site endpoint for hole punch monitoring
|
||||
err := pm.peerMonitor.AddPeer(siteConfig.SiteId, monitorPeer, siteConfig.Endpoint, siteConfig.LocalEndpoints) // always use the real site endpoint for hole punch monitoring
|
||||
if err != nil {
|
||||
logger.Warn("Failed to setup monitoring for site %d: %v", siteConfig.SiteId, err)
|
||||
} else {
|
||||
@@ -150,15 +214,38 @@ func (pm *PeerManager) AddPeer(siteConfig SiteConfig) error {
|
||||
|
||||
pm.peers[siteConfig.SiteId] = siteConfig
|
||||
|
||||
pm.APIServer.AddPeerStatus(siteConfig.SiteId, siteConfig.Name, false, 0, siteConfig.Endpoint, false)
|
||||
pm.APIServer.AddPeerStatus(siteConfig.SiteId, siteConfig.Name, false, 0, siteConfig.Endpoint, false, false)
|
||||
|
||||
// Perform rapid initial holepunch test (outside of lock to avoid blocking)
|
||||
// This quickly determines if holepunch is viable and triggers relay if not
|
||||
go pm.performRapidInitialTest(siteConfig.SiteId, siteConfig.Endpoint)
|
||||
go pm.performRapidInitialTest(siteConfig.SiteId, siteConfig.Endpoint, siteConfig.LocalEndpoints)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateAllPeersPersistentKeepalive updates the persistent keepalive interval for all peers at once
|
||||
// without recreating them. Returns a map of siteId to error for any peers that failed to update.
|
||||
func (pm *PeerManager) UpdateAllPeersPersistentKeepalive(interval int) map[int]error {
|
||||
pm.mu.RLock()
|
||||
defer pm.mu.RUnlock()
|
||||
|
||||
pm.PersistentKeepalive = interval
|
||||
|
||||
errors := make(map[int]error)
|
||||
|
||||
for siteId, peer := range pm.peers {
|
||||
err := UpdatePersistentKeepalive(pm.device, peer.PublicKey, interval)
|
||||
if err != nil {
|
||||
errors[siteId] = err
|
||||
}
|
||||
}
|
||||
|
||||
if len(errors) == 0 {
|
||||
return nil
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
func (pm *PeerManager) RemovePeer(siteId int) error {
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
@@ -194,7 +281,7 @@ func (pm *PeerManager) RemovePeer(siteId int) error {
|
||||
}
|
||||
}
|
||||
if !subnetStillInUse {
|
||||
if err := network.RemoveRoutes([]string{subnet}); err != nil {
|
||||
if err := network.RemoveRoutes([]string{subnet}, pm.interfaceName); err != nil {
|
||||
logger.Error("Failed to remove route for remote subnet %s: %v", subnet, err)
|
||||
}
|
||||
}
|
||||
@@ -238,7 +325,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)); 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)
|
||||
}
|
||||
}
|
||||
@@ -263,6 +350,33 @@ func (pm *PeerManager) UpdatePeer(siteConfig SiteConfig) error {
|
||||
return fmt.Errorf("peer with site ID %d not found", siteConfig.SiteId)
|
||||
}
|
||||
|
||||
// Preserve the currently active local endpoint (if any) across updates so an in-progress
|
||||
// local connection isn't disrupted by an unrelated site update.
|
||||
siteConfig.ActiveLocalEndpoint = oldPeer.ActiveLocalEndpoint
|
||||
|
||||
// Update aliases
|
||||
// Remove old aliases
|
||||
for _, alias := range oldPeer.Aliases {
|
||||
address := net.ParseIP(alias.AliasAddress)
|
||||
if address == nil {
|
||||
continue
|
||||
}
|
||||
pm.dnsProxy.RemoveDNSRecord(alias.Alias, address)
|
||||
}
|
||||
// Add new aliases
|
||||
for _, alias := range siteConfig.Aliases {
|
||||
address := net.ParseIP(alias.AliasAddress)
|
||||
if address == nil {
|
||||
continue
|
||||
}
|
||||
pm.dnsProxy.AddDNSRecord(alias.Alias, address, siteConfig.SiteId)
|
||||
}
|
||||
|
||||
if siteConfig.PublicKey == "" {
|
||||
logger.Debug("Skip updating site %d because no pub key", siteConfig.SiteId)
|
||||
return nil
|
||||
}
|
||||
|
||||
// If public key changed, remove old peer first
|
||||
if siteConfig.PublicKey != oldPeer.PublicKey {
|
||||
if err := RemovePeer(pm.device, siteConfig.SiteId, oldPeer.PublicKey); err != nil {
|
||||
@@ -314,7 +428,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)); err != nil {
|
||||
if err := ConfigurePeer(pm.device, wgConfig, pm.privateKey, pm.peerMonitor.IsPeerRelayed(siteConfig.SiteId), pm.PersistentKeepalive, pm.publicDNS); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -324,7 +438,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)); 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)
|
||||
}
|
||||
}
|
||||
@@ -373,7 +487,7 @@ func (pm *PeerManager) UpdatePeer(siteConfig SiteConfig) error {
|
||||
}
|
||||
}
|
||||
if !subnetStillInUse {
|
||||
if err := network.RemoveRoutes([]string{subnet}); err != nil {
|
||||
if err := network.RemoveRoutes([]string{subnet}, pm.interfaceName); err != nil {
|
||||
logger.Error("Failed to remove route for subnet %s: %v", subnet, err)
|
||||
}
|
||||
}
|
||||
@@ -386,25 +500,8 @@ func (pm *PeerManager) UpdatePeer(siteConfig SiteConfig) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Update aliases
|
||||
// Remove old aliases
|
||||
for _, alias := range oldPeer.Aliases {
|
||||
address := net.ParseIP(alias.AliasAddress)
|
||||
if address == nil {
|
||||
continue
|
||||
}
|
||||
pm.dnsProxy.RemoveDNSRecord(alias.Alias, address)
|
||||
}
|
||||
// Add new aliases
|
||||
for _, alias := range siteConfig.Aliases {
|
||||
address := net.ParseIP(alias.AliasAddress)
|
||||
if address == nil {
|
||||
continue
|
||||
}
|
||||
pm.dnsProxy.AddDNSRecord(alias.Alias, address)
|
||||
}
|
||||
|
||||
pm.peerMonitor.UpdateHolepunchEndpoint(siteConfig.SiteId, siteConfig.Endpoint)
|
||||
pm.peerMonitor.UpdateLocalEndpoints(siteConfig.SiteId, siteConfig.LocalEndpoints)
|
||||
|
||||
monitorAddress := strings.Split(siteConfig.ServerIP, "/")[0]
|
||||
monitorPeer := net.JoinHostPort(monitorAddress, strconv.Itoa(int(siteConfig.ServerPort+1))) // +1 for the monitor port
|
||||
@@ -440,6 +537,7 @@ func (pm *PeerManager) releaseAllowedIP(siteId int, cidr string) (newOwner int,
|
||||
delete(claims, siteId)
|
||||
if len(claims) == 0 {
|
||||
delete(pm.allowedIPClaims, cidr)
|
||||
delete(pm.lastOwnerChange, cidr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -658,7 +756,7 @@ func (pm *PeerManager) RemoveRemoteSubnet(siteId int, ip string) error {
|
||||
|
||||
// Only remove route if no other peer needs it
|
||||
if !subnetStillInUse {
|
||||
if err := network.RemoveRoutes([]string{ip}); err != nil {
|
||||
if err := network.RemoveRoutes([]string{ip}, pm.interfaceName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -681,7 +779,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
|
||||
@@ -715,7 +813,7 @@ func (pm *PeerManager) RemoveAlias(siteId int, aliasName string) error {
|
||||
if aliasToRemove != nil {
|
||||
address := net.ParseIP(aliasToRemove.AliasAddress)
|
||||
if address != nil {
|
||||
pm.dnsProxy.RemoveDNSRecord(aliasName, address)
|
||||
pm.dnsProxy.RemoveDNSRecordForSite(aliasName, address, siteId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -746,6 +844,11 @@ func (pm *PeerManager) RemoveAlias(siteId int, aliasName string) error {
|
||||
func (pm *PeerManager) RelayPeer(siteId int, relayEndpoint string, relayPort uint16) {
|
||||
pm.mu.Lock()
|
||||
peer, exists := pm.peers[siteId]
|
||||
if exists && peer.ActiveLocalEndpoint != "" {
|
||||
pm.mu.Unlock()
|
||||
logger.Info("Ignoring relay request for site %d: local connection is active", siteId)
|
||||
return
|
||||
}
|
||||
if exists {
|
||||
// Store the relay endpoint
|
||||
peer.RelayEndpoint = relayEndpoint
|
||||
@@ -788,15 +891,43 @@ endpoint=%s:%d`, util.FixKey(peer.PublicKey), formattedEndpoint, relayPort)
|
||||
}
|
||||
|
||||
// performRapidInitialTest performs a rapid holepunch test for a newly added peer.
|
||||
// If the test fails, it immediately requests relay to minimize connection delay.
|
||||
// This runs in a goroutine to avoid blocking AddPeer.
|
||||
func (pm *PeerManager) performRapidInitialTest(siteId int, endpoint string) {
|
||||
// It races a test of the public endpoint against a test of the local candidate endpoints
|
||||
// (if any) and waits for both to finish before acting, so we never request relay only to
|
||||
// have it immediately superseded by a local connection (or vice versa). Local wins if it's
|
||||
// viable at all; otherwise relay is requested only if the public endpoint isn't viable.
|
||||
// This runs in a goroutine to avoid blocking AddPeer - the peer already starts out pointed
|
||||
// at the public endpoint (set synchronously in AddPeer), so this just settles the peer onto
|
||||
// its steady-state connection within ~1-2 seconds.
|
||||
func (pm *PeerManager) performRapidInitialTest(siteId int, endpoint string, localEndpoints []string) {
|
||||
if pm.peerMonitor == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Perform rapid test - this takes ~1-2 seconds max
|
||||
holepunchViable := pm.peerMonitor.RapidTestPeer(siteId, endpoint)
|
||||
var wg sync.WaitGroup
|
||||
var localWinner string
|
||||
var holepunchViable bool
|
||||
|
||||
if len(localEndpoints) > 0 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
localWinner = pm.peerMonitor.RapidTestLocalEndpoints(siteId, localEndpoints)
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
holepunchViable = pm.peerMonitor.RapidTestPeer(siteId, endpoint)
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if localWinner != "" {
|
||||
logger.Info("Rapid test: local connection viable for site %d, switching to %s", siteId, localWinner)
|
||||
pm.LocalPeer(siteId, localWinner)
|
||||
return
|
||||
}
|
||||
|
||||
if !holepunchViable {
|
||||
// Holepunch failed rapid test, request relay immediately
|
||||
@@ -814,10 +945,12 @@ func (pm *PeerManager) Start() {
|
||||
if pm.peerMonitor != nil {
|
||||
pm.peerMonitor.Start()
|
||||
}
|
||||
pm.startRouteOptimizer()
|
||||
}
|
||||
|
||||
// Stop stops the peer monitor
|
||||
func (pm *PeerManager) Stop() {
|
||||
pm.stopRouteOptimizer()
|
||||
if pm.peerMonitor != nil {
|
||||
pm.peerMonitor.Stop()
|
||||
}
|
||||
@@ -825,6 +958,7 @@ func (pm *PeerManager) Stop() {
|
||||
|
||||
// Close stops the peer monitor and cleans up resources
|
||||
func (pm *PeerManager) Close() {
|
||||
pm.stopRouteOptimizer()
|
||||
if pm.peerMonitor != nil {
|
||||
pm.peerMonitor.Close()
|
||||
pm.peerMonitor = nil
|
||||
@@ -855,6 +989,11 @@ func (pm *PeerManager) MarkPeerRelayed(siteID int, relayed bool) {
|
||||
func (pm *PeerManager) UnRelayPeer(siteId int, endpoint string) error {
|
||||
pm.mu.Lock()
|
||||
peer, exists := pm.peers[siteId]
|
||||
if exists && peer.ActiveLocalEndpoint != "" {
|
||||
pm.mu.Unlock()
|
||||
logger.Info("Ignoring unrelay request for site %d: local connection is active", siteId)
|
||||
return nil
|
||||
}
|
||||
if exists {
|
||||
// Store the relay endpoint
|
||||
peer.Endpoint = endpoint
|
||||
@@ -886,3 +1025,285 @@ endpoint=%s`, util.FixKey(peer.PublicKey), endpoint)
|
||||
logger.Info("Switched peer %d back to direct connection at %s", siteId, endpoint)
|
||||
return nil
|
||||
}
|
||||
|
||||
// LocalPeer switches a peer to a local network endpoint discovered by the peer monitor.
|
||||
// Local endpoints take priority over both the public endpoint and the relay, so this
|
||||
// bypasses relay/public-endpoint bookkeeping entirely and just updates the WireGuard
|
||||
// endpoint directly.
|
||||
func (pm *PeerManager) LocalPeer(siteId int, localEndpoint string) {
|
||||
pm.mu.Lock()
|
||||
peer, exists := pm.peers[siteId]
|
||||
if exists {
|
||||
peer.ActiveLocalEndpoint = localEndpoint
|
||||
pm.peers[siteId] = peer
|
||||
}
|
||||
pm.mu.Unlock()
|
||||
|
||||
if !exists {
|
||||
logger.Error("Cannot switch to local connection: peer with site ID %d not found", siteId)
|
||||
return
|
||||
}
|
||||
|
||||
// Update only the endpoint for this peer (update_only preserves other settings)
|
||||
wgConfig := fmt.Sprintf(`public_key=%s
|
||||
update_only=true
|
||||
endpoint=%s`, util.FixKey(peer.PublicKey), localEndpoint)
|
||||
|
||||
if err := pm.device.IpcSet(wgConfig); err != nil {
|
||||
logger.Error("Failed to switch peer %d to local connection: %v", siteId, err)
|
||||
return
|
||||
}
|
||||
|
||||
if pm.APIServer != nil {
|
||||
pm.APIServer.UpdatePeerLocalStatus(siteId, localEndpoint, true)
|
||||
}
|
||||
|
||||
logger.Info("Switched peer %d to local connection at %s", siteId, localEndpoint)
|
||||
}
|
||||
|
||||
// UnLocalPeer switches a peer away from its active local endpoint back to the public
|
||||
// endpoint, resuming the normal public/relay monitoring logic from scratch (which will
|
||||
// re-trigger relay on its own if the public endpoint also turns out to be unreachable).
|
||||
func (pm *PeerManager) UnLocalPeer(siteId int) {
|
||||
pm.mu.Lock()
|
||||
peer, exists := pm.peers[siteId]
|
||||
publicDNS := pm.publicDNS
|
||||
if exists {
|
||||
peer.ActiveLocalEndpoint = ""
|
||||
pm.peers[siteId] = peer
|
||||
}
|
||||
pm.mu.Unlock()
|
||||
|
||||
if !exists {
|
||||
logger.Error("Cannot fall back from local connection: peer with site ID %d not found", siteId)
|
||||
return
|
||||
}
|
||||
|
||||
resolved, err := util.ResolveDomainUpstream(formatEndpoint(peer.Endpoint), publicDNS)
|
||||
if err != nil {
|
||||
logger.Error("Failed to resolve fallback endpoint for peer %d: %v", siteId, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := pm.UnRelayPeer(siteId, resolved); err != nil {
|
||||
logger.Error("Failed to fall back peer %d from local connection: %v", siteId, err)
|
||||
return
|
||||
}
|
||||
|
||||
if pm.APIServer != nil {
|
||||
pm.APIServer.UpdatePeerLocalStatus(siteId, resolved, false)
|
||||
}
|
||||
}
|
||||
|
||||
// isBetterConnection returns true if connection quality (a) is better than (b).
|
||||
// Priority: connected > disconnected, then direct > relayed, then lower RTT.
|
||||
func isBetterConnection(aConn bool, aRelay bool, aRTT time.Duration,
|
||||
bConn bool, bRelay bool, bRTT time.Duration) bool {
|
||||
if aConn != bConn {
|
||||
return aConn // connected beats disconnected
|
||||
}
|
||||
if !aConn {
|
||||
return false // both offline, no preference
|
||||
}
|
||||
if aRelay != bRelay {
|
||||
return !aRelay // direct beats relayed
|
||||
}
|
||||
// Same connectivity class: prefer lower RTT
|
||||
if aRTT == 0 {
|
||||
return false // unknown RTT, don't displace
|
||||
}
|
||||
if bRTT == 0 {
|
||||
return true // current has no RTT data, prefer known
|
||||
}
|
||||
return aRTT < bRTT
|
||||
}
|
||||
|
||||
// selectBestOwner returns the siteId of the best site to own the given IP,
|
||||
// based on connection quality. Must be called with pm.mu held.
|
||||
func (pm *PeerManager) selectBestOwner(claims map[int]bool) int {
|
||||
bestSiteId := -1
|
||||
var bestConn, bestRelay bool
|
||||
var bestRTT time.Duration
|
||||
|
||||
for siteId := range claims {
|
||||
conn, relay, rtt := pm.peerMonitor.GetConnectionQuality(siteId)
|
||||
if bestSiteId < 0 || isBetterConnection(conn, relay, rtt, bestConn, bestRelay, bestRTT) {
|
||||
bestSiteId = siteId
|
||||
bestConn = conn
|
||||
bestRelay = relay
|
||||
bestRTT = rtt
|
||||
}
|
||||
}
|
||||
return bestSiteId
|
||||
}
|
||||
|
||||
// shouldSwitchOwner decides whether ownership of cidr should move from the current
|
||||
// owner to the candidate. It applies hysteresis so two sites with roughly equal
|
||||
// performance don't flap back and forth:
|
||||
// - A switch driven by connectivity class (connected vs not, direct vs relayed) is
|
||||
// always allowed immediately - these are correctness issues, not noise.
|
||||
// - A switch driven purely by RTT requires both a minimum improvement margin and
|
||||
// that the cooldown since the last switch of this route has elapsed.
|
||||
//
|
||||
// Must be called with pm.mu held.
|
||||
func (pm *PeerManager) shouldSwitchOwner(cidr string, currentSiteId, candidateSiteId int) bool {
|
||||
curConn, curRelay, curRTT := pm.peerMonitor.GetConnectionQuality(currentSiteId)
|
||||
candConn, candRelay, candRTT := pm.peerMonitor.GetConnectionQuality(candidateSiteId)
|
||||
|
||||
// Connectivity-class differences (up/down, direct/relayed) are not subject to
|
||||
// hysteresis - always act on them so we don't stay stuck on a broken route.
|
||||
if curConn != candConn || curRelay != candRelay {
|
||||
return true
|
||||
}
|
||||
if !curConn {
|
||||
return false // both down, nothing to do
|
||||
}
|
||||
|
||||
// Same connectivity class: only switch on a meaningful, sustained RTT win.
|
||||
if candRTT == 0 || curRTT == 0 {
|
||||
return false
|
||||
}
|
||||
minImprovement := time.Duration(float64(curRTT) * routeSwitchRTTMargin)
|
||||
if minImprovement < routeSwitchMinAbsMargin {
|
||||
minImprovement = routeSwitchMinAbsMargin
|
||||
}
|
||||
if candRTT > curRTT-minImprovement {
|
||||
return false // not enough of an improvement to be worth switching
|
||||
}
|
||||
|
||||
if lastChange, ok := pm.lastOwnerChange[cidr]; ok {
|
||||
if time.Since(lastChange) < routeSwitchCooldown {
|
||||
return false // switched too recently, avoid flapping
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// getWireGuardAllowedIPs returns the full set of IPs that should be in WireGuard
|
||||
// for a peer: server IP /32 plus all shared IPs it currently owns.
|
||||
// Must be called with pm.mu held.
|
||||
func (pm *PeerManager) getWireGuardAllowedIPs(siteId int) []string {
|
||||
peer, exists := pm.peers[siteId]
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
serverIP := strings.Split(peer.ServerIP, "/")[0] + "/32"
|
||||
ips := []string{serverIP}
|
||||
for cidr, owner := range pm.allowedIPOwners {
|
||||
if owner == siteId {
|
||||
ips = append(ips, cidr)
|
||||
}
|
||||
}
|
||||
return ips
|
||||
}
|
||||
|
||||
// transferOwnership moves WireGuard ownership of cidr from fromSiteId to toSiteId.
|
||||
// Must be called with pm.mu held.
|
||||
func (pm *PeerManager) transferOwnership(cidr string, fromSiteId int, toSiteId int) error {
|
||||
// Update owner map first
|
||||
pm.allowedIPOwners[cidr] = toSiteId
|
||||
|
||||
// Remove cidr from old owner's WireGuard allowed IPs
|
||||
if fromPeer, exists := pm.peers[fromSiteId]; exists {
|
||||
remaining := pm.getWireGuardAllowedIPs(fromSiteId) // cidr is no longer in owners, so it won't appear here
|
||||
if err := RemoveAllowedIP(pm.device, fromPeer.PublicKey, remaining); err != nil {
|
||||
// Revert
|
||||
pm.allowedIPOwners[cidr] = fromSiteId
|
||||
return fmt.Errorf("remove IP %s from site %d: %v", cidr, fromSiteId, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Add cidr to new owner's WireGuard allowed IPs
|
||||
if toPeer, exists := pm.peers[toSiteId]; exists {
|
||||
if err := AddAllowedIP(pm.device, toPeer.PublicKey, cidr); err != nil {
|
||||
return fmt.Errorf("add IP %s to site %d: %v", cidr, toSiteId, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// optimizeRoutes evaluates all shared IPs and reassigns ownership to the best site.
|
||||
func (pm *PeerManager) optimizeRoutes() {
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
|
||||
for cidr, claims := range pm.allowedIPClaims {
|
||||
if len(claims) <= 1 {
|
||||
continue // No competition, nothing to optimize
|
||||
}
|
||||
|
||||
currentOwner, hasOwner := pm.allowedIPOwners[cidr]
|
||||
bestOwner := pm.selectBestOwner(claims)
|
||||
|
||||
if bestOwner < 0 {
|
||||
continue
|
||||
}
|
||||
if hasOwner && currentOwner == bestOwner {
|
||||
continue // Already on the best site
|
||||
}
|
||||
|
||||
if !hasOwner {
|
||||
// No current owner, just assign
|
||||
pm.allowedIPOwners[cidr] = bestOwner
|
||||
pm.lastOwnerChange[cidr] = time.Now()
|
||||
if toPeer, exists := pm.peers[bestOwner]; exists {
|
||||
if err := AddAllowedIP(pm.device, toPeer.PublicKey, cidr); err != nil {
|
||||
logger.Error("Failed to assign IP %s to site %d: %v", cidr, bestOwner, err)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if !pm.shouldSwitchOwner(cidr, currentOwner, bestOwner) {
|
||||
continue // Not a big enough or sustained enough improvement, avoid flapping
|
||||
}
|
||||
|
||||
logger.Info("Route optimizer: moving %s from site %d to site %d", cidr, currentOwner, bestOwner)
|
||||
if err := pm.transferOwnership(cidr, currentOwner, bestOwner); err != nil {
|
||||
logger.Error("Failed to transfer ownership of %s from site %d to site %d: %v",
|
||||
cidr, currentOwner, bestOwner, err)
|
||||
} else {
|
||||
pm.lastOwnerChange[cidr] = time.Now()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// startRouteOptimizer registers the status-change callback and launches the optimizer goroutine.
|
||||
func (pm *PeerManager) startRouteOptimizer() {
|
||||
pm.routeOptimizerStop = make(chan struct{})
|
||||
|
||||
// Trigger optimization whenever any peer's connection status changes
|
||||
if pm.peerMonitor != nil {
|
||||
pm.peerMonitor.SetStatusChangeCallback(func(_ int) {
|
||||
select {
|
||||
case pm.optimizerTrigger <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-pm.routeOptimizerStop:
|
||||
return
|
||||
case <-pm.optimizerTrigger:
|
||||
pm.optimizeRoutes()
|
||||
case <-ticker.C:
|
||||
pm.optimizeRoutes()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// stopRouteOptimizer stops the route optimizer goroutine if it is running.
|
||||
func (pm *PeerManager) stopRouteOptimizer() {
|
||||
if pm.routeOptimizerStop != nil {
|
||||
close(pm.routeOptimizerStop)
|
||||
pm.routeOptimizerStop = nil
|
||||
}
|
||||
}
|
||||
|
||||
+713
-148
File diff suppressed because it is too large
Load Diff
+100
-26
@@ -32,10 +32,19 @@ type Client struct {
|
||||
monitorLock sync.Mutex
|
||||
connLock sync.Mutex // Protects connection operations
|
||||
shutdownCh chan struct{}
|
||||
updateCh chan struct{}
|
||||
packetInterval time.Duration
|
||||
timeout time.Duration
|
||||
maxAttempts int
|
||||
dialer Dialer
|
||||
|
||||
// Exponential backoff fields
|
||||
defaultMinInterval time.Duration // Default minimum interval (initial)
|
||||
defaultMaxInterval time.Duration // Default maximum interval (cap for backoff)
|
||||
minInterval time.Duration // Minimum interval (initial)
|
||||
maxInterval time.Duration // Maximum interval (cap for backoff)
|
||||
backoffMultiplier float64 // Multiplier for each stable check
|
||||
stableCountToBackoff int // Number of stable checks before backing off
|
||||
}
|
||||
|
||||
// Dialer is a function that creates a connection
|
||||
@@ -50,28 +59,59 @@ type ConnectionStatus struct {
|
||||
// NewClient creates a new connection test client
|
||||
func NewClient(serverAddr string, dialer Dialer) (*Client, error) {
|
||||
return &Client{
|
||||
serverAddr: serverAddr,
|
||||
shutdownCh: make(chan struct{}),
|
||||
packetInterval: 2 * time.Second,
|
||||
timeout: 500 * time.Millisecond, // Timeout for individual packets
|
||||
maxAttempts: 3, // Default max attempts
|
||||
dialer: dialer,
|
||||
serverAddr: serverAddr,
|
||||
shutdownCh: make(chan struct{}),
|
||||
updateCh: make(chan struct{}, 1),
|
||||
packetInterval: 2 * time.Second,
|
||||
defaultMinInterval: 2 * time.Second,
|
||||
defaultMaxInterval: 30 * time.Second,
|
||||
minInterval: 2 * time.Second,
|
||||
maxInterval: 30 * time.Second,
|
||||
backoffMultiplier: 1.5,
|
||||
stableCountToBackoff: 3, // After 3 consecutive same-state results, start backing off
|
||||
timeout: 500 * time.Millisecond, // Timeout for individual packets
|
||||
maxAttempts: 3, // Default max attempts
|
||||
dialer: dialer,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetPacketInterval changes how frequently packets are sent in monitor mode
|
||||
func (c *Client) SetPacketInterval(interval time.Duration) {
|
||||
c.packetInterval = interval
|
||||
func (c *Client) SetPacketInterval(minInterval, maxInterval time.Duration) {
|
||||
c.monitorLock.Lock()
|
||||
c.packetInterval = minInterval
|
||||
c.minInterval = minInterval
|
||||
c.maxInterval = maxInterval
|
||||
updateCh := c.updateCh
|
||||
monitorRunning := c.monitorRunning
|
||||
c.monitorLock.Unlock()
|
||||
|
||||
// Signal the goroutine to apply the new interval if running
|
||||
if monitorRunning && updateCh != nil {
|
||||
select {
|
||||
case updateCh <- struct{}{}:
|
||||
default:
|
||||
// Channel full or closed, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetTimeout changes the timeout for waiting for responses
|
||||
func (c *Client) SetTimeout(timeout time.Duration) {
|
||||
c.timeout = timeout
|
||||
}
|
||||
func (c *Client) ResetPacketInterval() {
|
||||
c.monitorLock.Lock()
|
||||
c.packetInterval = c.defaultMinInterval
|
||||
c.minInterval = c.defaultMinInterval
|
||||
c.maxInterval = c.defaultMaxInterval
|
||||
updateCh := c.updateCh
|
||||
monitorRunning := c.monitorRunning
|
||||
c.monitorLock.Unlock()
|
||||
|
||||
// SetMaxAttempts changes the maximum number of attempts for TestConnection
|
||||
func (c *Client) SetMaxAttempts(attempts int) {
|
||||
c.maxAttempts = attempts
|
||||
// Signal the goroutine to apply the new interval if running
|
||||
if monitorRunning && updateCh != nil {
|
||||
select {
|
||||
case updateCh <- struct{}{}:
|
||||
default:
|
||||
// Channel full or closed, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateServerAddr updates the server address and resets the connection
|
||||
@@ -125,9 +165,10 @@ func (c *Client) ensureConnection() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestConnection checks if the connection to the server is working
|
||||
// TestPeerConnection checks if the connection to the server is working
|
||||
// Returns true if connected, false otherwise
|
||||
func (c *Client) TestConnection(ctx context.Context) (bool, time.Duration) {
|
||||
func (c *Client) TestPeerConnection(ctx context.Context) (bool, time.Duration) {
|
||||
// logger.Debug("wgtester: testing connection to peer %s", c.serverAddr)
|
||||
if err := c.ensureConnection(); err != nil {
|
||||
logger.Warn("Failed to ensure connection: %v", err)
|
||||
return false, 0
|
||||
@@ -138,6 +179,9 @@ func (c *Client) TestConnection(ctx context.Context) (bool, time.Duration) {
|
||||
binary.BigEndian.PutUint32(packet[0:4], magicHeader)
|
||||
packet[4] = packetTypeRequest
|
||||
|
||||
// Reusable response buffer
|
||||
responseBuffer := make([]byte, packetSize)
|
||||
|
||||
// Send multiple attempts as specified
|
||||
for attempt := 0; attempt < c.maxAttempts; attempt++ {
|
||||
select {
|
||||
@@ -157,20 +201,17 @@ func (c *Client) TestConnection(ctx context.Context) (bool, time.Duration) {
|
||||
return false, 0
|
||||
}
|
||||
|
||||
// logger.Debug("Attempting to send monitor packet to %s", c.serverAddr)
|
||||
_, err := c.conn.Write(packet)
|
||||
if err != nil {
|
||||
c.connLock.Unlock()
|
||||
logger.Info("Error sending packet: %v", err)
|
||||
continue
|
||||
}
|
||||
// logger.Debug("Successfully sent monitor packet")
|
||||
|
||||
// Set read deadline
|
||||
c.conn.SetReadDeadline(time.Now().Add(c.timeout))
|
||||
|
||||
// Wait for response
|
||||
responseBuffer := make([]byte, packetSize)
|
||||
n, err := c.conn.Read(responseBuffer)
|
||||
c.connLock.Unlock()
|
||||
|
||||
@@ -211,7 +252,7 @@ func (c *Client) TestConnection(ctx context.Context) (bool, time.Duration) {
|
||||
func (c *Client) TestConnectionWithTimeout(timeout time.Duration) (bool, time.Duration) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
return c.TestConnection(ctx)
|
||||
return c.TestPeerConnection(ctx)
|
||||
}
|
||||
|
||||
// MonitorCallback is the function type for connection status change callbacks
|
||||
@@ -238,28 +279,61 @@ func (c *Client) StartMonitor(callback MonitorCallback) error {
|
||||
go func() {
|
||||
var lastConnected bool
|
||||
firstRun := true
|
||||
stableCount := 0
|
||||
currentInterval := c.minInterval
|
||||
|
||||
ticker := time.NewTicker(c.packetInterval)
|
||||
defer ticker.Stop()
|
||||
timer := time.NewTimer(currentInterval)
|
||||
defer timer.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.shutdownCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
case <-c.updateCh:
|
||||
// Interval settings changed, reset to minimum
|
||||
c.monitorLock.Lock()
|
||||
currentInterval = c.minInterval
|
||||
c.monitorLock.Unlock()
|
||||
|
||||
// Reset backoff state
|
||||
stableCount = 0
|
||||
|
||||
timer.Reset(currentInterval)
|
||||
logger.Debug("Packet interval updated, reset to %v", currentInterval)
|
||||
case <-timer.C:
|
||||
ctx, cancel := context.WithTimeout(context.Background(), c.timeout)
|
||||
connected, rtt := c.TestConnection(ctx)
|
||||
connected, rtt := c.TestPeerConnection(ctx)
|
||||
cancel()
|
||||
|
||||
statusChanged := connected != lastConnected
|
||||
|
||||
// Callback if status changed or it's the first check
|
||||
if connected != lastConnected || firstRun {
|
||||
if statusChanged || firstRun {
|
||||
callback(ConnectionStatus{
|
||||
Connected: connected,
|
||||
RTT: rtt,
|
||||
})
|
||||
lastConnected = connected
|
||||
firstRun = false
|
||||
// Reset backoff on status change
|
||||
stableCount = 0
|
||||
currentInterval = c.minInterval
|
||||
} else {
|
||||
// Status is stable, increment counter
|
||||
stableCount++
|
||||
|
||||
// Apply exponential backoff after stable threshold
|
||||
if stableCount >= c.stableCountToBackoff {
|
||||
newInterval := time.Duration(float64(currentInterval) * c.backoffMultiplier)
|
||||
if newInterval > c.maxInterval {
|
||||
newInterval = c.maxInterval
|
||||
}
|
||||
currentInterval = newInterval
|
||||
}
|
||||
}
|
||||
|
||||
// Reset timer with current interval
|
||||
timer.Reset(currentInterval)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
+39
-13
@@ -10,17 +10,26 @@ import (
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
)
|
||||
|
||||
// ConfigurePeer sets up or updates a peer within the WireGuard device
|
||||
func ConfigurePeer(dev *device.Device, siteConfig SiteConfig, privateKey wgtypes.Key, relay bool) error {
|
||||
var endpoint string
|
||||
if relay && siteConfig.RelayEndpoint != "" {
|
||||
endpoint = formatEndpoint(siteConfig.RelayEndpoint)
|
||||
// ConfigurePeer sets up or updates a peer within the WireGuard device.
|
||||
// If siteConfig.ActiveLocalEndpoint is set, it takes priority over both the relay and the
|
||||
// public endpoint since it's a directly-reachable address on the site host's local network.
|
||||
func ConfigurePeer(dev *device.Device, siteConfig SiteConfig, privateKey wgtypes.Key, relay bool, persistentKeepalive int, publicDNS []string) error {
|
||||
var siteHost string
|
||||
if siteConfig.ActiveLocalEndpoint != "" {
|
||||
// Local endpoints are already literal ip:port pairs on the local network, no DNS resolution needed.
|
||||
siteHost = siteConfig.ActiveLocalEndpoint
|
||||
} else {
|
||||
endpoint = formatEndpoint(siteConfig.Endpoint)
|
||||
}
|
||||
siteHost, err := util.ResolveDomain(endpoint)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve endpoint for site %d: %v", siteConfig.SiteId, err)
|
||||
var endpoint string
|
||||
if relay && siteConfig.RelayEndpoint != "" {
|
||||
endpoint = formatEndpoint(siteConfig.RelayEndpoint)
|
||||
} else {
|
||||
endpoint = formatEndpoint(siteConfig.Endpoint)
|
||||
}
|
||||
var err error
|
||||
siteHost, err = util.ResolveDomainUpstream(endpoint, publicDNS)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve endpoint for site %d: %v", siteConfig.SiteId, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Split off the CIDR of the server IP which is just a string and add /32 for the allowed IP
|
||||
@@ -61,13 +70,12 @@ func ConfigurePeer(dev *device.Device, siteConfig SiteConfig, privateKey wgtypes
|
||||
}
|
||||
|
||||
configBuilder.WriteString(fmt.Sprintf("endpoint=%s\n", siteHost))
|
||||
configBuilder.WriteString("persistent_keepalive_interval=5\n")
|
||||
configBuilder.WriteString(fmt.Sprintf("persistent_keepalive_interval=%d\n", persistentKeepalive))
|
||||
|
||||
config := configBuilder.String()
|
||||
logger.Debug("Configuring peer with config: %s", config)
|
||||
|
||||
err = dev.IpcSet(config)
|
||||
if err != nil {
|
||||
if err := dev.IpcSet(config); err != nil {
|
||||
return fmt.Errorf("failed to configure WireGuard peer: %v", err)
|
||||
}
|
||||
|
||||
@@ -134,6 +142,24 @@ func RemoveAllowedIP(dev *device.Device, publicKey string, remainingAllowedIPs [
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdatePersistentKeepalive updates the persistent keepalive interval for a peer without recreating it
|
||||
func UpdatePersistentKeepalive(dev *device.Device, publicKey string, interval int) error {
|
||||
var configBuilder strings.Builder
|
||||
configBuilder.WriteString(fmt.Sprintf("public_key=%s\n", util.FixKey(publicKey)))
|
||||
configBuilder.WriteString("update_only=true\n")
|
||||
configBuilder.WriteString(fmt.Sprintf("persistent_keepalive_interval=%d\n", interval))
|
||||
|
||||
config := configBuilder.String()
|
||||
logger.Debug("Updating persistent keepalive for peer with config: %s", config)
|
||||
|
||||
err := dev.IpcSet(config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update persistent keepalive for WireGuard peer: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatEndpoint(endpoint string) string {
|
||||
if strings.Contains(endpoint, ":") {
|
||||
return endpoint
|
||||
|
||||
+22
-10
@@ -8,16 +8,21 @@ type PeerAction struct {
|
||||
|
||||
// UpdatePeerData represents the data needed to update a peer
|
||||
type SiteConfig struct {
|
||||
SiteId int `json:"siteId"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
RelayEndpoint string `json:"relayEndpoint,omitempty"`
|
||||
PublicKey string `json:"publicKey,omitempty"`
|
||||
ServerIP string `json:"serverIP,omitempty"`
|
||||
ServerPort uint16 `json:"serverPort,omitempty"`
|
||||
RemoteSubnets []string `json:"remoteSubnets,omitempty"` // optional, array of subnets that this site can access
|
||||
AllowedIps []string `json:"allowedIps,omitempty"` // optional, array of allowed IPs for the peer
|
||||
Aliases []Alias `json:"aliases,omitempty"` // optional, array of alias configurations
|
||||
SiteId int `json:"siteId"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
LocalEndpoints []string `json:"localEndpoints,omitempty"` // optional, ip:port endpoints on the site host's local network interfaces, ordered best-to-worst
|
||||
RelayEndpoint string `json:"relayEndpoint,omitempty"`
|
||||
PublicKey string `json:"publicKey,omitempty"`
|
||||
ServerIP string `json:"serverIP,omitempty"`
|
||||
ServerPort uint16 `json:"serverPort,omitempty"`
|
||||
RemoteSubnets []string `json:"remoteSubnets,omitempty"` // optional, array of subnets that this site can access
|
||||
AllowedIps []string `json:"allowedIps,omitempty"` // optional, array of allowed IPs for the peer
|
||||
Aliases []Alias `json:"aliases,omitempty"` // optional, array of alias configurations
|
||||
|
||||
// ActiveLocalEndpoint tracks the local network endpoint currently in use for this
|
||||
// peer, if any. Not part of the wire protocol; set internally by the PeerManager.
|
||||
ActiveLocalEndpoint string `json:"-"`
|
||||
}
|
||||
|
||||
type Alias struct {
|
||||
@@ -41,6 +46,13 @@ type UnRelayPeerData struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
}
|
||||
|
||||
// LocalPeerAckData represents the server's acknowledgement of an "olm/wg/local" or
|
||||
// "olm/wg/unlocal" message. olm has already applied the local connection switch by the time
|
||||
// it sends the notification, so the ack is only used to stop the retry sender.
|
||||
type LocalPeerAckData struct {
|
||||
SiteId int `json:"siteId"`
|
||||
}
|
||||
|
||||
// PeerAdd represents the data needed to add remote subnets to a peer
|
||||
type PeerAdd struct {
|
||||
SiteId int `json:"siteId"`
|
||||
|
||||
+293
-71
@@ -2,9 +2,11 @@ package websocket
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -54,8 +56,9 @@ type ExitNode struct {
|
||||
}
|
||||
|
||||
type WSMessage struct {
|
||||
Type string `json:"type"`
|
||||
Data interface{} `json:"data"`
|
||||
Type string `json:"type"`
|
||||
Data interface{} `json:"data"`
|
||||
ConfigVersion int `json:"configVersion,omitempty"`
|
||||
}
|
||||
|
||||
// this is not json anymore
|
||||
@@ -77,9 +80,9 @@ type Client struct {
|
||||
handlersMux sync.RWMutex
|
||||
reconnectInterval time.Duration
|
||||
isConnected bool
|
||||
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
|
||||
@@ -87,6 +90,19 @@ type Client struct {
|
||||
clientType string // Type of client (e.g., "newt", "olm")
|
||||
tlsConfig TLSConfig
|
||||
configNeedsSave bool // Flag to track if config needs to be saved
|
||||
configVersion int // Latest config version received from server
|
||||
configVersionMux sync.RWMutex
|
||||
token string // Cached authentication token
|
||||
exitNodes []ExitNode // Cached exit nodes from token response
|
||||
tokenMux sync.RWMutex // Protects token and exitNodes
|
||||
forceNewToken bool // Flag to force fetching a new token on next connection
|
||||
processingMessage bool // Flag to track if a message is currently being processed
|
||||
processingMux sync.RWMutex // Protects processingMessage
|
||||
processingWg sync.WaitGroup // WaitGroup to wait for message processing to complete
|
||||
getPingData func() map[string]any // Callback to get additional ping data
|
||||
pingStarted bool // Flag to track if ping monitor has been started
|
||||
pingStartedMux sync.Mutex // Protects pingStarted
|
||||
pingDone chan struct{} // Channel to stop the ping monitor independently
|
||||
}
|
||||
|
||||
type ClientOption func(*Client)
|
||||
@@ -122,6 +138,13 @@ func WithTLSConfig(config TLSConfig) ClientOption {
|
||||
}
|
||||
}
|
||||
|
||||
// WithPingDataProvider sets a callback to provide additional data for ping messages
|
||||
func WithPingDataProvider(fn func() map[string]any) ClientOption {
|
||||
return func(c *Client) {
|
||||
c.getPingData = fn
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) OnConnect(callback func() error) {
|
||||
c.onConnect = callback
|
||||
}
|
||||
@@ -135,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,
|
||||
@@ -152,8 +175,8 @@ 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{}),
|
||||
}
|
||||
|
||||
// Apply options before loading config
|
||||
@@ -173,6 +196,9 @@ func (c *Client) GetConfig() *Config {
|
||||
|
||||
// Connect establishes the WebSocket connection
|
||||
func (c *Client) Connect() error {
|
||||
if c.isDisconnected {
|
||||
c.isDisconnected = false
|
||||
}
|
||||
go c.connectWithRetry()
|
||||
return nil
|
||||
}
|
||||
@@ -205,9 +231,31 @@ func (c *Client) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Disconnect cleanly closes the websocket connection and suspends message intervals, but allows reconnecting later.
|
||||
func (c *Client) Disconnect() error {
|
||||
c.isDisconnected = true
|
||||
c.setConnected(false)
|
||||
|
||||
// Stop the ping monitor
|
||||
c.stopPingMonitor()
|
||||
|
||||
// Wait for any message currently being processed to complete
|
||||
c.processingWg.Wait()
|
||||
|
||||
if c.conn != nil {
|
||||
c.writeMux.Lock()
|
||||
c.conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
|
||||
c.writeMux.Unlock()
|
||||
err := c.conn.Close()
|
||||
c.conn = nil
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendMessage sends a message through the WebSocket connection
|
||||
func (c *Client) SendMessage(messageType string, data interface{}) error {
|
||||
if c.conn == nil {
|
||||
if c.isDisconnected || c.conn == nil {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
@@ -216,14 +264,14 @@ func (c *Client) SendMessage(messageType string, data interface{}) error {
|
||||
Data: data,
|
||||
}
|
||||
|
||||
logger.Debug("Sending message: %s, data: %+v", messageType, data)
|
||||
logger.Debug("websocket: Sending message: %s, data: %+v", messageType, data)
|
||||
|
||||
c.writeMux.Lock()
|
||||
defer c.writeMux.Unlock()
|
||||
return c.conn.WriteJSON(msg)
|
||||
}
|
||||
|
||||
func (c *Client) SendMessageInterval(messageType string, data interface{}, interval time.Duration) (stop func(), update func(newData interface{})) {
|
||||
func (c *Client) SendMessageInterval(messageType string, data interface{}, interval time.Duration, maxAttempts int) (stop func(), update func(newData interface{})) {
|
||||
stopChan := make(chan struct{})
|
||||
updateChan := make(chan interface{})
|
||||
var dataMux sync.Mutex
|
||||
@@ -231,30 +279,32 @@ func (c *Client) SendMessageInterval(messageType string, data interface{}, inter
|
||||
|
||||
go func() {
|
||||
count := 0
|
||||
maxAttempts := 10
|
||||
|
||||
err := c.SendMessage(messageType, currentData) // Send immediately
|
||||
if err != nil {
|
||||
logger.Error("Failed to send initial message: %v", err)
|
||||
send := func() {
|
||||
if c.isDisconnected || c.conn == nil {
|
||||
return
|
||||
}
|
||||
err := c.SendMessage(messageType, currentData)
|
||||
if err != nil {
|
||||
logger.Error("websocket: Failed to send message: %v", err)
|
||||
}
|
||||
count++
|
||||
}
|
||||
count++
|
||||
|
||||
send() // Send immediately
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if count >= maxAttempts {
|
||||
logger.Info("SendMessageInterval timed out after %d attempts for message type: %s", maxAttempts, messageType)
|
||||
if maxAttempts != -1 && count >= maxAttempts {
|
||||
logger.Info("websocket: SendMessageInterval timed out after %d attempts for message type: %s", maxAttempts, messageType)
|
||||
return
|
||||
}
|
||||
dataMux.Lock()
|
||||
err = c.SendMessage(messageType, currentData)
|
||||
send()
|
||||
dataMux.Unlock()
|
||||
if err != nil {
|
||||
logger.Error("Failed to send message: %v", err)
|
||||
}
|
||||
count++
|
||||
case newData := <-updateChan:
|
||||
dataMux.Lock()
|
||||
// Merge newData into currentData if both are maps
|
||||
@@ -277,6 +327,14 @@ func (c *Client) SendMessageInterval(messageType string, data interface{}, inter
|
||||
case <-stopChan:
|
||||
return
|
||||
}
|
||||
// Suspend sending if disconnected
|
||||
for c.isDisconnected {
|
||||
select {
|
||||
case <-stopChan:
|
||||
return
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return func() {
|
||||
@@ -323,12 +381,13 @@ func (c *Client) getToken() (string, []ExitNode, error) {
|
||||
tlsConfig = &tls.Config{}
|
||||
}
|
||||
tlsConfig.InsecureSkipVerify = true
|
||||
logger.Debug("TLS certificate verification disabled via SKIP_TLS_VERIFY environment variable")
|
||||
logger.Debug("websocket: TLS certificate verification disabled via SKIP_TLS_VERIFY environment variable")
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -352,7 +411,7 @@ func (c *Client) getToken() (string, []ExitNode, error) {
|
||||
req.Header.Set("X-CSRF-Token", "x-csrf-protection")
|
||||
|
||||
// print out the request for debugging
|
||||
logger.Debug("Requesting token from %s with body: %s", req.URL.String(), string(jsonData))
|
||||
logger.Debug("websocket: Requesting token from %s with body: %s", req.URL.String(), string(jsonData))
|
||||
|
||||
// Make the request
|
||||
client := &http.Client{}
|
||||
@@ -369,7 +428,7 @@ func (c *Client) getToken() (string, []ExitNode, error) {
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
logger.Error("Failed to get token with status code: %d, body: %s", resp.StatusCode, string(body))
|
||||
logger.Error("websocket: Failed to get token with status code: %d, body: %s", resp.StatusCode, string(body))
|
||||
|
||||
// Return AuthError for 401/403 status codes
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
@@ -385,7 +444,7 @@ func (c *Client) getToken() (string, []ExitNode, error) {
|
||||
|
||||
var tokenResp TokenResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
||||
logger.Error("Failed to decode token response.")
|
||||
logger.Error("websocket: Failed to decode token response.")
|
||||
return "", nil, fmt.Errorf("failed to decode token response: %w", err)
|
||||
}
|
||||
|
||||
@@ -397,7 +456,7 @@ func (c *Client) getToken() (string, []ExitNode, error) {
|
||||
return "", nil, fmt.Errorf("received empty token from server")
|
||||
}
|
||||
|
||||
logger.Debug("Received token: %s", tokenResp.Data.Token)
|
||||
logger.Debug("websocket: Received token: %s", tokenResp.Data.Token)
|
||||
|
||||
return tokenResp.Data.Token, tokenResp.Data.ExitNodes, nil
|
||||
}
|
||||
@@ -411,7 +470,8 @@ func (c *Client) connectWithRetry() {
|
||||
err := c.establishConnection()
|
||||
if err != nil {
|
||||
// Check if this is an auth error (401/403)
|
||||
if authErr, ok := err.(*AuthError); ok {
|
||||
var authErr *AuthError
|
||||
if errors.As(err, &authErr) {
|
||||
logger.Error("Authentication failed: %v. Terminating tunnel and retrying...", authErr)
|
||||
// Trigger auth error callback if set (this should terminate the tunnel)
|
||||
if c.onAuthError != nil {
|
||||
@@ -422,7 +482,7 @@ func (c *Client) connectWithRetry() {
|
||||
continue
|
||||
}
|
||||
// For other errors (5xx, network issues), continue retrying
|
||||
logger.Error("Failed to connect: %v. Retrying in %v...", err, c.reconnectInterval)
|
||||
logger.Error("websocket: Failed to connect: %v. Retrying in %v...", err, c.reconnectInterval)
|
||||
time.Sleep(c.reconnectInterval)
|
||||
continue
|
||||
}
|
||||
@@ -432,15 +492,25 @@ func (c *Client) connectWithRetry() {
|
||||
}
|
||||
|
||||
func (c *Client) establishConnection() error {
|
||||
// Get token for authentication
|
||||
token, exitNodes, err := c.getToken()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get token: %w", err)
|
||||
}
|
||||
// Get token for authentication - reuse cached token unless forced to get new one
|
||||
c.tokenMux.Lock()
|
||||
needNewToken := c.token == "" || c.forceNewToken
|
||||
if needNewToken {
|
||||
token, exitNodes, err := c.getToken()
|
||||
if err != nil {
|
||||
c.tokenMux.Unlock()
|
||||
return fmt.Errorf("failed to get token: %w", err)
|
||||
}
|
||||
c.token = token
|
||||
c.exitNodes = exitNodes
|
||||
c.forceNewToken = false
|
||||
|
||||
if c.onTokenUpdate != nil {
|
||||
c.onTokenUpdate(token, exitNodes)
|
||||
if c.onTokenUpdate != nil {
|
||||
c.onTokenUpdate(token, exitNodes)
|
||||
}
|
||||
}
|
||||
token := c.token
|
||||
c.tokenMux.Unlock()
|
||||
|
||||
// Parse the base URL to determine protocol and hostname
|
||||
baseURL, err := url.Parse(c.baseURL)
|
||||
@@ -475,7 +545,7 @@ func (c *Client) establishConnection() error {
|
||||
|
||||
// Use new TLS configuration method
|
||||
if c.tlsConfig.ClientCertFile != "" || c.tlsConfig.ClientKeyFile != "" || len(c.tlsConfig.CAFiles) > 0 || c.tlsConfig.PKCS12File != "" {
|
||||
logger.Info("Setting up TLS configuration for WebSocket connection")
|
||||
logger.Info("websocket: Setting up TLS configuration for WebSocket connection")
|
||||
tlsConfig, err := c.setupTLS()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to setup TLS configuration: %w", err)
|
||||
@@ -489,25 +559,38 @@ func (c *Client) establishConnection() error {
|
||||
dialer.TLSClientConfig = &tls.Config{}
|
||||
}
|
||||
dialer.TLSClientConfig.InsecureSkipVerify = true
|
||||
logger.Debug("WebSocket TLS certificate verification disabled via SKIP_TLS_VERIFY environment variable")
|
||||
logger.Debug("websocket: WebSocket TLS certificate verification disabled via SKIP_TLS_VERIFY environment variable")
|
||||
}
|
||||
|
||||
conn, _, err := dialer.Dial(u.String(), nil)
|
||||
conn, resp, err := dialer.Dial(u.String(), nil)
|
||||
if err != nil {
|
||||
// Check if this is an unauthorized error (401)
|
||||
if resp != nil && resp.StatusCode == http.StatusUnauthorized {
|
||||
logger.Error("websocket: WebSocket connection rejected with 401 Unauthorized")
|
||||
// Force getting a new token on next reconnect attempt
|
||||
c.tokenMux.Lock()
|
||||
c.forceNewToken = true
|
||||
c.tokenMux.Unlock()
|
||||
return &AuthError{
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
Message: "WebSocket connection unauthorized",
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("failed to connect to WebSocket: %w", err)
|
||||
}
|
||||
|
||||
c.conn = conn
|
||||
c.setConnected(true)
|
||||
|
||||
// Start the ping monitor
|
||||
go c.pingMonitor()
|
||||
// Note: ping monitor is NOT started here - it will be started when
|
||||
// StartPingMonitor() is called after registration completes
|
||||
|
||||
// Start the read pump with disconnect detection
|
||||
go c.readPumpWithDisconnectDetection()
|
||||
|
||||
if c.onConnect != nil {
|
||||
if err := c.onConnect(); err != nil {
|
||||
logger.Error("OnConnect callback failed: %v", err)
|
||||
logger.Error("websocket: OnConnect callback failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -520,9 +603,9 @@ func (c *Client) setupTLS() (*tls.Config, error) {
|
||||
|
||||
// Handle new separate certificate configuration
|
||||
if c.tlsConfig.ClientCertFile != "" && c.tlsConfig.ClientKeyFile != "" {
|
||||
logger.Info("Loading separate certificate files for mTLS")
|
||||
logger.Debug("Client cert: %s", c.tlsConfig.ClientCertFile)
|
||||
logger.Debug("Client key: %s", c.tlsConfig.ClientKeyFile)
|
||||
logger.Info("websocket: Loading separate certificate files for mTLS")
|
||||
logger.Debug("websocket: Client cert: %s", c.tlsConfig.ClientCertFile)
|
||||
logger.Debug("websocket: Client key: %s", c.tlsConfig.ClientKeyFile)
|
||||
|
||||
// Load client certificate and key
|
||||
cert, err := tls.LoadX509KeyPair(c.tlsConfig.ClientCertFile, c.tlsConfig.ClientKeyFile)
|
||||
@@ -533,7 +616,7 @@ func (c *Client) setupTLS() (*tls.Config, error) {
|
||||
|
||||
// Load CA certificates for remote validation if specified
|
||||
if len(c.tlsConfig.CAFiles) > 0 {
|
||||
logger.Debug("Loading CA certificates: %v", c.tlsConfig.CAFiles)
|
||||
logger.Debug("websocket: Loading CA certificates: %v", c.tlsConfig.CAFiles)
|
||||
caCertPool := x509.NewCertPool()
|
||||
for _, caFile := range c.tlsConfig.CAFiles {
|
||||
caCert, err := os.ReadFile(caFile)
|
||||
@@ -559,13 +642,13 @@ func (c *Client) setupTLS() (*tls.Config, error) {
|
||||
|
||||
// Fallback to existing PKCS12 implementation for backward compatibility
|
||||
if c.tlsConfig.PKCS12File != "" {
|
||||
logger.Info("Loading PKCS12 certificate for mTLS (deprecated)")
|
||||
logger.Info("websocket: Loading PKCS12 certificate for mTLS (deprecated)")
|
||||
return c.setupPKCS12TLS()
|
||||
}
|
||||
|
||||
// Legacy fallback using config.TlsClientCert
|
||||
if c.config.TlsClientCert != "" {
|
||||
logger.Info("Loading legacy PKCS12 certificate for mTLS (deprecated)")
|
||||
logger.Info("websocket: Loading legacy PKCS12 certificate for mTLS (deprecated)")
|
||||
return loadClientCertificate(c.config.TlsClientCert)
|
||||
}
|
||||
|
||||
@@ -577,6 +660,59 @@ func (c *Client) setupPKCS12TLS() (*tls.Config, error) {
|
||||
return loadClientCertificate(c.tlsConfig.PKCS12File)
|
||||
}
|
||||
|
||||
// sendPing sends a single ping message
|
||||
func (c *Client) sendPing() {
|
||||
if c.isDisconnected || c.conn == nil {
|
||||
return
|
||||
}
|
||||
// Skip ping if a message is currently being processed
|
||||
c.processingMux.RLock()
|
||||
isProcessing := c.processingMessage
|
||||
c.processingMux.RUnlock()
|
||||
if isProcessing {
|
||||
logger.Debug("websocket: Skipping ping, message is being processed")
|
||||
return
|
||||
}
|
||||
// Send application-level ping with config version
|
||||
c.configVersionMux.RLock()
|
||||
configVersion := c.configVersion
|
||||
c.configVersionMux.RUnlock()
|
||||
|
||||
pingData := map[string]any{
|
||||
"timestamp": time.Now().Unix(),
|
||||
"userToken": c.config.UserToken,
|
||||
}
|
||||
if c.getPingData != nil {
|
||||
for k, v := range c.getPingData() {
|
||||
pingData[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
pingMsg := WSMessage{
|
||||
Type: "olm/ping",
|
||||
Data: pingData,
|
||||
ConfigVersion: configVersion,
|
||||
}
|
||||
|
||||
logger.Debug("websocket: Sending ping: %+v", pingMsg)
|
||||
|
||||
c.writeMux.Lock()
|
||||
err := c.conn.WriteJSON(pingMsg)
|
||||
c.writeMux.Unlock()
|
||||
if err != nil {
|
||||
// Check if we're shutting down before logging error and reconnecting
|
||||
select {
|
||||
case <-c.done:
|
||||
// Expected during shutdown
|
||||
return
|
||||
default:
|
||||
logger.Error("websocket: Ping failed: %v", err)
|
||||
c.reconnect()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pingMonitor sends pings at a short interval and triggers reconnect on failure
|
||||
func (c *Client) pingMonitor() {
|
||||
ticker := time.NewTicker(c.pingInterval)
|
||||
@@ -586,29 +722,65 @@ func (c *Client) pingMonitor() {
|
||||
select {
|
||||
case <-c.done:
|
||||
return
|
||||
case <-c.pingDone:
|
||||
return
|
||||
case <-ticker.C:
|
||||
if c.conn == nil {
|
||||
return
|
||||
}
|
||||
c.writeMux.Lock()
|
||||
err := c.conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(c.pingTimeout))
|
||||
c.writeMux.Unlock()
|
||||
if err != nil {
|
||||
// Check if we're shutting down before logging error and reconnecting
|
||||
select {
|
||||
case <-c.done:
|
||||
// Expected during shutdown
|
||||
return
|
||||
default:
|
||||
logger.Error("Ping failed: %v", err)
|
||||
c.reconnect()
|
||||
return
|
||||
}
|
||||
}
|
||||
c.sendPing()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StartPingMonitor starts the ping monitor goroutine.
|
||||
// This should be called after the client is registered and connected.
|
||||
// It is safe to call multiple times - only the first call will start the monitor.
|
||||
func (c *Client) StartPingMonitor() {
|
||||
c.pingStartedMux.Lock()
|
||||
defer c.pingStartedMux.Unlock()
|
||||
|
||||
if c.pingStarted {
|
||||
return
|
||||
}
|
||||
c.pingStarted = true
|
||||
|
||||
// Create a new pingDone channel for this ping monitor instance
|
||||
c.pingDone = make(chan struct{})
|
||||
|
||||
// Send an initial ping immediately
|
||||
go func() {
|
||||
c.sendPing()
|
||||
c.pingMonitor()
|
||||
}()
|
||||
}
|
||||
|
||||
// stopPingMonitor stops the ping monitor goroutine if it's running.
|
||||
func (c *Client) stopPingMonitor() {
|
||||
c.pingStartedMux.Lock()
|
||||
defer c.pingStartedMux.Unlock()
|
||||
|
||||
if !c.pingStarted {
|
||||
return
|
||||
}
|
||||
|
||||
// Close the pingDone channel to stop the monitor
|
||||
close(c.pingDone)
|
||||
c.pingStarted = false
|
||||
}
|
||||
|
||||
// GetConfigVersion returns the current config version
|
||||
func (c *Client) GetConfigVersion() int {
|
||||
c.configVersionMux.RLock()
|
||||
defer c.configVersionMux.RUnlock()
|
||||
return c.configVersion
|
||||
}
|
||||
|
||||
// setConfigVersion updates the config version if the new version is higher
|
||||
func (c *Client) setConfigVersion(version int) {
|
||||
c.configVersionMux.Lock()
|
||||
defer c.configVersionMux.Unlock()
|
||||
logger.Debug("websocket: setting config version to %d", version)
|
||||
c.configVersion = version
|
||||
}
|
||||
|
||||
// readPumpWithDisconnectDetection reads messages and triggers reconnect on error
|
||||
func (c *Client) readPumpWithDisconnectDetection() {
|
||||
defer func() {
|
||||
@@ -630,29 +802,73 @@ 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 before logging error
|
||||
// Check if we're shutting down or explicitly disconnected before logging error
|
||||
select {
|
||||
case <-c.done:
|
||||
// Expected during shutdown, don't log as error
|
||||
logger.Debug("WebSocket connection closed during shutdown")
|
||||
logger.Debug("websocket: connection closed during shutdown")
|
||||
return
|
||||
default:
|
||||
// Check if explicitly disconnected
|
||||
if c.isDisconnected {
|
||||
logger.Debug("websocket: connection closed: client was explicitly disconnected")
|
||||
return
|
||||
}
|
||||
|
||||
// Unexpected error during normal operation
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure, websocket.CloseNormalClosure) {
|
||||
logger.Error("WebSocket read error: %v", err)
|
||||
logger.Error("websocket: read error: %v", err)
|
||||
} else {
|
||||
logger.Debug("WebSocket connection closed: %v", err)
|
||||
logger.Debug("websocket: connection closed: %v", err)
|
||||
}
|
||||
return // triggers reconnect via defer
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
c.handlersMux.RLock()
|
||||
if handler, ok := c.handlers[msg.Type]; ok {
|
||||
// Mark that we're processing a message
|
||||
c.processingMux.Lock()
|
||||
c.processingMessage = true
|
||||
c.processingMux.Unlock()
|
||||
c.processingWg.Add(1)
|
||||
|
||||
handler(msg)
|
||||
|
||||
// Mark that we're done processing
|
||||
c.processingWg.Done()
|
||||
c.processingMux.Lock()
|
||||
c.processingMessage = false
|
||||
c.processingMux.Unlock()
|
||||
}
|
||||
c.handlersMux.RUnlock()
|
||||
}
|
||||
@@ -666,6 +882,12 @@ func (c *Client) reconnect() {
|
||||
c.conn = nil
|
||||
}
|
||||
|
||||
// Don't reconnect if explicitly disconnected
|
||||
if c.isDisconnected {
|
||||
logger.Debug("websocket: websocket: Not reconnecting: client was explicitly disconnected")
|
||||
return
|
||||
}
|
||||
|
||||
// Only reconnect if we're not shutting down
|
||||
select {
|
||||
case <-c.done:
|
||||
@@ -683,7 +905,7 @@ func (c *Client) setConnected(status bool) {
|
||||
|
||||
// LoadClientCertificate Helper method to load client certificates (PKCS12 format)
|
||||
func loadClientCertificate(p12Path string) (*tls.Config, error) {
|
||||
logger.Info("Loading tls-client-cert %s", p12Path)
|
||||
logger.Info("websocket: Loading tls-client-cert %s", p12Path)
|
||||
// Read the PKCS12 file
|
||||
p12Data, err := os.ReadFile(p12Path)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user