mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-07-16 13:17:19 +02:00
Compare commits
153 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c43739e42d | |||
| 7f3d332b64 | |||
| 9cf8bc3b5b | |||
| 265573c744 | |||
| 5ef0a59081 | |||
| 3226c2d0e2 | |||
| eb8756bbe5 | |||
| ff14fc8ac9 | |||
| 8948fa402b | |||
| 8330049b66 | |||
| 9eb7067fbe | |||
| c1d3686f9a | |||
| 9399f4590b | |||
| a4bc23d17e | |||
| 7a8450cc40 | |||
| 5ccbf63e58 | |||
| 6c5489873b | |||
| f281466118 | |||
| 3c740b9693 | |||
| 0d183feddb | |||
| e15ca5c466 | |||
| 98ca63325f | |||
| 7b360288ed | |||
| 65d3d468f7 | |||
| 01490434a6 | |||
| a61677b6e4 | |||
| 8e2ef7ca65 | |||
| 1735d3d55b | |||
| 81aa6b237b | |||
| 8abeeea4c3 | |||
| b3c670b17e | |||
| 70b45710e7 | |||
| 2742669bc1 | |||
| a767ebdaa5 | |||
| a92d20cef8 | |||
| a8821e6d00 | |||
| a3880beec2 | |||
| d7124995d2 | |||
| 86dafc432d | |||
| ca8d07d3f5 | |||
| 2aaa78ef48 | |||
| 0f2a930cf8 | |||
| 5a6163142c | |||
| 402771bed6 | |||
| a99dcca288 | |||
| 395335162f | |||
| c56d4771de | |||
| ea48186d73 | |||
| bc71622deb | |||
| 846aaac27a | |||
| f1e49a7ee6 | |||
| 878b3d7920 | |||
| f1b93c81bc | |||
| 60dd366cc4 | |||
| e45e56c021 | |||
| 6f331f19c8 | |||
| e4ca728ef0 | |||
| 8117084d39 | |||
| e23451df37 | |||
| 43d4e1957c | |||
| ba3dd3a4b0 | |||
| 8b92dc97a3 | |||
| 9158cd7835 | |||
| 2d9603609f | |||
| e4e655414c | |||
| aacba92698 | |||
| c3c9e1a4d7 | |||
| 9a3840954b | |||
| 673308a1fe | |||
| 2cb0456265 | |||
| 9dd4183981 | |||
| aacbcad133 | |||
| 1489245f50 | |||
| 6aedc2b2d3 | |||
| 9b1f102315 | |||
| c365051732 | |||
| 6294ba4028 | |||
| 261f9483a2 | |||
| e17a538312 | |||
| 650e47a504 | |||
| f24059885f | |||
| 52cfb4c302 | |||
| 6cf754883d | |||
| 00c1e0fd76 | |||
| 4be262156f | |||
| 37ddbd90f1 | |||
| d3b01dc7e8 | |||
| 166b7f38fc | |||
| 88a297ad43 | |||
| 7040c2024a | |||
| 082f5a0fac | |||
| 7778c96f38 | |||
| 64393b7b6c | |||
| c8477fe442 | |||
| 36afb16e57 | |||
| 134561c85a | |||
| 54be78f092 | |||
| 5bc8da6470 | |||
| b187ec98a3 | |||
| 4d8e10ca0d | |||
| ed147a3362 | |||
| 38f0b84d44 | |||
| 0b4dc51c24 | |||
| f3a5fffc6f | |||
| 8959319382 | |||
| bfe6060df1 | |||
| a61cb1f5bf | |||
| 33dd720d80 | |||
| 76e602afc3 | |||
| dd930a30a1 | |||
| d81042089b | |||
| 8cc5b71c69 | |||
| d5281d5df4 | |||
| 7677c2fbbe | |||
| b510fe1af5 | |||
| 35cc8adecb | |||
| aa8af67365 | |||
| d81eef9585 | |||
| efee5b67c1 | |||
| 2d6fea19a6 | |||
| 1cd034d526 | |||
| f0d7cfaaa1 | |||
| d0830a7ba2 | |||
| ddb81b6f83 | |||
| f73a17f25d | |||
| f933664e7d | |||
| d41334c66f | |||
| 016c566307 | |||
| 48d0558103 | |||
| 6e1e9426da | |||
| 02032c8f9f | |||
| 7552f1ca7c | |||
| f573de851a | |||
| a63fa31969 | |||
| 975b465e3e | |||
| ba9057e466 | |||
| af1a6e9f3a | |||
| 38ae916068 | |||
| 719d76f641 | |||
| 2030025130 | |||
| eaa6ccc356 | |||
| 627eb23ed6 | |||
| 7ec4353d90 | |||
| abad9ef8d4 | |||
| 29b8b4277c | |||
| 95699fa4a1 | |||
| aaf31b6471 | |||
| 0e66697247 | |||
| 47c04bf0f6 | |||
| 6286a71f2a | |||
| 5ce92abf1f | |||
| 37c3331559 | |||
| f334993f79 |
@@ -21,6 +21,6 @@ jobs:
|
||||
- run: "go test -race ./..."
|
||||
- uses: dominikh/staticcheck-action@v1.4.0
|
||||
with:
|
||||
version: "2026.1"
|
||||
version: "2025.1.1"
|
||||
install-go: false
|
||||
cache-key: ${{ matrix.go }}
|
||||
|
||||
@@ -11,7 +11,6 @@ A highly configurable DNS forwarding proxy with support for:
|
||||
- Multiple upstreams with fallbacks
|
||||
- Multiple network policy driven DNS query steering (via network cidr, MAC address or FQDN)
|
||||
- Policy driven domain based "split horizon" DNS with wildcard support
|
||||
- Integrations with common router vendors and firmware
|
||||
- LAN client discovery via DHCP, mDNS, ARP, NDP, hosts file parsing
|
||||
- Prometheus metrics exporter
|
||||
|
||||
@@ -25,50 +24,32 @@ All DNS protocols are supported, including:
|
||||
- `DNS-over-HTTP/3` (DOH3)
|
||||
- `DNS-over-QUIC`
|
||||
|
||||
# Use Cases
|
||||
1. Use secure DNS protocols on networks and devices that don't natively support them (legacy routers, legacy OSes, TVs, smart toasters).
|
||||
## Use Cases
|
||||
1. Use secure DNS protocols on networks and devices that don't natively support them (legacy OSes, TVs, smart toasters).
|
||||
2. Create source IP based DNS routing policies with variable secure DNS upstreams. Subnet 1 (admin) uses upstream resolver A, while Subnet 2 (employee) uses upstream resolver B.
|
||||
3. Create destination IP based DNS routing policies with variable secure DNS upstreams. Listener 1 uses upstream resolver C, while Listener 2 uses upstream resolver D.
|
||||
4. Create domain level "split horizon" DNS routing policies to send internal domains (*.company.int) to a local DNS server, while everything else goes to another upstream.
|
||||
5. Deploy on a router and create LAN client specific DNS routing policies from a web GUI (When using ControlD.com).
|
||||
|
||||
|
||||
## OS Support
|
||||
- Windows (386, amd64, arm)
|
||||
- Windows Server (386, amd64)
|
||||
- Windows Desktop (386, amd64, arm64)
|
||||
- MacOS (amd64, arm64)
|
||||
- Linux (386, amd64, arm, mips)
|
||||
- FreeBSD (386, amd64, arm)
|
||||
- Common routers (See below)
|
||||
- Linux (386, amd64, arm, arm64, mips, mipsle, mips64)
|
||||
- FreeBSD (386, amd64, arm, arm64)
|
||||
|
||||
|
||||
### Supported Routers
|
||||
You can run `ctrld` on any supported router. The list of supported routers and firmware includes:
|
||||
- Asus Merlin
|
||||
- DD-WRT
|
||||
- Firewalla
|
||||
- FreshTomato
|
||||
- GL.iNet
|
||||
- OpenWRT
|
||||
- pfSense / OPNsense
|
||||
- Synology
|
||||
- Ubiquiti (UniFi, EdgeOS)
|
||||
|
||||
`ctrld` will attempt to interface with dnsmasq (or Windows Server) whenever possible and set itself as the upstream, while running on port 5354. On FreeBSD based OSes, `ctrld` will terminate dnsmasq and unbound in order to be able to listen on port 53 directly.
|
||||
|
||||
# Install
|
||||
## Install
|
||||
There are several ways to download and install `ctrld`.
|
||||
|
||||
## Quick Install
|
||||
The simplest way to download and install `ctrld` is to use the following installer command on any UNIX-like platform:
|
||||
|
||||
```shell
|
||||
sh -c 'sh -c "$(curl -sL https://api.controld.com/dl)"'
|
||||
sh -c 'sh -c "$(curl -sL https://api.controld.com/dl?version=2)"'
|
||||
```
|
||||
|
||||
Windows user and prefer Powershell (who doesn't)? No problem, execute this command instead in administrative PowerShell:
|
||||
```shell
|
||||
(Invoke-WebRequest -Uri 'https://api.controld.com/dl/ps1' -UseBasicParsing).Content | Set-Content "$env:TEMPctrld_install.ps1"; Invoke-Expression "& '$env:TEMPctrld_install.ps1'"
|
||||
(Invoke-WebRequest -Uri 'https://api.controld.com/dl/ps1?version=2' -UseBasicParsing).Content | Set-Content "$env:TEMP\ctrld_install.ps1"; Invoke-Expression "& '$env:TEMP\ctrld_install.ps1'"
|
||||
```
|
||||
|
||||
Or you can pull and run a Docker container from [Docker Hub](https://hub.docker.com/r/controldns/ctrld)
|
||||
@@ -80,7 +61,7 @@ docker run -d --name=ctrld -p 127.0.0.1:53:53/tcp -p 127.0.0.1:53:53/udp control
|
||||
Alternatively, if you know what you're doing you can download pre-compiled binaries from the [Releases](https://github.com/Control-D-Inc/ctrld/releases) section for the appropriate platform.
|
||||
|
||||
## Build
|
||||
Lastly, you can build `ctrld` from source which requires `go1.21+`:
|
||||
Lastly, you can build `ctrld` from source which requires `go1.24+`:
|
||||
|
||||
```shell
|
||||
go build ./cmd/ctrld
|
||||
@@ -99,7 +80,7 @@ docker build -t controldns/ctrld . -f docker/Dockerfile
|
||||
```
|
||||
|
||||
|
||||
# Usage
|
||||
## Usage
|
||||
The cli is self documenting, so feel free to run `--help` on any sub-command to get specific usages.
|
||||
|
||||
## Arguments
|
||||
@@ -130,7 +111,7 @@ Available Commands:
|
||||
Flags:
|
||||
-h, --help help for ctrld
|
||||
-s, --silent do not write any log output
|
||||
-v, --verbose count verbose log output, "-v" basic logging, "-vv" debug level logging
|
||||
-v, --verbose count verbose log output, "-v" basic logging, "-vv" debug logging
|
||||
--version version for ctrld
|
||||
|
||||
Use "ctrld [command] --help" for more information about a command.
|
||||
@@ -161,9 +142,7 @@ You can then run a test query using a DNS client, for example, `dig`:
|
||||
If `verify.controld.com` resolves, you're successfully using the default Control D upstream. From here, you can start editing the config file that was generated. To enforce a new config, restart the server.
|
||||
|
||||
## Service Mode
|
||||
This mode will run the application as a background system service on any Windows, MacOS, Linux, FreeBSD distribution or supported router. This will create a generic `ctrld.toml` file in the **C:\ControlD** directory (on Windows) or `/etc/controld/` (almost everywhere else), start the system service, and **configure the listener on all physical network interface**. Service will start on OS boot.
|
||||
|
||||
When Control D upstreams are used on a router type device, `ctrld` will [relay your network topology](https://docs.controld.com/docs/device-clients) to Control D (LAN IPs, MAC addresses, and hostnames), and you will be able to see your LAN devices in the web panel, view analytics and apply unique profiles to them.
|
||||
This mode will run the application as a background system service on any Windows, MacOS, Linux or FreeBSD distribution. This will create a generic `ctrld.toml` file in the **C:\ControlD** directory (on Windows) or `/etc/controld/` (almost everywhere else), start the system service, and **configure the listener on all physical network interface**. Service will start on OS boot.
|
||||
|
||||
### Command
|
||||
|
||||
@@ -196,11 +175,11 @@ Linux or Macos
|
||||
sudo ctrld service start
|
||||
```
|
||||
|
||||
# Configuration
|
||||
## Configuration
|
||||
`ctrld` can be configured in variety of different ways, which include: API, local config file or via cli launch args.
|
||||
|
||||
## API Based Auto Configuration
|
||||
Application can be started with a specific Control D resolver config, instead of the default one. Simply supply your Resolver ID with a `--cd` flag, when using the `start` (service) mode. In this mode, the application will automatically choose a non-conflicting IP and/or port and configure itself as the upstream to whatever process is running on port 53 (like dnsmasq or Windows DNS Server). This mode is used when the 1 liner installer command from the Control D onboarding guide is executed.
|
||||
Application can be started with a specific Control D resolver config, instead of the default one. Simply supply your Resolver ID with a `--cd` flag, when using the `start` (service) mode. This mode is used when the 1 liner installer command from the Control D onboarding guide is executed.
|
||||
|
||||
The following command will use your own personal Control D Device resolver, and start the application in service mode. Your resolver ID is displayed on the "Show Resolvers" screen for the relevant Control D Endpoint.
|
||||
|
||||
@@ -217,7 +196,7 @@ sudo ctrld start --cd abcd1234
|
||||
Once you run the above command, the following things will happen:
|
||||
- You resolver configuration will be fetched from the API, and config file templated with the resolver data
|
||||
- Application will start as a service, and keep running (even after reboot) until you run the `stop` or `uninstall` sub-commands
|
||||
- All physical network interface will be updated to use the listener started by the service or dnsmasq upstream will be switched to `ctrld`
|
||||
- All physical network interface will be updated to use the listener started by the service
|
||||
- All DNS queries will be sent to the listener
|
||||
|
||||
## Manual Configuration
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
package ctrld
|
||||
|
||||
// SelfDiscover reports whether ctrld should only do self discover.
|
||||
func SelfDiscover() bool { return true }
|
||||
@@ -1,6 +0,0 @@
|
||||
//go:build !windows && !darwin
|
||||
|
||||
package ctrld
|
||||
|
||||
// SelfDiscover reports whether ctrld should only do self discover.
|
||||
func SelfDiscover() bool { return false }
|
||||
@@ -1,18 +0,0 @@
|
||||
package ctrld
|
||||
|
||||
import (
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// isWindowsWorkStation reports whether ctrld was run on a Windows workstation machine.
|
||||
func isWindowsWorkStation() bool {
|
||||
// From https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-osversioninfoexa
|
||||
const VER_NT_WORKSTATION = 0x0000001
|
||||
osvi := windows.RtlGetVersion()
|
||||
return osvi.ProductType == VER_NT_WORKSTATION
|
||||
}
|
||||
|
||||
// SelfDiscover reports whether ctrld should only do self discover.
|
||||
func SelfDiscover() bool {
|
||||
return isWindowsWorkStation()
|
||||
}
|
||||
@@ -8,8 +8,3 @@ import (
|
||||
|
||||
// addExtraSplitDnsRule adds split DNS rule if present.
|
||||
func addExtraSplitDnsRule(_ *ctrld.Config) bool { return false }
|
||||
|
||||
// getActiveDirectoryDomain returns AD domain name of this computer.
|
||||
func getActiveDirectoryDomain() (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
+4
-32
@@ -1,14 +1,8 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/microsoft/wmi/pkg/base/host"
|
||||
hh "github.com/microsoft/wmi/pkg/hardware/host"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
"github.com/Control-D-Inc/ctrld/internal/system"
|
||||
)
|
||||
@@ -17,11 +11,11 @@ import (
|
||||
func addExtraSplitDnsRule(cfg *ctrld.Config) bool {
|
||||
domain, err := system.GetActiveDirectoryDomain()
|
||||
if err != nil {
|
||||
mainLog.Load().Debug().Msgf("unable to get active directory domain: %v", err)
|
||||
mainLog.Load().Debug().Msgf("Unable to get active directory domain: %v", err)
|
||||
return false
|
||||
}
|
||||
if domain == "" {
|
||||
mainLog.Load().Debug().Msg("no active directory domain found")
|
||||
mainLog.Load().Debug().Msg("No active directory domain found")
|
||||
return false
|
||||
}
|
||||
// Network rules are lowercase during toml config marshaling,
|
||||
@@ -41,34 +35,12 @@ func addSplitDnsRule(cfg *ctrld.Config, domain string) bool {
|
||||
}
|
||||
for _, rule := range lc.Policy.Rules {
|
||||
if _, ok := rule[domain]; ok {
|
||||
mainLog.Load().Debug().Msgf("split-rule %q already existed for listener.%s", domain, n)
|
||||
mainLog.Load().Debug().Msgf("Split-rule %q already existed for listener.%s", domain, n)
|
||||
return false
|
||||
}
|
||||
}
|
||||
mainLog.Load().Debug().Msgf("adding split-rule %q for listener.%s", domain, n)
|
||||
mainLog.Load().Debug().Msgf("Adding split-rule %q for listener.%s", domain, n)
|
||||
lc.Policy.Rules = append(lc.Policy.Rules, ctrld.Rule{domain: []string{}})
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// getActiveDirectoryDomain returns AD domain name of this computer.
|
||||
func getActiveDirectoryDomain() (string, error) {
|
||||
log.SetOutput(io.Discard)
|
||||
defer log.SetOutput(os.Stderr)
|
||||
whost := host.NewWmiLocalHost()
|
||||
cs, err := hh.GetComputerSystem(whost)
|
||||
if cs != nil {
|
||||
defer cs.Close()
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
pod, err := cs.GetPropertyPartOfDomain()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if pod {
|
||||
return cs.GetPropertyDomain()
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
+303
-304
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,10 @@
|
||||
package cli
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
)
|
||||
|
||||
func TestIsExplicitInterceptListener(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -26,3 +30,87 @@ func TestIsExplicitInterceptListener(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreserveBoundListeners is a regression test for #551: on reload, the on-disk
|
||||
// generated config still declares 127.0.0.1:53, but the running listener has fallen back
|
||||
// to 127.0.0.1:5354. preserveBoundListeners must keep the in-memory config on the actual
|
||||
// bound port so pf rdr rules and probes do not target the dead default port.
|
||||
func TestPreserveBoundListeners(t *testing.T) {
|
||||
// cur = actual running listener (fell back to 5354); newCfg = freshly read from disk (53).
|
||||
cur := map[string]*ctrld.ListenerConfig{"0": {IP: "127.0.0.1", Port: 5354}}
|
||||
newListeners := map[string]*ctrld.ListenerConfig{"0": {IP: "127.0.0.1", Port: 53}}
|
||||
|
||||
preserveBoundListeners(newListeners, cur)
|
||||
|
||||
if got := newListeners["0"].Port; got != 5354 {
|
||||
t.Errorf("listener port after reload = %d, want 5354 (actual bound port)", got)
|
||||
}
|
||||
if got := newListeners["0"].IP; got != "127.0.0.1" {
|
||||
t.Errorf("listener IP after reload = %q, want 127.0.0.1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreserveBoundListeners_NoChange verifies that when the on-disk config matches the
|
||||
// running listener, the config is left untouched (a legitimate reload with the same port).
|
||||
func TestPreserveBoundListeners_NoChange(t *testing.T) {
|
||||
cur := map[string]*ctrld.ListenerConfig{"0": {IP: "127.0.0.1", Port: 5354}}
|
||||
newListeners := map[string]*ctrld.ListenerConfig{"0": {IP: "127.0.0.1", Port: 5354}}
|
||||
|
||||
preserveBoundListeners(newListeners, cur)
|
||||
|
||||
if got := newListeners["0"].Port; got != 5354 {
|
||||
t.Errorf("listener port = %d, want 5354", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreserveBoundListeners_MissingCurrent verifies that a listener present on disk but not
|
||||
// in the current running set (e.g. newly added) is left as configured.
|
||||
func TestPreserveBoundListeners_MissingCurrent(t *testing.T) {
|
||||
cur := map[string]*ctrld.ListenerConfig{"0": {IP: "127.0.0.1", Port: 5354}}
|
||||
newListeners := map[string]*ctrld.ListenerConfig{
|
||||
"0": {IP: "127.0.0.1", Port: 53},
|
||||
"1": {IP: "127.0.0.1", Port: 5355},
|
||||
}
|
||||
|
||||
preserveBoundListeners(newListeners, cur)
|
||||
|
||||
if got := newListeners["0"].Port; got != 5354 {
|
||||
t.Errorf("listener 0 port = %d, want 5354 (preserved)", got)
|
||||
}
|
||||
if got := newListeners["1"].Port; got != 5355 {
|
||||
t.Errorf("listener 1 port = %d, want 5355 (unchanged, no current binding)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreserveBoundListeners_ExplicitChangeNotMasked verifies that an explicit, non-default
|
||||
// listener in the reloaded config is applied rather than reverted to the old bound listener.
|
||||
// Reverting an explicit change would make the control-server reload comparison return 200
|
||||
// instead of 201, silently dropping the new listener. Regression guard for #551 review.
|
||||
func TestPreserveBoundListeners_ExplicitChangeNotMasked(t *testing.T) {
|
||||
// Running listener fell back to 5354; user reloads with an explicit new listener.
|
||||
cur := map[string]*ctrld.ListenerConfig{"0": {IP: "127.0.0.1", Port: 5354}}
|
||||
newListeners := map[string]*ctrld.ListenerConfig{"0": {IP: "127.0.0.2", Port: 5399}}
|
||||
|
||||
preserveBoundListeners(newListeners, cur)
|
||||
|
||||
if got := newListeners["0"].IP; got != "127.0.0.2" {
|
||||
t.Errorf("explicit listener IP = %q, want 127.0.0.2 (not reverted)", got)
|
||||
}
|
||||
if got := newListeners["0"].Port; got != 5399 {
|
||||
t.Errorf("explicit listener port = %d, want 5399 (not reverted)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreserveBoundListeners_ExplicitDefaultPreserved verifies that the default
|
||||
// 127.0.0.1:53 listener remains fallback-eligible: when it diverges from the running
|
||||
// fallback port it is still preserved (isExplicitInterceptListener treats :53 as non-explicit).
|
||||
func TestPreserveBoundListeners_ExplicitDefaultPreserved(t *testing.T) {
|
||||
cur := map[string]*ctrld.ListenerConfig{"0": {IP: "127.0.0.1", Port: 5354}}
|
||||
newListeners := map[string]*ctrld.ListenerConfig{"0": {IP: "127.0.0.1", Port: 53}}
|
||||
|
||||
preserveBoundListeners(newListeners, cur)
|
||||
|
||||
if got := newListeners["0"].Port; got != 5354 {
|
||||
t.Errorf("default listener port = %d, want 5354 (preserved fallback)", got)
|
||||
}
|
||||
}
|
||||
|
||||
-1606
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,141 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/kardianos/service"
|
||||
"github.com/olekukonko/tablewriter"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld/internal/clientinfo"
|
||||
)
|
||||
|
||||
// ClientsCommand handles clients-related operations
|
||||
type ClientsCommand struct {
|
||||
controlClient *controlClient
|
||||
}
|
||||
|
||||
// NewClientsCommand creates a new clients command handler
|
||||
func NewClientsCommand() (*ClientsCommand, error) {
|
||||
dir, err := socketDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find ctrld home dir: %w", err)
|
||||
}
|
||||
|
||||
cc := newControlClient(filepath.Join(dir, ctrldControlUnixSock))
|
||||
return &ClientsCommand{
|
||||
controlClient: cc,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListClients lists all connected clients
|
||||
func (cc *ClientsCommand) ListClients(cmd *cobra.Command, args []string) error {
|
||||
// Check service status first
|
||||
sc := NewServiceCommand()
|
||||
s, _, err := sc.initializeServiceManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
status, err := s.Status()
|
||||
if errors.Is(err, service.ErrNotInstalled) {
|
||||
mainLog.Load().Warn().Msg("Service not installed")
|
||||
return nil
|
||||
}
|
||||
if status == service.StatusStopped {
|
||||
mainLog.Load().Warn().Msg("Service is not running")
|
||||
return nil
|
||||
}
|
||||
|
||||
resp, err := cc.controlClient.post(listClientsPath, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get clients: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var clients []*clientinfo.Client
|
||||
if err := json.NewDecoder(resp.Body).Decode(&clients); err != nil {
|
||||
return fmt.Errorf("failed to decode clients result: %w", err)
|
||||
}
|
||||
|
||||
map2Slice := func(m map[string]struct{}) []string {
|
||||
s := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
if k == "" { // skip empty source from output.
|
||||
continue
|
||||
}
|
||||
s = append(s, k)
|
||||
}
|
||||
sort.Strings(s)
|
||||
return s
|
||||
}
|
||||
|
||||
// If metrics is enabled, server set this for all clients, so we can check only the first one.
|
||||
// Ideally, we may have a field in response to indicate that query count should be shown, but
|
||||
// it would break earlier version of ctrld, which only look list of clients in response.
|
||||
withQueryCount := len(clients) > 0 && clients[0].IncludeQueryCount
|
||||
data := make([][]string, len(clients))
|
||||
for i, c := range clients {
|
||||
row := []string{
|
||||
c.IP.String(),
|
||||
c.Hostname,
|
||||
c.Mac,
|
||||
strings.Join(map2Slice(c.Source), ","),
|
||||
}
|
||||
if withQueryCount {
|
||||
row = append(row, strconv.FormatInt(c.QueryCount, 10))
|
||||
}
|
||||
data[i] = row
|
||||
}
|
||||
|
||||
table := tablewriter.NewWriter(os.Stdout)
|
||||
headers := []string{"IP", "Hostname", "Mac", "Discovered"}
|
||||
if withQueryCount {
|
||||
headers = append(headers, "Queries")
|
||||
}
|
||||
table.SetHeader(headers)
|
||||
table.SetAutoFormatHeaders(false)
|
||||
table.AppendBulk(data)
|
||||
table.Render()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// InitClientsCmd creates the clients command with proper logic
|
||||
func InitClientsCmd(rootCmd *cobra.Command) *cobra.Command {
|
||||
listClientsCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List clients that ctrld discovered",
|
||||
Args: cobra.NoArgs,
|
||||
PreRun: func(cmd *cobra.Command, args []string) {
|
||||
checkHasElevatedPrivilege()
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cc, err := NewClientsCommand()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return cc.ListClients(cmd, args)
|
||||
},
|
||||
}
|
||||
|
||||
clientsCmd := &cobra.Command{
|
||||
Use: "clients",
|
||||
Short: "Manage clients",
|
||||
Args: cobra.OnlyValidArgs,
|
||||
ValidArgs: []string{
|
||||
listClientsCmd.Use,
|
||||
},
|
||||
}
|
||||
clientsCmd.AddCommand(listClientsCmd)
|
||||
rootCmd.AddCommand(clientsCmd)
|
||||
|
||||
return clientsCmd
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// InterfacesCommand handles interfaces-related operations
|
||||
type InterfacesCommand struct{}
|
||||
|
||||
// NewInterfacesCommand creates a new interfaces command handler
|
||||
func NewInterfacesCommand() (*InterfacesCommand, error) {
|
||||
return &InterfacesCommand{}, nil
|
||||
}
|
||||
|
||||
// ListInterfaces lists all network interfaces
|
||||
func (ic *InterfacesCommand) ListInterfaces(cmd *cobra.Command, args []string) error {
|
||||
withEachPhysicalInterfaces("", "Interface list", func(i *net.Interface) error {
|
||||
fmt.Printf("Index : %d\n", i.Index)
|
||||
fmt.Printf("Name : %s\n", i.Name)
|
||||
var status string
|
||||
if i.Flags&net.FlagUp != 0 {
|
||||
status = "Up"
|
||||
} else {
|
||||
status = "Down"
|
||||
}
|
||||
fmt.Printf("Status: %s\n", status)
|
||||
addrs, _ := i.Addrs()
|
||||
for i, ipaddr := range addrs {
|
||||
if i == 0 {
|
||||
fmt.Printf("Addrs : %v\n", ipaddr)
|
||||
continue
|
||||
}
|
||||
fmt.Printf(" %v\n", ipaddr)
|
||||
}
|
||||
nss, err := currentStaticDNS(i)
|
||||
if err != nil {
|
||||
mainLog.Load().Warn().Err(err).Msg("Failed to get DNS")
|
||||
}
|
||||
if len(nss) == 0 {
|
||||
nss = currentDNS(i)
|
||||
}
|
||||
for i, dns := range nss {
|
||||
if i == 0 {
|
||||
fmt.Printf("DNS : %s\n", dns)
|
||||
continue
|
||||
}
|
||||
fmt.Printf(" : %s\n", dns)
|
||||
}
|
||||
println()
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// InitInterfacesCmd creates the interfaces command with proper logic
|
||||
func InitInterfacesCmd(_ *cobra.Command) *cobra.Command {
|
||||
listInterfacesCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List network interfaces",
|
||||
Args: cobra.NoArgs,
|
||||
PreRun: func(cmd *cobra.Command, args []string) {
|
||||
checkHasElevatedPrivilege()
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ic, err := NewInterfacesCommand()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ic.ListInterfaces(cmd, args)
|
||||
},
|
||||
}
|
||||
|
||||
interfacesCmd := &cobra.Command{
|
||||
Use: "interfaces",
|
||||
Short: "Manage network interfaces",
|
||||
Args: cobra.OnlyValidArgs,
|
||||
ValidArgs: []string{
|
||||
listInterfacesCmd.Use,
|
||||
},
|
||||
}
|
||||
interfacesCmd.AddCommand(listInterfacesCmd)
|
||||
|
||||
return interfacesCmd
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"github.com/docker/go-units"
|
||||
"github.com/kardianos/service"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// LogCommand handles log-related operations
|
||||
type LogCommand struct {
|
||||
controlClient *controlClient
|
||||
}
|
||||
|
||||
// NewLogCommand creates a new log command handler
|
||||
func NewLogCommand() (*LogCommand, error) {
|
||||
dir, err := socketDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find ctrld home dir: %w", err)
|
||||
}
|
||||
|
||||
cc := newControlClient(filepath.Join(dir, ctrldControlUnixSock))
|
||||
return &LogCommand{
|
||||
controlClient: cc,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// warnRuntimeLoggingNotEnabled logs a warning about runtime logging not being enabled
|
||||
func (lc *LogCommand) warnRuntimeLoggingNotEnabled() {
|
||||
mainLog.Load().Warn().Msg("Runtime debug logging is not enabled")
|
||||
mainLog.Load().Warn().Msg(`ctrld may be running without "--cd" flag or logging is already enabled`)
|
||||
}
|
||||
|
||||
// SendLogs sends runtime debug logs to ControlD
|
||||
func (lc *LogCommand) SendLogs(cmd *cobra.Command, args []string) error {
|
||||
sc := NewServiceCommand()
|
||||
s, _, err := sc.initializeServiceManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
status, err := s.Status()
|
||||
if errors.Is(err, service.ErrNotInstalled) {
|
||||
mainLog.Load().Warn().Msg("Service not installed")
|
||||
return nil
|
||||
}
|
||||
if status == service.StatusStopped {
|
||||
mainLog.Load().Warn().Msg("Service is not running")
|
||||
return nil
|
||||
}
|
||||
|
||||
resp, err := lc.controlClient.post(sendLogsPath, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send logs: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusServiceUnavailable:
|
||||
mainLog.Load().Warn().Msg("Runtime logs could only be sent once per minute")
|
||||
return nil
|
||||
case http.StatusMovedPermanently:
|
||||
lc.warnRuntimeLoggingNotEnabled()
|
||||
return nil
|
||||
}
|
||||
|
||||
var logs logSentResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&logs); err != nil {
|
||||
return fmt.Errorf("failed to decode sent logs result: %w", err)
|
||||
}
|
||||
|
||||
if logs.Error != "" {
|
||||
return fmt.Errorf("failed to send logs: %s", logs.Error)
|
||||
}
|
||||
|
||||
mainLog.Load().Notice().Msgf("Sent %s of runtime logs", units.BytesSize(float64(logs.Size)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ViewLogs views current runtime debug logs
|
||||
func (lc *LogCommand) ViewLogs(cmd *cobra.Command, args []string) error {
|
||||
sc := NewServiceCommand()
|
||||
s, _, err := sc.initializeServiceManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
status, err := s.Status()
|
||||
if errors.Is(err, service.ErrNotInstalled) {
|
||||
mainLog.Load().Warn().Msg("Service not installed")
|
||||
return nil
|
||||
}
|
||||
if status == service.StatusStopped {
|
||||
mainLog.Load().Warn().Msg("Service is not running")
|
||||
return nil
|
||||
}
|
||||
|
||||
resp, err := lc.controlClient.post(viewLogsPath, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get logs: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusMovedPermanently:
|
||||
lc.warnRuntimeLoggingNotEnabled()
|
||||
return nil
|
||||
case http.StatusBadRequest:
|
||||
mainLog.Load().Warn().Msg("Runtime debug logs are not available")
|
||||
buf, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
mainLog.Load().Fatal().Err(err).Msg("Failed to read response body")
|
||||
}
|
||||
mainLog.Load().Warn().Msgf("ctrld process response:\n\n%s\n", string(buf))
|
||||
return nil
|
||||
case http.StatusOK:
|
||||
}
|
||||
|
||||
var logs logViewResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&logs); err != nil {
|
||||
return fmt.Errorf("failed to decode view logs result: %w", err)
|
||||
}
|
||||
|
||||
fmt.Print(logs.Data)
|
||||
return nil
|
||||
}
|
||||
|
||||
// TailLogs streams live runtime debug logs to the terminal
|
||||
func (lc *LogCommand) TailLogs(cmd *cobra.Command, args []string) error {
|
||||
sc := NewServiceCommand()
|
||||
s, _, err := sc.initializeServiceManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
status, err := s.Status()
|
||||
if errors.Is(err, service.ErrNotInstalled) {
|
||||
mainLog.Load().Warn().Msg("Service not installed")
|
||||
return nil
|
||||
}
|
||||
if status == service.StatusStopped {
|
||||
mainLog.Load().Warn().Msg("Service is not running")
|
||||
return nil
|
||||
}
|
||||
|
||||
tailLines, _ := cmd.Flags().GetInt("lines")
|
||||
tailPath := fmt.Sprintf("%s?lines=%d", tailLogsPath, tailLines)
|
||||
resp, err := lc.controlClient.postStream(tailPath, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect for log tailing: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusMovedPermanently:
|
||||
lc.warnRuntimeLoggingNotEnabled()
|
||||
return nil
|
||||
case http.StatusOK:
|
||||
default:
|
||||
return fmt.Errorf("unexpected response status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Set up signal handling for clean shutdown.
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
// Stream output to stdout.
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, readErr := resp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
os.Stdout.Write(buf[:n])
|
||||
}
|
||||
if readErr != nil {
|
||||
if readErr != io.EOF {
|
||||
mainLog.Load().Error().Err(readErr).Msg("Error reading log stream")
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if errors.Is(ctx.Err(), context.Canceled) {
|
||||
msg := fmt.Sprintf("\nexiting: %s\n", context.Cause(ctx).Error())
|
||||
os.Stdout.WriteString(msg)
|
||||
}
|
||||
case <-done:
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// InitLogCmd creates the log command with proper logic
|
||||
func InitLogCmd(rootCmd *cobra.Command) *cobra.Command {
|
||||
lc, err := NewLogCommand()
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("failed to create log command: %v", err))
|
||||
}
|
||||
|
||||
logSendCmd := &cobra.Command{
|
||||
Use: "send",
|
||||
Short: "Send runtime debug logs to ControlD",
|
||||
Args: cobra.NoArgs,
|
||||
PreRun: func(cmd *cobra.Command, args []string) {
|
||||
checkHasElevatedPrivilege()
|
||||
},
|
||||
RunE: lc.SendLogs,
|
||||
}
|
||||
|
||||
logViewCmd := &cobra.Command{
|
||||
Use: "view",
|
||||
Short: "View current runtime debug logs",
|
||||
Args: cobra.NoArgs,
|
||||
PreRun: func(cmd *cobra.Command, args []string) {
|
||||
checkHasElevatedPrivilege()
|
||||
},
|
||||
RunE: lc.ViewLogs,
|
||||
}
|
||||
|
||||
logTailCmd := &cobra.Command{
|
||||
Use: "tail",
|
||||
Short: "Tail live runtime debug logs",
|
||||
Long: "Stream live runtime debug logs to the terminal, similar to tail -f. Press Ctrl+C to stop.",
|
||||
Args: cobra.NoArgs,
|
||||
PreRun: func(cmd *cobra.Command, args []string) {
|
||||
checkHasElevatedPrivilege()
|
||||
},
|
||||
RunE: lc.TailLogs,
|
||||
}
|
||||
logTailCmd.Flags().IntP("lines", "n", 10, "Number of historical lines to show on connect")
|
||||
|
||||
logCmd := &cobra.Command{
|
||||
Use: "log",
|
||||
Short: "Manage runtime debug logs",
|
||||
Args: cobra.OnlyValidArgs,
|
||||
ValidArgs: []string{
|
||||
logSendCmd.Use,
|
||||
logViewCmd.Use,
|
||||
logTailCmd.Use,
|
||||
},
|
||||
}
|
||||
logCmd.AddCommand(logSendCmd)
|
||||
logCmd.AddCommand(logViewCmd)
|
||||
logCmd.AddCommand(logTailCmd)
|
||||
rootCmd.AddCommand(logCmd)
|
||||
|
||||
return logCmd
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
)
|
||||
|
||||
// RunCommand handles run-related operations
|
||||
type RunCommand struct {
|
||||
// Add any dependencies here if needed in the future
|
||||
}
|
||||
|
||||
// NewRunCommand creates a new run command handler
|
||||
func NewRunCommand() *RunCommand {
|
||||
return &RunCommand{}
|
||||
}
|
||||
|
||||
// Run implements the logic for the run command
|
||||
func (rc *RunCommand) Run(cmd *cobra.Command, args []string) {
|
||||
RunCobraCommand(cmd)
|
||||
}
|
||||
|
||||
// InitRunCmd creates the run command with proper logic
|
||||
func InitRunCmd(rootCmd *cobra.Command) *cobra.Command {
|
||||
rc := NewRunCommand()
|
||||
|
||||
runCmd := &cobra.Command{
|
||||
Use: "run",
|
||||
Short: "Run the DNS proxy server",
|
||||
Args: cobra.NoArgs,
|
||||
Run: rc.Run,
|
||||
}
|
||||
runCmd.Flags().BoolVarP(&daemon, "daemon", "d", false, "Run as daemon")
|
||||
runCmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
|
||||
runCmd.Flags().StringVarP(&configBase64, "base64_config", "", "", "Base64 encoded config")
|
||||
runCmd.Flags().StringVarP(&listenAddress, "listen", "", "", "Listener address and port, in format: address:port")
|
||||
runCmd.Flags().StringVarP(&primaryUpstream, "primary_upstream", "", "", "Primary upstream endpoint")
|
||||
runCmd.Flags().StringVarP(&secondaryUpstream, "secondary_upstream", "", "", "Secondary upstream endpoint")
|
||||
runCmd.Flags().StringSliceVarP(&domains, "domains", "", nil, "List of domain to apply in a split DNS policy")
|
||||
runCmd.Flags().StringVarP(&logPath, "log", "", "", "Path to log file")
|
||||
runCmd.Flags().IntVarP(&cacheSize, "cache_size", "", 0, "Enable cache with size items")
|
||||
runCmd.Flags().StringVarP(&cdUID, cdUidFlagName, "", "", "Control D resolver uid")
|
||||
runCmd.Flags().StringVarP(&cdOrg, cdOrgFlagName, "", "", "Control D provision token")
|
||||
runCmd.Flags().StringVarP(&customHostname, customHostnameFlagName, "", "", "Custom hostname passed to ControlD API")
|
||||
runCmd.Flags().BoolVarP(&cdDev, "dev", "", false, "Use Control D dev resolver/domain")
|
||||
_ = runCmd.Flags().MarkHidden("dev")
|
||||
runCmd.Flags().StringVarP(&homedir, "homedir", "", "", "")
|
||||
_ = runCmd.Flags().MarkHidden("homedir")
|
||||
runCmd.Flags().StringVarP(&iface, "iface", "", "", `Update DNS setting for iface, "auto" means the default interface gateway`)
|
||||
_ = runCmd.Flags().MarkHidden("iface")
|
||||
runCmd.Flags().StringVarP(&cdUpstreamProto, "proto", "", ctrld.ResolverTypeDOH, `Control D upstream type, either "doh" or "doh3"`)
|
||||
runCmd.Flags().BoolVarP(&rfc1918, "rfc1918", "", false, "Listen on RFC1918 addresses when 127.0.0.1 is the only listener")
|
||||
runCmd.Flags().StringVarP(&interceptMode, "intercept-mode", "", "", "OS-level DNS interception mode: 'dns' (with VPN split routing) or 'hard' (all DNS through ctrld, no VPN split routing)")
|
||||
runCmd.Flags().StringVarP(&firewallMode, "firewall-mode", "", "off", "DNS-resolved IP allowlist: 'on' blocks connections to IPs not resolved by ctrld, 'off' allows all")
|
||||
|
||||
runCmd.FParseErrWhitelist = cobra.FParseErrWhitelist{UnknownFlags: true}
|
||||
rootCmd.AddCommand(runCmd)
|
||||
|
||||
return runCmd
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/kardianos/service"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// filterEmptyStrings removes empty strings from a slice
|
||||
// This is used to clean up command line arguments and configuration values
|
||||
func filterEmptyStrings(slice []string) []string {
|
||||
var result []string
|
||||
for _, s := range slice {
|
||||
if s != "" {
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ServiceCommand handles service-related operations
|
||||
// This encapsulates all service management functionality for the CLI
|
||||
type ServiceCommand struct {
|
||||
serviceManager *ServiceManager
|
||||
}
|
||||
|
||||
// initializeServiceManager creates a service manager with default configuration
|
||||
// This sets up the basic service infrastructure needed for all service operations
|
||||
func (sc *ServiceCommand) initializeServiceManager() (service.Service, *prog, error) {
|
||||
svcConfig := sc.createServiceConfig()
|
||||
return sc.initializeServiceManagerWithServiceConfig(svcConfig)
|
||||
}
|
||||
|
||||
// initializeServiceManagerWithServiceConfig creates a service manager with the given configuration
|
||||
// This allows for custom service configuration while maintaining the same initialization pattern
|
||||
func (sc *ServiceCommand) initializeServiceManagerWithServiceConfig(svcConfig *service.Config) (service.Service, *prog, error) {
|
||||
p := &prog{}
|
||||
|
||||
s, err := sc.newService(p, svcConfig)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create service: %w", err)
|
||||
}
|
||||
|
||||
sc.serviceManager = &ServiceManager{prog: p, svc: s}
|
||||
return s, p, nil
|
||||
}
|
||||
|
||||
// newService creates a new service instance using the provided program and configuration.
|
||||
// This abstracts the service creation process for different operating systems
|
||||
func (sc *ServiceCommand) newService(p *prog, svcConfig *service.Config) (service.Service, error) {
|
||||
s, err := newService(p, svcConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create service: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// NewServiceCommand creates a new service command handler
|
||||
// This provides a clean factory method for creating service command instances
|
||||
func NewServiceCommand() *ServiceCommand {
|
||||
return &ServiceCommand{}
|
||||
}
|
||||
|
||||
// createServiceConfig creates a properly initialized service configuration
|
||||
// This ensures consistent service naming and description across all platforms
|
||||
func (sc *ServiceCommand) createServiceConfig() *service.Config {
|
||||
return &service.Config{
|
||||
Name: ctrldServiceName,
|
||||
DisplayName: "Control-D Helper Service",
|
||||
Description: "A highly configurable, multi-protocol DNS forwarding proxy",
|
||||
Option: service.KeyValue{},
|
||||
}
|
||||
}
|
||||
|
||||
// InitServiceCmd creates the service command with proper logic and aliases
|
||||
// This sets up all service-related subcommands with appropriate permissions and flags
|
||||
func InitServiceCmd(rootCmd *cobra.Command) *cobra.Command {
|
||||
// Create service command handlers
|
||||
sc := NewServiceCommand()
|
||||
|
||||
startCmd, startCmdAlias := createStartCommands(sc)
|
||||
rootCmd.AddCommand(startCmdAlias)
|
||||
|
||||
// Stop command
|
||||
stopCmd := &cobra.Command{
|
||||
Use: "stop",
|
||||
Short: "Stop the ctrld service",
|
||||
Args: cobra.NoArgs,
|
||||
PreRun: func(cmd *cobra.Command, args []string) {
|
||||
checkHasElevatedPrivilege()
|
||||
},
|
||||
RunE: sc.Stop,
|
||||
}
|
||||
stopCmd.Flags().StringVarP(&iface, "iface", "", "", `Reset DNS setting for iface, "auto" means the default interface gateway`)
|
||||
stopCmd.Flags().Int64VarP(&deactivationPin, "pin", "", defaultDeactivationPin, `Pin code for stopping ctrld`)
|
||||
_ = stopCmd.Flags().MarkHidden("pin")
|
||||
|
||||
// Restart command
|
||||
restartCmd := &cobra.Command{
|
||||
Use: "restart",
|
||||
Short: "Restart the ctrld service",
|
||||
Args: cobra.NoArgs,
|
||||
PreRun: func(cmd *cobra.Command, args []string) {
|
||||
checkHasElevatedPrivilege()
|
||||
},
|
||||
RunE: sc.Restart,
|
||||
}
|
||||
|
||||
// Status command
|
||||
statusCmd := &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show status of the ctrld service",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: sc.Status,
|
||||
}
|
||||
if runtime.GOOS == "darwin" {
|
||||
// On darwin, running status command without privileges may return wrong information.
|
||||
statusCmd.PreRun = func(cmd *cobra.Command, args []string) {
|
||||
checkHasElevatedPrivilege()
|
||||
}
|
||||
}
|
||||
|
||||
// Reload command
|
||||
reloadCmd := &cobra.Command{
|
||||
Use: "reload",
|
||||
Short: "Reload the ctrld service",
|
||||
Args: cobra.NoArgs,
|
||||
PreRun: func(cmd *cobra.Command, args []string) {
|
||||
checkHasElevatedPrivilege()
|
||||
},
|
||||
RunE: sc.Reload,
|
||||
}
|
||||
|
||||
// Uninstall command
|
||||
uninstallCmd := &cobra.Command{
|
||||
Use: "uninstall",
|
||||
Short: "Stop and uninstall the ctrld service",
|
||||
Long: `Stop and uninstall the ctrld service.
|
||||
|
||||
NOTE: Uninstalling will set DNS to values provided by DHCP.`,
|
||||
Args: cobra.NoArgs,
|
||||
PreRun: func(cmd *cobra.Command, args []string) {
|
||||
checkHasElevatedPrivilege()
|
||||
},
|
||||
RunE: sc.Uninstall,
|
||||
}
|
||||
uninstallCmd.Flags().StringVarP(&iface, "iface", "", "", `Reset DNS setting for iface, "auto" means the default interface gateway`)
|
||||
uninstallCmd.Flags().Int64VarP(&deactivationPin, "pin", "", defaultDeactivationPin, `Pin code for stopping ctrld`)
|
||||
_ = uninstallCmd.Flags().MarkHidden("pin")
|
||||
uninstallCmd.Flags().BoolVarP(&cleanup, "cleanup", "", false, `Removing ctrld binary and config files`)
|
||||
|
||||
// Interfaces command - use the existing InitInterfacesCmd function
|
||||
interfacesCmd := InitInterfacesCmd(rootCmd)
|
||||
|
||||
stopCmdAlias := &cobra.Command{
|
||||
PreRun: func(cmd *cobra.Command, args []string) {
|
||||
checkHasElevatedPrivilege()
|
||||
},
|
||||
Use: "stop",
|
||||
Short: "Quick stop service and remove DNS from interface",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if !cmd.Flags().Changed("iface") {
|
||||
os.Args = append(os.Args, "--iface="+ifaceStartStop)
|
||||
}
|
||||
iface = ifaceStartStop
|
||||
return stopCmd.RunE(cmd, args)
|
||||
},
|
||||
}
|
||||
stopCmdAlias.Flags().StringVarP(&ifaceStartStop, "iface", "", "auto", `Reset DNS setting for iface, "auto" means the default interface gateway`)
|
||||
stopCmdAlias.Flags().AddFlagSet(stopCmd.Flags())
|
||||
rootCmd.AddCommand(stopCmdAlias)
|
||||
|
||||
// Create aliases for other service commands
|
||||
restartCmdAlias := &cobra.Command{
|
||||
PreRun: func(cmd *cobra.Command, args []string) {
|
||||
checkHasElevatedPrivilege()
|
||||
},
|
||||
Use: "restart",
|
||||
Short: "Restart the ctrld service",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return restartCmd.RunE(cmd, args)
|
||||
},
|
||||
}
|
||||
rootCmd.AddCommand(restartCmdAlias)
|
||||
|
||||
reloadCmdAlias := &cobra.Command{
|
||||
PreRun: func(cmd *cobra.Command, args []string) {
|
||||
checkHasElevatedPrivilege()
|
||||
},
|
||||
Use: "reload",
|
||||
Short: "Reload the ctrld service",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return reloadCmd.RunE(cmd, args)
|
||||
},
|
||||
}
|
||||
rootCmd.AddCommand(reloadCmdAlias)
|
||||
|
||||
statusCmdAlias := &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show status of the ctrld service",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: statusCmd.RunE,
|
||||
}
|
||||
rootCmd.AddCommand(statusCmdAlias)
|
||||
|
||||
uninstallCmdAlias := &cobra.Command{
|
||||
PreRun: func(cmd *cobra.Command, args []string) {
|
||||
checkHasElevatedPrivilege()
|
||||
},
|
||||
Use: "uninstall",
|
||||
Short: "Stop and uninstall the ctrld service",
|
||||
Long: `Stop and uninstall the ctrld service.
|
||||
|
||||
NOTE: Uninstalling will set DNS to values provided by DHCP.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if !cmd.Flags().Changed("iface") {
|
||||
os.Args = append(os.Args, "--iface="+ifaceStartStop)
|
||||
}
|
||||
iface = ifaceStartStop
|
||||
return uninstallCmd.RunE(cmd, args)
|
||||
},
|
||||
}
|
||||
uninstallCmdAlias.Flags().StringVarP(&ifaceStartStop, "iface", "", "auto", `Reset DNS setting for iface, "auto" means the default interface gateway`)
|
||||
uninstallCmdAlias.Flags().AddFlagSet(uninstallCmd.Flags())
|
||||
rootCmd.AddCommand(uninstallCmdAlias)
|
||||
|
||||
// Create service command
|
||||
serviceCmd := &cobra.Command{
|
||||
Use: "service",
|
||||
Short: "Manage ctrld service",
|
||||
Args: cobra.OnlyValidArgs,
|
||||
}
|
||||
serviceCmd.ValidArgs = make([]string, 7)
|
||||
serviceCmd.ValidArgs[0] = startCmd.Use
|
||||
serviceCmd.ValidArgs[1] = stopCmd.Use
|
||||
serviceCmd.ValidArgs[2] = restartCmd.Use
|
||||
serviceCmd.ValidArgs[3] = reloadCmd.Use
|
||||
serviceCmd.ValidArgs[4] = statusCmd.Use
|
||||
serviceCmd.ValidArgs[5] = uninstallCmd.Use
|
||||
serviceCmd.ValidArgs[6] = interfacesCmd.Use
|
||||
|
||||
serviceCmd.AddCommand(startCmd)
|
||||
serviceCmd.AddCommand(stopCmd)
|
||||
serviceCmd.AddCommand(restartCmd)
|
||||
serviceCmd.AddCommand(reloadCmd)
|
||||
serviceCmd.AddCommand(statusCmd)
|
||||
serviceCmd.AddCommand(uninstallCmd)
|
||||
serviceCmd.AddCommand(interfacesCmd)
|
||||
|
||||
rootCmd.AddCommand(serviceCmd)
|
||||
|
||||
return serviceCmd
|
||||
}
|
||||
|
||||
// validInterceptMode reports whether the given value is a recognized --intercept-mode.
|
||||
// This is the single source of truth for mode validation — used by the early start
|
||||
// command check, the runtime validation in prog.go, and onlyInterceptFlags below.
|
||||
// Add new modes here to have them recognized everywhere.
|
||||
func validInterceptMode(mode string) bool {
|
||||
switch mode {
|
||||
case "off", "dns", "hard":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// validFirewallMode reports whether the given value is a recognized --firewall-mode.
|
||||
func validFirewallMode(mode string) bool {
|
||||
switch mode {
|
||||
case "off", "on":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// onlyInterceptFlags reports whether args contain only intercept mode
|
||||
// flags (--intercept-mode <value>) and flags that are auto-added by the
|
||||
// start command alias (--iface). This is used to detect "ctrld start --intercept-mode dns"
|
||||
// (or "off" to disable) on an existing installation, where the intent is to modify the
|
||||
// intercept flag on the existing service without replacing other arguments.
|
||||
//
|
||||
// Note: the startCmdAlias appends "--iface=auto" to os.Args when --iface isn't
|
||||
// explicitly provided, so we must allow it here.
|
||||
func onlyInterceptFlags(args []string) bool {
|
||||
hasIntercept := false
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
switch {
|
||||
case arg == "--intercept-mode":
|
||||
// Next arg must be a valid mode value.
|
||||
if i+1 < len(args) && validInterceptMode(args[i+1]) {
|
||||
hasIntercept = true
|
||||
i++ // skip the value
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
case strings.HasPrefix(arg, "--intercept-mode="):
|
||||
val := strings.TrimPrefix(arg, "--intercept-mode=")
|
||||
if validInterceptMode(val) {
|
||||
hasIntercept = true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
case arg == "--iface="+autoIface || arg == "--iface" || arg == autoIface:
|
||||
// Auto-added by startCmdAlias or its value; safe to ignore.
|
||||
continue
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return hasIntercept
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/kardianos/service"
|
||||
)
|
||||
|
||||
// dialSocketControlServerTimeout is the default timeout to wait when ping control server.
|
||||
const dialSocketControlServerTimeout = 30 * time.Second
|
||||
|
||||
// ServiceManager handles service operations
|
||||
type ServiceManager struct {
|
||||
prog *prog
|
||||
svc service.Service
|
||||
}
|
||||
|
||||
// NewServiceManager creates a new service manager
|
||||
func NewServiceManager() (*ServiceManager, error) {
|
||||
p := &prog{}
|
||||
|
||||
// Create a proper service configuration
|
||||
svcConfig := &service.Config{
|
||||
Name: ctrldServiceName,
|
||||
DisplayName: "Control-D Helper Service",
|
||||
Description: "A highly configurable, multi-protocol DNS forwarding proxy",
|
||||
Option: service.KeyValue{},
|
||||
}
|
||||
|
||||
s, err := newService(p, svcConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create service: %w", err)
|
||||
}
|
||||
return &ServiceManager{prog: p, svc: s}, nil
|
||||
}
|
||||
|
||||
// Status returns the current service status
|
||||
func (sm *ServiceManager) Status() (service.Status, error) {
|
||||
return sm.svc.Status()
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/kardianos/service"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Reload implements the logic from cmdReload.Run
|
||||
func (sc *ServiceCommand) Reload(cmd *cobra.Command, args []string) error {
|
||||
logger := mainLog.Load()
|
||||
logger.Debug().Msg("Service reload command started")
|
||||
|
||||
s, _, err := sc.initializeServiceManager()
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("Failed to initialize service manager")
|
||||
return err
|
||||
}
|
||||
|
||||
status, err := s.Status()
|
||||
if errors.Is(err, service.ErrNotInstalled) {
|
||||
logger.Warn().Msg("Service not installed")
|
||||
return nil
|
||||
}
|
||||
if status == service.StatusStopped {
|
||||
logger.Warn().Msg("Service is not running")
|
||||
return nil
|
||||
}
|
||||
|
||||
dir, err := socketDir()
|
||||
if err != nil {
|
||||
logger.Fatal().Err(err).Msg("Failed to find ctrld home dir")
|
||||
}
|
||||
|
||||
cc := newControlClient(filepath.Join(dir, ctrldControlUnixSock))
|
||||
resp, err := cc.post(reloadPath, nil)
|
||||
if err != nil {
|
||||
logger.Fatal().Err(err).Msg("Failed to send reload signal to ctrld")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
logger.Notice().Msg("Service reloaded")
|
||||
case http.StatusCreated:
|
||||
logger.Warn().Msg("Service was reloaded, but new config requires service restart.")
|
||||
logger.Warn().Msg("Restarting service")
|
||||
if _, err := s.Status(); errors.Is(err, service.ErrNotInstalled) {
|
||||
logger.Warn().Msg("Service not installed")
|
||||
return nil
|
||||
}
|
||||
return sc.Restart(cmd, args)
|
||||
default:
|
||||
buf, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
logger.Fatal().Err(err).Msg("Could not read response from control server")
|
||||
}
|
||||
logger.Error().Err(err).Msgf("Failed to reload ctrld: %s", string(buf))
|
||||
}
|
||||
|
||||
logger.Debug().Msg("Service reload command completed")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/kardianos/service"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Restart implements the logic from cmdRestart.Run
|
||||
func (sc *ServiceCommand) Restart(cmd *cobra.Command, args []string) error {
|
||||
logger := mainLog.Load()
|
||||
logger.Debug().Msg("Service restart command started")
|
||||
|
||||
readConfig(false)
|
||||
v.Unmarshal(&cfg)
|
||||
cdUID = curCdUID()
|
||||
cdMode := cdUID != ""
|
||||
|
||||
s, p, err := sc.initializeServiceManager()
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("Failed to initialize service manager")
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := s.Status(); errors.Is(err, service.ErrNotInstalled) {
|
||||
logger.Warn().Msg("Service not installed")
|
||||
return nil
|
||||
}
|
||||
|
||||
p.cfg = &cfg
|
||||
if iface == "" {
|
||||
iface = autoIface
|
||||
}
|
||||
p.preRun()
|
||||
if ir := runningIface(s); ir != nil {
|
||||
p.runningIface = ir.Name
|
||||
p.requiredMultiNICsConfig = ir.All
|
||||
}
|
||||
|
||||
initInteractiveLogging()
|
||||
|
||||
var validateConfigErr error
|
||||
if cdMode {
|
||||
logger.Debug().Msg("Validating ControlD remote config")
|
||||
validateConfigErr = doValidateCdRemoteConfig(cdUID, false)
|
||||
if validateConfigErr != nil {
|
||||
logger.Warn().Err(validateConfigErr).Msg("ControlD remote config validation failed")
|
||||
}
|
||||
}
|
||||
|
||||
if ir := runningIface(s); ir != nil {
|
||||
iface = ir.Name
|
||||
}
|
||||
|
||||
doRestart := func() bool {
|
||||
logger.Debug().Msg("Starting service restart sequence")
|
||||
|
||||
tasks := []task{
|
||||
{s.Stop, true, "Stop"},
|
||||
{func() error {
|
||||
// restore static DNS settings or DHCP
|
||||
p.resetDNS(false, true)
|
||||
return nil
|
||||
}, false, "Cleanup"},
|
||||
{func() error {
|
||||
time.Sleep(time.Second * 1)
|
||||
return nil
|
||||
}, false, "Waiting for service to stop"},
|
||||
}
|
||||
if !doTasks(tasks) {
|
||||
logger.Error().Msg("Service stop tasks failed")
|
||||
return false
|
||||
}
|
||||
tasks = []task{
|
||||
{s.Start, true, "Start"},
|
||||
}
|
||||
success := doTasks(tasks)
|
||||
if success {
|
||||
logger.Debug().Msg("Service restart sequence completed successfully")
|
||||
} else {
|
||||
logger.Error().Msg("Service restart sequence failed")
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
if doRestart() {
|
||||
if dir, err := socketDir(); err == nil {
|
||||
timeout := dialSocketControlServerTimeout
|
||||
if validateConfigErr != nil {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
if cc := newSocketControlClientWithTimeout(context.TODO(), s, dir, timeout); cc != nil {
|
||||
_, _ = cc.post(ifacePath, nil)
|
||||
logger.Debug().Msg("Control server ping successful")
|
||||
} else {
|
||||
logger.Warn().Err(err).Msg("Service was restarted, but ctrld process may not be ready yet")
|
||||
}
|
||||
} else {
|
||||
logger.Warn().Err(err).Msg("Service was restarted, but could not ping the control server")
|
||||
}
|
||||
logger.Notice().Msg("Service restarted")
|
||||
} else {
|
||||
logger.Error().Msg("Service restart failed")
|
||||
}
|
||||
|
||||
logger.Debug().Msg("Service restart command completed")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/kardianos/service"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
)
|
||||
|
||||
// Start implements the logic from cmdStart.Run
|
||||
func (sc *ServiceCommand) Start(cmd *cobra.Command, args []string) error {
|
||||
logger := mainLog.Load()
|
||||
logger.Debug().Msg("Service start command started")
|
||||
|
||||
firewallModeFlagChanged = cmd.Flags().Changed("firewall-mode")
|
||||
checkStrFlagEmpty(cmd, cdUidFlagName)
|
||||
checkStrFlagEmpty(cmd, cdOrgFlagName)
|
||||
validateCdAndNextDNSFlags()
|
||||
|
||||
svcConfig := sc.createServiceConfig()
|
||||
osArgs := os.Args[2:]
|
||||
osArgs = filterEmptyStrings(osArgs)
|
||||
if os.Args[1] == "service" {
|
||||
osArgs = os.Args[3:]
|
||||
}
|
||||
setDependencies(svcConfig)
|
||||
svcConfig.Arguments = append([]string{"run"}, osArgs...)
|
||||
|
||||
// Validate --intercept-mode early, before installing the service.
|
||||
// Without this, a typo like "--intercept-mode fds" would install the service,
|
||||
// the child process would Fatal() on the invalid value, and the parent would
|
||||
// then uninstall — confusing and destructive.
|
||||
if interceptMode != "" && !validInterceptMode(interceptMode) {
|
||||
logger.Fatal().Msgf("invalid --intercept-mode value %q: must be 'off', 'dns', or 'hard'", interceptMode)
|
||||
}
|
||||
if firewallModeFlagChanged && !validFirewallMode(firewallMode) {
|
||||
logger.Fatal().Msgf("invalid --firewall-mode value %q: must be 'off' or 'on'", firewallMode)
|
||||
}
|
||||
|
||||
// Initialize service manager with proper configuration
|
||||
s, p, err := sc.initializeServiceManagerWithServiceConfig(svcConfig)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("Failed to initialize service manager")
|
||||
return err
|
||||
}
|
||||
|
||||
p.cfg = &cfg
|
||||
p.preRun()
|
||||
|
||||
status, err := s.Status()
|
||||
isCtrldRunning := status == service.StatusRunning
|
||||
isCtrldInstalled := !errors.Is(err, service.ErrNotInstalled)
|
||||
|
||||
// Get current running iface, if any.
|
||||
var currentIface *ifaceResponse
|
||||
|
||||
// Handle "ctrld start --intercept-mode dns|hard" on an existing
|
||||
// service BEFORE the pin check. Adding intercept mode is an enhancement, not
|
||||
// deactivation, so it doesn't require the deactivation pin. We modify the
|
||||
// plist/registry directly and restart the service via the OS service manager.
|
||||
osArgsEarly := os.Args[2:]
|
||||
if os.Args[1] == "service" {
|
||||
osArgsEarly = os.Args[3:]
|
||||
}
|
||||
osArgsEarly = filterEmptyStrings(osArgsEarly)
|
||||
interceptOnly := onlyInterceptFlags(osArgsEarly)
|
||||
svcExists := serviceConfigFileExists()
|
||||
logger.Debug().Msgf("intercept upgrade check: args=%v interceptOnly=%v svcConfigExists=%v interceptMode=%q", osArgsEarly, interceptOnly, svcExists, interceptMode)
|
||||
if interceptOnly && svcExists {
|
||||
// Remove any existing intercept flags before applying the new value.
|
||||
_ = removeServiceFlag("--intercept-mode")
|
||||
|
||||
if interceptMode == "off" {
|
||||
// "off" = remove intercept mode entirely (just the removal above).
|
||||
logger.Notice().Msg("Existing service detected — removing --intercept-mode from service arguments")
|
||||
} else {
|
||||
// Add the new mode value.
|
||||
logger.Notice().Msgf("Existing service detected — appending --intercept-mode %s to service arguments", interceptMode)
|
||||
if err := appendServiceFlag("--intercept-mode"); err != nil {
|
||||
logger.Fatal().Err(err).Msg("failed to append intercept flag to service arguments")
|
||||
}
|
||||
if err := appendServiceFlag(interceptMode); err != nil {
|
||||
logger.Fatal().Err(err).Msg("failed to append intercept mode value to service arguments")
|
||||
}
|
||||
}
|
||||
|
||||
// Stop the service if running (bypasses ctrld pin — this is an
|
||||
// enhancement, not deactivation). Then fall through to the normal
|
||||
// startOnly path which handles start, self-check, and reporting.
|
||||
if isCtrldRunning {
|
||||
logger.Notice().Msg("Stopping service for intercept mode upgrade")
|
||||
_ = s.Stop()
|
||||
isCtrldRunning = false
|
||||
}
|
||||
startOnly = true
|
||||
isCtrldInstalled = true
|
||||
// Fall through to startOnly path below.
|
||||
}
|
||||
|
||||
// If pin code was set, do not allow running start command.
|
||||
if isCtrldRunning {
|
||||
if err := checkDeactivationPin(s, nil); isCheckDeactivationPinErr(err) {
|
||||
logger.Error().Msg("Deactivation pin check failed")
|
||||
os.Exit(deactivationPinInvalidExitCode)
|
||||
}
|
||||
currentIface = runningIface(s)
|
||||
logger.Debug().Msgf("Current interface on start: %v", currentIface)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
reportSetDnsOk := func(sockDir string) {
|
||||
if cc := newSocketControlClient(ctx, s, sockDir); cc != nil {
|
||||
if resp, _ := cc.post(ifacePath, nil); resp != nil && resp.StatusCode == http.StatusOK {
|
||||
if iface == autoIface {
|
||||
iface = defaultIfaceName()
|
||||
}
|
||||
res := &ifaceResponse{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(res); err != nil {
|
||||
logger.Warn().Err(err).Msg("Failed to get iface info")
|
||||
return
|
||||
}
|
||||
if res.OK {
|
||||
// In intercept mode, show intercept-specific status instead of
|
||||
// per-interface DNS messages (which are irrelevant).
|
||||
if res.InterceptMode != "" {
|
||||
switch res.InterceptMode {
|
||||
case "hard":
|
||||
logger.Notice().Msg("DNS hard intercept mode active — all DNS traffic intercepted, no VPN split routing")
|
||||
default:
|
||||
logger.Notice().Msg("DNS intercept mode active — all DNS traffic intercepted via OS packet filter")
|
||||
}
|
||||
} else {
|
||||
name := res.Name
|
||||
if iff, err := net.InterfaceByName(name); err == nil {
|
||||
_, _ = patchNetIfaceName(iff)
|
||||
name = iff.Name
|
||||
}
|
||||
ifaceLogger := logger.With().Str("iface", name)
|
||||
ifaceLogger.Debug().Msg("Setting DNS successfully")
|
||||
if res.All {
|
||||
// Log that DNS is set for other interfaces.
|
||||
withEachPhysicalInterfaces(
|
||||
name,
|
||||
"set DNS",
|
||||
func(i *net.Interface) error { return nil },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No config path, generating config in HOME directory.
|
||||
noConfigStart := isNoConfigStart(cmd)
|
||||
writeDefaultConfig := !noConfigStart && configBase64 == ""
|
||||
|
||||
logServerStarted := make(chan struct{})
|
||||
stopLogCh := make(chan struct{})
|
||||
ud, err := userHomeDir()
|
||||
sockDir := ud
|
||||
var logServerSocketPath string
|
||||
if err != nil {
|
||||
logger.Warn().Err(err).Msg("Failed to get user home directory")
|
||||
logger.Warn().Msg("Log server did not start")
|
||||
close(logServerStarted)
|
||||
} else {
|
||||
setWorkingDirectory(svcConfig, ud)
|
||||
if configPath == "" && writeDefaultConfig {
|
||||
defaultConfigFile = filepath.Join(ud, defaultConfigFile)
|
||||
}
|
||||
svcConfig.Arguments = append(svcConfig.Arguments, "--homedir="+ud)
|
||||
if d, err := socketDir(); err == nil {
|
||||
sockDir = d
|
||||
}
|
||||
logServerSocketPath = filepath.Join(sockDir, ctrldLogUnixSock)
|
||||
_ = os.Remove(logServerSocketPath)
|
||||
go func() {
|
||||
defer os.Remove(logServerSocketPath)
|
||||
|
||||
close(logServerStarted)
|
||||
|
||||
// Start HTTP log server
|
||||
if err := httpLogServer(logServerSocketPath, stopLogCh); err != nil && err != http.ErrServerClosed {
|
||||
logger.Warn().Err(err).Msg("Failed to serve HTTP log server")
|
||||
return
|
||||
}
|
||||
}()
|
||||
}
|
||||
<-logServerStarted
|
||||
|
||||
if !startOnly {
|
||||
startOnly = len(osArgs) == 0
|
||||
}
|
||||
// If user run "ctrld start" and ctrld is already installed, starting existing service.
|
||||
if startOnly && isCtrldInstalled {
|
||||
tryReadingConfigWithNotice(false, true)
|
||||
if err := v.Unmarshal(&cfg); err != nil {
|
||||
logger.Fatal().Msgf("Failed to unmarshal config: %v", err)
|
||||
}
|
||||
|
||||
// if already running, dont restart
|
||||
if isCtrldRunning {
|
||||
logger.Notice().Msg("Service is already running")
|
||||
return nil
|
||||
}
|
||||
|
||||
initInteractiveLogging()
|
||||
tasks := []task{
|
||||
{func() error {
|
||||
// Save current DNS so we can restore later.
|
||||
withEachPhysicalInterfaces("", "saveCurrentStaticDNS", func(i *net.Interface) error {
|
||||
if err := saveCurrentStaticDNS(i); !errors.Is(err, errSaveCurrentStaticDNSNotSupported) && err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
}, false, "Save current DNS"},
|
||||
{func() error {
|
||||
return ConfigureWindowsServiceFailureActions(ctrldServiceName)
|
||||
}, false, "Configure service failure actions"},
|
||||
{s.Start, true, "Start"},
|
||||
{noticeWritingControlDConfig, false, "Notice writing ControlD config"},
|
||||
}
|
||||
logger.Notice().Msg("Starting existing ctrld service")
|
||||
if doTasks(tasks) {
|
||||
logger.Notice().Msg("Service started")
|
||||
sockDir, err := socketDir()
|
||||
if err != nil {
|
||||
logger.Warn().Err(err).Msg("Failed to get socket directory")
|
||||
os.Exit(1)
|
||||
}
|
||||
reportSetDnsOk(sockDir)
|
||||
// Verify service registration after successful start.
|
||||
if err := verifyServiceRegistration(); err != nil {
|
||||
logger.Warn().Err(err).Msg("Service registry verification failed")
|
||||
}
|
||||
} else {
|
||||
logger.Error().Err(err).Msg("Failed to start existing ctrld service")
|
||||
os.Exit(1)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if cdUID != "" {
|
||||
_ = doValidateCdRemoteConfig(cdUID, true)
|
||||
} else if uid := cdUIDFromProvToken(); uid != "" {
|
||||
cdUID = uid
|
||||
logger.Debug().Msg("Using uid from provision token")
|
||||
removeOrgFlagsFromArgs(svcConfig)
|
||||
// Pass --cd flag to "ctrld run" command, so the provision token takes no effect.
|
||||
svcConfig.Arguments = append(svcConfig.Arguments, "--cd="+cdUID)
|
||||
}
|
||||
if cdUID != "" {
|
||||
validateCdUpstreamProtocol()
|
||||
}
|
||||
|
||||
if configPath != "" {
|
||||
v.SetConfigFile(configPath)
|
||||
}
|
||||
|
||||
tryReadingConfigWithNotice(writeDefaultConfig, true)
|
||||
|
||||
if err := v.Unmarshal(&cfg); err != nil {
|
||||
logger.Fatal().Msgf("Failed to unmarshal config: %v", err)
|
||||
}
|
||||
|
||||
initInteractiveLogging()
|
||||
|
||||
if nextdns != "" {
|
||||
removeNextDNSFromArgs(svcConfig)
|
||||
}
|
||||
|
||||
// Explicitly passing config, so on system where home directory could not be obtained,
|
||||
// or sub-process env is different with the parent, we still behave correctly and use
|
||||
// the expected config file.
|
||||
if configPath == "" {
|
||||
svcConfig.Arguments = append(svcConfig.Arguments, "--config="+defaultConfigFile)
|
||||
}
|
||||
|
||||
tasks := []task{
|
||||
{s.Stop, false, "Stop"},
|
||||
{func() error { return doGenerateNextDNSConfig(nextdns) }, true, "Checking config"},
|
||||
{func() error { return ensureUninstall(s) }, false, "Ensure uninstall"},
|
||||
//resetDnsTask(p, s, isCtrldInstalled, currentIface),
|
||||
{func() error {
|
||||
// Save current DNS so we can restore later.
|
||||
withEachPhysicalInterfaces("", "saveCurrentStaticDNS", func(i *net.Interface) error {
|
||||
if err := saveCurrentStaticDNS(i); !errors.Is(err, errSaveCurrentStaticDNSNotSupported) && err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
}, false, "Save current DNS"},
|
||||
{s.Install, false, "Install"},
|
||||
{func() error {
|
||||
return ConfigureWindowsServiceFailureActions(ctrldServiceName)
|
||||
}, false, "Configure Windows service failure actions"},
|
||||
{s.Start, true, "Start"},
|
||||
// Note that startCmd do not actually write ControlD config, but the config file was
|
||||
// generated after s.Start, so we notice users here for consistent with nextdns mode.
|
||||
{noticeWritingControlDConfig, false, "Notice writing ControlD config"},
|
||||
}
|
||||
logger.Notice().Msg("Starting service")
|
||||
if doTasks(tasks) {
|
||||
// add a small delay to ensure the service is started and did not crash
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
ok, status, err := selfCheckStatus(ctx, s, sockDir)
|
||||
switch {
|
||||
case ok && status == service.StatusRunning:
|
||||
logger.Notice().Msg("Service started")
|
||||
default:
|
||||
marker := append(bytes.Repeat([]byte("="), 32), '\n')
|
||||
// If ctrld service is not running, emitting log obtained from ctrld process.
|
||||
if status != service.StatusRunning || ctx.Err() != nil {
|
||||
logger.Error().Msg("Ctrld service may not have started due to an error or misconfiguration, service log:")
|
||||
_, _ = logger.Write(marker)
|
||||
|
||||
// Wait for log collection to complete
|
||||
<-stopLogCh
|
||||
|
||||
// Retrieve logs from HTTP server if available
|
||||
if logServerSocketPath != "" {
|
||||
hlc := newHTTPLogClient(logServerSocketPath)
|
||||
logs, err := hlc.GetLogs()
|
||||
if err != nil {
|
||||
logger.Warn().Err(err).Msg("Failed to get logs from HTTP log server")
|
||||
}
|
||||
if len(logs) == 0 {
|
||||
logger.Write([]byte("<no log output is obtained from ctrld process>\n"))
|
||||
} else {
|
||||
logger.Write(logs)
|
||||
logger.Write([]byte("\n"))
|
||||
}
|
||||
} else {
|
||||
logger.Write([]byte("<no log output from HTTP log server>\n"))
|
||||
}
|
||||
}
|
||||
// Report any error if occurred.
|
||||
if err != nil {
|
||||
_, _ = logger.Write(marker)
|
||||
msg := fmt.Sprintf("An error occurred while performing test query: %s\n", err)
|
||||
logger.Write([]byte(msg))
|
||||
}
|
||||
// If ctrld service is running but selfCheckStatus failed, it could be related
|
||||
// to user's system firewall configuration, notice users about it.
|
||||
if status == service.StatusRunning && err == nil {
|
||||
_, _ = logger.Write(marker)
|
||||
logger.Write([]byte("ctrld service was running, but a DNS query could not be sent to its listener\n"))
|
||||
logger.Write([]byte("Please check your system firewall if it is configured to block/intercept/redirect DNS queries\n"))
|
||||
}
|
||||
|
||||
_, _ = logger.Write(marker)
|
||||
uninstall(p, s)
|
||||
os.Exit(1)
|
||||
}
|
||||
reportSetDnsOk(sockDir)
|
||||
// Verify service registration after successful start.
|
||||
if err := verifyServiceRegistration(); err != nil {
|
||||
logger.Warn().Err(err).Msg("Service registry verification failed")
|
||||
}
|
||||
}
|
||||
|
||||
logger.Debug().Msg("Service start command completed")
|
||||
return nil
|
||||
}
|
||||
|
||||
// createStartCommands creates the start command and its alias
|
||||
func createStartCommands(sc *ServiceCommand) (*cobra.Command, *cobra.Command) {
|
||||
// Start command
|
||||
startCmd := &cobra.Command{
|
||||
Use: "start",
|
||||
Short: "Install and start the ctrld service",
|
||||
Long: `Install and start the ctrld service
|
||||
|
||||
NOTE: running "ctrld start" without any arguments will start already installed ctrld service.`,
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
args = filterEmptyStrings(args)
|
||||
if len(args) > 0 {
|
||||
return fmt.Errorf("'ctrld start' doesn't accept positional arguments\n" +
|
||||
"Use flags instead (e.g. --cd, --iface) or see 'ctrld start --help' for all options")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
PreRun: func(cmd *cobra.Command, args []string) {
|
||||
checkHasElevatedPrivilege()
|
||||
},
|
||||
RunE: sc.Start,
|
||||
}
|
||||
// Keep these flags in sync with runCmd above, except for "-d"/"--nextdns".
|
||||
startCmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
|
||||
startCmd.Flags().StringVarP(&configBase64, "base64_config", "", "", "Base64 encoded config")
|
||||
startCmd.Flags().StringVarP(&listenAddress, "listen", "", "", "Listener address and port, in format: address:port")
|
||||
startCmd.Flags().StringVarP(&primaryUpstream, "primary_upstream", "", "", "Primary upstream endpoint")
|
||||
startCmd.Flags().StringVarP(&secondaryUpstream, "secondary_upstream", "", "", "Secondary upstream endpoint")
|
||||
startCmd.Flags().StringSliceVarP(&domains, "domains", "", nil, "List of domain to apply in a split DNS policy")
|
||||
startCmd.Flags().StringVarP(&logPath, "log", "", "", "Path to log file")
|
||||
startCmd.Flags().IntVarP(&cacheSize, "cache_size", "", 0, "Enable cache with size items")
|
||||
startCmd.Flags().StringVarP(&cdUID, cdUidFlagName, "", "", "Control D resolver uid")
|
||||
startCmd.Flags().StringVarP(&cdOrg, cdOrgFlagName, "", "", "Control D provision token")
|
||||
startCmd.Flags().StringVarP(&customHostname, customHostnameFlagName, "", "", "Custom hostname passed to ControlD API")
|
||||
startCmd.Flags().BoolVarP(&cdDev, "dev", "", false, "Use Control D dev resolver/domain")
|
||||
_ = startCmd.Flags().MarkHidden("dev")
|
||||
startCmd.Flags().StringVarP(&iface, "iface", "", "", `Update DNS setting for iface, "auto" means the default interface gateway`)
|
||||
startCmd.Flags().StringVarP(&nextdns, nextdnsFlagName, "", "", "NextDNS resolver id")
|
||||
startCmd.Flags().StringVarP(&cdUpstreamProto, "proto", "", ctrld.ResolverTypeDOH, `Control D upstream type, either "doh" or "doh3"`)
|
||||
startCmd.Flags().BoolVarP(&skipSelfChecks, "skip_self_checks", "", false, `Skip self checks after installing ctrld service`)
|
||||
startCmd.Flags().BoolVarP(&startOnly, "start_only", "", false, "Do not install new service")
|
||||
_ = startCmd.Flags().MarkHidden("start_only")
|
||||
startCmd.Flags().BoolVarP(&rfc1918, "rfc1918", "", false, "Listen on RFC1918 addresses when 127.0.0.1 is the only listener")
|
||||
startCmd.Flags().StringVarP(&interceptMode, "intercept-mode", "", "", "OS-level DNS interception mode: 'dns' (with VPN split routing) or 'hard' (all DNS through ctrld, no VPN split routing)")
|
||||
startCmd.Flags().StringVarP(&firewallMode, "firewall-mode", "", "off", "DNS-resolved IP allowlist: 'on' blocks connections to IPs not resolved by ctrld, 'off' allows all")
|
||||
|
||||
// Start command alias
|
||||
startCmdAlias := &cobra.Command{
|
||||
PreRun: func(cmd *cobra.Command, args []string) {
|
||||
checkHasElevatedPrivilege()
|
||||
},
|
||||
Use: "start",
|
||||
Short: "Quick start service and configure DNS on interface",
|
||||
Long: `Quick start service and configure DNS on interface
|
||||
|
||||
NOTE: running "ctrld start" without any arguments will start already installed ctrld service.`,
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
args = filterEmptyStrings(args)
|
||||
if len(args) > 0 {
|
||||
return fmt.Errorf("'ctrld start' doesn't accept positional arguments\n" +
|
||||
"Use flags instead (e.g. --cd, --iface) or see 'ctrld start --help' for all options")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(os.Args) == 2 {
|
||||
startOnly = true
|
||||
}
|
||||
if !cmd.Flags().Changed("iface") {
|
||||
os.Args = append(os.Args, "--iface="+ifaceStartStop)
|
||||
}
|
||||
iface = ifaceStartStop
|
||||
return startCmd.RunE(cmd, args)
|
||||
},
|
||||
}
|
||||
startCmdAlias.Flags().StringVarP(&ifaceStartStop, "iface", "", autoIface, `Update DNS setting for iface, "auto" means the default interface gateway`)
|
||||
startCmdAlias.Flags().AddFlagSet(startCmd.Flags())
|
||||
|
||||
return startCmd, startCmdAlias
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/kardianos/service"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Status implements the logic from cmdStatus.Run
|
||||
func (sc *ServiceCommand) Status(cmd *cobra.Command, args []string) error {
|
||||
logger := mainLog.Load()
|
||||
logger.Debug().Msg("Service status command started")
|
||||
|
||||
s, _, err := sc.initializeServiceManager()
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("Failed to initialize service manager")
|
||||
return err
|
||||
}
|
||||
|
||||
status, err := s.Status()
|
||||
if err != nil {
|
||||
logger.Error().Msg(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
switch status {
|
||||
case service.StatusUnknown:
|
||||
logger.Notice().Msg("Unknown status")
|
||||
os.Exit(2)
|
||||
case service.StatusRunning:
|
||||
logger.Notice().Msg("Service is running")
|
||||
os.Exit(0)
|
||||
case service.StatusStopped:
|
||||
logger.Notice().Msg("Service is stopped")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
logger.Debug().Msg("Service status command completed")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
"github.com/kardianos/service"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Stop implements the logic from cmdStop.Run
|
||||
func (sc *ServiceCommand) Stop(cmd *cobra.Command, args []string) error {
|
||||
logger := mainLog.Load()
|
||||
logger.Debug().Msg("Service stop command started")
|
||||
|
||||
readConfig(false)
|
||||
v.Unmarshal(&cfg)
|
||||
|
||||
s, p, err := sc.initializeServiceManager()
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("Failed to initialize service manager")
|
||||
return err
|
||||
}
|
||||
|
||||
p.cfg = &cfg
|
||||
if iface == "" {
|
||||
iface = autoIface
|
||||
}
|
||||
p.preRun()
|
||||
if ir := runningIface(s); ir != nil {
|
||||
p.runningIface = ir.Name
|
||||
p.requiredMultiNICsConfig = ir.All
|
||||
}
|
||||
|
||||
initInteractiveLogging()
|
||||
|
||||
status, err := s.Status()
|
||||
if errors.Is(err, service.ErrNotInstalled) {
|
||||
logger.Warn().Msg("Service not installed")
|
||||
return nil
|
||||
}
|
||||
if status == service.StatusStopped {
|
||||
logger.Warn().Msg("Service is already stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := checkDeactivationPin(s, nil); isCheckDeactivationPinErr(err) {
|
||||
logger.Error().Msg("Deactivation pin check failed")
|
||||
os.Exit(deactivationPinInvalidExitCode)
|
||||
}
|
||||
|
||||
logger.Debug().Msg("Stopping service")
|
||||
if doTasks([]task{{s.Stop, true, "Stop"}}) {
|
||||
logger.Notice().Msg("Service stopped")
|
||||
} else {
|
||||
logger.Error().Msg("Service stop failed")
|
||||
}
|
||||
|
||||
logger.Debug().Msg("Service stop command completed")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
)
|
||||
|
||||
// Uninstall implements the logic from cmdUninstall.Run
|
||||
func (sc *ServiceCommand) Uninstall(cmd *cobra.Command, args []string) error {
|
||||
logger := mainLog.Load()
|
||||
logger.Debug().Msg("Service uninstall command started")
|
||||
|
||||
readConfig(false)
|
||||
v.Unmarshal(&cfg)
|
||||
|
||||
s, p, err := sc.initializeServiceManager()
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("Failed to initialize service manager")
|
||||
return err
|
||||
}
|
||||
|
||||
p.cfg = &cfg
|
||||
if iface == "" {
|
||||
iface = autoIface
|
||||
}
|
||||
p.preRun()
|
||||
if ir := runningIface(s); ir != nil {
|
||||
p.runningIface = ir.Name
|
||||
p.requiredMultiNICsConfig = ir.All
|
||||
}
|
||||
|
||||
if err := checkDeactivationPin(s, nil); isCheckDeactivationPinErr(err) {
|
||||
logger.Error().Msg("Deactivation pin check failed")
|
||||
os.Exit(deactivationPinInvalidExitCode)
|
||||
}
|
||||
|
||||
logger.Debug().Msg("Starting service uninstall")
|
||||
uninstall(p, s)
|
||||
|
||||
if cleanup {
|
||||
logger.Debug().Msg("Performing cleanup operations")
|
||||
var files []string
|
||||
// Config file.
|
||||
files = append(files, v.ConfigFileUsed())
|
||||
// Log file and backup log file.
|
||||
// For safety, only process if log file path is absolute.
|
||||
if logFile := normalizeLogFilePath(cfg.Service.LogPath); filepath.IsAbs(logFile) {
|
||||
files = append(files, logFile)
|
||||
oldLogFile := logFile + oldLogSuffix
|
||||
if _, err := os.Stat(oldLogFile); err == nil {
|
||||
files = append(files, oldLogFile)
|
||||
}
|
||||
}
|
||||
// Socket files.
|
||||
if dir, _ := socketDir(); dir != "" {
|
||||
files = append(files, filepath.Join(dir, ctrldControlUnixSock))
|
||||
files = append(files, filepath.Join(dir, ctrldLogUnixSock))
|
||||
}
|
||||
// Static DNS settings files.
|
||||
withEachPhysicalInterfaces("", "", func(i *net.Interface) error {
|
||||
file := ctrld.SavedStaticDnsSettingsFilePath(i)
|
||||
files = append(files, file)
|
||||
return nil
|
||||
})
|
||||
bin, err := os.Executable()
|
||||
if err != nil {
|
||||
logger.Warn().Err(err).Msg("Failed to get executable path")
|
||||
}
|
||||
if bin != "" && supportedSelfDelete {
|
||||
files = append(files, bin)
|
||||
}
|
||||
// Backup file after upgrading.
|
||||
oldBin := bin + oldBinSuffix
|
||||
if _, err := os.Stat(oldBin); err == nil {
|
||||
files = append(files, oldBin)
|
||||
}
|
||||
for _, file := range files {
|
||||
if file == "" {
|
||||
continue
|
||||
}
|
||||
if err := os.Remove(file); err == nil {
|
||||
logger.Notice().Str("file", file).Msg("File removed during cleanup")
|
||||
} else {
|
||||
logger.Debug().Err(err).Str("file", file).Msg("Failed to remove file during cleanup")
|
||||
}
|
||||
}
|
||||
// Self-delete the ctrld binary if supported
|
||||
if err := selfDeleteExe(); err != nil {
|
||||
logger.Warn().Err(err).Msg("Failed to delete ctrld binary")
|
||||
} else {
|
||||
if !supportedSelfDelete {
|
||||
logger.Debug().Msgf("File removed: %s", bin)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Debug().Msg("Cleanup operations completed")
|
||||
}
|
||||
|
||||
logger.Debug().Msg("Service uninstall command completed")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestBasicCommandStructure tests the actual root command structure
|
||||
func TestBasicCommandStructure(t *testing.T) {
|
||||
// Test the actual root command that's returned from initCLI()
|
||||
rootCmd := initCLI()
|
||||
|
||||
// Test that root command has basic properties
|
||||
assert.Equal(t, "ctrld", rootCmd.Use)
|
||||
assert.NotEmpty(t, rootCmd.Short, "Root command should have a short description")
|
||||
|
||||
// Test that root command has subcommands
|
||||
commands := rootCmd.Commands()
|
||||
assert.NotNil(t, commands, "Root command should have subcommands")
|
||||
assert.Greater(t, len(commands), 0, "Root command should have at least one subcommand")
|
||||
|
||||
// Test that expected commands exist
|
||||
expectedCommands := []string{"run", "service", "clients", "upgrade", "log"}
|
||||
for _, cmdName := range expectedCommands {
|
||||
found := false
|
||||
for _, cmd := range commands {
|
||||
if cmd.Name() == cmdName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "Expected command %s not found in root command", cmdName)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServiceCommandCreation tests service command creation
|
||||
func TestServiceCommandCreation(t *testing.T) {
|
||||
sc := NewServiceCommand()
|
||||
require.NotNil(t, sc, "ServiceCommand should be created")
|
||||
|
||||
// Test service config creation
|
||||
config := sc.createServiceConfig()
|
||||
require.NotNil(t, config, "Service config should be created")
|
||||
assert.Equal(t, ctrldServiceName, config.Name)
|
||||
assert.Equal(t, "Control-D Helper Service", config.DisplayName)
|
||||
assert.Equal(t, "A highly configurable, multi-protocol DNS forwarding proxy", config.Description)
|
||||
}
|
||||
|
||||
// TestServiceCommandSubCommands tests service command sub commands
|
||||
func TestServiceCommandSubCommands(t *testing.T) {
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "ctrld",
|
||||
Short: "DNS forwarding proxy",
|
||||
}
|
||||
|
||||
serviceCmd := InitServiceCmd(rootCmd)
|
||||
require.NotNil(t, serviceCmd, "Service command should be created")
|
||||
|
||||
// Test that service command has subcommands
|
||||
subcommands := serviceCmd.Commands()
|
||||
assert.Greater(t, len(subcommands), 0, "Service command should have subcommands")
|
||||
|
||||
// Test specific subcommands exist
|
||||
expectedCommands := []string{"start", "stop", "restart", "reload", "status", "uninstall", "interfaces"}
|
||||
|
||||
for _, cmdName := range expectedCommands {
|
||||
found := false
|
||||
for _, cmd := range subcommands {
|
||||
if cmd.Name() == cmdName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "Expected service subcommand %s not found", cmdName)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCommandHelp tests basic help functionality
|
||||
func TestCommandHelp(t *testing.T) {
|
||||
// Initialize the CLI to set up the root command
|
||||
rootCmd := initCLI()
|
||||
|
||||
// Test help command execution
|
||||
var buf bytes.Buffer
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
|
||||
rootCmd.SetArgs([]string{"--help"})
|
||||
err := rootCmd.Execute()
|
||||
assert.NoError(t, err, "Help command should execute without error")
|
||||
assert.Contains(t, buf.String(), "dns forwarding proxy", "Help output should contain description")
|
||||
}
|
||||
|
||||
// TestCommandVersion tests version command
|
||||
func TestCommandVersion(t *testing.T) {
|
||||
// Initialize the CLI to set up the root command
|
||||
rootCmd := initCLI()
|
||||
|
||||
var buf bytes.Buffer
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
|
||||
// Test version command
|
||||
rootCmd.SetArgs([]string{"--version"})
|
||||
err := rootCmd.Execute()
|
||||
assert.NoError(t, err, "Version command should execute without error")
|
||||
assert.Contains(t, buf.String(), "version", "Version output should contain version information")
|
||||
}
|
||||
|
||||
// TestCommandErrorHandling tests error handling
|
||||
func TestCommandErrorHandling(t *testing.T) {
|
||||
// Initialize the CLI to set up the root command
|
||||
rootCmd := initCLI()
|
||||
|
||||
// Test invalid flag instead of invalid command
|
||||
rootCmd.SetArgs([]string{"--invalid-flag"})
|
||||
err := rootCmd.Execute()
|
||||
assert.Error(t, err, "Invalid flag should return error")
|
||||
}
|
||||
|
||||
// TestCommandFlags tests flag functionality
|
||||
func TestCommandFlags(t *testing.T) {
|
||||
// Initialize the CLI to set up the root command
|
||||
rootCmd := initCLI()
|
||||
|
||||
// Test that root command has expected flags
|
||||
verboseFlag := rootCmd.PersistentFlags().Lookup("verbose")
|
||||
assert.NotNil(t, verboseFlag, "Verbose flag should exist")
|
||||
assert.Equal(t, "v", verboseFlag.Shorthand)
|
||||
|
||||
silentFlag := rootCmd.PersistentFlags().Lookup("silent")
|
||||
assert.NotNil(t, silentFlag, "Silent flag should exist")
|
||||
assert.Equal(t, "s", silentFlag.Shorthand)
|
||||
}
|
||||
|
||||
// TestCommandExecution tests basic command execution
|
||||
func TestCommandExecution(t *testing.T) {
|
||||
// Initialize the CLI to set up the root command
|
||||
rootCmd := initCLI()
|
||||
|
||||
// Test that root command can be executed (help command)
|
||||
var buf bytes.Buffer
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
|
||||
rootCmd.SetArgs([]string{"--help"})
|
||||
err := rootCmd.Execute()
|
||||
assert.NoError(t, err, "Root command should execute without error")
|
||||
assert.Contains(t, buf.String(), "dns forwarding proxy", "Help output should contain description")
|
||||
}
|
||||
|
||||
// TestCommandArgs tests argument handling
|
||||
func TestCommandArgs(t *testing.T) {
|
||||
// Initialize the CLI to set up the root command
|
||||
rootCmd := initCLI()
|
||||
|
||||
// Test that root command can handle arguments properly
|
||||
// Test with no args (should succeed)
|
||||
err := rootCmd.Execute()
|
||||
assert.NoError(t, err, "Root command with no args should execute")
|
||||
|
||||
// Test with help flag (should succeed)
|
||||
rootCmd.SetArgs([]string{"--help"})
|
||||
err = rootCmd.Execute()
|
||||
assert.NoError(t, err, "Root command with help flag should execute")
|
||||
}
|
||||
|
||||
// TestCommandSubcommands tests subcommand functionality
|
||||
func TestCommandSubcommands(t *testing.T) {
|
||||
// Initialize the CLI to set up the root command
|
||||
rootCmd := initCLI()
|
||||
|
||||
// Test that root command has subcommands
|
||||
commands := rootCmd.Commands()
|
||||
assert.Greater(t, len(commands), 0, "Root command should have subcommands")
|
||||
|
||||
// Test that specific subcommands exist and can be executed
|
||||
expectedSubcommands := []string{"run", "service", "clients", "upgrade", "log"}
|
||||
for _, subCmdName := range expectedSubcommands {
|
||||
// Find the subcommand
|
||||
var subCmd *cobra.Command
|
||||
for _, cmd := range commands {
|
||||
if cmd.Name() == subCmdName {
|
||||
subCmd = cmd
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.NotNil(t, subCmd, "Subcommand %s should exist", subCmdName)
|
||||
|
||||
// Test that subcommand has help
|
||||
assert.NotEmpty(t, subCmd.Short, "Subcommand %s should have a short description", subCmdName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kardianos/service"
|
||||
"github.com/minio/selfupdate"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
upgradeChannelDev = "dev"
|
||||
upgradeChannelProd = "prod"
|
||||
upgradeChannelDefault = "default"
|
||||
)
|
||||
|
||||
// UpgradeCommand handles upgrade-related operations
|
||||
type UpgradeCommand struct {
|
||||
}
|
||||
|
||||
// NewUpgradeCommand creates a new upgrade command handler
|
||||
func NewUpgradeCommand() (*UpgradeCommand, error) {
|
||||
return &UpgradeCommand{}, nil
|
||||
}
|
||||
|
||||
// Upgrade performs the upgrade operation
|
||||
func (uc *UpgradeCommand) Upgrade(cmd *cobra.Command, args []string) error {
|
||||
upgradeChannel := map[string]string{
|
||||
upgradeChannelDefault: "https://dl.controld.dev",
|
||||
upgradeChannelDev: "https://dl.controld.dev",
|
||||
upgradeChannelProd: "https://dl.controld.com",
|
||||
}
|
||||
if isStableVersion(curVersion()) {
|
||||
upgradeChannel[upgradeChannelDefault] = upgradeChannel[upgradeChannelProd]
|
||||
}
|
||||
|
||||
bin, err := os.Executable()
|
||||
if err != nil {
|
||||
mainLog.Load().Fatal().Err(err).Msg("Failed to get current ctrld binary path")
|
||||
}
|
||||
|
||||
readConfig(false)
|
||||
v.Unmarshal(&cfg)
|
||||
svcCmd := NewServiceCommand()
|
||||
s, p, err := svcCmd.initializeServiceManager()
|
||||
if err != nil {
|
||||
mainLog.Load().Error().Msg(err.Error())
|
||||
return nil
|
||||
}
|
||||
|
||||
if iface == "" {
|
||||
iface = autoIface
|
||||
}
|
||||
p.preRun()
|
||||
if ir := runningIface(s); ir != nil {
|
||||
p.runningIface = ir.Name
|
||||
p.requiredMultiNICsConfig = ir.All
|
||||
}
|
||||
|
||||
svcInstalled := true
|
||||
if _, err := s.Status(); errors.Is(err, service.ErrNotInstalled) {
|
||||
svcInstalled = false
|
||||
}
|
||||
|
||||
oldBin := bin + oldBinSuffix
|
||||
baseUrl := upgradeChannel[upgradeChannelDefault]
|
||||
if len(args) > 0 {
|
||||
channel := args[0]
|
||||
switch channel {
|
||||
case upgradeChannelProd, upgradeChannelDev: // ok
|
||||
default:
|
||||
mainLog.Load().Fatal().Msgf("Upgrade argument must be either %q or %q", upgradeChannelProd, upgradeChannelDev)
|
||||
}
|
||||
baseUrl = upgradeChannel[channel]
|
||||
}
|
||||
|
||||
dlUrl := upgradeUrl(baseUrl)
|
||||
mainLog.Load().Debug().Msgf("Downloading binary: %s", dlUrl)
|
||||
|
||||
resp, err := getWithRetry(dlUrl, downloadServerIp)
|
||||
if err != nil {
|
||||
mainLog.Load().Fatal().Err(err).Msg("Failed to download binary")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
mainLog.Load().Fatal().Msgf("Could not download binary: %s", http.StatusText(resp.StatusCode))
|
||||
}
|
||||
|
||||
mainLog.Load().Debug().Msg("Updating current binary")
|
||||
if err := selfupdate.Apply(resp.Body, selfupdate.Options{OldSavePath: oldBin}); err != nil {
|
||||
if rerr := selfupdate.RollbackError(err); rerr != nil {
|
||||
mainLog.Load().Error().Err(rerr).Msg("Could not rollback old binary")
|
||||
}
|
||||
mainLog.Load().Fatal().Err(err).Msg("Failed to update current binary")
|
||||
}
|
||||
|
||||
doRestart := func() bool {
|
||||
if !svcInstalled {
|
||||
return true
|
||||
}
|
||||
tasks := []task{
|
||||
{s.Stop, true, "Stop"},
|
||||
{func() error {
|
||||
// restore static DNS settings or DHCP
|
||||
p.resetDNS(false, true)
|
||||
return nil
|
||||
}, false, "Cleanup"},
|
||||
{func() error {
|
||||
time.Sleep(time.Second * 1)
|
||||
return nil
|
||||
}, false, "Waiting for service to stop"},
|
||||
}
|
||||
doTasks(tasks)
|
||||
|
||||
tasks = []task{
|
||||
{s.Start, true, "Start"},
|
||||
}
|
||||
if doTasks(tasks) {
|
||||
if dir, err := socketDir(); err == nil {
|
||||
if cc := newSocketControlClient(context.TODO(), s, dir); cc != nil {
|
||||
_, _ = cc.post(ifacePath, nil)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if svcInstalled {
|
||||
mainLog.Load().Debug().Msg("Restarting ctrld service using new binary")
|
||||
}
|
||||
|
||||
if doRestart() {
|
||||
_ = os.Remove(oldBin)
|
||||
_ = os.Chmod(bin, 0755)
|
||||
ver := "unknown version"
|
||||
out, err := exec.Command(bin, "--version").CombinedOutput()
|
||||
if err != nil {
|
||||
mainLog.Load().Warn().Err(err).Msg("Failed to get new binary version")
|
||||
}
|
||||
if after, found := strings.CutPrefix(string(out), "ctrld version "); found {
|
||||
ver = after
|
||||
}
|
||||
mainLog.Load().Notice().Msgf("Upgrade successful - %s", ver)
|
||||
return nil
|
||||
}
|
||||
|
||||
mainLog.Load().Warn().Msgf("Upgrade failed, restoring previous binary: %s", oldBin)
|
||||
if err := os.Remove(bin); err != nil {
|
||||
mainLog.Load().Fatal().Err(err).Msg("Failed to remove new binary")
|
||||
}
|
||||
if err := os.Rename(oldBin, bin); err != nil {
|
||||
mainLog.Load().Fatal().Err(err).Msg("Failed to restore old binary")
|
||||
}
|
||||
if doRestart() {
|
||||
mainLog.Load().Notice().Msg("Restored previous binary successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// InitUpgradeCmd creates the upgrade command with proper logic
|
||||
func InitUpgradeCmd(rootCmd *cobra.Command) *cobra.Command {
|
||||
upgradeCmd := &cobra.Command{
|
||||
Use: "upgrade",
|
||||
Short: "Upgrading ctrld to latest version",
|
||||
ValidArgs: []string{upgradeChannelDev, upgradeChannelProd},
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
PreRun: func(cmd *cobra.Command, args []string) {
|
||||
checkHasElevatedPrivilege()
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uc, err := NewUpgradeCommand()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return uc.Upgrade(cmd, args)
|
||||
},
|
||||
}
|
||||
|
||||
rootCmd.AddCommand(upgradeCmd)
|
||||
|
||||
return upgradeCmd
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
// logConn wraps a net.Conn, override the Write behavior.
|
||||
// runCmd uses this wrapper, so as long as startCmd finished,
|
||||
// ctrld log won't be flushed with un-necessary write errors.
|
||||
type logConn struct {
|
||||
conn net.Conn
|
||||
}
|
||||
|
||||
func (lc *logConn) Read(b []byte) (n int, err error) {
|
||||
return lc.conn.Read(b)
|
||||
}
|
||||
|
||||
func (lc *logConn) Close() error {
|
||||
return lc.conn.Close()
|
||||
}
|
||||
|
||||
func (lc *logConn) LocalAddr() net.Addr {
|
||||
return lc.conn.LocalAddr()
|
||||
}
|
||||
|
||||
func (lc *logConn) RemoteAddr() net.Addr {
|
||||
return lc.conn.RemoteAddr()
|
||||
}
|
||||
|
||||
func (lc *logConn) SetDeadline(t time.Time) error {
|
||||
return lc.conn.SetDeadline(t)
|
||||
}
|
||||
|
||||
func (lc *logConn) SetReadDeadline(t time.Time) error {
|
||||
return lc.conn.SetReadDeadline(t)
|
||||
}
|
||||
|
||||
func (lc *logConn) SetWriteDeadline(t time.Time) error {
|
||||
return lc.conn.SetWriteDeadline(t)
|
||||
}
|
||||
|
||||
func (lc *logConn) Write(b []byte) (int, error) {
|
||||
// Write performs writes with underlying net.Conn, ignore any errors happen.
|
||||
// "ctrld run" command use this wrapper to report errors to "ctrld start".
|
||||
// If no error occurred, "ctrld start" may finish before "ctrld run" attempt
|
||||
// to close the connection, so ignore errors conservatively here, prevent
|
||||
// un-necessary error "write to closed connection" flushed to ctrld log.
|
||||
_, _ = lc.conn.Write(b)
|
||||
return len(b), nil
|
||||
}
|
||||
@@ -8,10 +8,12 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// controlClient represents an HTTP client for communicating with the control server
|
||||
type controlClient struct {
|
||||
c *http.Client
|
||||
}
|
||||
|
||||
// newControlClient creates a new control client with Unix socket transport
|
||||
func newControlClient(addr string) *controlClient {
|
||||
return &controlClient{c: &http.Client{
|
||||
Transport: &http.Transport{
|
||||
|
||||
+37
-31
@@ -40,12 +40,14 @@ type ifaceResponse struct {
|
||||
InterceptMode string `json:"intercept_mode,omitempty"` // "dns", "hard", or "" (not intercepting)
|
||||
}
|
||||
|
||||
// controlServer represents an HTTP server for handling control requests
|
||||
type controlServer struct {
|
||||
server *http.Server
|
||||
mux *http.ServeMux
|
||||
addr string
|
||||
}
|
||||
|
||||
// newControlServer creates a new control server instance
|
||||
func newControlServer(addr string) (*controlServer, error) {
|
||||
mux := http.NewServeMux()
|
||||
s := &controlServer{
|
||||
@@ -88,34 +90,34 @@ func (s *controlServer) register(pattern string, handler http.Handler) {
|
||||
|
||||
func (p *prog) registerControlServerHandler() {
|
||||
p.cs.register(listClientsPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
mainLog.Load().Debug().Msg("handling list clients request")
|
||||
p.Debug().Msg("Handling list clients request")
|
||||
|
||||
clients := p.ciTable.ListClients()
|
||||
mainLog.Load().Debug().Int("client_count", len(clients)).Msg("retrieved clients list")
|
||||
p.Debug().Int("client_count", len(clients)).Msg("Retrieved clients list")
|
||||
|
||||
sort.Slice(clients, func(i, j int) bool {
|
||||
return clients[i].IP.Less(clients[j].IP)
|
||||
})
|
||||
mainLog.Load().Debug().Msg("sorted clients by IP address")
|
||||
p.Debug().Msg("Sorted clients by IP address")
|
||||
|
||||
if p.metricsQueryStats.Load() {
|
||||
mainLog.Load().Debug().Msg("metrics query stats enabled, collecting query counts")
|
||||
p.Debug().Msg("Metrics query stats enabled, collecting query counts")
|
||||
|
||||
for idx, client := range clients {
|
||||
mainLog.Load().Debug().
|
||||
p.Debug().
|
||||
Int("index", idx).
|
||||
Str("ip", client.IP.String()).
|
||||
Str("mac", client.Mac).
|
||||
Str("hostname", client.Hostname).
|
||||
Msg("processing client metrics")
|
||||
Msg("Processing client metrics")
|
||||
|
||||
client.IncludeQueryCount = true
|
||||
dm := &dto.Metric{}
|
||||
|
||||
if statsClientQueriesCount.MetricVec == nil {
|
||||
mainLog.Load().Debug().
|
||||
p.Debug().
|
||||
Str("client_ip", client.IP.String()).
|
||||
Msg("skipping metrics collection: MetricVec is nil")
|
||||
Msg("Skipping metrics collection: MetricVec is nil")
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -125,44 +127,44 @@ func (p *prog) registerControlServerHandler() {
|
||||
client.Hostname,
|
||||
)
|
||||
if err != nil {
|
||||
mainLog.Load().Debug().
|
||||
p.Debug().
|
||||
Err(err).
|
||||
Str("client_ip", client.IP.String()).
|
||||
Str("mac", client.Mac).
|
||||
Str("hostname", client.Hostname).
|
||||
Msg("failed to get metrics for client")
|
||||
Msg("Failed to get metrics for client")
|
||||
continue
|
||||
}
|
||||
|
||||
if err := m.Write(dm); err == nil && dm.Counter != nil {
|
||||
client.QueryCount = int64(dm.Counter.GetValue())
|
||||
mainLog.Load().Debug().
|
||||
p.Debug().
|
||||
Str("client_ip", client.IP.String()).
|
||||
Int64("query_count", client.QueryCount).
|
||||
Msg("successfully collected query count")
|
||||
Msg("Successfully collected query count")
|
||||
} else if err != nil {
|
||||
mainLog.Load().Debug().
|
||||
p.Debug().
|
||||
Err(err).
|
||||
Str("client_ip", client.IP.String()).
|
||||
Msg("failed to write metric")
|
||||
Msg("Failed to write metric")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
mainLog.Load().Debug().Msg("metrics query stats disabled, skipping query counts")
|
||||
p.Debug().Msg("Metrics query stats disabled, skipping query counts")
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(&clients); err != nil {
|
||||
mainLog.Load().Error().
|
||||
p.Error().
|
||||
Err(err).
|
||||
Int("client_count", len(clients)).
|
||||
Msg("failed to encode clients response")
|
||||
Msg("Failed to encode clients response")
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
mainLog.Load().Debug().
|
||||
p.Debug().
|
||||
Int("client_count", len(clients)).
|
||||
Msg("successfully sent clients list response")
|
||||
Msg("Successfully sent clients list response")
|
||||
}))
|
||||
p.cs.register(startedPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
select {
|
||||
@@ -184,14 +186,14 @@ func (p *prog) registerControlServerHandler() {
|
||||
oldSvc := p.cfg.Service
|
||||
p.mu.Unlock()
|
||||
if err := p.sendReloadSignal(); err != nil {
|
||||
mainLog.Load().Err(err).Msg("could not send reload signal")
|
||||
p.Error().Err(err).Msg("Could not send reload signal")
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-p.reloadDoneCh:
|
||||
case <-time.After(5 * time.Second):
|
||||
http.Error(w, "timeout waiting for ctrld reload", http.StatusInternalServerError)
|
||||
http.Error(w, "Timeout waiting for ctrld reload", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -225,6 +227,8 @@ func (p *prog) registerControlServerHandler() {
|
||||
return
|
||||
}
|
||||
|
||||
loggerCtx := ctrld.LoggerCtx(context.Background(), p.logger.Load())
|
||||
|
||||
// Reject further attempts while locked out due to repeated wrong PINs.
|
||||
if now := time.Now().Unix(); now < deactivationLockedUntil.Load() {
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
@@ -234,17 +238,17 @@ func (p *prog) registerControlServerHandler() {
|
||||
// Re-fetch pin code from API.
|
||||
rcReq := &controld.ResolverConfigRequest{
|
||||
RawUID: cdUID,
|
||||
Version: rootCmd.Version,
|
||||
Version: appVersion,
|
||||
Metadata: ctrld.SystemMetadataRuntime(context.Background()),
|
||||
}
|
||||
if rc, err := controld.FetchResolverConfig(rcReq, cdDev); rc != nil {
|
||||
if rc, err := controld.FetchResolverConfig(loggerCtx, rcReq, cdDev); rc != nil {
|
||||
if rc.DeactivationPin != nil {
|
||||
cdDeactivationPin.Store(*rc.DeactivationPin)
|
||||
} else {
|
||||
cdDeactivationPin.Store(defaultDeactivationPin)
|
||||
}
|
||||
} else {
|
||||
mainLog.Load().Warn().Err(err).Msg("could not re-fetch deactivation pin code")
|
||||
p.Warn().Err(err).Msg("Could not re-fetch deactivation pin code")
|
||||
}
|
||||
|
||||
// If pin code not set, allowing deactivation.
|
||||
@@ -256,7 +260,7 @@ func (p *prog) registerControlServerHandler() {
|
||||
var req deactivationRequest
|
||||
if err := json.NewDecoder(request.Body).Decode(&req); err != nil {
|
||||
w.WriteHeader(http.StatusPreconditionFailed)
|
||||
mainLog.Load().Err(err).Msg("invalid deactivation request")
|
||||
p.Error().Err(err).Msg("Invalid deactivation request")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -310,7 +314,7 @@ func (p *prog) registerControlServerHandler() {
|
||||
}
|
||||
}))
|
||||
p.cs.register(viewLogsPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
lr, err := p.logReader()
|
||||
lr, err := p.logReaderRaw()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
@@ -336,7 +340,7 @@ func (p *prog) registerControlServerHandler() {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
r, err := p.logReader()
|
||||
r, err := p.logReaderNoColor()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
@@ -349,14 +353,15 @@ func (p *prog) registerControlServerHandler() {
|
||||
UID: cdUID,
|
||||
Data: r.r,
|
||||
}
|
||||
mainLog.Load().Debug().Msg("sending log file to ControlD server")
|
||||
p.Debug().Msg("Sending log file to ControlD server")
|
||||
resp := logSentResponse{Size: r.size}
|
||||
if err := controld.SendLogs(req, cdDev); err != nil {
|
||||
mainLog.Load().Error().Msgf("could not send log file to ControlD server: %v", err)
|
||||
loggerCtx := ctrld.LoggerCtx(context.Background(), p.logger.Load())
|
||||
if err := controld.SendLogs(loggerCtx, req, cdDev); err != nil {
|
||||
p.Error().Msgf("Could not send log file to ControlD server: %v", err)
|
||||
resp.Error = err.Error()
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
} else {
|
||||
mainLog.Load().Debug().Msg("sending log file successfully")
|
||||
p.Debug().Msg("Sending log file successfully")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(&resp); err != nil {
|
||||
@@ -530,6 +535,7 @@ func tailFileLastLines(f *os.File, n int) []byte {
|
||||
return lines
|
||||
}
|
||||
|
||||
// jsonResponse wraps an HTTP handler to set JSON content type
|
||||
func jsonResponse(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
package cli
|
||||
|
||||
//lint:ignore U1000 use in os_linux.go
|
||||
type getDNS func(iface string) []string
|
||||
+137
-121
@@ -41,6 +41,11 @@ const (
|
||||
// 2s re-check misses.
|
||||
pfAnchorRecheckDelayLong = 4 * time.Second
|
||||
|
||||
// pfExecFailureBackoff suppresses repeated pfctl/scutil checks briefly after
|
||||
// macOS reports local resource exhaustion. Without this, network-change storms
|
||||
// can turn a pf ruleset race into a fork/file-descriptor exhaustion loop.
|
||||
pfExecFailureBackoff = 5 * time.Second
|
||||
|
||||
// pfVPNInterfacePrefixes lists interface name prefixes that indicate VPN/tunnel
|
||||
// interfaces on macOS. Used to add interface-specific DNS intercept rules so that
|
||||
// VPN software with "pass out quick on <iface>" rules cannot bypass our intercept.
|
||||
@@ -56,10 +61,6 @@ const (
|
||||
|
||||
const (
|
||||
// pfProbeDomain is the suffix used for pf interception probe queries.
|
||||
// The full probe domain is "_pf-probe-<hex>.<pfProbeDomain>".
|
||||
// These queries are sent by a subprocess WITHOUT the _ctrld group GID,
|
||||
// so pf should intercept them and redirect to ctrld. If ctrld receives
|
||||
// the query, pf interception is working. If not (timeout), rdr is broken.
|
||||
// No trailing dot — canonicalName() in the DNS handler strips trailing dots.
|
||||
pfProbeDomain = "pf-probe.ctrld.test"
|
||||
|
||||
@@ -215,10 +216,8 @@ func (p *prog) startDNSIntercept() error {
|
||||
|
||||
// Pre-discover VPN DNS configurations before building initial rules.
|
||||
// Without this, there's a startup gap where the initial anchor has no VPN DNS
|
||||
// exemptions, causing queries to be intercepted and routed to ctrld. The
|
||||
// vpnDNSManager.Refresh() call later would add the exemptions, but stale pf
|
||||
// state entries from the gap persist and keep routing packets to lo0.
|
||||
// By discovering upfront, the initial rules exclude VPN DNS interfaces from interception.
|
||||
// exemptions, causing queries to be intercepted and routed to ctrld. Stale pf
|
||||
// state entries from the gap persist even after vpnDNS.Refresh() adds exemptions.
|
||||
var initialExemptions []vpnDNSExemption
|
||||
if !hardIntercept {
|
||||
initialConfigs := ctrld.DiscoverVPNDNS(context.Background())
|
||||
@@ -374,7 +373,7 @@ func (p *prog) ensurePFAnchorReference() error {
|
||||
|
||||
// Inject our references if missing. PREPEND both references to ensure our
|
||||
// anchor is evaluated BEFORE any other anchors (e.g., Windscribe's
|
||||
// "vpn_app_traffic"). pf evaluates rules top-to-bottom, so "quick"
|
||||
// "windscribe_vpn_traffic"). pf evaluates rules top-to-bottom, so "quick"
|
||||
// rules in whichever anchor appears first win. By prepending, our DNS
|
||||
// intercept rules match port 53 traffic before a VPN app's broader
|
||||
// "pass out quick on <iface> all" rules in their anchor.
|
||||
@@ -421,7 +420,7 @@ func (p *prog) ensurePFAnchorReference() error {
|
||||
|
||||
// checkAnchorOrdering logs a warning if our anchor reference is not the first
|
||||
// anchor in the filter ruleset. When another anchor (e.g., Windscribe's
|
||||
// "vpn_app_traffic") appears before ours, its "quick" rules may match
|
||||
// "windscribe_vpn_traffic") appears before ours, its "quick" rules may match
|
||||
// DNS traffic first. The interface-specific tunnel rules in our anchor provide
|
||||
// a secondary defense, but first position is still preferred.
|
||||
func (p *prog) checkAnchorOrdering(filterLines []string, ourAnchorRef string) {
|
||||
@@ -642,16 +641,9 @@ func (p *prog) removePFAnchorReference() error {
|
||||
|
||||
// pfAddressFamily returns "inet" for IPv4 addresses and "inet6" for IPv6 addresses.
|
||||
// Used to generate pf rules with the correct address family for each IP.
|
||||
// flushPFStates flushes ALL pf state entries. Called after anchor reloads to ensure
|
||||
// packets are re-evaluated against the new rules instead of matching stale state
|
||||
// entries from the old ruleset. This is necessary because pf checks its state table
|
||||
// BEFORE rule evaluation — a state entry created by a route-to rule will keep
|
||||
// routing packets to lo0 even after VPN DNS interfaces are excluded from interception.
|
||||
//
|
||||
// We flush all states (not just port 53) because:
|
||||
// 1. pfctl doesn't support port-based state killing
|
||||
// 2. State flush is fast and brief — existing TCP connections (DoH) will
|
||||
// re-establish quickly, and UDP connections are stateless at the transport level
|
||||
// flushPFStates flushes ALL pf state entries after anchor reloads.
|
||||
// pf checks state table BEFORE rules — stale entries from old rules keep routing
|
||||
// packets via route-to even after interface-scoped exemptions are added.
|
||||
func flushPFStates() {
|
||||
if out, err := exec.Command("pfctl", "-F", "states").CombinedOutput(); err != nil {
|
||||
mainLog.Load().Warn().Err(err).Msgf("DNS intercept: failed to flush pf states (output: %s)", strings.TrimSpace(string(out)))
|
||||
@@ -795,12 +787,9 @@ func (p *prog) buildPFAnchorRules(vpnExemptions []vpnDNSExemption) string {
|
||||
// evaluation, and its implicit state alone is insufficient for response delivery —
|
||||
// proven by commit 51cf029 where responses were silently dropped.
|
||||
rules.WriteString("# --- Translation rules (rdr) ---\n")
|
||||
|
||||
rules.WriteString("# Redirect DNS on loopback to ctrld's listener.\n")
|
||||
rules.WriteString(fmt.Sprintf("rdr on lo0 inet proto udp from any to ! %s port 53 -> %s\n", listenerIP, listenerAddr))
|
||||
rules.WriteString(fmt.Sprintf("rdr on lo0 inet proto tcp from any to ! %s port 53 -> %s\n", listenerIP, listenerAddr))
|
||||
// No IPv6 rdr — IPv6 DNS is blocked at the filter level (see below).
|
||||
rules.WriteString("\n")
|
||||
rules.WriteString(fmt.Sprintf("rdr on lo0 inet proto tcp from any to ! %s port 53 -> %s\n\n", listenerIP, listenerAddr))
|
||||
|
||||
// --- Filtering rules ---
|
||||
rules.WriteString("# --- Filtering rules (pass) ---\n\n")
|
||||
@@ -841,16 +830,6 @@ func (p *prog) buildPFAnchorRules(vpnExemptions []vpnDNSExemption) string {
|
||||
|
||||
// Build sets of VPN DNS interfaces and server IPs for exclusion from intercept rules.
|
||||
//
|
||||
// VPN DNS handlers that use macOS Network Extensions (Tailscale MagicDNS, modern
|
||||
// Cisco AnyConnect, F5 BIG-IP) intercept packets at the NE layer — BEFORE pf sees
|
||||
// them on the return path, but AFTER pf's outbound rules fire. Any pf rule that
|
||||
// touches packets on a VPN DNS interface (even "pass" with "keep state") interferes
|
||||
// with the NE's packet handling, causing timeouts.
|
||||
//
|
||||
// Solution: exclude VPN DNS interfaces from tunnel intercept rules entirely, and
|
||||
// exclude VPN DNS server IPs from the generic intercept rule. This lets all DNS
|
||||
// traffic to/from VPN DNS flow naturally without any pf interference.
|
||||
//
|
||||
// EXIT MODE EXCEPTION: When a VPN is in exit/full-tunnel mode (VPN DNS server is
|
||||
// also the system default resolver), we do NOT exempt the interface. In exit mode,
|
||||
// all traffic routes through the VPN, so exempting the interface would bypass ctrld
|
||||
@@ -858,7 +837,7 @@ func (p *prog) buildPFAnchorRules(vpnExemptions []vpnDNSExemption) string {
|
||||
// intercepting and let ctrld's VPN DNS split routing + group exemption handle it.
|
||||
vpnDNSIfaces := make(map[string]bool) // non-exit interfaces to skip in tunnel intercept
|
||||
vpnDNSIfacePassthrough := make(map[string]bool) // non-exit interfaces needing passthrough rules
|
||||
vpnDNSServerIPs := make(map[string]bool) // IPs to exclude from generic intercept
|
||||
vpnDNSServerIPs := make(map[string]bool) // IPs for group exemptions and <vpn_dns> table
|
||||
for _, ex := range vpnExemptions {
|
||||
if ex.Interface != "" && !ex.IsExitMode {
|
||||
vpnDNSIfaces[ex.Interface] = true
|
||||
@@ -884,28 +863,33 @@ func (p *prog) buildPFAnchorRules(vpnExemptions []vpnDNSExemption) string {
|
||||
rules.WriteString("\n")
|
||||
}
|
||||
|
||||
// NOTE: IPv6 DNS is now intercepted (not blocked). ctrld listens on [::1] and pf
|
||||
// redirects IPv6 DNS the same way as IPv4. This eliminates the ~1s timeout caused by
|
||||
// blocking IPv6 DNS (BSD doesn't deliver ICMP errors to unconnected UDP sockets).
|
||||
// Block all outbound IPv6 DNS. ctrld only listens on 0.0.0.0:53 (IPv4), so we cannot
|
||||
// redirect IPv6 DNS to our listener. Without this rule, macOS may use IPv6 link-local
|
||||
// DNS servers (e.g., fe80::...%en0) assigned by the router, completely bypassing the
|
||||
// IPv4 pf intercept. Blocking forces macOS to fall back to IPv4 DNS, which is intercepted.
|
||||
// This rule must come BEFORE the IPv4 route-to rules (pf evaluates last match by default,
|
||||
// but "quick" makes first-match — and exemptions above are already "quick").
|
||||
rules.WriteString("# Block outbound IPv6 DNS — ctrld listens on IPv4 only (0.0.0.0:53).\n")
|
||||
rules.WriteString("# Without this, macOS may use IPv6 link-local DNS servers from the router,\n")
|
||||
rules.WriteString("# bypassing the IPv4 intercept entirely.\n")
|
||||
rules.WriteString("block out quick on ! lo0 inet6 proto { udp, tcp } from any to any port 53\n\n")
|
||||
|
||||
// --- VPN DNS interface passthrough (split DNS mode only) ---
|
||||
//
|
||||
// In split DNS mode, the VPN's DNS handler (e.g., Tailscale MagicDNS) runs as a
|
||||
// Network Extension that intercepts packets on its tunnel interface. MagicDNS then
|
||||
// forwards queries to its own upstream nameservers (e.g., 10.0.0.11) — IPs we
|
||||
// forwards queries to its own upstream nameservers (e.g., 10.3.112.11) — IPs we
|
||||
// can't know in advance. Without these rules, pf's generic "on !lo0" intercept
|
||||
// catches MagicDNS's upstream queries, routing them back to ctrld in a loop.
|
||||
//
|
||||
// These "pass" rules (no route-to) let MagicDNS's upstream queries pass through.
|
||||
// Traffic TO the VPN DNS server itself (e.g., 100.100.100.100) is excluded so those
|
||||
// queries get intercepted → ctrld enforces its profile on non-search-domain queries.
|
||||
// Traffic TO the VPN DNS server (e.g., 100.100.100.100) is excluded via <vpn_dns>
|
||||
// so those queries get intercepted → ctrld enforces its profile on non-search-domain queries.
|
||||
//
|
||||
// NOT applied in exit mode — in exit mode, all traffic routes through the VPN
|
||||
// interface, so exempting it would bypass ctrld's profile enforcement entirely.
|
||||
if len(vpnDNSIfacePassthrough) > 0 {
|
||||
// Build table of VPN DNS server IPs to exclude from passthrough.
|
||||
// Queries TO these IPs must still be intercepted (profile enforcement).
|
||||
// Only MagicDNS's upstream queries to other IPs should pass through.
|
||||
var vpnDNSTableMembers []string
|
||||
for ip := range vpnDNSServerIPs {
|
||||
if net.ParseIP(ip) != nil && net.ParseIP(ip).To4() != nil {
|
||||
@@ -931,66 +915,31 @@ func (p *prog) buildPFAnchorRules(vpnExemptions []vpnDNSExemption) string {
|
||||
}
|
||||
|
||||
// --- Interface-specific VPN/tunnel intercept rules ---
|
||||
// VPN apps (e.g., Windscribe, Cisco AnyConnect) often add pf rules like:
|
||||
// pass out quick on ipsec0 inet all flags S/SA keep state
|
||||
// inside their own anchors. If their anchor is evaluated before ours, their
|
||||
// "quick" match on the VPN interface captures DNS traffic before our generic
|
||||
// "on ! lo0" rule can intercept it. To counter this, we add explicit intercept
|
||||
// rules for each active tunnel interface. These use "quick" and match port 53
|
||||
// specifically, so they take priority over the VPN app's broader "all" rules
|
||||
// regardless of anchor ordering.
|
||||
//
|
||||
// NOTE: If anchor ordering alone proves insufficient in the future, a "nuclear
|
||||
// option" is available: inject DNS intercept rules directly into the MAIN pf
|
||||
// ruleset (not inside our anchor). Main ruleset rules are evaluated before ALL
|
||||
// anchors, making them impossible for another app to override without explicitly
|
||||
// removing them. See docs/dns-intercept-mode.md for details.
|
||||
tunnelIfaces := discoverTunnelInterfaces()
|
||||
if len(tunnelIfaces) > 0 {
|
||||
rules.WriteString("# --- VPN/tunnel interface intercept rules ---\n")
|
||||
rules.WriteString("# Explicit intercept on tunnel interfaces prevents VPN apps from capturing\n")
|
||||
rules.WriteString("# DNS traffic with their own broad \"pass out quick on <iface>\" rules.\n")
|
||||
rules.WriteString("# These port-53-specific rules take priority over broader \"all\" matches.\n")
|
||||
rules.WriteString("#\n")
|
||||
rules.WriteString("# Interfaces with VPN DNS servers (from scutil) are EXCLUDED — those carry\n")
|
||||
rules.WriteString("# DNS traffic for Network Extension-based VPNs (e.g., Tailscale MagicDNS)\n")
|
||||
rules.WriteString("# that must flow without any pf interference.\n")
|
||||
rules.WriteString("# VPN DNS interfaces (split DNS mode) are excluded — passthrough rules above handle them.\n")
|
||||
for _, iface := range tunnelIfaces {
|
||||
if vpnDNSIfaces[iface] {
|
||||
rules.WriteString(fmt.Sprintf("# Skipped %s — VPN DNS interface (Network Extension needs unintercepted flow)\n", iface))
|
||||
rules.WriteString(fmt.Sprintf("# Skipped %s — VPN DNS interface (passthrough rules handle this)\n", iface))
|
||||
continue
|
||||
}
|
||||
rules.WriteString(fmt.Sprintf("pass out quick on %s route-to lo0 inet proto udp from any to ! %s port 53\n", iface, listenerIP))
|
||||
rules.WriteString(fmt.Sprintf("pass out quick on %s route-to lo0 inet proto tcp from any to ! %s port 53\n", iface, listenerIP))
|
||||
// No IPv6 route-to — IPv6 DNS is blocked, not intercepted.
|
||||
}
|
||||
rules.WriteString("\n")
|
||||
}
|
||||
|
||||
// Force all remaining outbound IPv4 DNS through loopback for interception.
|
||||
// VPN DNS server IPs are excluded — those must reach their VPN DNS handler
|
||||
// without pf interference (especially for Network Extension-based VPNs).
|
||||
//
|
||||
// IMPORTANT: pf expands negated lists like { !a, !b } into separate rules where
|
||||
// each rule matches everything the other excludes — effectively matching ALL addresses.
|
||||
// This is a well-documented pf pitfall (OpenBSD FAQ, "negated lists").
|
||||
// Fix: use a pf table with a single negated match: "to ! <table>".
|
||||
// Force all remaining outbound IPv4 DNS through loopback for interception.
|
||||
// Only loopback (127.0.0.1) is excluded — ctrld's own outbound queries to VPN DNS
|
||||
// servers are handled by the group-scoped exemption rules above (group _ctrld).
|
||||
// route-to rules use stateful tracking (keep state, the default). State is floating
|
||||
// (matches on any interface), but "pass out on lo0 no state" below ensures no state
|
||||
// is created on the lo0 outbound path, allowing rdr to fire on lo0 inbound.
|
||||
rules.WriteString("# Force remaining outbound IPv4 DNS through loopback for interception.\n")
|
||||
rules.WriteString(fmt.Sprintf("pass out quick on ! lo0 route-to lo0 inet proto udp from any to ! %s port 53\n", listenerIP))
|
||||
rules.WriteString(fmt.Sprintf("pass out quick on ! lo0 route-to lo0 inet proto tcp from any to ! %s port 53\n\n", listenerIP))
|
||||
|
||||
// Block all outbound IPv6 DNS. ctrld only intercepts IPv4 DNS via the loopback
|
||||
// redirect. IPv6 DNS interception on macOS is not feasible because the kernel rejects
|
||||
// sendmsg from [::1] to global unicast IPv6 (EINVAL), and pf's nat-on-lo0 doesn't
|
||||
// fire for route-to'd packets. Blocking forces macOS to fall back to IPv4 DNS,
|
||||
// which is fully intercepted. See docs/pf-dns-intercept.md for details.
|
||||
rules.WriteString("# Block outbound IPv6 DNS — ctrld intercepts IPv4 only.\n")
|
||||
rules.WriteString("# macOS falls back to IPv4 DNS automatically.\n")
|
||||
rules.WriteString("block out quick on ! lo0 inet6 proto { udp, tcp } from any to any port 53\n\n")
|
||||
|
||||
// Allow route-to'd DNS packets to pass outbound on lo0.
|
||||
// Without this, VPN firewalls with "block drop all" (e.g., Windscribe) drop the packet
|
||||
// after route-to redirects it to lo0 but before it can reflect inbound for rdr processing.
|
||||
@@ -1000,9 +949,7 @@ func (p *prog) buildPFAnchorRules(vpnExemptions []vpnDNSExemption) string {
|
||||
// rdr entirely. With "no state", the inbound packet gets fresh evaluation and rdr fires.
|
||||
rules.WriteString("# Pass route-to'd DNS outbound on lo0 — no state to avoid bypassing rdr inbound.\n")
|
||||
rules.WriteString(fmt.Sprintf("pass out quick on lo0 inet proto udp from any to ! %s port 53 no state\n", listenerIP))
|
||||
rules.WriteString(fmt.Sprintf("pass out quick on lo0 inet proto tcp from any to ! %s port 53 no state\n", listenerIP))
|
||||
// No IPv6 lo0 pass — IPv6 DNS is blocked, not routed through lo0.
|
||||
rules.WriteString("\n")
|
||||
rules.WriteString(fmt.Sprintf("pass out quick on lo0 inet proto tcp from any to ! %s port 53 no state\n\n", listenerIP))
|
||||
|
||||
// Allow the redirected traffic through on loopback (inbound after rdr).
|
||||
//
|
||||
@@ -1017,7 +964,13 @@ func (p *prog) buildPFAnchorRules(vpnExemptions []vpnDNSExemption) string {
|
||||
// (source 127.0.0.1 → original DNS server IP, e.g., 10.255.255.3).
|
||||
rules.WriteString("# Accept redirected DNS — reply-to lo0 forces response through loopback.\n")
|
||||
rules.WriteString(fmt.Sprintf("pass in quick on lo0 reply-to lo0 inet proto { udp, tcp } from any to %s\n", listenerAddr))
|
||||
// No IPv6 pass-in — IPv6 DNS is blocked, not redirected to [::1].
|
||||
|
||||
// Firewall mode: append IP allowlist enforcement rules AFTER DNS intercept rules.
|
||||
// DNS intercept rules must evaluate first so that DNS queries work (they're how
|
||||
// IPs get into the allowlist in the first place).
|
||||
if p.firewallModeEnabled() {
|
||||
rules.WriteString(buildPFFirewallRules())
|
||||
}
|
||||
|
||||
return rules.String()
|
||||
}
|
||||
@@ -1030,17 +983,14 @@ func (p *prog) verifyPFState() {
|
||||
anchorRef := fmt.Sprintf("anchor \"%s\"", pfAnchorName)
|
||||
verified := true
|
||||
|
||||
// Check main ruleset for anchor references (rdr-anchor in translation rules).
|
||||
// Check main ruleset for anchor references.
|
||||
natOut, err := exec.Command("pfctl", "-sn").CombinedOutput()
|
||||
if err != nil {
|
||||
mainLog.Load().Error().Err(err).Msg("DNS intercept: VERIFICATION FAILED — could not dump NAT rules")
|
||||
verified = false
|
||||
} else {
|
||||
natStr := string(natOut)
|
||||
if !strings.Contains(natStr, rdrAnchorRef) {
|
||||
mainLog.Load().Error().Msg("DNS intercept: VERIFICATION FAILED — rdr-anchor reference missing from running NAT rules")
|
||||
verified = false
|
||||
}
|
||||
} else if !strings.Contains(string(natOut), rdrAnchorRef) {
|
||||
mainLog.Load().Error().Msg("DNS intercept: VERIFICATION FAILED — rdr-anchor reference missing from running NAT rules")
|
||||
verified = false
|
||||
}
|
||||
|
||||
filterOut, err := exec.Command("pfctl", "-sr").CombinedOutput()
|
||||
@@ -1109,7 +1059,7 @@ func (p *prog) resetUpstreamTransports() {
|
||||
if uc == nil {
|
||||
continue
|
||||
}
|
||||
uc.ForceReBootstrap()
|
||||
uc.ForceReBootstrap(ctrld.LoggerCtx(context.Background(), p.logger.Load()))
|
||||
count++
|
||||
}
|
||||
if count > 0 {
|
||||
@@ -1173,7 +1123,7 @@ func (p *prog) checkTunnelInterfaceChanges() bool {
|
||||
mainLog.Load().Info().Msgf("DNS intercept: tunnel interfaces changed (was %v, now %v) — rebuilding pf anchor rules", prev, current)
|
||||
|
||||
// Rebuild anchor rules with the updated tunnel interface list.
|
||||
// Pass current VPN DNS servers so exemptions are preserved for still-active VPNs.
|
||||
// Pass current VPN DNS exemptions so they are preserved for still-active VPNs.
|
||||
var vpnExemptions []vpnDNSExemption
|
||||
if p.vpnDNS != nil {
|
||||
vpnExemptions = p.vpnDNS.CurrentExemptions()
|
||||
@@ -1189,7 +1139,7 @@ func (p *prog) checkTunnelInterfaceChanges() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
flushPFStates() // Clear stale states so new rules (incl. VPN DNS exemptions) take effect
|
||||
flushPFStates()
|
||||
mainLog.Load().Info().Msgf("DNS intercept: rebuilt pf anchor with %d tunnel interfaces", len(current))
|
||||
return true
|
||||
}
|
||||
@@ -1303,6 +1253,15 @@ func (p *prog) ensurePFAnchorActive() bool {
|
||||
if p.dnsInterceptState == nil {
|
||||
return false
|
||||
}
|
||||
if !p.pfEnsureRunning.CompareAndSwap(false, true) {
|
||||
mainLog.Load().Debug().Msg("DNS intercept watchdog: check already running, skipping duplicate")
|
||||
return false
|
||||
}
|
||||
defer p.pfEnsureRunning.Store(false)
|
||||
|
||||
if p.pfExecBackoffActive() {
|
||||
return false
|
||||
}
|
||||
|
||||
// While stabilizing (VPN connecting), suppress all restores.
|
||||
// The stabilization loop will restore once pf settles.
|
||||
@@ -1336,11 +1295,13 @@ func (p *prog) ensurePFAnchorActive() bool {
|
||||
// Check 1: anchor references in the main ruleset.
|
||||
natOut, err := exec.Command("pfctl", "-sn").CombinedOutput()
|
||||
if err != nil {
|
||||
if p.pfBackoffResourceExhaustion(err, natOut, "dump NAT rules") {
|
||||
return false
|
||||
}
|
||||
mainLog.Load().Warn().Err(err).Msg("DNS intercept watchdog: could not dump NAT rules")
|
||||
return false
|
||||
}
|
||||
natStr := string(natOut)
|
||||
if !strings.Contains(natStr, rdrAnchorRef) {
|
||||
if !strings.Contains(string(natOut), rdrAnchorRef) {
|
||||
mainLog.Load().Warn().Msg("DNS intercept watchdog: rdr-anchor reference missing from running ruleset")
|
||||
needsRestore = true
|
||||
}
|
||||
@@ -1348,6 +1309,9 @@ func (p *prog) ensurePFAnchorActive() bool {
|
||||
if !needsRestore {
|
||||
filterOut, err := exec.Command("pfctl", "-sr").CombinedOutput()
|
||||
if err != nil {
|
||||
if p.pfBackoffResourceExhaustion(err, filterOut, "dump filter rules") {
|
||||
return false
|
||||
}
|
||||
mainLog.Load().Warn().Err(err).Msg("DNS intercept watchdog: could not dump filter rules")
|
||||
return false
|
||||
}
|
||||
@@ -1365,6 +1329,9 @@ func (p *prog) ensurePFAnchorActive() bool {
|
||||
if !needsRestore {
|
||||
anchorFilter, err := exec.Command("pfctl", "-a", pfAnchorName, "-sr").CombinedOutput()
|
||||
if err != nil || len(strings.TrimSpace(string(anchorFilter))) == 0 {
|
||||
if p.pfBackoffResourceExhaustion(err, anchorFilter, "dump anchor filter rules") {
|
||||
return false
|
||||
}
|
||||
mainLog.Load().Warn().Msg("DNS intercept watchdog: anchor has no filter rules — content was flushed")
|
||||
needsRestore = true
|
||||
}
|
||||
@@ -1372,6 +1339,9 @@ func (p *prog) ensurePFAnchorActive() bool {
|
||||
if !needsRestore {
|
||||
anchorNat, err := exec.Command("pfctl", "-a", pfAnchorName, "-sn").CombinedOutput()
|
||||
if err != nil || len(strings.TrimSpace(string(anchorNat))) == 0 {
|
||||
if p.pfBackoffResourceExhaustion(err, anchorNat, "dump anchor NAT rules") {
|
||||
return false
|
||||
}
|
||||
mainLog.Load().Warn().Msg("DNS intercept watchdog: anchor has no rdr rules — translation was flushed (will cause packet loop on lo0)")
|
||||
needsRestore = true
|
||||
}
|
||||
@@ -1405,6 +1375,7 @@ func (p *prog) ensurePFAnchorActive() bool {
|
||||
// Restore: re-inject anchor references into the main ruleset.
|
||||
mainLog.Load().Info().Msg("DNS intercept watchdog: restoring pf anchor references")
|
||||
if err := p.ensurePFAnchorReference(); err != nil {
|
||||
p.pfBackoffResourceExhaustion(err, nil, "restore anchor references")
|
||||
mainLog.Load().Error().Err(err).Msg("DNS intercept watchdog: failed to restore anchor references")
|
||||
return true
|
||||
}
|
||||
@@ -1421,6 +1392,7 @@ func (p *prog) ensurePFAnchorActive() bool {
|
||||
if err := os.WriteFile(pfAnchorFile, []byte(rulesStr), 0644); err != nil {
|
||||
mainLog.Load().Error().Err(err).Msg("DNS intercept watchdog: failed to write anchor file")
|
||||
} else if out, err := exec.Command("pfctl", "-a", pfAnchorName, "-f", pfAnchorFile).CombinedOutput(); err != nil {
|
||||
p.pfBackoffResourceExhaustion(err, out, "load rebuilt anchor")
|
||||
mainLog.Load().Error().Err(err).Msgf("DNS intercept watchdog: failed to load rebuilt anchor (output: %s)", strings.TrimSpace(string(out)))
|
||||
} else {
|
||||
flushPFStates()
|
||||
@@ -1449,6 +1421,48 @@ func (p *prog) ensurePFAnchorActive() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *prog) pfExecBackoffActive() bool {
|
||||
until := p.pfExecBackoffUntil.Load()
|
||||
if until == 0 {
|
||||
return false
|
||||
}
|
||||
remaining := time.Until(time.UnixMilli(until))
|
||||
if remaining <= 0 {
|
||||
p.pfExecBackoffUntil.CompareAndSwap(until, 0)
|
||||
return false
|
||||
}
|
||||
mainLog.Load().Debug().Msgf("DNS intercept watchdog: suppressed during pf exec backoff (remaining: %s)", remaining)
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *prog) pfBackoffResourceExhaustion(err error, output []byte, operation string) bool {
|
||||
if !isResourceExhaustion(err, output) {
|
||||
return false
|
||||
}
|
||||
until := time.Now().Add(pfExecFailureBackoff)
|
||||
p.pfExecBackoffUntil.Store(until.UnixMilli())
|
||||
mainLog.Load().Warn().Err(err).Msgf("DNS intercept watchdog: backing off after local exec resource exhaustion (operation: %s, backoff: %s)", operation, pfExecFailureBackoff)
|
||||
return true
|
||||
}
|
||||
|
||||
func isResourceExhaustion(err error, output []byte) bool {
|
||||
if err == nil && len(output) == 0 {
|
||||
return false
|
||||
}
|
||||
var msg string
|
||||
if err != nil {
|
||||
msg = err.Error()
|
||||
}
|
||||
if len(output) > 0 {
|
||||
msg += "\n" + string(output)
|
||||
}
|
||||
msg = strings.ToLower(msg)
|
||||
return strings.Contains(msg, "resource temporarily unavailable") ||
|
||||
strings.Contains(msg, "too many open files") ||
|
||||
strings.Contains(msg, "too many processes") ||
|
||||
strings.Contains(msg, "cannot allocate memory")
|
||||
}
|
||||
|
||||
func (p *prog) scheduleDNSAfterVPNSettleRefresh(reason string, delay time.Duration) {
|
||||
time.AfterFunc(delay, func() {
|
||||
if p.dnsInterceptState == nil {
|
||||
@@ -1470,8 +1484,19 @@ func (p *prog) scheduleDNSAfterVPNSettleRefresh(reason string, delay time.Durati
|
||||
//
|
||||
// Two delays (2s and 4s) cover both fast and slow VPN teardowns.
|
||||
func (p *prog) scheduleDelayedRechecks() {
|
||||
p.pfDelayedRecheckMu.Lock()
|
||||
defer p.pfDelayedRecheckMu.Unlock()
|
||||
|
||||
for _, timer := range p.pfDelayedRecheckTimers {
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
}
|
||||
p.pfDelayedRecheckTimers = p.pfDelayedRecheckTimers[:0]
|
||||
|
||||
for _, delay := range []time.Duration{pfAnchorRecheckDelay, pfAnchorRecheckDelayLong} {
|
||||
time.AfterFunc(delay, func() {
|
||||
delay := delay
|
||||
timer := time.AfterFunc(delay, func() {
|
||||
if p.dnsInterceptState == nil || p.pfStabilizing.Load() {
|
||||
return
|
||||
}
|
||||
@@ -1479,12 +1504,14 @@ func (p *prog) scheduleDelayedRechecks() {
|
||||
p.checkTunnelInterfaceChanges()
|
||||
// Refresh OS resolver — VPN may have finished DNS cleanup since the
|
||||
// immediate handler ran. This clears stale LAN nameservers (e.g.,
|
||||
// a VPN's DNS IP (e.g., 10.255.255.3) lingering in scutil --dns).
|
||||
ctrld.InitializeOsResolver(true)
|
||||
// Windscribe's 10.255.255.3 lingering in scutil --dns).
|
||||
ctx := ctrld.LoggerCtx(context.Background(), p.logger.Load())
|
||||
ctrld.InitializeOsResolver(ctx, true)
|
||||
if p.vpnDNS != nil {
|
||||
p.vpnDNS.Refresh(true)
|
||||
p.vpnDNS.Refresh(ctx, true)
|
||||
}
|
||||
})
|
||||
p.pfDelayedRecheckTimers = append(p.pfDelayedRecheckTimers, timer)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1511,13 +1538,11 @@ func (p *prog) pfWatchdog() {
|
||||
restored := p.ensurePFAnchorActive()
|
||||
if !restored {
|
||||
// Rules are intact in text form — also probe actual interception.
|
||||
// This catches cases where rules survive but pf's internal translation
|
||||
// state is corrupted (e.g., after a hypervisor reloads pf.conf).
|
||||
if !p.pfStabilizing.Load() && !p.pfMonitorRunning.Load() {
|
||||
if !p.probePFIntercept() {
|
||||
mainLog.Load().Warn().Msg("DNS intercept watchdog: rules intact but probe FAILED — forcing full reload")
|
||||
p.forceReloadPFMainRuleset()
|
||||
restored = true // treat as a restore for logging
|
||||
restored = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1546,9 +1571,9 @@ func (p *prog) pfWatchdog() {
|
||||
}
|
||||
}
|
||||
|
||||
// exemptVPNDNSServers rebuilds the pf anchor rules to exclude VPN DNS interfaces
|
||||
// and server IPs from interception. VPN DNS handlers using Network Extensions
|
||||
// (e.g., Tailscale MagicDNS) need DNS traffic to flow without any pf interference.
|
||||
// exemptVPNDNSServers updates the pf anchor rules with interface-scoped exemptions
|
||||
// for VPN DNS servers, allowing VPN local DNS handlers (e.g., Tailscale MagicDNS
|
||||
// via Network Extension) to receive DNS queries from all processes on their interface.
|
||||
//
|
||||
// Called by vpnDNSManager.Refresh() whenever VPN DNS servers change.
|
||||
func (p *prog) exemptVPNDNSServers(exemptions []vpnDNSExemption) error {
|
||||
@@ -1567,9 +1592,7 @@ func (p *prog) exemptVPNDNSServers(exemptions []vpnDNSExemption) error {
|
||||
return fmt.Errorf("dns intercept: failed to reload pf anchor: %w (output: %s)", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
// Flush pf states after anchor reload so packets are re-evaluated against new rules.
|
||||
// Stale state entries from previous rules would keep routing packets via route-to
|
||||
// even after VPN DNS interfaces/IPs are excluded from interception.
|
||||
// Flush stale pf states so packets are re-evaluated against new rules.
|
||||
flushPFStates()
|
||||
|
||||
// Ensure the anchor reference still exists in the main ruleset.
|
||||
@@ -1578,15 +1601,8 @@ func (p *prog) exemptVPNDNSServers(exemptions []vpnDNSExemption) error {
|
||||
mainLog.Load().Warn().Err(err).Msg("DNS intercept: failed to verify anchor reference during VPN DNS update")
|
||||
}
|
||||
|
||||
// Count unique excluded interfaces for logging.
|
||||
excludedIfaces := make(map[string]bool)
|
||||
for _, ex := range exemptions {
|
||||
if ex.Interface != "" {
|
||||
excludedIfaces[ex.Interface] = true
|
||||
}
|
||||
}
|
||||
mainLog.Load().Info().Msgf("DNS intercept: updated pf rules — %d VPN DNS servers (%d interfaces excluded from intercept), %d OS resolver servers",
|
||||
len(exemptions), len(excludedIfaces), len(ctrld.OsResolverNameservers()))
|
||||
mainLog.Load().Info().Msgf("DNS intercept: updated pf rules — exempted %d VPN DNS + %d OS resolver servers",
|
||||
len(exemptions), len(ctrld.OsResolverNameservers()))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -122,6 +123,35 @@ func TestPFBuildAnchorRules_Ordering(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPFBuildAnchorRules_FallbackPort verifies that when the listener falls back
|
||||
// to an alternate local port (e.g. 5354 because mDNSResponder owns *:53), the pf
|
||||
// rdr rules redirect DNS to the ACTUAL bound port, not the configured default 53.
|
||||
// Regression test for #551: pf redirected to a dead port after listener fallback.
|
||||
func TestPFBuildAnchorRules_FallbackPort(t *testing.T) {
|
||||
// Configured/generated listener is 127.0.0.1:53, but the runtime bound port is 5354.
|
||||
p := &prog{cfg: &ctrld.Config{Listener: map[string]*ctrld.ListenerConfig{"0": {IP: "127.0.0.1", Port: 5354}}}}
|
||||
rules := p.buildPFAnchorRules(nil)
|
||||
|
||||
// rdr must redirect to the actual bound port 5354.
|
||||
if !strings.Contains(rules, "rdr on lo0 inet proto udp from any to ! 127.0.0.1 port 53 -> 127.0.0.1 port 5354") {
|
||||
t.Errorf("UDP rdr must redirect to bound port 5354, got:\n%s", rules)
|
||||
}
|
||||
if !strings.Contains(rules, "rdr on lo0 inet proto tcp from any to ! 127.0.0.1 port 53 -> 127.0.0.1 port 5354") {
|
||||
t.Errorf("TCP rdr must redirect to bound port 5354, got:\n%s", rules)
|
||||
}
|
||||
|
||||
// The rdr redirect target must NOT point at the dead default port 53.
|
||||
// Match the exact port at line end so "port 5354" is not a false positive.
|
||||
if strings.Contains(rules, "-> 127.0.0.1 port 53\n") {
|
||||
t.Errorf("rdr must not redirect to dead port 53 after fallback, got:\n%s", rules)
|
||||
}
|
||||
|
||||
// The inbound accept rule must also target the actual bound port.
|
||||
if !strings.Contains(rules, "127.0.0.1 port 5354") {
|
||||
t.Errorf("pass in rule must reference bound port 5354, got:\n%s", rules)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPFAddressFamily tests the pfAddressFamily helper.
|
||||
func TestPFAddressFamily(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -141,3 +171,47 @@ func TestPFAddressFamily(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsResourceExhaustion(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
output []byte
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "exec start failure",
|
||||
err: errors.New("fork/exec /sbin/pfctl: resource temporarily unavailable"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "fd exhaustion from stderr output",
|
||||
err: errors.New("exit status 1"),
|
||||
output: []byte("pfctl: Pipe: Too many open files"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "process exhaustion from wrapped restore error",
|
||||
err: errors.New("failed to dump running filter rules: exit status 1 (output: too many processes)"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "ordinary pf syntax failure",
|
||||
err: errors.New("exit status 1"),
|
||||
output: []byte("pfctl: syntax error"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "nil error and empty output",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isResourceExhaustion(tt.err, tt.output); got != tt.want {
|
||||
t.Fatalf("isResourceExhaustion() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
package cli
|
||||
|
||||
import "github.com/Control-D-Inc/ctrld"
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
)
|
||||
|
||||
var initializeOsResolver = ctrld.InitializeOsResolver
|
||||
|
||||
func (p *prog) refreshDNSAfterVPNSettle(reason string) (routes, domainlessServers, exemptions int) {
|
||||
mainLog.Load().Info().Msgf("DNS intercept: refreshing OS/VPN DNS route state after VPN settle (%s)", reason)
|
||||
ns := initializeOsResolver(true)
|
||||
ctx := ctrld.LoggerCtx(context.Background(), mainLog.Load())
|
||||
ns := initializeOsResolver(ctx, true)
|
||||
mainLog.Load().Debug().Msgf("DNS intercept: post-settle OS resolver nameservers: %v", ns)
|
||||
|
||||
if p.vpnDNS == nil {
|
||||
|
||||
@@ -12,14 +12,14 @@ func TestRefreshDNSAfterVPNSettleRefreshesOSResolverAndVPNRoutes(t *testing.T) {
|
||||
defer func() { initializeOsResolver = oldInitialize }()
|
||||
|
||||
var initialized []bool
|
||||
initializeOsResolver = func(force bool) []string {
|
||||
initializeOsResolver = func(ctx context.Context, force bool) []string {
|
||||
initialized = append(initialized, force)
|
||||
return []string{"10.102.26.10:53"}
|
||||
}
|
||||
|
||||
var exemptionUpdates [][]vpnDNSExemption
|
||||
p := &prog{}
|
||||
p.vpnDNS = newVPNDNSManager(func(exemptions []vpnDNSExemption) error {
|
||||
p.vpnDNS = newVPNDNSManager(&mainLog, func(exemptions []vpnDNSExemption) error {
|
||||
exemptionUpdates = append(exemptionUpdates, append([]vpnDNSExemption{}, exemptions...))
|
||||
return nil
|
||||
})
|
||||
|
||||
+207
-148
@@ -112,6 +112,7 @@ const (
|
||||
fwpUint32 uint32 = 3 // FWP_UINT32
|
||||
fwpByteArray16Type uint32 = 11 // FWP_BYTE_ARRAY16_TYPE
|
||||
fwpV4AddrMask uint32 = 0x100 // FWP_V4_ADDR_MASK (after FWP_SINGLE_DATA_TYPE_MAX=0xff)
|
||||
fwpV6AddrMask uint32 = 0x101 // FWP_V6_ADDR_MASK
|
||||
|
||||
// IP protocol numbers.
|
||||
ipprotoUDP uint8 = 17
|
||||
@@ -228,6 +229,12 @@ type fwpV4AddrAndMask struct {
|
||||
mask uint32
|
||||
}
|
||||
|
||||
// fwpV6AddrAndMask represents FWP_V6_ADDR_AND_MASK for IPv6 subnet matching.
|
||||
type fwpV6AddrAndMask struct {
|
||||
addr [16]byte
|
||||
prefixLength uint8
|
||||
}
|
||||
|
||||
// fwpmAction0 represents FWPM_ACTION0 for specifying what happens on match.
|
||||
// Size: 20 bytes (uint32 + GUID). No padding needed — GUID has 4-byte alignment.
|
||||
type fwpmAction0 struct {
|
||||
@@ -240,6 +247,9 @@ type fwpmAction0 struct {
|
||||
// All filter IDs are stored so we can remove them individually without
|
||||
// needing to enumerate the sublayer's filters via WFP API.
|
||||
//
|
||||
// In "dns" mode, engineHandle is 0 (no WFP filters) and only NRPT is active.
|
||||
// In "hard" mode, both NRPT and WFP filters are active.
|
||||
//
|
||||
// The engine handle is opened once at startup and kept for the lifetime
|
||||
// of the ctrld process. Filter additions/removals happen through this handle.
|
||||
type wfpState struct {
|
||||
@@ -278,6 +288,9 @@ type wfpState struct {
|
||||
loopbackProtectActive bool
|
||||
// loopbackPermitIDs stores the filter IDs for the loopback protect permits.
|
||||
loopbackPermitIDs []uint64
|
||||
// nrptRecoveryLimiter prevents repeated Windows policy/Dnscache signaling
|
||||
// when another agent keeps putting NRPT back into a broken state.
|
||||
nrptRecoveryLimiter nrptRecoveryLimiter
|
||||
}
|
||||
|
||||
// Lazy-loaded WFP DLL procedures.
|
||||
@@ -343,9 +356,10 @@ const (
|
||||
// - GP path: SOFTWARE\Policies\...\DnsPolicyConfig (Group Policy)
|
||||
// - Local path: SYSTEM\CurrentControlSet\...\DnsPolicyConfig (service store)
|
||||
//
|
||||
// If ANY rules exist in the GP path (from IT policy, VPN, MDM, etc.), DNS Client
|
||||
// enters "GP mode" and ignores ALL local-path rules entirely. Conversely, if the
|
||||
// GP path is empty/absent, DNS Client reads from the local path only.
|
||||
// If the GP path contains real rules (from IT policy, VPN, MDM, etc.), DNS
|
||||
// Client enters "GP mode" and ignores ALL local-path rules entirely. An empty GP
|
||||
// parent key is worse: it still puts DNS Client in GP mode, but contributes no
|
||||
// usable rule, so our local catch-all is hidden until that empty parent is gone.
|
||||
//
|
||||
// Strategy (matching Tailscale's approach):
|
||||
// - Always write to the local path (baseline for non-domain machines).
|
||||
@@ -395,18 +409,20 @@ func otherGPRulesExist() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// cleanGPPath removes our CtrldCatchAll rule from the GP path and deletes
|
||||
// the GP DnsPolicyConfig parent key if no other rules remain. Removing the
|
||||
// empty GP key is critical: its mere existence forces DNS Client into "GP mode"
|
||||
// where local-path rules are ignored.
|
||||
func cleanGPPath() {
|
||||
// cleanGPPath removes only ctrld's GP-path rule and deletes the GP parent when
|
||||
// no rules remain. The return value tells callers whether the parent key was
|
||||
// actually deleted, which means DNS Client should be signaled once.
|
||||
//
|
||||
// Do not leave an empty GP parent behind: Windows treats the parent key itself
|
||||
// as the policy store boundary, so an empty key can still hide local-path rules.
|
||||
func cleanGPPath() bool {
|
||||
// Delete our specific rule.
|
||||
registry.DeleteKey(registry.LOCAL_MACHINE, nrptBaseKey+`\`+nrptRuleName)
|
||||
|
||||
// If the GP parent key is now empty, delete it entirely to exit "GP mode".
|
||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE, nrptBaseKey, registry.ENUMERATE_SUB_KEYS)
|
||||
if err != nil {
|
||||
return // Key doesn't exist — clean state.
|
||||
return false // Key doesn't exist — clean state.
|
||||
}
|
||||
names, err := k.ReadSubKeyNames(-1)
|
||||
k.Close()
|
||||
@@ -414,12 +430,14 @@ func cleanGPPath() {
|
||||
if len(names) > 0 {
|
||||
mainLog.Load().Debug().Strs("remaining", names).Msg("DNS intercept: GP path has other rules, leaving parent key")
|
||||
}
|
||||
return
|
||||
return false
|
||||
}
|
||||
// Empty — delete it to exit "GP mode".
|
||||
if err := registry.DeleteKey(registry.LOCAL_MACHINE, nrptBaseKey); err == nil {
|
||||
mainLog.Load().Info().Msg("DNS intercept: deleted empty GP DnsPolicyConfig key (exits GP mode)")
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// writeNRPTRule writes a single NRPT catch-all rule at the given registry keyPath.
|
||||
@@ -527,8 +545,6 @@ func refreshNRPTPolicy() {
|
||||
exec.Command("gpupdate", "/target:computer", "/force").Run()
|
||||
return
|
||||
}
|
||||
// RefreshPolicyEx(BOOL bMachine, DWORD dwOptions)
|
||||
// bMachine=1 (TRUE) = refresh computer policy, dwOptions=1 (RP_FORCE) = force refresh
|
||||
ret, _, _ := procRefreshPolicyEx.Call(1, 1)
|
||||
if ret != 0 {
|
||||
mainLog.Load().Debug().Msg("DNS intercept: triggered machine GP refresh via RefreshPolicyEx")
|
||||
@@ -540,12 +556,12 @@ func refreshNRPTPolicy() {
|
||||
|
||||
// flushDNSCache flushes the Windows DNS Client resolver cache and triggers a
|
||||
// Group Policy refresh so NRPT changes take effect immediately.
|
||||
// Uses DnsFlushResolverCache from dnsapi.dll + RefreshPolicyEx from userenv.dll.
|
||||
func flushDNSCache() {
|
||||
// Step 1: Refresh GP so DNS Client loads the new NRPT rules from registry.
|
||||
refreshNRPTPolicy()
|
||||
flushDNSCacheOnly()
|
||||
}
|
||||
|
||||
// Step 2: Flush the DNS cache so stale entries from pre-NRPT resolution are cleared.
|
||||
func flushDNSCacheOnly() {
|
||||
if err := dnsapiDLL.Load(); err == nil {
|
||||
if err := procDnsFlushResolverCache.Find(); err == nil {
|
||||
ret, _, _ := procDnsFlushResolverCache.Call()
|
||||
@@ -555,7 +571,6 @@ func flushDNSCache() {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: use ipconfig /flushdns.
|
||||
if out, err := exec.Command("ipconfig", "/flushdns").CombinedOutput(); err != nil {
|
||||
mainLog.Load().Debug().Msgf("DNS intercept: ipconfig /flushdns failed: %v: %s", err, string(out))
|
||||
} else {
|
||||
@@ -563,6 +578,93 @@ func flushDNSCache() {
|
||||
}
|
||||
}
|
||||
|
||||
func signalNRPTChange() {
|
||||
refreshNRPTPolicy()
|
||||
sendParamChange()
|
||||
flushDNSCacheOnly()
|
||||
}
|
||||
|
||||
// sendParamChange sends SERVICE_CONTROL_PARAMCHANGE to the DNS Client (Dnscache)
|
||||
// service, signaling it to re-read its configuration including NRPT rules from
|
||||
// the registry. This is the standard mechanism used by FortiClient, Tailscale,
|
||||
// and other DNS-aware software — it's reliable and non-disruptive unlike
|
||||
// restarting the Dnscache service (which always fails on modern Windows because
|
||||
// Dnscache is a protected shared svchost service).
|
||||
func sendParamChange() {
|
||||
if out, err := exec.Command("sc", "control", "dnscache", "paramchange").CombinedOutput(); err != nil {
|
||||
mainLog.Load().Debug().Err(err).Str("output", string(out)).Msg("DNS intercept: sc control dnscache paramchange failed")
|
||||
} else {
|
||||
mainLog.Load().Debug().Msg("DNS intercept: sent paramchange to Dnscache service")
|
||||
}
|
||||
}
|
||||
|
||||
// cleanEmptyNRPTParent removes empty NRPT parent keys that block activation.
|
||||
// Empty GP and local parents have different failure shapes:
|
||||
// - empty GP parent: DNS Client is in GP mode and ignores local-path rules;
|
||||
// - empty local parent: DNS Client can cache an empty local policy store.
|
||||
//
|
||||
// This helper only changes registry state. The caller sends the single
|
||||
// RefreshPolicyEx/paramchange/flush signal after it knows cleanup occurred.
|
||||
//
|
||||
// Returns true if cleanup was performed (caller should signal DNS Client).
|
||||
func cleanEmptyNRPTParent() bool {
|
||||
// Always clean the GP path — its existence blocks local path activation.
|
||||
cleaned := cleanGPPath()
|
||||
|
||||
// Clean empty local/direct path parent key.
|
||||
if !nrptParentKeyEmpty(nrptDirectKey) {
|
||||
return cleaned
|
||||
}
|
||||
|
||||
mainLog.Load().Warn().Msg("DNS intercept: found empty NRPT local parent key (blocks activation) — removing")
|
||||
if err := registry.DeleteKey(registry.LOCAL_MACHINE, nrptDirectKey); err != nil {
|
||||
mainLog.Load().Warn().Err(err).Msg("DNS intercept: failed to delete empty NRPT local parent key")
|
||||
return cleaned
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func nrptParentKeyEmpty(keyPath string) bool {
|
||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE, keyPath, registry.ENUMERATE_SUB_KEYS)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
names, err := k.ReadSubKeyNames(-1)
|
||||
k.Close()
|
||||
return err == nil && len(names) == 0
|
||||
}
|
||||
|
||||
// logNRPTParentKeyState logs the state of both NRPT registry paths for diagnostics.
|
||||
func logNRPTParentKeyState(context string) {
|
||||
for _, path := range []struct {
|
||||
name string
|
||||
key string
|
||||
}{
|
||||
{"GP", nrptBaseKey},
|
||||
{"local", nrptDirectKey},
|
||||
} {
|
||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE, path.key, registry.ENUMERATE_SUB_KEYS)
|
||||
if err != nil {
|
||||
mainLog.Load().Debug().Str("context", context).Str("path", path.name).
|
||||
Msg("DNS intercept: NRPT parent key does not exist")
|
||||
continue
|
||||
}
|
||||
names, err := k.ReadSubKeyNames(-1)
|
||||
k.Close()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if len(names) == 0 {
|
||||
mainLog.Load().Warn().Str("context", context).Str("path", path.name).
|
||||
Msg("DNS intercept: NRPT parent key exists but is EMPTY — blocks activation")
|
||||
} else {
|
||||
mainLog.Load().Debug().Str("context", context).Str("path", path.name).
|
||||
Int("subkeys", len(names)).Strs("names", names).
|
||||
Msg("DNS intercept: NRPT parent key state")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// startDNSIntercept activates WFP-based DNS interception on Windows.
|
||||
// It creates a WFP sublayer and adds filters that block all outbound DNS (port 53)
|
||||
// traffic except to localhost (127.0.0.1/::1), ensuring all DNS queries must go
|
||||
@@ -596,23 +698,23 @@ func (p *prog) startDNSIntercept() error {
|
||||
// Step 1: Add NRPT catch-all rule (both dns and hard modes).
|
||||
// NRPT must succeed before proceeding with WFP in hard mode.
|
||||
mainLog.Load().Info().Msgf("DNS intercept: initializing (mode: %s)", interceptMode)
|
||||
|
||||
logNRPTParentKeyState("pre-write")
|
||||
|
||||
// Two-phase empty parent key recovery: if the GP DnsPolicyConfig key exists
|
||||
// but is empty, DNS Client has cached a "no rules" state and won't accept
|
||||
// new rules even after they're written. Delete the empty key and signal DNS
|
||||
// Client to reset before writing our rule.
|
||||
// Two-phase recovery handles its own 2s signaling burst internally.
|
||||
cleanEmptyNRPTParent()
|
||||
// Empty parent key recovery: if the GP DnsPolicyConfig key exists but is
|
||||
// empty, DNS Client enters GP mode and hides local rules. Delete empty
|
||||
// parents first, then send one change signal so DNS Client drops stale state.
|
||||
if cleanEmptyNRPTParent() {
|
||||
signalNRPTChange()
|
||||
}
|
||||
|
||||
if err := addNRPTCatchAllRule(listenerIP); err != nil {
|
||||
return fmt.Errorf("dns intercept: failed to add NRPT catch-all rule: %w", err)
|
||||
}
|
||||
logNRPTParentKeyState("post-write")
|
||||
|
||||
state.nrptActive = true
|
||||
refreshNRPTPolicy()
|
||||
sendParamChange()
|
||||
flushDNSCache()
|
||||
signalNRPTChange()
|
||||
mainLog.Load().Info().Msgf("DNS intercept: NRPT catch-all rule active — all DNS queries directed to %s", listenerIP)
|
||||
|
||||
// Step 2: In hard mode, also set up WFP filters to block non-local DNS.
|
||||
@@ -762,13 +864,13 @@ func (p *prog) startWFPFilters(state *wfpState) error {
|
||||
// to work without dynamic per-server exemptions.
|
||||
privateRanges := []struct {
|
||||
name string
|
||||
addr uint32 // host byte order
|
||||
mask uint32 // host byte order
|
||||
addr uint32
|
||||
mask uint32
|
||||
}{
|
||||
{"10.0.0.0/8", 0x0A000000, 0xFF000000},
|
||||
{"172.16.0.0/12", 0xAC100000, 0xFFF00000},
|
||||
{"192.168.0.0/16", 0xC0A80000, 0xFFFF0000},
|
||||
{"100.64.0.0/10", 0x64400000, 0xFFC00000}, // CGNAT (includes Tailscale)
|
||||
{"100.64.0.0/10", 0x64400000, 0xFFC00000},
|
||||
}
|
||||
for _, r := range privateRanges {
|
||||
for _, proto := range []struct {
|
||||
@@ -990,7 +1092,6 @@ func wfpSublayerExists(engineHandle uintptr) bool {
|
||||
if r1 != 0 {
|
||||
return false
|
||||
}
|
||||
// Free the returned sublayer struct.
|
||||
if sublayerPtr != 0 {
|
||||
procFwpmFreeMemory0.Call(uintptr(unsafe.Pointer(&sublayerPtr)))
|
||||
}
|
||||
@@ -1088,8 +1189,6 @@ func (p *prog) cleanupWFPFilters(state *wfpState) {
|
||||
// installs WFP block filters via block-outside-dns. The hard permit (with
|
||||
// FWPM_FILTER_FLAG_CLEAR_ACTION_RIGHT) in a max-weight sublayer overrides the
|
||||
// third-party blocks without affecting their protection for non-loopback DNS.
|
||||
//
|
||||
// See: https://gitlab.int.windscribe.com/controld/clients/ctrld/-/issues/526
|
||||
func (p *prog) activateLoopbackWFPProtect(state *wfpState) error {
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
@@ -1294,9 +1393,12 @@ func (p *prog) stopDNSIntercept() error {
|
||||
//
|
||||
// The function is idempotent: it first removes ALL existing VPN permit filters,
|
||||
// then adds new ones for the current server list. When called with nil/empty
|
||||
// servers (VPN disconnected), it just removes the old permits — leaving only
|
||||
// exemptions (VPN disconnected), it just removes the old permits — leaving only
|
||||
// the localhost permits and block-all filters active.
|
||||
//
|
||||
// On Windows, WFP filters are process-scoped (not interface-scoped like macOS pf),
|
||||
// so we only use the server IPs from the exemptions.
|
||||
//
|
||||
// Supports both IPv4 and IPv6 VPN DNS servers.
|
||||
//
|
||||
// Called by vpnDNSManager.onServersChanged() whenever VPN DNS servers change.
|
||||
@@ -1323,7 +1425,7 @@ func (p *prog) exemptVPNDNSServers(exemptions []vpnDNSExemption) error {
|
||||
}
|
||||
state.vpnPermitFilterIDs = nil
|
||||
|
||||
// Extract unique server IPs (WFP doesn't need interface info).
|
||||
// Extract unique server IPs from exemptions (WFP doesn't need interface info).
|
||||
seen := make(map[string]bool)
|
||||
var servers []string
|
||||
for _, ex := range exemptions {
|
||||
@@ -1541,9 +1643,10 @@ func (p *prog) scheduleDelayedRechecks() {
|
||||
}
|
||||
// Refresh OS resolver — VPN may have finished DNS cleanup since the
|
||||
// immediate handler ran.
|
||||
ctrld.InitializeOsResolver(true)
|
||||
ctx := ctrld.LoggerCtx(context.Background(), p.logger.Load())
|
||||
ctrld.InitializeOsResolver(ctx, true)
|
||||
if p.vpnDNS != nil {
|
||||
p.vpnDNS.Refresh(true)
|
||||
p.vpnDNS.Refresh(ctx, true)
|
||||
}
|
||||
|
||||
// NRPT watchdog: some VPN software clears NRPT policy rules on
|
||||
@@ -1555,17 +1658,14 @@ func (p *prog) scheduleDelayedRechecks() {
|
||||
mainLog.Load().Error().Err(err).Msg("DNS intercept: failed to re-add NRPT catch-all rule")
|
||||
state.nrptActive = false
|
||||
} else {
|
||||
flushDNSCache()
|
||||
signalNRPTChange()
|
||||
mainLog.Load().Info().Msg("DNS intercept: NRPT catch-all rule restored")
|
||||
}
|
||||
}
|
||||
|
||||
// WFP watchdog: verify our sublayer still exists. If another program
|
||||
// or a crash removed it, the block filters are gone too.
|
||||
// WFP watchdog: verify our sublayer still exists.
|
||||
if ok && state.engineHandle != 0 && !wfpSublayerExists(state.engineHandle) {
|
||||
mainLog.Load().Warn().Msg("DNS intercept: WFP sublayer was removed externally — re-creating all filters")
|
||||
// Full teardown + re-init. stopDNSIntercept clears state,
|
||||
// then startDNSIntercept creates everything fresh.
|
||||
_ = p.stopDNSIntercept()
|
||||
if err := p.startDNSIntercept(); err != nil {
|
||||
mainLog.Load().Error().Err(err).Msg("DNS intercept: failed to re-create WFP filters")
|
||||
@@ -1590,17 +1690,24 @@ func (p *prog) nrptHealthMonitor(state *wfpState) {
|
||||
if !state.nrptActive {
|
||||
continue
|
||||
}
|
||||
|
||||
// Step 1: Check registry key exists.
|
||||
if !nrptCatchAllRuleExists() {
|
||||
now := time.Now()
|
||||
if ok, wait := state.nrptRecoveryLimiter.allow(now, p.cfg); !ok {
|
||||
if state.nrptRecoveryLimiter.shouldLogSkip(now) {
|
||||
mainLog.Load().Warn().Str("remaining", wait.String()).
|
||||
Msg("DNS intercept: NRPT rule restore suppressed after repeated recovery flows")
|
||||
}
|
||||
continue
|
||||
}
|
||||
mainLog.Load().Warn().Msg("DNS intercept: NRPT health check — catch-all rule missing, restoring")
|
||||
if err := addNRPTCatchAllRule(state.listenerIP); err != nil {
|
||||
mainLog.Load().Error().Err(err).Msg("DNS intercept: failed to restore NRPT catch-all rule")
|
||||
state.nrptActive = false
|
||||
continue
|
||||
}
|
||||
refreshNRPTPolicy()
|
||||
flushDNSCache()
|
||||
signalNRPTChange()
|
||||
state.nrptRecoveryLimiter.recordRecoveryFlow(time.Now(), p.cfg)
|
||||
mainLog.Load().Info().Msg("DNS intercept: NRPT catch-all rule restored by health monitor")
|
||||
// After restoring, verify it's actually working.
|
||||
go p.nrptProbeAndHeal()
|
||||
@@ -1612,6 +1719,8 @@ func (p *prog) nrptHealthMonitor(state *wfpState) {
|
||||
if !p.probeNRPT() {
|
||||
mainLog.Load().Warn().Msg("DNS intercept: NRPT health check — rule present but probe failed, running heal cycle")
|
||||
go p.nrptProbeAndHeal()
|
||||
} else {
|
||||
state.nrptRecoveryLimiter.recordStableSuccess()
|
||||
}
|
||||
|
||||
// Step 3: In hard mode, also verify WFP sublayer.
|
||||
@@ -1695,106 +1804,42 @@ func (p *prog) probeNRPT() bool {
|
||||
}
|
||||
}
|
||||
|
||||
// sendParamChange sends SERVICE_CONTROL_PARAMCHANGE to the DNS Client (Dnscache)
|
||||
// service, signaling it to re-read its configuration including NRPT rules from
|
||||
// the registry. This is the standard mechanism used by FortiClient, Tailscale,
|
||||
// and other DNS-aware software — it's reliable and non-disruptive unlike
|
||||
// restarting the Dnscache service (which always fails on modern Windows because
|
||||
// Dnscache is a protected shared svchost service).
|
||||
func sendParamChange() {
|
||||
if out, err := exec.Command("sc", "control", "dnscache", "paramchange").CombinedOutput(); err != nil {
|
||||
mainLog.Load().Debug().Err(err).Str("output", string(out)).Msg("DNS intercept: sc control dnscache paramchange failed")
|
||||
} else {
|
||||
mainLog.Load().Debug().Msg("DNS intercept: sent paramchange to Dnscache service")
|
||||
}
|
||||
}
|
||||
|
||||
// cleanEmptyNRPTParent removes empty NRPT parent keys that block activation.
|
||||
// An empty DnsPolicyConfig key (exists but no subkeys) causes DNS Client to
|
||||
// cache "no rules" and ignore subsequently-added rules.
|
||||
//
|
||||
// Also cleans the GP path entirely if it has no non-ctrld rules, since the GP
|
||||
// path's existence forces DNS Client into "GP mode" where it ignores the local
|
||||
// service store path.
|
||||
//
|
||||
// Returns true if cleanup was performed (caller should add a delay).
|
||||
func cleanEmptyNRPTParent() bool {
|
||||
cleaned := false
|
||||
|
||||
// Always clean the GP path — its existence blocks local path activation.
|
||||
cleanGPPath()
|
||||
|
||||
// Clean empty local/direct path parent key.
|
||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE, nrptDirectKey, registry.ENUMERATE_SUB_KEYS)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
names, err := k.ReadSubKeyNames(-1)
|
||||
k.Close()
|
||||
if err != nil || len(names) > 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
mainLog.Load().Warn().Msg("DNS intercept: found empty NRPT local parent key (blocks activation) — removing")
|
||||
if err := registry.DeleteKey(registry.LOCAL_MACHINE, nrptDirectKey); err != nil {
|
||||
mainLog.Load().Warn().Err(err).Msg("DNS intercept: failed to delete empty NRPT local parent key")
|
||||
return false
|
||||
}
|
||||
cleaned = true
|
||||
|
||||
// Signal DNS Client to process the deletion and reset its internal cache.
|
||||
mainLog.Load().Info().Msg("DNS intercept: empty NRPT parent key removed — signaling DNS Client")
|
||||
sendParamChange()
|
||||
flushDNSCache()
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// logNRPTParentKeyState logs the state of both NRPT registry paths for diagnostics.
|
||||
func logNRPTParentKeyState(context string) {
|
||||
for _, path := range []struct {
|
||||
name string
|
||||
key string
|
||||
}{
|
||||
{"GP", nrptBaseKey},
|
||||
{"local", nrptDirectKey},
|
||||
} {
|
||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE, path.key, registry.ENUMERATE_SUB_KEYS)
|
||||
if err != nil {
|
||||
mainLog.Load().Debug().Str("context", context).Str("path", path.name).
|
||||
Msg("DNS intercept: NRPT parent key does not exist")
|
||||
continue
|
||||
}
|
||||
names, err := k.ReadSubKeyNames(-1)
|
||||
k.Close()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if len(names) == 0 {
|
||||
mainLog.Load().Warn().Str("context", context).Str("path", path.name).
|
||||
Msg("DNS intercept: NRPT parent key exists but is EMPTY — blocks activation")
|
||||
} else {
|
||||
mainLog.Load().Debug().Str("context", context).Str("path", path.name).
|
||||
Int("subkeys", len(names)).Strs("names", names).
|
||||
Msg("DNS intercept: NRPT parent key state")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// nrptProbeAndHeal runs the NRPT probe with retries and escalating remediation.
|
||||
// Called asynchronously after startup and from the health monitor.
|
||||
//
|
||||
// Retry sequence (each attempt: GP refresh + paramchange + flush → sleep → probe):
|
||||
// 1. Immediate probe
|
||||
// 2. GP refresh + paramchange + flush → 1s → probe
|
||||
// 3. GP refresh + paramchange + flush → 2s → probe
|
||||
// 4. GP refresh + paramchange + flush → 4s → probe
|
||||
// Retry sequence:
|
||||
// 1. Immediate probe.
|
||||
// 2. If the GP parent is empty, clean it immediately, signal once, then probe.
|
||||
// This is intentionally before the normal retry loop: policy refresh and
|
||||
// Dnscache paramchange cannot make local rules visible while GP mode is
|
||||
// selected by an empty GP parent.
|
||||
// 3. Otherwise, signal DNS Client with increasing backoff between probes.
|
||||
func (p *prog) nrptProbeAndHeal() {
|
||||
state, _ := p.dnsInterceptState.(*wfpState)
|
||||
if state != nil {
|
||||
now := time.Now()
|
||||
if ok, wait := state.nrptRecoveryLimiter.allow(now, p.cfg); !ok {
|
||||
if state.nrptRecoveryLimiter.shouldLogSkip(now) {
|
||||
mainLog.Load().Warn().Str("remaining", wait.String()).
|
||||
Msg("DNS intercept: NRPT recovery suppressed after repeated failed recovery flows")
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if !nrptProbeRunning.CompareAndSwap(false, true) {
|
||||
mainLog.Load().Debug().Msg("DNS intercept: NRPT probe already running, skipping")
|
||||
return
|
||||
}
|
||||
defer nrptProbeRunning.Store(false)
|
||||
|
||||
remediated := false
|
||||
defer func() {
|
||||
if remediated && state != nil {
|
||||
state.nrptRecoveryLimiter.recordRecoveryFlow(time.Now(), p.cfg)
|
||||
}
|
||||
}()
|
||||
|
||||
mainLog.Load().Info().Msg("DNS intercept: starting NRPT verification probe sequence")
|
||||
|
||||
// Log parent key state for diagnostics.
|
||||
@@ -1805,17 +1850,37 @@ func (p *prog) nrptProbeAndHeal() {
|
||||
mainLog.Load().Info().Msg("DNS intercept: NRPT verified working")
|
||||
return
|
||||
}
|
||||
remediated = true
|
||||
|
||||
// Attempts 2-4: GP refresh + paramchange + flush with increasing backoff
|
||||
// If the GP parent exists but is empty, do not burn retries on Windows
|
||||
// signaling. Those retries create SIEM noise but cannot succeed because DNS
|
||||
// Client is still reading the empty GP store instead of the populated local
|
||||
// store. Delete the blocker, send one notification, then re-probe.
|
||||
if nrptParentKeyEmpty(nrptBaseKey) {
|
||||
mainLog.Load().Warn().Msg("DNS intercept: NRPT probe failed with empty GP parent — cleaning before retry signaling")
|
||||
if cleanEmptyNRPTParent() {
|
||||
signalNRPTChange()
|
||||
time.Sleep(1 * time.Second)
|
||||
logNRPTParentKeyState("empty-gp-after-clean")
|
||||
if p.probeNRPT() {
|
||||
mainLog.Load().Info().Msg("DNS intercept: NRPT verified working after empty GP parent cleanup")
|
||||
return
|
||||
}
|
||||
}
|
||||
if nrptParentKeyEmpty(nrptBaseKey) {
|
||||
mainLog.Load().Warn().Msg("DNS intercept: empty GP NRPT parent still present after cleanup; skipping redundant policy refresh retries")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Attempts 2-4: signal DNS Client with increasing backoff between probes.
|
||||
delays := []time.Duration{1 * time.Second, 2 * time.Second, 4 * time.Second}
|
||||
for i, delay := range delays {
|
||||
attempt := i + 2
|
||||
mainLog.Load().Info().Int("attempt", attempt).Dur("delay", delay).
|
||||
Msg("DNS intercept: NRPT probe failed, retrying with GP refresh + paramchange")
|
||||
mainLog.Load().Info().Int("attempt", attempt).Str("delay", delay.String()).
|
||||
Msg("DNS intercept: NRPT probe failed, retrying with policy refresh + paramchange")
|
||||
logNRPTParentKeyState(fmt.Sprintf("probe-attempt-%d", attempt))
|
||||
refreshNRPTPolicy()
|
||||
sendParamChange()
|
||||
flushDNSCache()
|
||||
signalNRPTChange()
|
||||
time.Sleep(delay)
|
||||
if p.probeNRPT() {
|
||||
mainLog.Load().Info().Int("attempt", attempt).
|
||||
@@ -1829,17 +1894,14 @@ func (p *prog) nrptProbeAndHeal() {
|
||||
// signal DNS Client to forget it, wait, then re-add and signal again.
|
||||
mainLog.Load().Warn().Msg("DNS intercept: all probes failed — attempting two-phase NRPT recovery (delete → signal → re-add)")
|
||||
listenerIP := "127.0.0.1"
|
||||
if state, ok := p.dnsInterceptState.(*wfpState); ok {
|
||||
if state != nil {
|
||||
listenerIP = state.listenerIP
|
||||
}
|
||||
|
||||
// Phase 1: Remove our rule and the parent key if now empty.
|
||||
_ = removeNRPTCatchAllRule()
|
||||
// If parent key is now empty after removing our rule, delete it too.
|
||||
cleanEmptyNRPTParent()
|
||||
refreshNRPTPolicy()
|
||||
sendParamChange()
|
||||
flushDNSCache()
|
||||
signalNRPTChange()
|
||||
logNRPTParentKeyState("nuclear-after-delete")
|
||||
|
||||
// Wait for DNS Client to process the deletion.
|
||||
@@ -1850,9 +1912,7 @@ func (p *prog) nrptProbeAndHeal() {
|
||||
mainLog.Load().Error().Err(err).Msg("DNS intercept: failed to re-add NRPT after nuclear recovery")
|
||||
return
|
||||
}
|
||||
refreshNRPTPolicy()
|
||||
sendParamChange()
|
||||
flushDNSCache()
|
||||
signalNRPTChange()
|
||||
logNRPTParentKeyState("nuclear-after-readd")
|
||||
|
||||
// Final probe after recovery.
|
||||
@@ -1870,7 +1930,6 @@ func (p *prog) nrptProbeAndHeal() {
|
||||
// installed WFP filters that block DNS to non-tunnel interfaces, including
|
||||
// loopback. A high-priority "hard permit" for localhost DNS overrides these
|
||||
// blocks and restores NRPT routing to ctrld's listener.
|
||||
// See: https://gitlab.int.windscribe.com/controld/clients/ctrld/-/issues/526
|
||||
loopbackState, ok := p.dnsInterceptState.(*wfpState)
|
||||
if !ok || loopbackState == nil {
|
||||
mainLog.Load().Error().Msg("DNS intercept: no state available for loopback WFP protect")
|
||||
|
||||
+1074
-707
File diff suppressed because it is too large
Load Diff
+388
-14
@@ -22,15 +22,15 @@ func Test_wildcardMatches(t *testing.T) {
|
||||
domain string
|
||||
match bool
|
||||
}{
|
||||
{"domain - prefix parent should not match", "*.example.com", "example.com", false},
|
||||
{"domain - prefix", "*.example.com", "anything.example.com", true},
|
||||
{"domain - prefix not match other s", "*.example.com", "other.org", false},
|
||||
{"domain - prefix not match s in name", "*.example.com", "eexample.com", false},
|
||||
{"domain - suffix", "suffix.*", "suffix.example.com", true},
|
||||
{"domain - suffix not match other", "suffix.*", "suffix1.example.com", false},
|
||||
{"domain - both", "suffix.*.example.com", "suffix.anything.example.com", true},
|
||||
{"domain - both not match", "suffix.*.example.com", "suffix1.suffix.example.com", false},
|
||||
{"domain - case-insensitive", "*.EXAMPLE.com", "anything.example.com", true},
|
||||
{"domain - prefix parent should not match", "*.windscribe.com", "windscribe.com", false},
|
||||
{"domain - prefix", "*.windscribe.com", "anything.windscribe.com", true},
|
||||
{"domain - prefix not match other s", "*.windscribe.com", "example.com", false},
|
||||
{"domain - prefix not match s in name", "*.windscribe.com", "wwindscribe.com", false},
|
||||
{"domain - suffix", "suffix.*", "suffix.windscribe.com", true},
|
||||
{"domain - suffix not match other", "suffix.*", "suffix1.windscribe.com", false},
|
||||
{"domain - both", "suffix.*.windscribe.com", "suffix.anything.windscribe.com", true},
|
||||
{"domain - both not match", "suffix.*.windscribe.com", "suffix1.suffix.windscribe.com", false},
|
||||
{"domain - case-insensitive", "*.WINDSCRIBE.com", "anything.windscribe.com", true},
|
||||
{"mac - prefix", "*:98:05:b4:2b", "d4:67:98:05:b4:2b", true},
|
||||
{"mac - prefix not match other s", "*:98:05:b4:2b", "0d:ba:54:09:94:2c", false},
|
||||
{"mac - prefix not match s in name", "*:98:05:b4:2b", "e4:67:97:05:b4:2b", false},
|
||||
@@ -57,9 +57,9 @@ func Test_canonicalName(t *testing.T) {
|
||||
domain string
|
||||
canonical string
|
||||
}{
|
||||
{"fqdn to canonical", "example.com.", "example.com"},
|
||||
{"already canonical", "example.com", "example.com"},
|
||||
{"case insensitive", "Example.Com.", "example.com"},
|
||||
{"fqdn to canonical", "windscribe.com.", "windscribe.com"},
|
||||
{"already canonical", "windscribe.com", "windscribe.com"},
|
||||
{"case insensitive", "Windscribe.Com.", "windscribe.com"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
@@ -77,7 +77,8 @@ func Test_prog_upstreamFor(t *testing.T) {
|
||||
cfg := testhelper.SampleConfig(t)
|
||||
cfg.Service.LeakOnUpstreamFailure = func(v bool) *bool { return &v }(false)
|
||||
p := &prog{cfg: cfg}
|
||||
p.um = newUpstreamMonitor(p.cfg)
|
||||
p.logger.Store(mainLog.Load())
|
||||
p.um = newUpstreamMonitor(p.cfg, mainLog.Load())
|
||||
p.lanLoopGuard = newLoopGuard()
|
||||
p.ptrLoopGuard = newLoopGuard()
|
||||
for _, nc := range p.cfg.Network {
|
||||
@@ -142,9 +143,94 @@ func Test_prog_upstreamFor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func Test_prog_upstreamForWithCustomMatching(t *testing.T) {
|
||||
cfg := testhelper.SampleConfig(t)
|
||||
prog := &prog{cfg: cfg}
|
||||
prog.logger.Store(mainLog.Load())
|
||||
for _, nc := range prog.cfg.Network {
|
||||
for _, cidr := range nc.Cidrs {
|
||||
_, ipNet, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nc.IPNets = append(nc.IPNets, ipNet)
|
||||
}
|
||||
}
|
||||
|
||||
// Create a custom policy with domain-first matching order
|
||||
customPolicy := &ctrld.ListenerPolicyConfig{
|
||||
Name: "Custom Policy",
|
||||
Networks: []ctrld.Rule{
|
||||
{"network.0": []string{"upstream.1", "upstream.0"}},
|
||||
},
|
||||
Macs: []ctrld.Rule{
|
||||
{"14:45:A0:67:83:0A": []string{"upstream.2"}},
|
||||
},
|
||||
Rules: []ctrld.Rule{
|
||||
{"*.ru": []string{"upstream.1"}},
|
||||
},
|
||||
Matching: &ctrld.MatchingConfig{
|
||||
Order: []string{"domain", "mac", "network"},
|
||||
},
|
||||
}
|
||||
|
||||
customListener := &ctrld.ListenerConfig{
|
||||
Policy: customPolicy,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ip string
|
||||
mac string
|
||||
domain string
|
||||
upstreams []string
|
||||
matched bool
|
||||
}{
|
||||
{
|
||||
name: "Domain rule should match first with custom order",
|
||||
ip: "192.168.0.1:0",
|
||||
mac: "14:45:A0:67:83:0A",
|
||||
domain: "example.ru",
|
||||
upstreams: []string{"upstream.1"},
|
||||
matched: true,
|
||||
},
|
||||
{
|
||||
name: "MAC rule should match when no domain rule",
|
||||
ip: "192.168.0.1:0",
|
||||
mac: "14:45:A0:67:83:0A",
|
||||
domain: "example.com",
|
||||
upstreams: []string{"upstream.2"},
|
||||
matched: true,
|
||||
},
|
||||
{
|
||||
name: "Network rule should match when no domain or MAC rule",
|
||||
ip: "192.168.0.1:0",
|
||||
mac: "00:11:22:33:44:55",
|
||||
domain: "example.com",
|
||||
upstreams: []string{"upstream.1", "upstream.0"},
|
||||
matched: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
addr, err := net.ResolveUDPAddr("udp", tc.ip)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, addr)
|
||||
|
||||
ctx := context.WithValue(context.Background(), ctrld.ReqIdCtxKey{}, requestID())
|
||||
ufr := prog.upstreamFor(ctx, "0", customListener, addr, tc.mac, tc.domain)
|
||||
|
||||
assert.Equal(t, tc.matched, ufr.matched)
|
||||
assert.Equal(t, tc.upstreams, ufr.upstreams)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache(t *testing.T) {
|
||||
cfg := testhelper.SampleConfig(t)
|
||||
prog := &prog{cfg: cfg}
|
||||
prog.logger.Store(mainLog.Load())
|
||||
for _, nc := range prog.cfg.Network {
|
||||
for _, cidr := range nc.Cidrs {
|
||||
_, ipNet, err := net.ParseCIDR(cidr)
|
||||
@@ -405,6 +491,8 @@ func Test_isPrivatePtrLookup(t *testing.T) {
|
||||
{"CGNAT", newDnsMsgPtr("100.66.27.28", t), true},
|
||||
{"Loopback", newDnsMsgPtr("127.0.0.1", t), true},
|
||||
{"Link Local Unicast", newDnsMsgPtr("fe80::69f6:e16e:8bdb:433f", t), true},
|
||||
// RFC 7335 IPv4 Service Continuity Prefix (464XLAT/DS-Lite CLAT), see #552.
|
||||
{"464XLAT CLAT host", newDnsMsgPtr("192.0.0.2", t), true},
|
||||
{"Public IP", newDnsMsgPtr("8.8.8.8", t), false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
@@ -452,6 +540,11 @@ func Test_isWanClient(t *testing.T) {
|
||||
{"CGNAT", &net.UDPAddr{IP: net.ParseIP("100.66.27.28")}, false},
|
||||
{"Loopback", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")}, false},
|
||||
{"Link Local Unicast", &net.UDPAddr{IP: net.ParseIP("fe80::69f6:e16e:8bdb:433f")}, false},
|
||||
// RFC 7335 IPv4 Service Continuity Prefix (464XLAT/DS-Lite CLAT), see #552.
|
||||
{"464XLAT PLAT side", &net.UDPAddr{IP: net.ParseIP("192.0.0.1")}, false},
|
||||
{"464XLAT CLAT host", &net.UDPAddr{IP: net.ParseIP("192.0.0.2")}, false},
|
||||
// Outside the /29 but inside 192.0.0.0/24: still WAN (fix is scoped to /29).
|
||||
{"192.0.0.0/24 outside /29", &net.UDPAddr{IP: net.ParseIP("192.0.0.100")}, true},
|
||||
{"Public", &net.UDPAddr{IP: net.ParseIP("8.8.8.8")}, true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
@@ -465,8 +558,251 @@ func Test_isWanClient(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func Test_shouldStartRecovery(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
reason RecoveryReason
|
||||
hasExistingRecovery bool
|
||||
expectedResult bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "network change with existing recovery",
|
||||
reason: RecoveryReasonNetworkChange,
|
||||
hasExistingRecovery: true,
|
||||
expectedResult: true,
|
||||
description: "should cancel existing recovery and start new one for network change",
|
||||
},
|
||||
{
|
||||
name: "network change without existing recovery",
|
||||
reason: RecoveryReasonNetworkChange,
|
||||
hasExistingRecovery: false,
|
||||
expectedResult: true,
|
||||
description: "should start new recovery for network change",
|
||||
},
|
||||
{
|
||||
name: "regular failure with existing recovery",
|
||||
reason: RecoveryReasonRegularFailure,
|
||||
hasExistingRecovery: true,
|
||||
expectedResult: false,
|
||||
description: "should skip duplicate recovery for regular failure",
|
||||
},
|
||||
{
|
||||
name: "regular failure without existing recovery",
|
||||
reason: RecoveryReasonRegularFailure,
|
||||
hasExistingRecovery: false,
|
||||
expectedResult: true,
|
||||
description: "should start new recovery for regular failure",
|
||||
},
|
||||
{
|
||||
name: "OS failure with existing recovery",
|
||||
reason: RecoveryReasonOSFailure,
|
||||
hasExistingRecovery: true,
|
||||
expectedResult: false,
|
||||
description: "should skip duplicate recovery for OS failure",
|
||||
},
|
||||
{
|
||||
name: "OS failure without existing recovery",
|
||||
reason: RecoveryReasonOSFailure,
|
||||
hasExistingRecovery: false,
|
||||
expectedResult: true,
|
||||
description: "should start new recovery for OS failure",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p := newTestProg(t)
|
||||
|
||||
// Setup existing recovery if needed
|
||||
if tc.hasExistingRecovery {
|
||||
p.recoveryCancelMu.Lock()
|
||||
p.recoveryCancel = func() {} // Mock cancel function
|
||||
p.recoveryCancelMu.Unlock()
|
||||
}
|
||||
|
||||
result := p.shouldStartRecovery(tc.reason)
|
||||
assert.Equal(t, tc.expectedResult, result, tc.description)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_createRecoveryContext(t *testing.T) {
|
||||
p := newTestProg(t)
|
||||
|
||||
ctx, cleanup := p.createRecoveryContext()
|
||||
|
||||
// Verify context is created
|
||||
assert.NotNil(t, ctx)
|
||||
assert.NotNil(t, cleanup)
|
||||
|
||||
// Verify recoveryCancel is set
|
||||
p.recoveryCancelMu.Lock()
|
||||
assert.NotNil(t, p.recoveryCancel)
|
||||
p.recoveryCancelMu.Unlock()
|
||||
|
||||
// Test cleanup function
|
||||
cleanup()
|
||||
|
||||
// Verify recoveryCancel is cleared
|
||||
p.recoveryCancelMu.Lock()
|
||||
assert.Nil(t, p.recoveryCancel)
|
||||
p.recoveryCancelMu.Unlock()
|
||||
}
|
||||
|
||||
func Test_prepareForRecovery(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
reason RecoveryReason
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "regular failure",
|
||||
reason: RecoveryReasonRegularFailure,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "network change",
|
||||
reason: RecoveryReasonNetworkChange,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "OS failure",
|
||||
reason: RecoveryReasonOSFailure,
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p := newTestProg(t)
|
||||
|
||||
err := p.prepareForRecovery(tc.reason)
|
||||
|
||||
if tc.wantErr {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
// Verify recoveryRunning is set to true
|
||||
assert.True(t, p.recoveryRunning.Load())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_completeRecovery(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
reason RecoveryReason
|
||||
recovered string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "regular failure recovery",
|
||||
reason: RecoveryReasonRegularFailure,
|
||||
recovered: "upstream1",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "network change recovery",
|
||||
reason: RecoveryReasonNetworkChange,
|
||||
recovered: "upstream2",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "OS failure recovery",
|
||||
reason: RecoveryReasonOSFailure,
|
||||
recovered: "upstream3",
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p := newTestProg(t)
|
||||
|
||||
err := p.completeRecovery(tc.reason, tc.recovered)
|
||||
|
||||
if tc.wantErr {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
// Verify recoveryRunning is set to false
|
||||
assert.False(t, p.recoveryRunning.Load())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_reinitializeOSResolver(t *testing.T) {
|
||||
p := newTestProg(t)
|
||||
|
||||
err := p.reinitializeOSResolver("Test message")
|
||||
|
||||
// This function should not return an error under normal circumstances
|
||||
// The actual behavior depends on the OS resolver implementation
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func Test_handleRecovery_Integration(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
reason RecoveryReason
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "network change recovery",
|
||||
reason: RecoveryReasonNetworkChange,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "regular failure recovery",
|
||||
reason: RecoveryReasonRegularFailure,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "OS failure recovery",
|
||||
reason: RecoveryReasonOSFailure,
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p := newTestProg(t)
|
||||
|
||||
// This is an integration test that exercises the full recovery flow
|
||||
// In a real test environment, you would mock the dependencies
|
||||
// For now, we're just testing that the method doesn't panic
|
||||
// and that the recovery logic flows correctly
|
||||
assert.NotPanics(t, func() {
|
||||
// Test only the preparation phase to avoid actual upstream checking
|
||||
if !p.shouldStartRecovery(tc.reason) {
|
||||
return
|
||||
}
|
||||
|
||||
_, cleanup := p.createRecoveryContext()
|
||||
defer cleanup()
|
||||
|
||||
if err := p.prepareForRecovery(tc.reason); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip the actual upstream recovery check for this test
|
||||
// as it requires properly configured upstreams
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_prog_queryFromSelf(t *testing.T) {
|
||||
p := &prog{}
|
||||
p := newTestProg(t)
|
||||
require.NotPanics(t, func() {
|
||||
p.queryFromSelf("")
|
||||
})
|
||||
@@ -474,3 +810,41 @@ func Test_prog_queryFromSelf(t *testing.T) {
|
||||
p.queryFromSelf("foo")
|
||||
})
|
||||
}
|
||||
|
||||
func Test_sameQuestion(t *testing.T) {
|
||||
mk := func(name string, qtype uint16) *dns.Msg {
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion(name, qtype)
|
||||
return m
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
req *dns.Msg
|
||||
answer *dns.Msg
|
||||
want bool
|
||||
}{
|
||||
{"identical", mk("example.com.", dns.TypeA), mk("example.com.", dns.TypeA), true},
|
||||
{"case insensitive", mk("Example.COM.", dns.TypeA), mk("example.com.", dns.TypeA), true},
|
||||
{"different name", mk("victim.example.", dns.TypeA), mk("attacker.example.", dns.TypeA), false},
|
||||
{"different type", mk("example.com.", dns.TypeA), mk("example.com.", dns.TypeAAAA), false},
|
||||
{"nil req", nil, mk("example.com.", dns.TypeA), false},
|
||||
{"nil answer", mk("example.com.", dns.TypeA), nil, false},
|
||||
{"empty answer question", mk("example.com.", dns.TypeA), new(dns.Msg), false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := sameQuestion(tc.req, tc.answer); got != tc.want {
|
||||
t.Errorf("sameQuestion() = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// newTestProg creates a properly initialized *prog for testing.
|
||||
func newTestProg(t *testing.T) *prog {
|
||||
p := &prog{cfg: testhelper.SampleConfig(t)}
|
||||
p.logger.Store(mainLog.Load())
|
||||
p.um = newUpstreamMonitor(p.cfg, mainLog.Load())
|
||||
return p
|
||||
}
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kardianos/service"
|
||||
"github.com/miekg/dns"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld/internal/firewall"
|
||||
)
|
||||
|
||||
// firewallModeEnabled reports whether firewall mode is active for this prog instance.
|
||||
func (p *prog) firewallModeEnabled() bool {
|
||||
return p.allowList != nil
|
||||
}
|
||||
|
||||
// initFirewallAllowList populates the permanent allowlist entries and starts the
|
||||
// background reaper. Called once during prog.run() when firewall_mode is "on".
|
||||
//
|
||||
// Permanent entries include:
|
||||
// - Loopback (127.0.0.0/8, ::1)
|
||||
// - RFC1918 private ranges (configurable — enabled by default)
|
||||
// - Link-local (169.254.0.0/16, fe80::/10)
|
||||
// - CGNAT range (100.64.0.0/10) — used by Tailscale, carrier NAT
|
||||
// - ctrld listener IPs
|
||||
// - DoH/DoT/DoQ upstream resolver IPs
|
||||
// - ControlD API endpoint IPs
|
||||
func (p *prog) initFirewallAllowList(ctx context.Context) {
|
||||
al := p.allowList
|
||||
|
||||
// Loopback.
|
||||
al.AddPermanentPrefix(netip.MustParsePrefix("127.0.0.0/8"))
|
||||
al.AddPermanent(netip.MustParseAddr("::1"))
|
||||
|
||||
// RFC1918 private ranges — needed for LAN access, printers, NAS, etc.
|
||||
al.AddPermanentPrefix(netip.MustParsePrefix("10.0.0.0/8"))
|
||||
al.AddPermanentPrefix(netip.MustParsePrefix("172.16.0.0/12"))
|
||||
al.AddPermanentPrefix(netip.MustParsePrefix("192.168.0.0/16"))
|
||||
|
||||
// Link-local.
|
||||
al.AddPermanentPrefix(netip.MustParsePrefix("169.254.0.0/16"))
|
||||
al.AddPermanentPrefix(netip.MustParsePrefix("fe80::/10"))
|
||||
|
||||
// CGNAT range — used by Tailscale (100.x.x.x), carrier-grade NAT, etc.
|
||||
al.AddPermanentPrefix(netip.MustParsePrefix("100.64.0.0/10"))
|
||||
|
||||
// Multicast.
|
||||
al.AddPermanentPrefix(netip.MustParsePrefix("224.0.0.0/4"))
|
||||
al.AddPermanentPrefix(netip.MustParsePrefix("ff00::/8"))
|
||||
|
||||
// ctrld listener IPs — traffic to ourselves must always be allowed.
|
||||
for _, lc := range p.cfg.Listener {
|
||||
if ip, err := netip.ParseAddr(lc.IP); err == nil {
|
||||
al.AddPermanent(ip)
|
||||
}
|
||||
}
|
||||
|
||||
// Upstream resolver IPs — ctrld needs to reach its upstreams.
|
||||
p.addUpstreamIPsToPermanent(al)
|
||||
|
||||
// Platform-specific enforcement (pf on macOS, WFP on Windows) is initialized
|
||||
// from postRun() after startDNSIntercept() has prepared dnsInterceptState.
|
||||
|
||||
p.Info().Msgf("Firewall allowlist initialized with %d permanent entries",
|
||||
al.Stats().PermanentIPs)
|
||||
}
|
||||
|
||||
// syncFirewallMode applies the current firewall_mode setting for this run.
|
||||
// Reloads create a new run-scoped context, so background firewall workers must
|
||||
// be restarted each run even when the allowlist object is reused.
|
||||
func (p *prog) syncFirewallMode(ctx context.Context) {
|
||||
if p.cfg.Service.FirewallMode != "on" {
|
||||
if p.allowList != nil || p.platformFirewallState != nil {
|
||||
p.Info().Msg("Firewall mode disabled: removing platform enforcement and clearing allowlist")
|
||||
}
|
||||
if p.allowList != nil {
|
||||
p.allowList.SetOnChange(nil)
|
||||
p.allowList.SetOnBatchChange(nil)
|
||||
p.allowList = nil
|
||||
}
|
||||
if p.platformFirewallState != nil {
|
||||
p.shutdownPlatformFirewall()
|
||||
p.platformFirewallState = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if p.allowList == nil {
|
||||
p.allowList = firewall.New()
|
||||
p.initFirewallAllowList(ctx)
|
||||
if service.Interactive() {
|
||||
p.Warn().Msg("Firewall mode has no effect in interactive mode; run ctrld as a service for enforcement")
|
||||
} else {
|
||||
p.Info().Msg("Firewall mode enabled: only DNS-resolved IPs will be allowed")
|
||||
}
|
||||
} else {
|
||||
p.addUpstreamIPsToPermanent(p.allowList)
|
||||
}
|
||||
|
||||
// The run context is canceled on each reload. Restart the reaper/stats
|
||||
// workers for this run so reused allowlists keep expiring entries.
|
||||
p.allowList.StartReaper(ctx)
|
||||
go p.logFirewallStats(ctx)
|
||||
|
||||
// On reload, postRun() is not called, so initialize platform enforcement here
|
||||
// if intercept state already exists. Initial startup still defers to postRun()
|
||||
// because DNS intercept state is prepared there.
|
||||
if p.dnsInterceptState != nil && p.platformFirewallState == nil {
|
||||
p.initPlatformFirewall()
|
||||
}
|
||||
}
|
||||
|
||||
// addUpstreamIPsToPermanent resolves upstream endpoint hostnames and adds their
|
||||
// IPs to the permanent allowlist. Called at startup and on config reload.
|
||||
func (p *prog) addUpstreamIPsToPermanent(al *firewall.AllowList) {
|
||||
for _, uc := range p.cfg.Upstream {
|
||||
if uc == nil || uc.Endpoint == "" {
|
||||
continue
|
||||
}
|
||||
// Extract host from the endpoint URL.
|
||||
host := extractHostFromEndpoint(uc.Endpoint)
|
||||
if host == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// If it's already an IP, add directly.
|
||||
if ip, err := netip.ParseAddr(host); err == nil {
|
||||
al.AddPermanent(ip)
|
||||
p.Debug().Msgf("Firewall: added upstream IP %s to permanent allowlist", ip)
|
||||
continue
|
||||
}
|
||||
|
||||
// Resolve hostname to IPs.
|
||||
ips, err := net.LookupHost(host)
|
||||
if err != nil {
|
||||
p.Warn().Err(err).Msgf("Firewall: could not resolve upstream host %s", host)
|
||||
continue
|
||||
}
|
||||
for _, ipStr := range ips {
|
||||
if ip, err := netip.ParseAddr(ipStr); err == nil {
|
||||
al.AddPermanent(ip)
|
||||
p.Debug().Msgf("Firewall: added upstream IP %s (%s) to permanent allowlist", ip, host)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// extractHostFromEndpoint extracts the hostname or IP from a DoH/DoT/DoQ endpoint URL.
|
||||
// Handles formats like:
|
||||
// - "https://dns.controld.com/abcdef"
|
||||
// - "tls://dns.controld.com"
|
||||
// - "quic://dns.controld.com:784"
|
||||
// - "1.2.3.4:53"
|
||||
// - "sdns://..." (DNS stamps — host is encoded inside, skip)
|
||||
func extractHostFromEndpoint(endpoint string) string {
|
||||
// DNS stamps encode the server info in base64 — we can't extract the host
|
||||
// without decoding. The upstream IPs will be resolved by the sdns upstream
|
||||
// initialization path at runtime.
|
||||
if strings.HasPrefix(endpoint, "sdns://") {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Try parsing as URL first (covers https://, tls://, quic://).
|
||||
if host := extractHostFromURL(endpoint); host != "" {
|
||||
return host
|
||||
}
|
||||
|
||||
// Try as host:port.
|
||||
host, _, err := net.SplitHostPort(endpoint)
|
||||
if err == nil {
|
||||
return host
|
||||
}
|
||||
|
||||
// Try as bare IP.
|
||||
if _, err := netip.ParseAddr(endpoint); err == nil {
|
||||
return endpoint
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractHostFromURL extracts the host from a URL string.
|
||||
func extractHostFromURL(s string) string {
|
||||
u, err := url.Parse(s)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return ""
|
||||
}
|
||||
return u.Hostname()
|
||||
}
|
||||
|
||||
// firewallRecordResolvedIPs extracts A and AAAA records from a DNS response
|
||||
// and adds them to the firewall allowlist. Called from postProcessStandardQuery()
|
||||
// after a successful DNS resolution.
|
||||
//
|
||||
// This is the primary feed for the allowlist — every IP that ctrld resolves
|
||||
// gets added here, making it allowed for outbound connections.
|
||||
func (p *prog) firewallRecordResolvedIPs(answer *dns.Msg, domain string) {
|
||||
if p.allowList == nil || answer == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Only record IPs from successful responses.
|
||||
if answer.Rcode != dns.RcodeSuccess {
|
||||
return
|
||||
}
|
||||
|
||||
for _, rr := range answer.Answer {
|
||||
switch r := rr.(type) {
|
||||
case *dns.A:
|
||||
if ip, ok := netip.AddrFromSlice(r.A); ok {
|
||||
ttl := time.Duration(r.Hdr.Ttl) * time.Second
|
||||
if ttl < 30*time.Second {
|
||||
// Enforce minimum TTL to prevent constant churn for very short TTLs.
|
||||
ttl = 30 * time.Second
|
||||
}
|
||||
p.allowList.Add(ip, domain, ttl)
|
||||
}
|
||||
case *dns.AAAA:
|
||||
if ip, ok := netip.AddrFromSlice(r.AAAA); ok {
|
||||
ttl := time.Duration(r.Hdr.Ttl) * time.Second
|
||||
if ttl < 30*time.Second {
|
||||
ttl = 30 * time.Second
|
||||
}
|
||||
p.allowList.Add(ip, domain, ttl)
|
||||
}
|
||||
case *dns.CNAME:
|
||||
// For CNAME chains: the final A/AAAA records will be caught above.
|
||||
// We don't need to do anything special for the CNAME itself, but we
|
||||
// log it for debugging CNAME chain issues.
|
||||
p.Debug().Msgf("Firewall: CNAME %s → %s (IPs from target will be allowlisted)", domain, r.Target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// firewallOnConfigReload is called when apiConfigReload() detects a config change.
|
||||
// It flushes the entire allowlist so that DNS queries against the new policy
|
||||
// repopulate it with the correct set of allowed IPs.
|
||||
//
|
||||
// This is the simple approach (vs. selective re-resolution per domain).
|
||||
// The tradeoff is a brief window where connections may fail until DNS cache
|
||||
// repopulates. Apps that reconnect directly to a previously resolved IP without
|
||||
// making a fresh DNS query can remain blocked longer; this is an explicit v1
|
||||
// limitation to call out in release notes and app-compatibility testing.
|
||||
func (p *prog) firewallOnConfigReload() {
|
||||
if p.allowList == nil {
|
||||
return
|
||||
}
|
||||
stats := p.allowList.Stats()
|
||||
p.Info().Msgf("Firewall: config reload detected, flushing allowlist (%d IPs, %d domains)",
|
||||
stats.AllowedIPs, stats.TrackedDomains)
|
||||
// Flush platform-specific state first (pf table / WFP filters),
|
||||
// then flush the allowlist. The AllowList's batch callbacks will
|
||||
// also fire, but the platform flush handles the bulk operation more
|
||||
// efficiently than removing IPs one-by-one.
|
||||
p.firewallFlushPlatform()
|
||||
p.allowList.Flush()
|
||||
}
|
||||
|
||||
// firewallOnNetworkChange is called when monitorNetworkChanges() detects a major
|
||||
// network transition (WiFi↔cellular, interface IP changes). Stale IPs from the
|
||||
// old network may no longer be valid, so we flush and let DNS repopulate.
|
||||
func (p *prog) firewallOnNetworkChange() {
|
||||
if p.allowList == nil {
|
||||
return
|
||||
}
|
||||
stats := p.allowList.Stats()
|
||||
p.Info().Msgf("Firewall: network change detected, flushing allowlist (%d IPs, %d domains)",
|
||||
stats.AllowedIPs, stats.TrackedDomains)
|
||||
p.firewallFlushPlatform()
|
||||
p.allowList.Flush()
|
||||
}
|
||||
|
||||
// logFirewallStats logs allowlist metrics immediately, then every 5 minutes
|
||||
// while firewall mode is active.
|
||||
func (p *prog) logFirewallStats(ctx context.Context) {
|
||||
if p.allowList == nil {
|
||||
return
|
||||
}
|
||||
p.logFirewallStatsOnce()
|
||||
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
p.logFirewallStatsOnce()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *prog) logFirewallStatsOnce() {
|
||||
if p.allowList == nil {
|
||||
return
|
||||
}
|
||||
stats := p.allowList.Stats()
|
||||
p.Info().
|
||||
Int("allowed_ips", stats.AllowedIPs).
|
||||
Int("permanent_ips", stats.PermanentIPs).
|
||||
Int("tracked_domains", stats.TrackedDomains).
|
||||
Int64("total_hits", stats.TotalHits).
|
||||
Int64("total_misses", stats.TotalMisses).
|
||||
Msg("Firewall allowlist stats")
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
//go:build darwin
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// pfFirewallState holds the state for pf-based firewall mode enforcement on macOS.
|
||||
// When firewall mode is active, we maintain a pf table of allowed IPs and add
|
||||
// block/pass rules to the ctrld anchor that enforce the allowlist.
|
||||
type pfFirewallState struct {
|
||||
// mu protects batch accumulation.
|
||||
mu sync.Mutex
|
||||
|
||||
// pendingAdds and pendingRemoves accumulate changes for batched pf updates.
|
||||
pendingAdds []netip.Addr
|
||||
pendingRemoves []netip.Addr
|
||||
|
||||
// batchTimer fires after the accumulation window to flush pending changes.
|
||||
batchTimer *time.Timer
|
||||
}
|
||||
|
||||
const (
|
||||
// pfFirewallTable is the pf table name for dynamically-allowed IPs.
|
||||
pfFirewallTable = "ctrld_allowed"
|
||||
|
||||
// pfFirewallBatchInterval is the accumulation window for batching pf table updates.
|
||||
// Short enough for responsiveness, long enough to avoid per-DNS-response pfctl calls.
|
||||
pfFirewallBatchInterval = 200 * time.Millisecond
|
||||
)
|
||||
|
||||
// firewallFlushPlatform flushes the pf table on macOS.
|
||||
func (p *prog) firewallFlushPlatform() {
|
||||
p.pfFirewallFlushTable()
|
||||
}
|
||||
|
||||
// shutdownPlatformFirewall removes macOS firewall-mode dynamic state. The pf
|
||||
// anchor itself is rebuilt by DNS intercept without firewall rules once
|
||||
// p.allowList is nil.
|
||||
func (p *prog) shutdownPlatformFirewall() {
|
||||
p.pfFirewallFlushTable()
|
||||
|
||||
if p.dnsInterceptState == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var vpnExemptions []vpnDNSExemption
|
||||
if p.vpnDNS != nil {
|
||||
vpnExemptions = p.vpnDNS.CurrentExemptions()
|
||||
}
|
||||
rulesStr := p.buildPFAnchorRules(vpnExemptions)
|
||||
if err := os.WriteFile(pfAnchorFile, []byte(rulesStr), 0644); err != nil {
|
||||
p.Warn().Err(err).Msg("Firewall: failed to write pf anchor during shutdown")
|
||||
return
|
||||
}
|
||||
if out, err := exec.Command("pfctl", "-a", pfAnchorName, "-f", pfAnchorFile).CombinedOutput(); err != nil {
|
||||
p.Warn().Err(err).Str("output", strings.TrimSpace(string(out))).Msg("Firewall: failed to reload pf anchor during shutdown")
|
||||
}
|
||||
}
|
||||
|
||||
// initPlatformFirewall initializes macOS-specific firewall enforcement (pf tables).
|
||||
func (p *prog) initPlatformFirewall() {
|
||||
if _, ok := p.platformFirewallState.(*pfFirewallState); ok {
|
||||
return
|
||||
}
|
||||
|
||||
// pf enforcement is only meaningful when intercept mode is active —
|
||||
// without it, we have no pf anchor to add rules to.
|
||||
if dnsIntercept && p.dnsInterceptState != nil {
|
||||
p.initPFFirewall()
|
||||
} else {
|
||||
p.Info().Msg("Firewall: pf enforcement deferred until intercept mode starts")
|
||||
}
|
||||
}
|
||||
|
||||
// initPFFirewall sets up pf-based firewall mode enforcement. Called from
|
||||
// initFirewallAllowList() when running on macOS with intercept mode active.
|
||||
//
|
||||
// Architecture:
|
||||
// - Creates a pf table <ctrld_allowed> for dynamic IP allowlisting
|
||||
// - Registers batch change callbacks on the AllowList
|
||||
// - The actual pf anchor rules are injected via buildPFFirewallRules() which
|
||||
// is called from buildPFAnchorRules() when firewall mode is active
|
||||
//
|
||||
// The table approach (vs. per-IP pass rules) is critical for performance:
|
||||
// pfctl table operations are O(log n) and don't require a full anchor reload.
|
||||
func (p *prog) initPFFirewall() {
|
||||
state := &pfFirewallState{}
|
||||
p.platformFirewallState = state
|
||||
|
||||
// Register batch callback — AllowList reaper and FlushDomain use this.
|
||||
p.allowList.SetOnBatchChange(func(added []netip.Addr, removed []netip.Addr) {
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
|
||||
if len(added) > 0 {
|
||||
state.pendingAdds = append(state.pendingAdds, added...)
|
||||
}
|
||||
if len(removed) > 0 {
|
||||
state.pendingRemoves = append(state.pendingRemoves, removed...)
|
||||
}
|
||||
state.scheduleBatchFlush(p)
|
||||
})
|
||||
|
||||
// Register individual change callback — Add() and Remove() use this.
|
||||
p.allowList.SetOnChange(func(ip netip.Addr, added bool) {
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
|
||||
if added {
|
||||
state.pendingAdds = append(state.pendingAdds, ip)
|
||||
} else {
|
||||
state.pendingRemoves = append(state.pendingRemoves, ip)
|
||||
}
|
||||
state.scheduleBatchFlush(p)
|
||||
})
|
||||
|
||||
// DNS responses may have populated the allowlist before platform callbacks
|
||||
// were registered. Bulk-load that snapshot so pf starts with the same view
|
||||
// as the in-memory allowlist.
|
||||
p.pfFirewallPopulateTable()
|
||||
|
||||
p.Info().Msg("Firewall: pf table enforcement initialized")
|
||||
}
|
||||
|
||||
// scheduleBatchFlush starts or resets the batch timer. Must be called with state.mu held.
|
||||
func (s *pfFirewallState) scheduleBatchFlush(p *prog) {
|
||||
if s.batchTimer != nil {
|
||||
return
|
||||
}
|
||||
s.batchTimer = time.AfterFunc(pfFirewallBatchInterval, func() {
|
||||
s.flushBatch(p)
|
||||
})
|
||||
}
|
||||
|
||||
// flushBatch applies accumulated pf table changes in a single pfctl call per direction.
|
||||
func (s *pfFirewallState) flushBatch(p *prog) {
|
||||
s.mu.Lock()
|
||||
adds := s.pendingAdds
|
||||
removes := s.pendingRemoves
|
||||
s.pendingAdds = nil
|
||||
s.pendingRemoves = nil
|
||||
s.batchTimer = nil
|
||||
s.mu.Unlock()
|
||||
|
||||
if len(adds) == 0 && len(removes) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Collapse add/remove deltas into the current primary allowlist state. This
|
||||
// avoids leaving pf opposite the allowlist when an Add and Remove for the
|
||||
// same IP land in one batch window.
|
||||
ipsToSync := make(map[netip.Addr]struct{}, len(adds)+len(removes))
|
||||
for _, ip := range adds {
|
||||
ipsToSync[ip] = struct{}{}
|
||||
}
|
||||
for _, ip := range removes {
|
||||
ipsToSync[ip] = struct{}{}
|
||||
}
|
||||
|
||||
var tableAdds, tableRemoves []string
|
||||
for ip := range ipsToSync {
|
||||
if p.allowList != nil && p.allowList.Contains(ip) {
|
||||
tableAdds = append(tableAdds, ip.String())
|
||||
} else {
|
||||
tableRemoves = append(tableRemoves, ip.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Apply additions.
|
||||
if len(tableAdds) > 0 {
|
||||
// pfctl -t <table> -T add accepts multiple IPs space-separated.
|
||||
args := append([]string{"-a", pfAnchorName, "-t", pfFirewallTable, "-T", "add"}, tableAdds...)
|
||||
if out, err := exec.Command("pfctl", args...).CombinedOutput(); err != nil {
|
||||
p.Warn().Err(err).Str("output", string(out)).
|
||||
Msgf("Firewall: failed to add %d IPs to pf table", len(tableAdds))
|
||||
} else {
|
||||
p.Debug().Msgf("Firewall: added %d IPs to pf table %s", len(tableAdds), pfFirewallTable)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply removals.
|
||||
if len(tableRemoves) > 0 {
|
||||
args := append([]string{"-a", pfAnchorName, "-t", pfFirewallTable, "-T", "delete"}, tableRemoves...)
|
||||
if out, err := exec.Command("pfctl", args...).CombinedOutput(); err != nil {
|
||||
// Not a hard error — the IP may have already been removed (e.g., by a Flush).
|
||||
p.Debug().Err(err).Str("output", string(out)).
|
||||
Msgf("Firewall: failed to remove %d IPs from pf table (may already be gone)", len(tableRemoves))
|
||||
} else {
|
||||
p.Debug().Msgf("Firewall: removed %d IPs from pf table %s", len(tableRemoves), pfFirewallTable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pfFirewallFlushTable removes all entries from the pf firewall table.
|
||||
// Called on network changes and config reloads before the AllowList is flushed.
|
||||
func (p *prog) pfFirewallFlushTable() {
|
||||
if state, ok := p.platformFirewallState.(*pfFirewallState); ok && state != nil {
|
||||
state.mu.Lock()
|
||||
if state.batchTimer != nil {
|
||||
state.batchTimer.Stop()
|
||||
state.batchTimer = nil
|
||||
}
|
||||
state.pendingAdds = nil
|
||||
state.pendingRemoves = nil
|
||||
state.mu.Unlock()
|
||||
}
|
||||
|
||||
out, err := exec.Command("pfctl", "-a", pfAnchorName, "-t", pfFirewallTable, "-T", "flush").CombinedOutput()
|
||||
if err != nil {
|
||||
p.Debug().Err(err).Str("output", string(out)).Msg("Firewall: failed to flush pf table (may not exist yet)")
|
||||
} else {
|
||||
p.Info().Msg("Firewall: flushed pf table " + pfFirewallTable)
|
||||
}
|
||||
}
|
||||
|
||||
// pfFirewallPopulateTable bulk-loads all currently allowed IPs into the pf table.
|
||||
// Called after anchor rule installation to ensure the table is populated.
|
||||
func (p *prog) pfFirewallPopulateTable() {
|
||||
if p.allowList == nil {
|
||||
return
|
||||
}
|
||||
ips := p.allowList.AllowedIPs()
|
||||
if len(ips) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
ipStrs := make([]string, 0, len(ips))
|
||||
for _, ip := range ips {
|
||||
ipStrs = append(ipStrs, ip.String())
|
||||
}
|
||||
|
||||
args := append([]string{"-a", pfAnchorName, "-t", pfFirewallTable, "-T", "add"}, ipStrs...)
|
||||
if out, err := exec.Command("pfctl", args...).CombinedOutput(); err != nil {
|
||||
p.Warn().Err(err).Str("output", string(out)).
|
||||
Msgf("Firewall: failed to populate pf table with %d IPs", len(ips))
|
||||
} else {
|
||||
p.Info().Msgf("Firewall: populated pf table with %d allowed IPs", len(ips))
|
||||
}
|
||||
}
|
||||
|
||||
// buildPFFirewallRules generates the pf rules for firewall mode enforcement.
|
||||
// These rules are appended to the anchor by buildPFAnchorRules() when firewall
|
||||
// mode is active.
|
||||
//
|
||||
// The strategy is:
|
||||
// - Define table <ctrld_allowed> (dynamically populated via pfctl -T add/delete)
|
||||
// - Block all outbound traffic by default (after DNS intercept rules)
|
||||
// - Pass outbound to IPs in <ctrld_allowed>
|
||||
// - Pass outbound from ctrld's group (already handled by blanket exemption)
|
||||
// - Pass loopback, link-local, multicast (already handled by permanent allowlist,
|
||||
// but explicit pf rules prevent kernel-level blocking before our check)
|
||||
//
|
||||
// IMPORTANT: These rules must come AFTER the DNS intercept rules in the anchor
|
||||
// so that DNS itself still works (DNS is how IPs get into the allowlist).
|
||||
func buildPFFirewallRules() string {
|
||||
var rules strings.Builder
|
||||
|
||||
rules.WriteString("\n# --- Firewall Mode: DNS-resolved IP allowlist enforcement ---\n")
|
||||
rules.WriteString("# Only IPs resolved by ctrld are allowed for outbound connections.\n")
|
||||
rules.WriteString("# Table is dynamically populated from DNS responses.\n\n")
|
||||
|
||||
// Declare the table. pfctl -T add/delete operates on this table dynamically.
|
||||
fmt.Fprintf(&rules, "table <%s> persist\n\n", pfFirewallTable)
|
||||
|
||||
// Pass traffic to allowed IPs (both IPv4 and IPv6).
|
||||
rules.WriteString("# Allow outbound to DNS-resolved IPs.\n")
|
||||
fmt.Fprintf(&rules, "pass out quick inet proto { tcp, udp } from any to <%s>\n", pfFirewallTable)
|
||||
fmt.Fprintf(&rules, "pass out quick inet6 proto { tcp, udp } from any to <%s>\n\n", pfFirewallTable)
|
||||
|
||||
// Allow ICMP/ICMPv6 — needed for path MTU discovery, ping, etc.
|
||||
rules.WriteString("# Allow ICMP (path MTU discovery, ping, etc.)\n")
|
||||
rules.WriteString("pass out quick inet proto icmp\n")
|
||||
rules.WriteString("pass out quick inet6 proto icmp6\n\n")
|
||||
|
||||
// Allow all loopback traffic (safety net — permanent allowlist covers this too).
|
||||
rules.WriteString("# Allow all loopback traffic.\n")
|
||||
rules.WriteString("pass out quick on lo0\n")
|
||||
rules.WriteString("pass in quick on lo0\n\n")
|
||||
|
||||
// Allow RFC1918 and link-local — these are in the permanent allowlist but
|
||||
// explicit pf rules prevent the block rule below from catching them.
|
||||
rules.WriteString("# Allow private/link-local ranges (LAN, printers, NAS, mDNS, DHCP).\n")
|
||||
rules.WriteString("pass out quick inet proto { tcp, udp } from any to 10.0.0.0/8\n")
|
||||
rules.WriteString("pass out quick inet proto { tcp, udp } from any to 172.16.0.0/12\n")
|
||||
rules.WriteString("pass out quick inet proto { tcp, udp } from any to 192.168.0.0/16\n")
|
||||
rules.WriteString("pass out quick inet proto { tcp, udp } from any to 169.254.0.0/16\n")
|
||||
rules.WriteString("pass out quick inet proto { tcp, udp } from any to 100.64.0.0/10\n")
|
||||
rules.WriteString("pass out quick inet6 proto { tcp, udp } from any to fe80::/10\n\n")
|
||||
|
||||
// Allow multicast (mDNS, SSDP, etc.).
|
||||
rules.WriteString("# Allow multicast (mDNS, SSDP, etc.).\n")
|
||||
rules.WriteString("pass out quick inet proto { tcp, udp } from any to 224.0.0.0/4\n")
|
||||
rules.WriteString("pass out quick inet6 proto { tcp, udp } from any to ff00::/8\n\n")
|
||||
|
||||
// Allow DHCP (UDP 67/68) — needed for network configuration.
|
||||
rules.WriteString("# Allow DHCP.\n")
|
||||
rules.WriteString("pass out quick inet proto udp from any port 68 to any port 67\n\n")
|
||||
|
||||
// Block everything else. This is the enforcement rule.
|
||||
// "block return" sends TCP RST / ICMP unreachable so apps fail fast instead of timing out.
|
||||
rules.WriteString("# Block all other outbound traffic (IPs not resolved by ctrld).\n")
|
||||
rules.WriteString("block return out quick inet proto { tcp, udp } from any to any\n")
|
||||
rules.WriteString("block return out quick inet6 proto { tcp, udp } from any to any\n")
|
||||
|
||||
return rules.String()
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//go:build !windows && !darwin
|
||||
|
||||
package cli
|
||||
|
||||
// initPlatformFirewall is a no-op on unsupported platforms (Linux, etc.).
|
||||
// Firewall mode on Linux would require iptables/nftables or eBPF — future work.
|
||||
func (p *prog) initPlatformFirewall() {
|
||||
p.Warn().Msg("Firewall: platform enforcement not available on this OS; firewall_mode fails open and only records allowlist stats")
|
||||
}
|
||||
|
||||
// firewallFlushPlatform is a no-op on unsupported platforms.
|
||||
func (p *prog) firewallFlushPlatform() {}
|
||||
|
||||
// shutdownPlatformFirewall is a no-op on unsupported platforms.
|
||||
func (p *prog) shutdownPlatformFirewall() {}
|
||||
@@ -0,0 +1,26 @@
|
||||
package cli
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExtractHostFromEndpoint(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
endpoint string
|
||||
want string
|
||||
}{
|
||||
{name: "https URL", endpoint: "https://dns.controld.com/abcdef", want: "dns.controld.com"},
|
||||
{name: "URL with userinfo", endpoint: "https://user:pass@dns.controld.com/abcdef", want: "dns.controld.com"},
|
||||
{name: "URL with IPv6 literal", endpoint: "https://[2606:4700:4700::1111]:443/dns-query", want: "2606:4700:4700::1111"},
|
||||
{name: "host port", endpoint: "1.2.3.4:53", want: "1.2.3.4"},
|
||||
{name: "bare IP", endpoint: "1.2.3.4", want: "1.2.3.4"},
|
||||
{name: "DNS stamp", endpoint: "sdns://AgcAAAAAAAAAAA", want: ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := extractHostFromEndpoint(tt.endpoint); got != tt.want {
|
||||
t.Fatalf("extractHostFromEndpoint(%q) = %q, want %q", tt.endpoint, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
//go:build windows
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// wfpFirewallState holds the state for WFP-based firewall mode enforcement on Windows.
|
||||
// When firewall mode is active, we add dynamic WFP permit filters for each allowed IP
|
||||
// on top of a block-all base filter.
|
||||
type wfpFirewallState struct {
|
||||
mu sync.Mutex
|
||||
|
||||
// pendingAdds and pendingRemoves accumulate changes for batched WFP updates.
|
||||
pendingAdds []netip.Addr
|
||||
pendingRemoves []netip.Addr
|
||||
|
||||
// batchTimer fires after the accumulation window to flush pending changes.
|
||||
batchTimer *time.Timer
|
||||
|
||||
// filterMap tracks WFP filter IDs for each dynamically allowed IP so we can remove them.
|
||||
// Maps IP string → WFP filter ID.
|
||||
filterMap map[string]uint64
|
||||
|
||||
// permanentFilterMap tracks permit filters for permanent allowlist entries
|
||||
// (loopback/private/link-local/listener/upstream IPs). These stay installed
|
||||
// across dynamic allowlist flushes.
|
||||
permanentFilterMap map[string]uint64
|
||||
|
||||
// blockFilterIDv4 and blockFilterIDv6 are the base block-all filters.
|
||||
blockFilterIDv4 uint64
|
||||
blockFilterIDv6 uint64
|
||||
|
||||
// engineHandle is the WFP engine handle from the intercept state.
|
||||
engineHandle uintptr
|
||||
}
|
||||
|
||||
const (
|
||||
// wfpFirewallBatchInterval is the accumulation window for batching WFP filter updates.
|
||||
wfpFirewallBatchInterval = 200 * time.Millisecond
|
||||
)
|
||||
|
||||
// firewallFlushPlatform removes all dynamic WFP permit filters on Windows.
|
||||
// Called on network changes and config reloads before the in-memory allowlist is flushed.
|
||||
func (p *prog) firewallFlushPlatform() {
|
||||
fwState, ok := p.platformFirewallState.(*wfpFirewallState)
|
||||
if !ok || fwState == nil {
|
||||
return
|
||||
}
|
||||
fwState.flushAll(p)
|
||||
}
|
||||
|
||||
// shutdownPlatformFirewall removes Windows firewall-mode WFP filters, including
|
||||
// dynamic permits, permanent permits, and the base block-all filters.
|
||||
func (p *prog) shutdownPlatformFirewall() {
|
||||
fwState, ok := p.platformFirewallState.(*wfpFirewallState)
|
||||
if !ok || fwState == nil {
|
||||
return
|
||||
}
|
||||
fwState.shutdown(p)
|
||||
}
|
||||
|
||||
// initPlatformFirewall initializes Windows-specific firewall enforcement (WFP filters).
|
||||
func (p *prog) initPlatformFirewall() {
|
||||
if fwState, ok := p.platformFirewallState.(*wfpFirewallState); ok && fwState != nil {
|
||||
fwState.populatePermanentFilters(p)
|
||||
fwState.populateFilters(p)
|
||||
return
|
||||
}
|
||||
|
||||
if !hardIntercept || p.dnsInterceptState == nil {
|
||||
p.Info().Msg("Firewall: WFP enforcement requires hard intercept mode")
|
||||
return
|
||||
}
|
||||
|
||||
state, ok := p.dnsInterceptState.(*wfpState)
|
||||
if !ok || state == nil {
|
||||
p.Warn().Msg("Firewall: could not access WFP state for firewall enforcement")
|
||||
return
|
||||
}
|
||||
|
||||
fwState := &wfpFirewallState{
|
||||
filterMap: make(map[string]uint64),
|
||||
permanentFilterMap: make(map[string]uint64),
|
||||
engineHandle: state.engineHandle,
|
||||
}
|
||||
p.platformFirewallState = fwState
|
||||
|
||||
// Install base block-all outbound filters. We add our firewall filters to the
|
||||
// SAME sublayer as DNS intercept with carefully chosen weights:
|
||||
// - Existing DNS permits (localhost): weight 10 (highest, evaluated first)
|
||||
// - Firewall IP permits: weight 5 (middle)
|
||||
// - Existing DNS block: weight 1 (blocks non-localhost DNS)
|
||||
// - Firewall block-all: weight 1 (catch-all for non-DNS)
|
||||
//
|
||||
// WFP evaluates higher weights first within a sublayer. DNS permits at 10
|
||||
// always win, ensuring DNS resolution works. Firewall IP permits at 5
|
||||
// override the block-all for resolved IPs. The existing DNS block at 1
|
||||
// and our block-all at 1 are both catch-alls (DNS block has port 53
|
||||
// conditions so it only catches DNS; our block-all has no conditions
|
||||
// so it catches everything else).
|
||||
if err := p.addWFPFirewallBlockFilters(fwState); err != nil {
|
||||
p.Error().Err(err).Msg("Firewall: failed to install WFP block-all filters")
|
||||
return
|
||||
}
|
||||
|
||||
// Install permits for the permanent allowlist before enabling dynamic updates.
|
||||
// Without these, the block-all filters would also block ctrld upstreams,
|
||||
// listener/loopback traffic, LAN ranges, and other permanent exceptions.
|
||||
fwState.populatePermanentFilters(p)
|
||||
|
||||
// Register batch callback.
|
||||
p.allowList.SetOnBatchChange(func(added []netip.Addr, removed []netip.Addr) {
|
||||
fwState.mu.Lock()
|
||||
defer fwState.mu.Unlock()
|
||||
|
||||
if len(added) > 0 {
|
||||
fwState.pendingAdds = append(fwState.pendingAdds, added...)
|
||||
}
|
||||
if len(removed) > 0 {
|
||||
fwState.pendingRemoves = append(fwState.pendingRemoves, removed...)
|
||||
}
|
||||
fwState.scheduleBatchFlush(p)
|
||||
})
|
||||
|
||||
// Register individual change callback.
|
||||
p.allowList.SetOnChange(func(ip netip.Addr, isAdded bool) {
|
||||
fwState.mu.Lock()
|
||||
defer fwState.mu.Unlock()
|
||||
|
||||
if isAdded {
|
||||
fwState.pendingAdds = append(fwState.pendingAdds, ip)
|
||||
} else {
|
||||
fwState.pendingRemoves = append(fwState.pendingRemoves, ip)
|
||||
}
|
||||
fwState.scheduleBatchFlush(p)
|
||||
})
|
||||
|
||||
// DNS responses may have populated the allowlist before platform callbacks
|
||||
// were registered. Add permit filters for that snapshot so WFP starts with
|
||||
// the same view as the in-memory allowlist.
|
||||
fwState.populateFilters(p)
|
||||
|
||||
p.Info().Msg("Firewall: WFP enforcement initialized with block-all base filters")
|
||||
}
|
||||
|
||||
// scheduleBatchFlush starts or resets the batch timer. Must be called with fwState.mu held.
|
||||
func (s *wfpFirewallState) scheduleBatchFlush(p *prog) {
|
||||
if s.batchTimer != nil {
|
||||
return
|
||||
}
|
||||
s.batchTimer = time.AfterFunc(wfpFirewallBatchInterval, func() {
|
||||
s.flushBatch(p)
|
||||
})
|
||||
}
|
||||
|
||||
// flushBatch applies accumulated WFP filter changes.
|
||||
func (s *wfpFirewallState) flushBatch(p *prog) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
adds := s.pendingAdds
|
||||
removes := s.pendingRemoves
|
||||
s.pendingAdds = nil
|
||||
s.pendingRemoves = nil
|
||||
s.batchTimer = nil
|
||||
|
||||
// Collapse add/remove deltas into the current primary allowlist state. This
|
||||
// avoids leaving WFP opposite the allowlist when an Add and Remove for the
|
||||
// same IP land in one batch window.
|
||||
ipsToSync := make(map[netip.Addr]struct{}, len(adds)+len(removes))
|
||||
for _, ip := range adds {
|
||||
ipsToSync[ip] = struct{}{}
|
||||
}
|
||||
for _, ip := range removes {
|
||||
ipsToSync[ip] = struct{}{}
|
||||
}
|
||||
|
||||
for ip := range ipsToSync {
|
||||
key := ip.String()
|
||||
allowed := p.allowList != nil && p.allowList.Contains(ip)
|
||||
if !allowed {
|
||||
s.removePermitFilterLocked(p, key)
|
||||
continue
|
||||
}
|
||||
|
||||
if _, exists := s.filterMap[key]; exists {
|
||||
continue // Already has a permit filter.
|
||||
}
|
||||
|
||||
filterID, err := p.addWFPFirewallPermitFilter(s, ip)
|
||||
if err != nil {
|
||||
p.Warn().Err(err).Msgf("Firewall: failed to add WFP permit filter for %s", key)
|
||||
continue
|
||||
}
|
||||
s.filterMap[key] = filterID
|
||||
p.Debug().Msgf("Firewall: added WFP permit filter for %s (ID: %d)", key, filterID)
|
||||
}
|
||||
}
|
||||
|
||||
// removePermitFilterLocked removes one dynamic WFP permit filter. s.mu must be held.
|
||||
func (s *wfpFirewallState) removePermitFilterLocked(p *prog, key string) {
|
||||
filterID, ok := s.filterMap[key]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
r1, _, _ := procFwpmFilterDeleteById0.Call(s.engineHandle, uintptr(filterID))
|
||||
if r1 != 0 {
|
||||
p.Debug().Msgf("Firewall: failed to remove WFP filter for %s (HRESULT 0x%x, may already be gone)", key, r1)
|
||||
} else {
|
||||
p.Debug().Msgf("Firewall: removed WFP permit filter for %s", key)
|
||||
}
|
||||
delete(s.filterMap, key)
|
||||
}
|
||||
|
||||
// flushAll synchronously removes every dynamic WFP permit filter and clears any
|
||||
// queued batch work. The block-all filters stay installed.
|
||||
func (s *wfpFirewallState) flushAll(p *prog) {
|
||||
s.mu.Lock()
|
||||
if s.batchTimer != nil {
|
||||
s.batchTimer.Stop()
|
||||
s.batchTimer = nil
|
||||
}
|
||||
s.pendingAdds = nil
|
||||
s.pendingRemoves = nil
|
||||
|
||||
filters := make(map[string]uint64, len(s.filterMap))
|
||||
for key, filterID := range s.filterMap {
|
||||
filters[key] = filterID
|
||||
}
|
||||
s.filterMap = make(map[string]uint64)
|
||||
s.mu.Unlock()
|
||||
|
||||
for key, filterID := range filters {
|
||||
r1, _, _ := procFwpmFilterDeleteById0.Call(s.engineHandle, uintptr(filterID))
|
||||
if r1 != 0 {
|
||||
p.Debug().Msgf("Firewall: failed to remove WFP filter for %s during flush (HRESULT 0x%x, may already be gone)", key, r1)
|
||||
} else {
|
||||
p.Debug().Msgf("Firewall: removed WFP permit filter for %s during flush", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// shutdown removes every WFP filter owned by firewall mode.
|
||||
func (s *wfpFirewallState) shutdown(p *prog) {
|
||||
s.flushAll(p)
|
||||
|
||||
s.mu.Lock()
|
||||
permanentFilters := make(map[string]uint64, len(s.permanentFilterMap))
|
||||
for key, filterID := range s.permanentFilterMap {
|
||||
permanentFilters[key] = filterID
|
||||
}
|
||||
s.permanentFilterMap = make(map[string]uint64)
|
||||
|
||||
blockIDs := []uint64{s.blockFilterIDv4, s.blockFilterIDv6}
|
||||
s.blockFilterIDv4 = 0
|
||||
s.blockFilterIDv6 = 0
|
||||
s.mu.Unlock()
|
||||
|
||||
for key, filterID := range permanentFilters {
|
||||
if r1, _, _ := procFwpmFilterDeleteById0.Call(s.engineHandle, uintptr(filterID)); r1 != 0 {
|
||||
p.Debug().Msgf("Firewall: failed to remove permanent WFP filter for %s during shutdown (HRESULT 0x%x, may already be gone)", key, r1)
|
||||
}
|
||||
}
|
||||
for _, filterID := range blockIDs {
|
||||
if filterID == 0 {
|
||||
continue
|
||||
}
|
||||
if r1, _, _ := procFwpmFilterDeleteById0.Call(s.engineHandle, uintptr(filterID)); r1 != 0 {
|
||||
p.Debug().Msgf("Firewall: failed to remove WFP block filter %d during shutdown (HRESULT 0x%x, may already be gone)", filterID, r1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// populatePermanentFilters mirrors the in-memory permanent allowlist into WFP
|
||||
// permit filters so the base block-all rule does not block ctrld itself, local
|
||||
// network traffic, or upstream resolver endpoints.
|
||||
func (s *wfpFirewallState) populatePermanentFilters(p *prog) {
|
||||
if p.allowList == nil {
|
||||
return
|
||||
}
|
||||
addrs, prefixes := p.allowList.PermanentEntries()
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for _, ip := range addrs {
|
||||
key := "addr:" + ip.String()
|
||||
if _, exists := s.permanentFilterMap[key]; exists {
|
||||
continue
|
||||
}
|
||||
filterID, err := p.addWFPFirewallPermitFilter(s, ip)
|
||||
if err != nil {
|
||||
p.Warn().Err(err).Msgf("Firewall: failed to add permanent WFP permit for %s", ip)
|
||||
continue
|
||||
}
|
||||
s.permanentFilterMap[key] = filterID
|
||||
p.Debug().Msgf("Firewall: added permanent WFP permit for %s (ID: %d)", ip, filterID)
|
||||
}
|
||||
|
||||
for _, prefix := range prefixes {
|
||||
key := "prefix:" + prefix.String()
|
||||
if _, exists := s.permanentFilterMap[key]; exists {
|
||||
continue
|
||||
}
|
||||
filterID, err := p.addWFPFirewallPermitPrefix(s, prefix)
|
||||
if err != nil {
|
||||
p.Warn().Err(err).Msgf("Firewall: failed to add permanent WFP permit for %s", prefix)
|
||||
continue
|
||||
}
|
||||
s.permanentFilterMap[key] = filterID
|
||||
p.Debug().Msgf("Firewall: added permanent WFP permit for %s (ID: %d)", prefix, filterID)
|
||||
}
|
||||
}
|
||||
|
||||
// populateFilters installs permit filters for IPs already present in the allowlist
|
||||
// before WFP callbacks were registered.
|
||||
func (s *wfpFirewallState) populateFilters(p *prog) {
|
||||
if p.allowList == nil {
|
||||
return
|
||||
}
|
||||
ips := p.allowList.AllowedIPs()
|
||||
if len(ips) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for _, ip := range ips {
|
||||
key := ip.String()
|
||||
if _, exists := s.filterMap[key]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
filterID, err := p.addWFPFirewallPermitFilter(s, ip)
|
||||
if err != nil {
|
||||
p.Warn().Err(err).Msgf("Firewall: failed to add initial WFP permit filter for %s", key)
|
||||
continue
|
||||
}
|
||||
s.filterMap[key] = filterID
|
||||
p.Debug().Msgf("Firewall: added initial WFP permit filter for %s (ID: %d)", key, filterID)
|
||||
}
|
||||
}
|
||||
|
||||
// addWFPFirewallBlockFilters installs the base block-all outbound filters.
|
||||
// These block ALL non-loopback outbound TCP/UDP traffic. Per-IP permit filters
|
||||
// (added dynamically from the allowlist) override these for resolved IPs.
|
||||
func (p *prog) addWFPFirewallBlockFilters(fwState *wfpFirewallState) error {
|
||||
// Block all outbound IPv4 TCP/UDP.
|
||||
filterName, _ := windows.UTF16PtrFromString("ctrld Firewall Block All IPv4")
|
||||
filter := fwpmFilter0{
|
||||
subLayerKey: ctrldSubLayerGUID,
|
||||
weight: fwpValue0{
|
||||
valueType: fwpUint8, // FWP_UINT8
|
||||
value: 1, // Must be lower than DNS permits (10). Firewall IP permits (5) override this.
|
||||
},
|
||||
action: fwpmAction0{
|
||||
actionType: fwpActionBlock, // FWP_ACTION_BLOCK
|
||||
},
|
||||
layerKey: fwpmLayerALEAuthConnectV4,
|
||||
}
|
||||
filter.displayData.name = filterName
|
||||
|
||||
var filterID uint64
|
||||
r1, _, _ := procFwpmFilterAdd0.Call(
|
||||
fwState.engineHandle,
|
||||
uintptr(unsafe.Pointer(&filter)),
|
||||
0,
|
||||
uintptr(unsafe.Pointer(&filterID)),
|
||||
)
|
||||
if r1 != 0 {
|
||||
return fmt.Errorf("FwpmFilterAdd0 (block IPv4) failed: HRESULT 0x%x", r1)
|
||||
}
|
||||
fwState.blockFilterIDv4 = filterID
|
||||
|
||||
// Block all outbound IPv6 TCP/UDP.
|
||||
filterNameV6, _ := windows.UTF16PtrFromString("ctrld Firewall Block All IPv6")
|
||||
filterV6 := fwpmFilter0{
|
||||
subLayerKey: ctrldSubLayerGUID,
|
||||
weight: fwpValue0{
|
||||
valueType: fwpUint8,
|
||||
value: 1,
|
||||
},
|
||||
action: fwpmAction0{
|
||||
actionType: fwpActionBlock,
|
||||
},
|
||||
layerKey: fwpmLayerALEAuthConnectV6,
|
||||
}
|
||||
filterV6.displayData.name = filterNameV6
|
||||
|
||||
var filterIDv6 uint64
|
||||
r1, _, _ = procFwpmFilterAdd0.Call(
|
||||
fwState.engineHandle,
|
||||
uintptr(unsafe.Pointer(&filterV6)),
|
||||
0,
|
||||
uintptr(unsafe.Pointer(&filterIDv6)),
|
||||
)
|
||||
if r1 != 0 {
|
||||
if fwState.blockFilterIDv4 != 0 {
|
||||
deleteResult, _, _ := procFwpmFilterDeleteById0.Call(fwState.engineHandle, uintptr(fwState.blockFilterIDv4))
|
||||
if deleteResult != 0 {
|
||||
p.Debug().Msgf("Firewall: failed to roll back IPv4 block filter %d after IPv6 setup failure (HRESULT 0x%x)", fwState.blockFilterIDv4, deleteResult)
|
||||
}
|
||||
fwState.blockFilterIDv4 = 0
|
||||
}
|
||||
return fmt.Errorf("FwpmFilterAdd0 (block IPv6) failed: HRESULT 0x%x", r1)
|
||||
}
|
||||
fwState.blockFilterIDv6 = filterIDv6
|
||||
|
||||
p.Info().Msgf("Firewall: WFP block-all filters installed (v4 ID: %d, v6 ID: %d)", filterID, filterIDv6)
|
||||
return nil
|
||||
}
|
||||
|
||||
// addWFPFirewallPermitPrefix adds a WFP permit filter for a permanent CIDR prefix.
|
||||
func (p *prog) addWFPFirewallPermitPrefix(fwState *wfpFirewallState, prefix netip.Prefix) (uint64, error) {
|
||||
prefix = prefix.Masked()
|
||||
if prefix.Addr().Is4() {
|
||||
return p.addWFPFirewallPermitIPv4Prefix(fwState, prefix)
|
||||
}
|
||||
return p.addWFPFirewallPermitIPv6Prefix(fwState, prefix)
|
||||
}
|
||||
|
||||
func (p *prog) addWFPFirewallPermitIPv4Prefix(fwState *wfpFirewallState, prefix netip.Prefix) (uint64, error) {
|
||||
addr4 := prefix.Addr().As4()
|
||||
addr := uint32(addr4[0])<<24 | uint32(addr4[1])<<16 | uint32(addr4[2])<<8 | uint32(addr4[3])
|
||||
bits := prefix.Bits()
|
||||
var mask uint32
|
||||
if bits == 0 {
|
||||
mask = 0
|
||||
} else {
|
||||
mask = ^uint32(0) << uint(32-bits)
|
||||
}
|
||||
addrMask := fwpV4AddrAndMask{addr: addr & mask, mask: mask}
|
||||
|
||||
filterName, _ := windows.UTF16PtrFromString("ctrld Firewall Permit " + prefix.String())
|
||||
condition := fwpmFilterCondition0{
|
||||
fieldKey: fwpmConditionIPRemoteAddress,
|
||||
matchType: fwpMatchEqual,
|
||||
}
|
||||
condition.condValue.valueType = fwpV4AddrMask
|
||||
condition.condValue.value = uint64(uintptr(unsafe.Pointer(&addrMask)))
|
||||
|
||||
filter := fwpmFilter0{
|
||||
subLayerKey: ctrldSubLayerGUID,
|
||||
numFilterConds: 1,
|
||||
filterCondition: (*fwpmFilterCondition0)(unsafe.Pointer(&condition)),
|
||||
weight: fwpValue0{
|
||||
valueType: fwpUint8,
|
||||
value: 5,
|
||||
},
|
||||
action: fwpmAction0{
|
||||
actionType: fwpActionPermit,
|
||||
},
|
||||
layerKey: fwpmLayerALEAuthConnectV4,
|
||||
}
|
||||
filter.displayData.name = filterName
|
||||
|
||||
var filterID uint64
|
||||
r1, _, _ := procFwpmFilterAdd0.Call(
|
||||
fwState.engineHandle,
|
||||
uintptr(unsafe.Pointer(&filter)),
|
||||
0,
|
||||
uintptr(unsafe.Pointer(&filterID)),
|
||||
)
|
||||
runtime.KeepAlive(&addrMask)
|
||||
if r1 != 0 {
|
||||
return 0, fmt.Errorf("FwpmFilterAdd0 (permit IPv4 prefix %s) failed: HRESULT 0x%x", prefix, r1)
|
||||
}
|
||||
return filterID, nil
|
||||
}
|
||||
|
||||
func (p *prog) addWFPFirewallPermitIPv6Prefix(fwState *wfpFirewallState, prefix netip.Prefix) (uint64, error) {
|
||||
addrMask := fwpV6AddrAndMask{addr: prefix.Addr().As16(), prefixLength: uint8(prefix.Bits())}
|
||||
|
||||
filterName, _ := windows.UTF16PtrFromString("ctrld Firewall Permit " + prefix.String())
|
||||
condition := fwpmFilterCondition0{
|
||||
fieldKey: fwpmConditionIPRemoteAddress,
|
||||
matchType: fwpMatchEqual,
|
||||
}
|
||||
condition.condValue.valueType = fwpV6AddrMask
|
||||
condition.condValue.value = uint64(uintptr(unsafe.Pointer(&addrMask)))
|
||||
|
||||
filter := fwpmFilter0{
|
||||
subLayerKey: ctrldSubLayerGUID,
|
||||
numFilterConds: 1,
|
||||
filterCondition: (*fwpmFilterCondition0)(unsafe.Pointer(&condition)),
|
||||
weight: fwpValue0{
|
||||
valueType: fwpUint8,
|
||||
value: 5,
|
||||
},
|
||||
action: fwpmAction0{
|
||||
actionType: fwpActionPermit,
|
||||
},
|
||||
layerKey: fwpmLayerALEAuthConnectV6,
|
||||
}
|
||||
filter.displayData.name = filterName
|
||||
|
||||
var filterID uint64
|
||||
r1, _, _ := procFwpmFilterAdd0.Call(
|
||||
fwState.engineHandle,
|
||||
uintptr(unsafe.Pointer(&filter)),
|
||||
0,
|
||||
uintptr(unsafe.Pointer(&filterID)),
|
||||
)
|
||||
runtime.KeepAlive(&addrMask)
|
||||
if r1 != 0 {
|
||||
return 0, fmt.Errorf("FwpmFilterAdd0 (permit IPv6 prefix %s) failed: HRESULT 0x%x", prefix, r1)
|
||||
}
|
||||
return filterID, nil
|
||||
}
|
||||
|
||||
// addWFPFirewallPermitFilter adds a WFP permit filter for a single IP address.
|
||||
// Returns the filter ID for later removal.
|
||||
func (p *prog) addWFPFirewallPermitFilter(fwState *wfpFirewallState, ip netip.Addr) (uint64, error) {
|
||||
ip = ip.Unmap()
|
||||
|
||||
if ip.Is4() {
|
||||
return p.addWFPFirewallPermitIPv4(fwState, ip)
|
||||
}
|
||||
return p.addWFPFirewallPermitIPv6(fwState, ip)
|
||||
}
|
||||
|
||||
// addWFPFirewallPermitIPv4 adds a WFP permit filter for an IPv4 address.
|
||||
func (p *prog) addWFPFirewallPermitIPv4(fwState *wfpFirewallState, ip netip.Addr) (uint64, error) {
|
||||
addr4 := ip.As4()
|
||||
ipUint32 := uint32(addr4[0])<<24 | uint32(addr4[1])<<16 | uint32(addr4[2])<<8 | uint32(addr4[3])
|
||||
|
||||
filterName, _ := windows.UTF16PtrFromString("ctrld Firewall Permit " + ip.String())
|
||||
|
||||
condition := fwpmFilterCondition0{
|
||||
fieldKey: fwpmConditionIPRemoteAddress,
|
||||
matchType: fwpMatchEqual,
|
||||
}
|
||||
condition.condValue.valueType = fwpUint32
|
||||
condition.condValue.value = uint64(ipUint32)
|
||||
|
||||
filter := fwpmFilter0{
|
||||
subLayerKey: ctrldSubLayerGUID,
|
||||
numFilterConds: 1,
|
||||
filterCondition: (*fwpmFilterCondition0)(unsafe.Pointer(&condition)),
|
||||
weight: fwpValue0{
|
||||
valueType: fwpUint8,
|
||||
value: 5, // Higher than block-all (1), lower than DNS permits (10).
|
||||
},
|
||||
action: fwpmAction0{
|
||||
actionType: fwpActionPermit, // FWP_ACTION_PERMIT
|
||||
},
|
||||
layerKey: fwpmLayerALEAuthConnectV4,
|
||||
}
|
||||
filter.displayData.name = filterName
|
||||
|
||||
var filterID uint64
|
||||
r1, _, _ := procFwpmFilterAdd0.Call(
|
||||
fwState.engineHandle,
|
||||
uintptr(unsafe.Pointer(&filter)),
|
||||
0,
|
||||
uintptr(unsafe.Pointer(&filterID)),
|
||||
)
|
||||
if r1 != 0 {
|
||||
return 0, fmt.Errorf("FwpmFilterAdd0 (permit IPv4 %s) failed: HRESULT 0x%x", ip, r1)
|
||||
}
|
||||
return filterID, nil
|
||||
}
|
||||
|
||||
// addWFPFirewallPermitIPv6 adds a WFP permit filter for an IPv6 address.
|
||||
func (p *prog) addWFPFirewallPermitIPv6(fwState *wfpFirewallState, ip netip.Addr) (uint64, error) {
|
||||
addr16 := ip.As16()
|
||||
|
||||
filterName, _ := windows.UTF16PtrFromString("ctrld Firewall Permit " + ip.String())
|
||||
|
||||
condition := fwpmFilterCondition0{
|
||||
fieldKey: fwpmConditionIPRemoteAddress,
|
||||
matchType: fwpMatchEqual,
|
||||
}
|
||||
condition.condValue.valueType = fwpByteArray16Type
|
||||
condition.condValue.value = uint64(uintptr(unsafe.Pointer(&addr16)))
|
||||
|
||||
filter := fwpmFilter0{
|
||||
subLayerKey: ctrldSubLayerGUID,
|
||||
numFilterConds: 1,
|
||||
filterCondition: (*fwpmFilterCondition0)(unsafe.Pointer(&condition)),
|
||||
weight: fwpValue0{
|
||||
valueType: fwpUint8,
|
||||
value: 5, // Higher than block-all (1), lower than DNS permits (10).
|
||||
},
|
||||
action: fwpmAction0{
|
||||
actionType: fwpActionPermit,
|
||||
},
|
||||
layerKey: fwpmLayerALEAuthConnectV6,
|
||||
}
|
||||
filter.displayData.name = filterName
|
||||
|
||||
var filterID uint64
|
||||
r1, _, _ := procFwpmFilterAdd0.Call(
|
||||
fwState.engineHandle,
|
||||
uintptr(unsafe.Pointer(&filter)),
|
||||
0,
|
||||
uintptr(unsafe.Pointer(&filterID)),
|
||||
)
|
||||
runtime.KeepAlive(addr16)
|
||||
if r1 != 0 {
|
||||
return 0, fmt.Errorf("FwpmFilterAdd0 (permit IPv6 %s) failed: HRESULT 0x%x", ip, r1)
|
||||
}
|
||||
return filterID, nil
|
||||
}
|
||||
@@ -4,11 +4,15 @@ import "regexp"
|
||||
|
||||
// validHostname reports whether hostname is a valid hostname.
|
||||
// A valid hostname contains 3 -> 64 characters and conform to RFC1123.
|
||||
// This function validates hostnames to ensure they meet DNS naming standards
|
||||
// and prevents invalid hostnames from being used in DNS configurations
|
||||
func validHostname(hostname string) bool {
|
||||
hostnameLen := len(hostname)
|
||||
if hostnameLen < 3 || hostnameLen > 64 {
|
||||
return false
|
||||
}
|
||||
// RFC1123 regex pattern ensures hostnames follow DNS naming conventions
|
||||
// This prevents issues with DNS resolution and system compatibility
|
||||
validHostnameRfc1123 := regexp.MustCompile(`^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$`)
|
||||
return validHostnameRfc1123.MatchString(hostname)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// HTTP log server endpoint constants
|
||||
const (
|
||||
httpLogEndpointPing = "/ping"
|
||||
httpLogEndpointLogs = "/logs"
|
||||
httpLogEndpointExit = "/exit"
|
||||
)
|
||||
|
||||
// httpLogClient sends logs to an HTTP server via POST requests.
|
||||
// This replaces the logConn functionality with HTTP-based communication.
|
||||
type httpLogClient struct {
|
||||
baseURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// newHTTPLogClient creates a new HTTP log client
|
||||
func newHTTPLogClient(sockPath string) *httpLogClient {
|
||||
return &httpLogClient{
|
||||
baseURL: "http://unix",
|
||||
client: &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return net.Dial("unix", sockPath)
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Write sends log data to the HTTP server via POST request
|
||||
func (hlc *httpLogClient) Write(b []byte) (int, error) {
|
||||
// Send log data via HTTP POST to /logs endpoint
|
||||
resp, err := hlc.client.Post(hlc.baseURL+httpLogEndpointLogs, "text/plain", bytes.NewReader(b))
|
||||
if err != nil {
|
||||
// Ignore errors to prevent log pollution, just like the original logConn
|
||||
return len(b), nil
|
||||
}
|
||||
resp.Body.Close()
|
||||
return len(b), nil
|
||||
}
|
||||
|
||||
// Ping tests if the HTTP log server is available
|
||||
func (hlc *httpLogClient) Ping() error {
|
||||
resp, err := hlc.client.Get(hlc.baseURL + httpLogEndpointPing)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close sends exit signal to the HTTP server
|
||||
func (hlc *httpLogClient) Close() error {
|
||||
// Send exit signal via HTTP POST with empty body
|
||||
resp, err := hlc.client.Post(hlc.baseURL+httpLogEndpointExit, "text/plain", bytes.NewReader([]byte{}))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLogs retrieves all collected logs from the HTTP server
|
||||
func (hlc *httpLogClient) GetLogs() ([]byte, error) {
|
||||
resp, err := hlc.client.Get(hlc.baseURL + httpLogEndpointLogs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNoContent {
|
||||
return []byte{}, nil
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
// httpLogServer starts an HTTP server listening on unix socket to collect logs from runCmd.
|
||||
func httpLogServer(sockPath string, stopLogCh chan struct{}) error {
|
||||
addr, err := net.ResolveUnixAddr("unix", sockPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid log sock path: %w", err)
|
||||
}
|
||||
|
||||
ln, err := net.ListenUnix("unix", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not listen log socket: %w", err)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
// Create a log writer to store all logs
|
||||
logWriter := newLogWriter()
|
||||
|
||||
// Use a sync.Once to ensure channel is only closed once
|
||||
var channelClosed sync.Once
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc(httpLogEndpointPing, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
mux.HandleFunc(httpLogEndpointLogs, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
// POST /logs - Store log data
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Store log data in log writer
|
||||
logWriter.Write(body)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
case http.MethodGet:
|
||||
// GET /logs - Retrieve all logs
|
||||
// Get all logs from the log writer
|
||||
logWriter.mu.Lock()
|
||||
logs := logWriter.buf.Bytes()
|
||||
logWriter.mu.Unlock()
|
||||
|
||||
if len(logs) == 0 {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(logs)
|
||||
|
||||
default:
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
|
||||
mux.HandleFunc(httpLogEndpointExit, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Close the stop channel to signal completion (only once)
|
||||
channelClosed.Do(func() {
|
||||
close(stopLogCh)
|
||||
})
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
server := &http.Server{Handler: mux}
|
||||
return server.Serve(ln)
|
||||
}
|
||||
@@ -0,0 +1,747 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/nettest"
|
||||
)
|
||||
|
||||
func unixDomainSocketPath(t *testing.T) string {
|
||||
t.Helper()
|
||||
sockPath, err := nettest.LocalPath()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temporary directory: %v", err)
|
||||
}
|
||||
return sockPath
|
||||
}
|
||||
|
||||
func TestHTTPLogServer(t *testing.T) {
|
||||
sockPath := unixDomainSocketPath(t)
|
||||
|
||||
// Create log channel
|
||||
stopLogCh := make(chan struct{})
|
||||
|
||||
// Start HTTP log server in a goroutine
|
||||
serverErr := make(chan error, 1)
|
||||
go func() {
|
||||
serverErr <- httpLogServer(sockPath, stopLogCh)
|
||||
}()
|
||||
|
||||
// Wait a bit for server to start
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Create HTTP client
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return net.Dial("unix", sockPath)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("Ping endpoint", func(t *testing.T) {
|
||||
resp, err := client.Get("http://unix" + httpLogEndpointPing)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to ping server: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Ping endpoint wrong method", func(t *testing.T) {
|
||||
resp, err := client.Post("http://unix"+httpLogEndpointPing, "text/plain", bytes.NewReader([]byte("test")))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to send POST to ping: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusMethodNotAllowed {
|
||||
t.Errorf("Expected status 405, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Log endpoint", func(t *testing.T) {
|
||||
testLog := "test log message"
|
||||
resp, err := client.Post("http://unix"+httpLogEndpointLogs, "text/plain", bytes.NewReader([]byte(testLog)))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to send log: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Check if log was stored by retrieving it
|
||||
logsResp, err := client.Get("http://unix" + httpLogEndpointLogs)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get logs: %v", err)
|
||||
}
|
||||
defer logsResp.Body.Close()
|
||||
|
||||
if logsResp.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status 200 for logs, got %d", logsResp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(logsResp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read logs: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(body), testLog) {
|
||||
t.Errorf("Expected log '%s' not found in stored logs", testLog)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Log endpoint wrong method", func(t *testing.T) {
|
||||
// Test unsupported method (PUT) on /logs endpoint
|
||||
req, err := http.NewRequest("PUT", "http://unix"+httpLogEndpointLogs, bytes.NewReader([]byte("test")))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create PUT request: %v", err)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to send PUT to logs: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusMethodNotAllowed {
|
||||
t.Errorf("Expected status 405, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Exit endpoint", func(t *testing.T) {
|
||||
resp, err := client.Post("http://unix"+httpLogEndpointExit, "text/plain", bytes.NewReader([]byte{}))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to send exit: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Check if channel is closed by trying to read from it
|
||||
select {
|
||||
case _, ok := <-stopLogCh:
|
||||
if ok {
|
||||
t.Error("Expected channel to be closed, but it's still open")
|
||||
}
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Error("Timeout waiting for channel closure")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Exit endpoint wrong method", func(t *testing.T) {
|
||||
resp, err := client.Get("http://unix" + httpLogEndpointExit)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to send GET to exit: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusMethodNotAllowed {
|
||||
t.Errorf("Expected status 405, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Multiple log messages", func(t *testing.T) {
|
||||
logs := []string{"log1", "log2", "log3"}
|
||||
|
||||
for _, log := range logs {
|
||||
resp, err := client.Post("http://unix"+httpLogEndpointLogs, "text/plain", bytes.NewReader([]byte(log+"\n")))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to send log '%s': %v", log, err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// Check if all logs were stored by retrieving them
|
||||
logsResp, err := client.Get("http://unix" + httpLogEndpointLogs)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get logs: %v", err)
|
||||
}
|
||||
defer logsResp.Body.Close()
|
||||
|
||||
if logsResp.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status 200 for logs, got %d", logsResp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(logsResp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read logs: %v", err)
|
||||
}
|
||||
|
||||
logContent := string(body)
|
||||
for i, expectedLog := range logs {
|
||||
if !strings.Contains(logContent, expectedLog) {
|
||||
t.Errorf("Log %d: expected '%s' not found in stored logs", i, expectedLog)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Large log message", func(t *testing.T) {
|
||||
largeLog := strings.Repeat("a", 1024*10) // 10KB log message
|
||||
resp, err := client.Post("http://unix"+httpLogEndpointLogs, "text/plain", bytes.NewReader([]byte(largeLog)))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to send large log: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Check if large log was stored by retrieving it
|
||||
logsResp, err := client.Get("http://unix" + httpLogEndpointLogs)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get logs: %v", err)
|
||||
}
|
||||
defer logsResp.Body.Close()
|
||||
|
||||
if logsResp.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status 200 for logs, got %d", logsResp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(logsResp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read logs: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(body), largeLog) {
|
||||
t.Error("Large log message was not stored correctly")
|
||||
}
|
||||
})
|
||||
|
||||
// Clean up
|
||||
os.Remove(sockPath)
|
||||
}
|
||||
|
||||
func TestHTTPLogServerInvalidSocketPath(t *testing.T) {
|
||||
// Test with invalid socket path
|
||||
invalidPath := "/invalid/path/that/does/not/exist.sock"
|
||||
stopLogCh := make(chan struct{})
|
||||
|
||||
err := httpLogServer(invalidPath, stopLogCh)
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid socket path")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "could not listen log socket") {
|
||||
t.Errorf("Expected 'could not listen log socket' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPLogServerSocketInUse(t *testing.T) {
|
||||
// Create a temporary socket path
|
||||
sockPath := unixDomainSocketPath(t)
|
||||
defer os.Remove(sockPath)
|
||||
|
||||
// Create the first server
|
||||
stopLogCh1 := make(chan struct{})
|
||||
serverErr1 := make(chan error, 1)
|
||||
go func() {
|
||||
serverErr1 <- httpLogServer(sockPath, stopLogCh1)
|
||||
}()
|
||||
|
||||
// Wait for first server to start
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Try to create a second server on the same socket
|
||||
stopLogCh2 := make(chan struct{})
|
||||
err := httpLogServer(sockPath, stopLogCh2)
|
||||
if err == nil {
|
||||
t.Error("Expected error when socket is already in use")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "could not listen log socket") {
|
||||
t.Errorf("Expected 'could not listen log socket' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPLogServerConcurrentRequests(t *testing.T) {
|
||||
// Create a temporary socket path
|
||||
sockPath := unixDomainSocketPath(t)
|
||||
defer os.Remove(sockPath)
|
||||
|
||||
// Create log channel
|
||||
stopLogCh := make(chan struct{})
|
||||
|
||||
// Start HTTP log server in a goroutine
|
||||
serverErr := make(chan error, 1)
|
||||
go func() {
|
||||
serverErr <- httpLogServer(sockPath, stopLogCh)
|
||||
}()
|
||||
|
||||
// Wait for server to start
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Create HTTP client
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return net.Dial("unix", sockPath)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Send concurrent requests
|
||||
numRequests := 10
|
||||
done := make(chan bool, numRequests)
|
||||
|
||||
for i := 0; i < numRequests; i++ {
|
||||
go func(i int) {
|
||||
defer func() { done <- true }()
|
||||
|
||||
logMsg := fmt.Sprintf("concurrent log %d", i)
|
||||
resp, err := client.Post("http://unix"+httpLogEndpointLogs, "text/plain", bytes.NewReader([]byte(logMsg)))
|
||||
if err != nil {
|
||||
t.Errorf("Failed to send concurrent log %d: %v", i, err)
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status 200 for request %d, got %d", i, resp.StatusCode)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Wait for all requests to complete
|
||||
for i := 0; i < numRequests; i++ {
|
||||
select {
|
||||
case <-done:
|
||||
// Request completed
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Errorf("Timeout waiting for concurrent request %d", i)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if all logs were stored by retrieving them
|
||||
logsResp, err := client.Get("http://unix" + httpLogEndpointLogs)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get logs: %v", err)
|
||||
}
|
||||
defer logsResp.Body.Close()
|
||||
|
||||
if logsResp.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status 200 for logs, got %d", logsResp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(logsResp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read logs: %v", err)
|
||||
}
|
||||
|
||||
logContent := string(body)
|
||||
// Verify all logs were stored
|
||||
for i := 0; i < numRequests; i++ {
|
||||
expectedLog := fmt.Sprintf("concurrent log %d", i)
|
||||
if !strings.Contains(logContent, expectedLog) {
|
||||
t.Errorf("Log '%s' was not stored", expectedLog)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPLogServerErrorHandling(t *testing.T) {
|
||||
// Create a temporary socket path
|
||||
sockPath := unixDomainSocketPath(t)
|
||||
defer os.Remove(sockPath)
|
||||
|
||||
// Create log channel
|
||||
stopLogCh := make(chan struct{})
|
||||
|
||||
// Start HTTP log server in a goroutine
|
||||
serverErr := make(chan error, 1)
|
||||
go func() {
|
||||
serverErr <- httpLogServer(sockPath, stopLogCh)
|
||||
}()
|
||||
|
||||
// Wait for server to start
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Create HTTP client
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return net.Dial("unix", sockPath)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("Invalid request body", func(t *testing.T) {
|
||||
// Test with malformed request - this will fail at HTTP level, not server level
|
||||
// The server will return 400 Bad Request for invalid body
|
||||
resp, err := client.Post("http://unix"+httpLogEndpointLogs, "text/plain", strings.NewReader(""))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to send request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Empty body should still be processed successfully
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkHTTPLogServer(b *testing.B) {
|
||||
// Create a temporary socket path
|
||||
tmpDir := b.TempDir()
|
||||
sockPath := filepath.Join(tmpDir, "bench.sock")
|
||||
|
||||
// Create log channel
|
||||
stopLogCh := make(chan struct{})
|
||||
|
||||
// Start HTTP log server in a goroutine
|
||||
go func() {
|
||||
httpLogServer(sockPath, stopLogCh)
|
||||
}()
|
||||
|
||||
// Wait for server to start
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Create HTTP client
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return net.Dial("unix", sockPath)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Benchmark log sending
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
logMsg := fmt.Sprintf("benchmark log %d", i)
|
||||
resp, err := client.Post("http://unix"+httpLogEndpointLogs, "text/plain", bytes.NewReader([]byte(logMsg)))
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to send log: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// Clean up
|
||||
os.Remove(sockPath)
|
||||
}
|
||||
|
||||
func TestHTTPLogClient(t *testing.T) {
|
||||
// Create a temporary socket path
|
||||
sockPath := unixDomainSocketPath(t)
|
||||
defer os.Remove(sockPath)
|
||||
|
||||
// Create log channel
|
||||
stopLogCh := make(chan struct{})
|
||||
|
||||
// Start HTTP log server in a goroutine
|
||||
serverErr := make(chan error, 1)
|
||||
go func() {
|
||||
serverErr <- httpLogServer(sockPath, stopLogCh)
|
||||
}()
|
||||
|
||||
// Wait for server to start
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Create HTTP log client
|
||||
client := newHTTPLogClient(sockPath)
|
||||
|
||||
t.Run("Ping server", func(t *testing.T) {
|
||||
err := client.Ping()
|
||||
if err != nil {
|
||||
t.Errorf("Ping failed: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Write logs", func(t *testing.T) {
|
||||
testLog := "test log message from client"
|
||||
n, err := client.Write([]byte(testLog))
|
||||
if err != nil {
|
||||
t.Errorf("Write failed: %v", err)
|
||||
}
|
||||
if n != len(testLog) {
|
||||
t.Errorf("Expected to write %d bytes, wrote %d", len(testLog), n)
|
||||
}
|
||||
|
||||
// Check if log was stored by retrieving it
|
||||
logs, err := client.GetLogs()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get logs: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(logs), testLog) {
|
||||
t.Errorf("Expected log '%s' not found in stored logs", testLog)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Close client", func(t *testing.T) {
|
||||
err := client.Close()
|
||||
if err != nil {
|
||||
t.Errorf("Close failed: %v", err)
|
||||
}
|
||||
|
||||
// Check if channel is closed (signaling completion)
|
||||
select {
|
||||
case _, ok := <-stopLogCh:
|
||||
if ok {
|
||||
t.Error("Expected channel to be closed, but it's still open")
|
||||
}
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Error("Timeout waiting for channel closure")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHTTPLogClientServerUnavailable(t *testing.T) {
|
||||
// Create client with non-existent socket
|
||||
sockPath := "/non/existent/socket.sock"
|
||||
client := newHTTPLogClient(sockPath)
|
||||
|
||||
t.Run("Ping unavailable server", func(t *testing.T) {
|
||||
err := client.Ping()
|
||||
if err == nil {
|
||||
t.Error("Expected ping to fail for unavailable server")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Write to unavailable server", func(t *testing.T) {
|
||||
testLog := "test log message"
|
||||
n, err := client.Write([]byte(testLog))
|
||||
if err != nil {
|
||||
t.Errorf("Write should not return error (ignores errors): %v", err)
|
||||
}
|
||||
if n != len(testLog) {
|
||||
t.Errorf("Expected to write %d bytes, wrote %d", len(testLog), n)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Close unavailable server", func(t *testing.T) {
|
||||
err := client.Close()
|
||||
if err == nil {
|
||||
t.Error("Expected close to fail for unavailable server")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkHTTPLogClient(b *testing.B) {
|
||||
// Create a temporary socket path
|
||||
tmpDir := b.TempDir()
|
||||
sockPath := filepath.Join(tmpDir, "bench.sock")
|
||||
|
||||
// Create log channel
|
||||
stopLogCh := make(chan struct{})
|
||||
|
||||
// Start HTTP log server in a goroutine
|
||||
go func() {
|
||||
httpLogServer(sockPath, stopLogCh)
|
||||
}()
|
||||
|
||||
// Wait for server to start
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Create HTTP log client
|
||||
client := newHTTPLogClient(sockPath)
|
||||
|
||||
// Benchmark client writes
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
logMsg := fmt.Sprintf("benchmark write %d", i)
|
||||
client.Write([]byte(logMsg))
|
||||
}
|
||||
|
||||
// Clean up
|
||||
os.Remove(sockPath)
|
||||
}
|
||||
|
||||
func TestHTTPLogServerWithLogWriter(t *testing.T) {
|
||||
// Create a temporary socket path
|
||||
sockPath := unixDomainSocketPath(t)
|
||||
defer os.Remove(sockPath)
|
||||
|
||||
// Create log channel
|
||||
stopLogCh := make(chan struct{})
|
||||
|
||||
// Start HTTP log server in a goroutine
|
||||
serverErr := make(chan error, 1)
|
||||
go func() {
|
||||
serverErr <- httpLogServer(sockPath, stopLogCh)
|
||||
}()
|
||||
|
||||
// Wait a bit for server to start
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Create HTTP client
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return net.Dial("unix", sockPath)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("Store and retrieve logs", func(t *testing.T) {
|
||||
// Send multiple log messages
|
||||
logs := []string{"log message 1", "log message 2", "log message 3"}
|
||||
|
||||
for _, log := range logs {
|
||||
resp, err := client.Post("http://unix"+httpLogEndpointLogs, "text/plain", bytes.NewReader([]byte(log+"\n")))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to send log '%s': %v", log, err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// Retrieve all logs
|
||||
resp, err := client.Get("http://unix" + httpLogEndpointLogs)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get logs: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read logs response: %v", err)
|
||||
}
|
||||
|
||||
logContent := string(body)
|
||||
for _, log := range logs {
|
||||
if !strings.Contains(logContent, log) {
|
||||
t.Errorf("Expected log '%s' not found in retrieved logs", log)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Empty logs endpoint", func(t *testing.T) {
|
||||
// Create a new server for this test
|
||||
sockPath2 := unixDomainSocketPath(t)
|
||||
stopLogCh2 := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
httpLogServer(sockPath2, stopLogCh2)
|
||||
}()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
client2 := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return net.Dial("unix", sockPath2)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := client2.Get("http://unix" + httpLogEndpointLogs)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get logs: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("Expected status 204, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
os.Remove(sockPath2)
|
||||
})
|
||||
|
||||
t.Run("Channel closure on exit", func(t *testing.T) {
|
||||
// Send exit signal
|
||||
resp, err := client.Post("http://unix"+httpLogEndpointExit, "text/plain", bytes.NewReader([]byte{}))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to send exit: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Check if channel is closed by trying to read from it
|
||||
select {
|
||||
case _, ok := <-stopLogCh:
|
||||
if ok {
|
||||
t.Error("Expected channel to be closed, but it's still open")
|
||||
}
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Error("Timeout waiting for channel closure")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHTTPLogClientGetLogs(t *testing.T) {
|
||||
// Create a temporary socket path
|
||||
sockPath := unixDomainSocketPath(t)
|
||||
defer os.Remove(sockPath)
|
||||
|
||||
// Create log channel
|
||||
stopLogCh := make(chan struct{})
|
||||
|
||||
// Start HTTP log server in a goroutine
|
||||
go func() {
|
||||
httpLogServer(sockPath, stopLogCh)
|
||||
}()
|
||||
|
||||
// Wait a bit for server to start
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Create HTTP log client
|
||||
client := newHTTPLogClient(sockPath)
|
||||
|
||||
t.Run("Get logs from client", func(t *testing.T) {
|
||||
// Send some logs
|
||||
testLogs := []string{"client log 1", "client log 2", "client log 3"}
|
||||
for _, log := range testLogs {
|
||||
client.Write([]byte(log + "\n"))
|
||||
}
|
||||
|
||||
// Retrieve logs using client method
|
||||
logs, err := client.GetLogs()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get logs: %v", err)
|
||||
}
|
||||
|
||||
logContent := string(logs)
|
||||
for _, log := range testLogs {
|
||||
if !strings.Contains(logContent, log) {
|
||||
t.Errorf("Expected log '%s' not found in retrieved logs", log)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Get empty logs", func(t *testing.T) {
|
||||
// Create a new client for empty logs test
|
||||
sockPath2 := unixDomainSocketPath(t)
|
||||
stopLogCh2 := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
httpLogServer(sockPath2, stopLogCh2)
|
||||
}()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
client2 := newHTTPLogClient(sockPath2)
|
||||
logs, err := client2.GetLogs()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get empty logs: %v", err)
|
||||
}
|
||||
|
||||
if len(logs) != 0 {
|
||||
t.Errorf("Expected empty logs, got %d bytes", len(logs))
|
||||
}
|
||||
|
||||
os.Remove(sockPath2)
|
||||
})
|
||||
}
|
||||
+21
-5
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
// AppCallback provides hooks for injecting certain functionalities
|
||||
// from mobile platforms to main ctrld cli.
|
||||
// This allows mobile applications to customize behavior without modifying core CLI code
|
||||
type AppCallback struct {
|
||||
HostName func() string
|
||||
LanIp func() string
|
||||
@@ -17,6 +18,7 @@ type AppCallback struct {
|
||||
}
|
||||
|
||||
// AppConfig allows overwriting ctrld cli flags from mobile platforms.
|
||||
// This provides a clean interface for mobile apps to configure ctrld behavior
|
||||
type AppConfig struct {
|
||||
CdUID string
|
||||
ProvisionID string
|
||||
@@ -27,18 +29,29 @@ type AppConfig struct {
|
||||
LogPath string
|
||||
}
|
||||
|
||||
// Network and HTTP configuration constants
|
||||
const (
|
||||
// defaultHTTPTimeout provides reasonable timeout for HTTP operations
|
||||
// This prevents hanging requests while allowing sufficient time for network delays
|
||||
defaultHTTPTimeout = 30 * time.Second
|
||||
defaultMaxRetries = 3
|
||||
downloadServerIp = "23.171.240.151"
|
||||
|
||||
// defaultMaxRetries provides retry attempts for failed HTTP requests
|
||||
// This improves reliability in unstable network conditions
|
||||
defaultMaxRetries = 3
|
||||
|
||||
// downloadServerIp is the fallback IP for download operations
|
||||
// This ensures downloads work even when DNS resolution fails
|
||||
downloadServerIp = "23.171.240.151"
|
||||
)
|
||||
|
||||
// httpClientWithFallback returns an HTTP client configured with timeout and IPv4 fallback
|
||||
// This ensures reliable HTTP operations by preferring IPv4 and handling timeouts gracefully
|
||||
func httpClientWithFallback(timeout time.Duration) *http.Client {
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
// Prefer IPv4 over IPv6
|
||||
// This improves compatibility with networks that have IPv6 issues
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
@@ -49,6 +62,7 @@ func httpClientWithFallback(timeout time.Duration) *http.Client {
|
||||
}
|
||||
|
||||
// doWithRetry performs an HTTP request with retries
|
||||
// This improves reliability by automatically retrying failed requests with exponential backoff
|
||||
func doWithRetry(req *http.Request, maxRetries int, ip string) (*http.Response, error) {
|
||||
var lastErr error
|
||||
client := httpClientWithFallback(defaultHTTPTimeout)
|
||||
@@ -60,7 +74,8 @@ func doWithRetry(req *http.Request, maxRetries int, ip string) (*http.Response,
|
||||
}
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
time.Sleep(time.Second * time.Duration(attempt+1)) // Exponential backoff
|
||||
// Linear backoff reduces server load and improves success rate
|
||||
time.Sleep(time.Second * time.Duration(attempt+1))
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
@@ -68,8 +83,8 @@ func doWithRetry(req *http.Request, maxRetries int, ip string) (*http.Response,
|
||||
return resp, nil
|
||||
}
|
||||
if ipReq != nil {
|
||||
mainLog.Load().Warn().Err(err).Msgf("dial to %q failed", req.Host)
|
||||
mainLog.Load().Warn().Msgf("fallback to direct IP to download prod version: %q", ip)
|
||||
mainLog.Load().Warn().Err(err).Msgf("Dial to %q failed", req.Host)
|
||||
mainLog.Load().Warn().Msgf("Fallback to direct ip to download prod version: %q", ip)
|
||||
resp, err = client.Do(ipReq)
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
@@ -86,6 +101,7 @@ func doWithRetry(req *http.Request, maxRetries int, ip string) (*http.Response,
|
||||
}
|
||||
|
||||
// Helper for making GET requests with retries
|
||||
// This provides a simplified interface for common GET operations with built-in retry logic
|
||||
func getWithRetry(url string, ip string) (*http.Response, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
|
||||
+237
-36
@@ -7,36 +7,101 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
)
|
||||
|
||||
// Log writer constants for buffer management and log formatting
|
||||
const (
|
||||
logWriterSize = 1024 * 1024 * 5 // 5 MB
|
||||
logWriterSmallSize = 1024 * 1024 * 1 // 1 MB
|
||||
logWriterInitialSize = 32 * 1024 // 32 KB
|
||||
logWriterSentInterval = time.Minute
|
||||
// logWriterSize is the default buffer size for log writers
|
||||
// This provides sufficient space for runtime logs without excessive memory usage
|
||||
logWriterSize = 1024 * 1024 * 5 // 5 MB
|
||||
|
||||
// logWriterSmallSize is used for memory-constrained environments
|
||||
// This reduces memory footprint while still maintaining log functionality
|
||||
logWriterSmallSize = 1024 * 1024 * 1 // 1 MB
|
||||
|
||||
// logWriterInitialSize is the initial buffer allocation
|
||||
// This provides immediate space for early log entries
|
||||
logWriterInitialSize = 32 * 1024 // 32 KB
|
||||
|
||||
// logWriterSentInterval controls how often logs are sent to external systems
|
||||
// This balances real-time logging with system performance
|
||||
logWriterSentInterval = time.Minute
|
||||
|
||||
// logWriterInitEndMarker marks the end of initialization logs
|
||||
// This helps separate startup logs from runtime logs
|
||||
logWriterInitEndMarker = "\n\n=== INIT_END ===\n\n"
|
||||
logWriterLogEndMarker = "\n\n=== LOG_END ===\n\n"
|
||||
|
||||
// logWriterLogEndMarker marks the end of log sections
|
||||
// This provides clear boundaries for log parsing and analysis
|
||||
logWriterLogEndMarker = "\n\n=== LOG_END ===\n\n"
|
||||
|
||||
logFileName = "ctrld.log"
|
||||
logFileMaxSize = 1024 * 1024 * 5 // 5 MB
|
||||
)
|
||||
|
||||
// Custom level encoders that handle NOTICE level
|
||||
// Since NOTICE and WARN share the same numeric value (1), we handle them specially
|
||||
// in the encoder to display NOTICE messages with the "NOTICE" prefix.
|
||||
// Note: WARN messages will also display as "NOTICE" because they share the same level value.
|
||||
// This is the intended behavior for visual distinction.
|
||||
|
||||
// noticeLevelEncoder provides custom level encoding for NOTICE level
|
||||
// This ensures NOTICE messages are clearly distinguished from other log levels
|
||||
func noticeLevelEncoder(l zapcore.Level, enc zapcore.PrimitiveArrayEncoder) {
|
||||
switch l {
|
||||
case ctrld.NoticeLevel:
|
||||
enc.AppendString("NOTICE")
|
||||
default:
|
||||
zapcore.CapitalLevelEncoder(l, enc)
|
||||
}
|
||||
}
|
||||
|
||||
// noticeColorLevelEncoder provides colored level encoding for NOTICE level
|
||||
// This uses cyan color to make NOTICE messages visually distinct in terminal output
|
||||
func noticeColorLevelEncoder(l zapcore.Level, enc zapcore.PrimitiveArrayEncoder) {
|
||||
switch l {
|
||||
case ctrld.NoticeLevel:
|
||||
enc.AppendString("\x1b[36mNOTICE\x1b[0m") // Cyan color for NOTICE
|
||||
default:
|
||||
zapcore.CapitalColorLevelEncoder(l, enc)
|
||||
}
|
||||
}
|
||||
|
||||
// logViewResponse represents the response structure for log viewing requests
|
||||
// This provides a consistent JSON format for log data retrieval
|
||||
type logViewResponse struct {
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
// logSentResponse represents the response structure for log sending operations
|
||||
// This includes size information and error details for debugging
|
||||
type logSentResponse struct {
|
||||
Size int64 `json:"size"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// logReader provides read access to log data with size information.
|
||||
//
|
||||
// This struct encapsulates log reading functionality for external consumers,
|
||||
// providing both the log content and metadata about the log size. It supports
|
||||
// reading from both internal log buffers (when no external logging is configured)
|
||||
// and external log files (when logging to file is enabled).
|
||||
//
|
||||
// Fields:
|
||||
// - r: An io.ReadCloser that provides access to the log content
|
||||
// - size: The total size of the log data in bytes
|
||||
//
|
||||
// The logReader is used by the control server to serve log content to clients
|
||||
// and by various CLI commands that need to display or process log data.
|
||||
type logReader struct {
|
||||
r io.ReadCloser
|
||||
size int64
|
||||
@@ -63,16 +128,19 @@ type logWriter struct {
|
||||
}
|
||||
|
||||
// newLogWriter creates an internal log writer.
|
||||
// This provides the default log writer with standard buffer size
|
||||
func newLogWriter() *logWriter {
|
||||
return newLogWriterWithSize(logWriterSize)
|
||||
}
|
||||
|
||||
// newSmallLogWriter creates an internal log writer with small buffer size.
|
||||
// This is used in memory-constrained environments or for temporary logging
|
||||
func newSmallLogWriter() *logWriter {
|
||||
return newLogWriterWithSize(logWriterSmallSize)
|
||||
}
|
||||
|
||||
// newLogWriterWithSize creates an internal log writer with a given buffer size.
|
||||
// This allows customization of log buffer size based on specific requirements
|
||||
func newLogWriterWithSize(size int) *logWriter {
|
||||
lw := &logWriter{size: size}
|
||||
return lw
|
||||
@@ -199,6 +267,8 @@ func (lw *logWriter) tailLastLines(n int) []byte {
|
||||
return result
|
||||
}
|
||||
|
||||
// Write implements io.Writer interface for logWriter
|
||||
// This manages buffer overflow by discarding old data while preserving important markers
|
||||
func (lw *logWriter) Write(p []byte) (int, error) {
|
||||
lw.mu.Lock()
|
||||
defer lw.mu.Unlock()
|
||||
@@ -227,10 +297,12 @@ func (lw *logWriter) Write(p []byte) (int, error) {
|
||||
}
|
||||
|
||||
// If writing p causes overflows, discard old data.
|
||||
// This prevents unbounded memory growth while maintaining recent logs
|
||||
if lw.buf.Len()+len(p) > lw.size {
|
||||
buf := lw.buf.Bytes()
|
||||
haveEndMarker := false
|
||||
// If there's init end marker already, preserve the data til the marker.
|
||||
// This ensures initialization logs are always available for debugging
|
||||
if idx := bytes.LastIndex(buf, []byte(logWriterInitEndMarker)); idx >= 0 {
|
||||
buf = buf[:idx+len(logWriterInitEndMarker)]
|
||||
haveEndMarker = true
|
||||
@@ -256,26 +328,26 @@ func (lw *logWriter) Write(p []byte) (int, error) {
|
||||
|
||||
// initLogging initializes global logging setup.
|
||||
func (p *prog) initLogging(backup bool) {
|
||||
zerolog.TimeFieldFormat = time.RFC3339 + ".000"
|
||||
logWriters := initLoggingWithBackup(backup)
|
||||
logCores := initLoggingWithBackup(backup)
|
||||
|
||||
// Initializing internal logging after global logging.
|
||||
p.initInternalLogging(logWriters)
|
||||
p.initInternalLogging(logCores)
|
||||
p.logger.Store(mainLog.Load())
|
||||
}
|
||||
|
||||
// internalLogFilePath returns the path for persisted internal logs.
|
||||
// The file lives in the ctrld home directory alongside other runtime state.
|
||||
func internalLogFilePath() string {
|
||||
return absHomeDir(logFileName)
|
||||
return ctrld.AbsHomeDir(logFileName)
|
||||
}
|
||||
|
||||
// initInternalLogging performs internal logging if there's no log enabled.
|
||||
func (p *prog) initInternalLogging(writers []io.Writer) {
|
||||
func (p *prog) initInternalLogging(externalCores []zapcore.Core) {
|
||||
if !p.needInternalLogging() {
|
||||
return
|
||||
}
|
||||
p.initInternalLogWriterOnce.Do(func() {
|
||||
mainLog.Load().Notice().Msg("internal logging enabled")
|
||||
p.Notice().Msg("Internal logging enabled")
|
||||
p.internalLogWriter = newLogWriter()
|
||||
p.internalLogSent = time.Now().Add(-logWriterSentInterval)
|
||||
p.internalWarnLogWriter = newSmallLogWriter()
|
||||
@@ -292,28 +364,26 @@ func (p *prog) initInternalLogging(writers []io.Writer) {
|
||||
lw := p.internalLogWriter
|
||||
wlw := p.internalWarnLogWriter
|
||||
p.mu.Unlock()
|
||||
// If ctrld was run without explicit verbose level,
|
||||
// run the internal logging at debug level, so we could
|
||||
|
||||
// Create zap cores for different writers
|
||||
var cores []zapcore.Core
|
||||
cores = append(cores, externalCores...)
|
||||
|
||||
// Add core for internal log writer.
|
||||
// Run the internal logging at debug level, so we could
|
||||
// have enough information for troubleshooting.
|
||||
if verbose == 0 {
|
||||
for i := range writers {
|
||||
w := &zerolog.FilteredLevelWriter{
|
||||
Writer: zerolog.LevelWriterAdapter{Writer: writers[i]},
|
||||
Level: zerolog.NoticeLevel,
|
||||
}
|
||||
writers[i] = w
|
||||
}
|
||||
zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
}
|
||||
writers = append(writers, lw)
|
||||
writers = append(writers, &zerolog.FilteredLevelWriter{
|
||||
Writer: zerolog.LevelWriterAdapter{Writer: wlw},
|
||||
Level: zerolog.WarnLevel,
|
||||
})
|
||||
multi := zerolog.MultiLevelWriter(writers...)
|
||||
l := mainLog.Load().Output(multi).With().Logger()
|
||||
mainLog.Store(&l)
|
||||
ctrld.ProxyLogger.Store(&l)
|
||||
internalCore := newHumanReadableZapCore(lw, zapcore.DebugLevel)
|
||||
cores = append(cores, internalCore)
|
||||
|
||||
// Add core for internal warn log writer
|
||||
warnCore := newHumanReadableZapCore(wlw, zapcore.WarnLevel)
|
||||
cores = append(cores, warnCore)
|
||||
|
||||
// Create a multi-core logger
|
||||
multiCore := zapcore.NewTee(cores...)
|
||||
logger := zap.New(multiCore)
|
||||
|
||||
mainLog.Store(&ctrld.Logger{Logger: logger})
|
||||
}
|
||||
|
||||
// needInternalLogging reports whether prog needs to run internal logging.
|
||||
@@ -329,7 +399,69 @@ func (p *prog) needInternalLogging() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *prog) logReader() (*logReader, error) {
|
||||
// logReaderNoColor returns a logReader with ANSI color codes stripped from the log content.
|
||||
//
|
||||
// This method is useful when log content needs to be processed by tools that don't
|
||||
// handle ANSI escape sequences properly, or when storing logs in plain text format.
|
||||
// It internally calls logReader(true) to strip color codes.
|
||||
//
|
||||
// Returns:
|
||||
// - *logReader: A logReader instance with color codes removed, or nil if no logs available
|
||||
// - error: Any error encountered during log reading (e.g., empty logs, file access issues)
|
||||
//
|
||||
// Use cases:
|
||||
// - Log processing pipelines that require plain text
|
||||
// - Storing logs in databases or text files
|
||||
// - Displaying logs in environments that don't support color
|
||||
func (p *prog) logReaderNoColor() (*logReader, error) {
|
||||
return p.logReader(true)
|
||||
}
|
||||
|
||||
// logReaderRaw returns a logReader with ANSI color codes preserved in the log content.
|
||||
//
|
||||
// This method maintains the original formatting of log entries including color codes,
|
||||
// which is useful for displaying logs in terminals that support ANSI colors or when
|
||||
// the original visual formatting needs to be preserved. It internally calls logReader(false).
|
||||
//
|
||||
// Returns:
|
||||
// - *logReader: A logReader instance with color codes preserved, or nil if no logs available
|
||||
// - error: Any error encountered during log reading (e.g., empty logs, file access issues)
|
||||
//
|
||||
// Use cases:
|
||||
// - Terminal-based log viewers that support color
|
||||
// - Interactive debugging sessions
|
||||
// - Preserving original log formatting for display
|
||||
func (p *prog) logReaderRaw() (*logReader, error) {
|
||||
return p.logReader(false)
|
||||
}
|
||||
|
||||
// logReader creates a logReader instance for accessing log content with optional color stripping.
|
||||
//
|
||||
// This is the core method that handles log reading from different sources based on the
|
||||
// current logging configuration. It supports both internal logging (when no external
|
||||
// logging is configured) and external file logging (when logging to file is enabled).
|
||||
//
|
||||
// Behavior:
|
||||
// - Internal logging: Reads from internal log buffers (normal logs + warning logs)
|
||||
// and combines them with appropriate markers for separation
|
||||
// - External logging: Reads directly from the configured log file
|
||||
// - Empty logs: Returns appropriate error messages when no log content is available
|
||||
//
|
||||
// Parameters:
|
||||
// - stripColor: If true, removes ANSI color codes from log content; if false, preserves them
|
||||
//
|
||||
// Returns:
|
||||
// - *logReader: A logReader instance providing access to log content and size metadata
|
||||
// - error: Any error encountered during log reading, including:
|
||||
// - "nil internal log writer" - Internal logging not properly initialized
|
||||
// - "nil internal warn log writer" - Warning log writer not properly initialized
|
||||
// - "internal log is empty" - No content in internal log buffers
|
||||
// - "log file is empty" - External log file exists but contains no data
|
||||
// - File system errors when accessing external log files
|
||||
//
|
||||
// The method handles thread-safe access to internal log buffers and provides
|
||||
// comprehensive error handling for various edge cases.
|
||||
func (p *prog) logReader(stripColor bool) (*logReader, error) {
|
||||
if p.needInternalLogging() {
|
||||
p.mu.Lock()
|
||||
lw := p.internalLogWriter
|
||||
@@ -351,12 +483,12 @@ func (p *prog) logReader() (*logReader, error) {
|
||||
|
||||
// Fall back to in-memory buffer.
|
||||
lw.mu.Lock()
|
||||
lwReader := bytes.NewReader(lw.buf.Bytes())
|
||||
lwReader := newLogReader(&lw.buf, stripColor)
|
||||
lwSize := lw.buf.Len()
|
||||
lw.mu.Unlock()
|
||||
// Warn log content.
|
||||
wlw.mu.Lock()
|
||||
wlwReader := bytes.NewReader(wlw.buf.Bytes())
|
||||
wlwReader := newLogReader(&wlw.buf, stripColor)
|
||||
wlwSize := wlw.buf.Len()
|
||||
wlw.mu.Unlock()
|
||||
reader := io.MultiReader(lwReader, bytes.NewReader([]byte(logWriterLogEndMarker)), wlwReader)
|
||||
@@ -386,6 +518,75 @@ func (p *prog) logReader() (*logReader, error) {
|
||||
return lr, nil
|
||||
}
|
||||
|
||||
// newHumanReadableZapCore creates a zap core optimized for human-readable log output.
|
||||
//
|
||||
// Features:
|
||||
// - Uses development encoder configuration for enhanced readability
|
||||
// - Console encoding with colored log levels for easy visual scanning
|
||||
// - Millisecond precision timestamps in human-friendly format
|
||||
// - Structured field output with clear key-value pairs
|
||||
// - Ideal for development, debugging, and interactive terminal sessions
|
||||
//
|
||||
// Parameters:
|
||||
// - w: The output writer (e.g., os.Stdout, file, buffer)
|
||||
// - level: Minimum log level to capture (e.g., Debug, Info, Warn, Error)
|
||||
//
|
||||
// Returns a zapcore.Core configured for human consumption.
|
||||
func newHumanReadableZapCore(w io.Writer, level zapcore.Level) zapcore.Core {
|
||||
encoderConfig := zap.NewDevelopmentEncoderConfig()
|
||||
encoderConfig.TimeKey = "time"
|
||||
encoderConfig.EncodeTime = zapcore.TimeEncoderOfLayout(time.StampMilli)
|
||||
encoderConfig.EncodeLevel = noticeColorLevelEncoder
|
||||
encoder := zapcore.NewConsoleEncoder(encoderConfig)
|
||||
return zapcore.NewCore(encoder, zapcore.AddSync(w), level)
|
||||
}
|
||||
|
||||
// newMachineFriendlyZapCore creates a zap core optimized for machine processing and log aggregation.
|
||||
//
|
||||
// Features:
|
||||
// - Uses production encoder configuration for consistent, parseable output
|
||||
// - Console encoding with non-colored log levels for log parsing tools
|
||||
// - Millisecond precision timestamps in ISO-like format
|
||||
// - Structured field output optimized for log aggregation systems
|
||||
// - Ideal for production environments, log shipping, and automated analysis
|
||||
//
|
||||
// Parameters:
|
||||
// - w: The output writer (e.g., os.Stdout, file, buffer)
|
||||
// - level: Minimum log level to capture (e.g., Debug, Info, Warn, Error)
|
||||
//
|
||||
// Returns a zapcore.Core configured for machine consumption and log aggregation.
|
||||
func newMachineFriendlyZapCore(w io.Writer, level zapcore.Level) zapcore.Core {
|
||||
encoderConfig := zap.NewProductionEncoderConfig()
|
||||
encoderConfig.TimeKey = "time"
|
||||
encoderConfig.EncodeTime = zapcore.TimeEncoderOfLayout(time.StampMilli)
|
||||
encoderConfig.EncodeLevel = noticeLevelEncoder
|
||||
encoder := zapcore.NewConsoleEncoder(encoderConfig)
|
||||
return zapcore.NewCore(encoder, zapcore.AddSync(w), level)
|
||||
}
|
||||
|
||||
// ansiRegex is a regular expression to match ANSI color codes.
|
||||
var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`)
|
||||
|
||||
// newLogReader creates a reader for log buffer content with optional ANSI color stripping.
|
||||
//
|
||||
// This function provides flexible log content access by allowing consumers to choose
|
||||
// between raw log data (with ANSI color codes) or stripped content (without color codes).
|
||||
// The color stripping is useful when logs need to be processed by tools that don't
|
||||
// handle ANSI escape sequences properly, or when storing logs in plain text format.
|
||||
//
|
||||
// Parameters:
|
||||
// - buf: The log buffer containing the log data to read
|
||||
// - stripColor: If true, strips ANSI color codes from the log content;
|
||||
// if false, returns raw log content with color codes preserved
|
||||
//
|
||||
// Returns an io.Reader that provides access to the processed log content.
|
||||
func newLogReader(buf *bytes.Buffer, stripColor bool) io.Reader {
|
||||
if stripColor {
|
||||
return strings.NewReader(ansiRegex.ReplaceAllString(buf.String(), ""))
|
||||
}
|
||||
return strings.NewReader(buf.String())
|
||||
}
|
||||
|
||||
// logReaderFromFiles builds a logReader that concatenates the backup file
|
||||
// (if it exists), the current log file, and the in-memory warn log buffer.
|
||||
func (p *prog) logReaderFromFiles(current, backup string, wlw *logWriter) (*logReader, error) {
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
)
|
||||
|
||||
func Test_logWriter_Write(t *testing.T) {
|
||||
@@ -86,6 +93,331 @@ func Test_logWriter_MarkerInitEnd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoticeLevel tests that the custom NOTICE level works correctly
|
||||
func TestNoticeLevel(t *testing.T) {
|
||||
// Create a buffer to capture log output
|
||||
var buf bytes.Buffer
|
||||
|
||||
// Create encoder config with custom NOTICE level support
|
||||
encoderConfig := zap.NewDevelopmentEncoderConfig()
|
||||
encoderConfig.TimeKey = "time"
|
||||
encoderConfig.EncodeTime = zapcore.TimeEncoderOfLayout("15:04:05.000")
|
||||
encoderConfig.EncodeLevel = noticeLevelEncoder
|
||||
|
||||
// Test with NOTICE level
|
||||
encoder := zapcore.NewConsoleEncoder(encoderConfig)
|
||||
core := zapcore.NewCore(encoder, zapcore.AddSync(&buf), ctrld.NoticeLevel)
|
||||
logger := zap.New(core)
|
||||
ctrldLogger := &ctrld.Logger{Logger: logger}
|
||||
|
||||
// Log messages at different levels
|
||||
ctrldLogger.Debug().Msg("This is a DEBUG message")
|
||||
ctrldLogger.Info().Msg("This is an INFO message")
|
||||
ctrldLogger.Notice().Msg("This is a NOTICE message")
|
||||
ctrldLogger.Warn().Msg("This is a WARN message")
|
||||
ctrldLogger.Error().Msg("This is an ERROR message")
|
||||
|
||||
output := buf.String()
|
||||
|
||||
// Verify that DEBUG and INFO messages are NOT logged (filtered out)
|
||||
if strings.Contains(output, "DEBUG") {
|
||||
t.Error("DEBUG message should not be logged when level is NOTICE")
|
||||
}
|
||||
if strings.Contains(output, "INFO") {
|
||||
t.Error("INFO message should not be logged when level is NOTICE")
|
||||
}
|
||||
|
||||
// Verify that NOTICE, WARN, and ERROR messages ARE logged
|
||||
if !strings.Contains(output, "NOTICE") {
|
||||
t.Error("NOTICE message should be logged when level is NOTICE")
|
||||
}
|
||||
if !strings.Contains(output, "WARN") {
|
||||
t.Error("WARN message should be logged when level is NOTICE")
|
||||
}
|
||||
if !strings.Contains(output, "ERROR") {
|
||||
t.Error("ERROR message should be logged when level is NOTICE")
|
||||
}
|
||||
|
||||
// Verify the NOTICE message content
|
||||
if !strings.Contains(output, "This is a NOTICE message") {
|
||||
t.Error("NOTICE message content should be present")
|
||||
}
|
||||
|
||||
t.Logf("Log output with NOTICE level:\n%s", output)
|
||||
}
|
||||
|
||||
func TestNewLogReader(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
bufContent string
|
||||
stripColor bool
|
||||
expected string
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "empty_buffer_no_color_strip",
|
||||
bufContent: "",
|
||||
stripColor: false,
|
||||
expected: "",
|
||||
description: "Empty buffer should return empty reader",
|
||||
},
|
||||
{
|
||||
name: "empty_buffer_with_color_strip",
|
||||
bufContent: "",
|
||||
stripColor: true,
|
||||
expected: "",
|
||||
description: "Empty buffer with color strip should return empty reader",
|
||||
},
|
||||
{
|
||||
name: "plain_text_no_color_strip",
|
||||
bufContent: "This is plain text without any color codes",
|
||||
stripColor: false,
|
||||
expected: "This is plain text without any color codes",
|
||||
description: "Plain text should be returned as-is when not stripping colors",
|
||||
},
|
||||
{
|
||||
name: "plain_text_with_color_strip",
|
||||
bufContent: "This is plain text without any color codes",
|
||||
stripColor: true,
|
||||
expected: "This is plain text without any color codes",
|
||||
description: "Plain text should be returned as-is when stripping colors",
|
||||
},
|
||||
{
|
||||
name: "text_with_ansi_codes_no_strip",
|
||||
bufContent: "Normal text \x1b[31mred text\x1b[0m normal again",
|
||||
stripColor: false,
|
||||
expected: "Normal text \x1b[31mred text\x1b[0m normal again",
|
||||
description: "ANSI color codes should be preserved when not stripping",
|
||||
},
|
||||
{
|
||||
name: "text_with_ansi_codes_with_strip",
|
||||
bufContent: "Normal text \x1b[31mred text\x1b[0m normal again",
|
||||
stripColor: true,
|
||||
expected: "Normal text red text normal again",
|
||||
description: "ANSI color codes should be removed when stripping colors",
|
||||
},
|
||||
{
|
||||
name: "multiple_ansi_codes_no_strip",
|
||||
bufContent: "\x1b[1mBold\x1b[0m \x1b[32mGreen\x1b[0m \x1b[34mBlue\x1b[0m text",
|
||||
stripColor: false,
|
||||
expected: "\x1b[1mBold\x1b[0m \x1b[32mGreen\x1b[0m \x1b[34mBlue\x1b[0m text",
|
||||
description: "Multiple ANSI codes should be preserved when not stripping",
|
||||
},
|
||||
{
|
||||
name: "multiple_ansi_codes_with_strip",
|
||||
bufContent: "\x1b[1mBold\x1b[0m \x1b[32mGreen\x1b[0m \x1b[34mBlue\x1b[0m text",
|
||||
stripColor: true,
|
||||
expected: "Bold Green Blue text",
|
||||
description: "Multiple ANSI codes should be removed when stripping colors",
|
||||
},
|
||||
{
|
||||
name: "complex_ansi_sequences_no_strip",
|
||||
bufContent: "\x1b[1;31;42mBold red on green\x1b[0m \x1b[38;5;208mOrange\x1b[0m",
|
||||
stripColor: false,
|
||||
expected: "\x1b[1;31;42mBold red on green\x1b[0m \x1b[38;5;208mOrange\x1b[0m",
|
||||
description: "Complex ANSI sequences should be preserved when not stripping",
|
||||
},
|
||||
{
|
||||
name: "complex_ansi_sequences_with_strip",
|
||||
bufContent: "\x1b[1;31;42mBold red on green\x1b[0m \x1b[38;5;208mOrange\x1b[0m",
|
||||
stripColor: true,
|
||||
expected: "Bold red on green Orange",
|
||||
description: "Complex ANSI sequences should be removed when stripping colors",
|
||||
},
|
||||
{
|
||||
name: "ansi_codes_with_newlines_no_strip",
|
||||
bufContent: "Line 1\n\x1b[31mRed line\x1b[0m\nLine 3",
|
||||
stripColor: false,
|
||||
expected: "Line 1\n\x1b[31mRed line\x1b[0m\nLine 3",
|
||||
description: "ANSI codes with newlines should be preserved when not stripping",
|
||||
},
|
||||
{
|
||||
name: "ansi_codes_with_newlines_with_strip",
|
||||
bufContent: "Line 1\n\x1b[31mRed line\x1b[0m\nLine 3",
|
||||
stripColor: true,
|
||||
expected: "Line 1\nRed line\nLine 3",
|
||||
description: "ANSI codes with newlines should be removed when stripping colors",
|
||||
},
|
||||
{
|
||||
name: "malformed_ansi_codes_no_strip",
|
||||
bufContent: "Text \x1b[invalidm \x1b[0m normal",
|
||||
stripColor: false,
|
||||
expected: "Text \x1b[invalidm \x1b[0m normal",
|
||||
description: "Malformed ANSI codes should be preserved when not stripping",
|
||||
},
|
||||
{
|
||||
name: "malformed_ansi_codes_with_strip",
|
||||
bufContent: "Text \x1b[invalidm \x1b[0m normal",
|
||||
stripColor: true,
|
||||
expected: "Text \x1b[invalidm normal",
|
||||
description: "Non-matching ANSI sequences should be preserved when stripping colors",
|
||||
},
|
||||
{
|
||||
name: "large_buffer_no_strip",
|
||||
bufContent: strings.Repeat("A", 10000) + "\x1b[31m" + strings.Repeat("B", 1000) + "\x1b[0m",
|
||||
stripColor: false,
|
||||
expected: strings.Repeat("A", 10000) + "\x1b[31m" + strings.Repeat("B", 1000) + "\x1b[0m",
|
||||
description: "Large buffer should handle ANSI codes correctly when not stripping",
|
||||
},
|
||||
{
|
||||
name: "large_buffer_with_strip",
|
||||
bufContent: strings.Repeat("A", 10000) + "\x1b[31m" + strings.Repeat("B", 1000) + "\x1b[0m",
|
||||
stripColor: true,
|
||||
expected: strings.Repeat("A", 10000) + strings.Repeat("B", 1000),
|
||||
description: "Large buffer should remove ANSI codes correctly when stripping",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create a buffer with the test content
|
||||
buf := &bytes.Buffer{}
|
||||
buf.WriteString(tt.bufContent)
|
||||
|
||||
// Create the log reader
|
||||
reader := newLogReader(buf, tt.stripColor)
|
||||
|
||||
// Read all content from the reader
|
||||
content, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read from log reader: %v", err)
|
||||
}
|
||||
|
||||
// Verify the content matches expected
|
||||
actual := string(content)
|
||||
if actual != tt.expected {
|
||||
t.Errorf("Expected content: %q, got: %q", tt.expected, actual)
|
||||
t.Logf("Description: %s", tt.description)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLogReader_ReaderBehavior(t *testing.T) {
|
||||
// Test that the returned reader behaves correctly
|
||||
buf := &bytes.Buffer{}
|
||||
buf.WriteString("Test content with \x1b[31mred\x1b[0m text")
|
||||
|
||||
// Test with color stripping
|
||||
reader := newLogReader(buf, true)
|
||||
|
||||
// Test reading in chunks
|
||||
chunk1 := make([]byte, 10)
|
||||
n1, err := reader.Read(chunk1)
|
||||
if err != nil && err != io.EOF {
|
||||
t.Fatalf("Unexpected error reading first chunk: %v", err)
|
||||
}
|
||||
if n1 != 10 {
|
||||
t.Errorf("Expected to read 10 bytes, got %d", n1)
|
||||
}
|
||||
|
||||
// Test reading remaining content
|
||||
remaining, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read remaining content: %v", err)
|
||||
}
|
||||
|
||||
// Verify total content
|
||||
totalContent := string(chunk1[:n1]) + string(remaining)
|
||||
expected := "Test content with red text"
|
||||
if totalContent != expected {
|
||||
t.Errorf("Expected total content: %q, got: %q", expected, totalContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLogReader_ConcurrentAccess(t *testing.T) {
|
||||
// Test concurrent access to the same buffer
|
||||
buf := &bytes.Buffer{}
|
||||
buf.WriteString("Concurrent test with \x1b[32mgreen\x1b[0m text")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
numGoroutines := 10
|
||||
results := make(chan string, numGoroutines)
|
||||
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
reader := newLogReader(buf, true)
|
||||
content, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to read content: %v", err)
|
||||
return
|
||||
}
|
||||
results <- string(content)
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(results)
|
||||
|
||||
// Verify all goroutines got the same result
|
||||
expected := "Concurrent test with green text"
|
||||
for result := range results {
|
||||
if result != expected {
|
||||
t.Errorf("Expected: %q, got: %q", expected, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLogReader_ANSIRegexEdgeCases(t *testing.T) {
|
||||
// Test edge cases for ANSI regex matching
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "empty_escape_sequence",
|
||||
input: "Text \x1b[m normal",
|
||||
expected: "Text normal",
|
||||
},
|
||||
{
|
||||
name: "multiple_semicolons",
|
||||
input: "Text \x1b[1;2;3;4m normal",
|
||||
expected: "Text normal",
|
||||
},
|
||||
{
|
||||
name: "numeric_only",
|
||||
input: "Text \x1b[123m normal",
|
||||
expected: "Text normal",
|
||||
},
|
||||
{
|
||||
name: "mixed_numeric_semicolon",
|
||||
input: "Text \x1b[1;23;456m normal",
|
||||
expected: "Text normal",
|
||||
},
|
||||
{
|
||||
name: "no_closing_bracket",
|
||||
input: "Text \x1b[31 normal",
|
||||
expected: "Text \x1b[31 normal",
|
||||
},
|
||||
{
|
||||
name: "no_opening_bracket",
|
||||
input: "Text 31m normal",
|
||||
expected: "Text 31m normal",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
buf.WriteString(tt.input)
|
||||
|
||||
reader := newLogReader(buf, true)
|
||||
content, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read content: %v", err)
|
||||
}
|
||||
|
||||
actual := string(content)
|
||||
if actual != tt.expected {
|
||||
t.Errorf("Expected: %q, got: %q", tt.expected, actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_logWriter_SetLogFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.log")
|
||||
|
||||
+8
-7
@@ -84,7 +84,7 @@ func (p *prog) detectLoop(msg *dns.Msg) {
|
||||
//
|
||||
// See: https://thekelleys.org.uk/dnsmasq/docs/dnsmasq-man.html
|
||||
func (p *prog) checkDnsLoop() {
|
||||
mainLog.Load().Debug().Msg("start checking DNS loop")
|
||||
p.Debug().Msg("Start checking DNS loop")
|
||||
upstream := make(map[string]*ctrld.UpstreamConfig)
|
||||
p.loopMu.Lock()
|
||||
for n, uc := range p.cfg.Upstream {
|
||||
@@ -93,7 +93,7 @@ func (p *prog) checkDnsLoop() {
|
||||
}
|
||||
// Do not send test query to external upstream.
|
||||
if !canBeLocalUpstream(uc.Domain) {
|
||||
mainLog.Load().Debug().Msgf("skipping external: upstream.%s", n)
|
||||
p.Debug().Msgf("Skipping external: upstream.%s", n)
|
||||
continue
|
||||
}
|
||||
uid := uc.UID()
|
||||
@@ -102,6 +102,7 @@ func (p *prog) checkDnsLoop() {
|
||||
}
|
||||
p.loopMu.Unlock()
|
||||
|
||||
loggerCtx := ctrld.LoggerCtx(context.Background(), p.logger.Load())
|
||||
for uid := range p.loop {
|
||||
msg := loopTestMsg(uid)
|
||||
uc := upstream[uid]
|
||||
@@ -109,16 +110,16 @@ func (p *prog) checkDnsLoop() {
|
||||
if uc == nil {
|
||||
continue
|
||||
}
|
||||
resolver, err := ctrld.NewResolver(uc)
|
||||
resolver, err := ctrld.NewResolver(loggerCtx, uc)
|
||||
if err != nil {
|
||||
mainLog.Load().Warn().Err(err).Msgf("could not perform loop check for upstream: %q, endpoint: %q", uc.Name, uc.Endpoint)
|
||||
p.Warn().Err(err).Msgf("Could not perform loop check for upstream: %q, endpoint: %q", uc.Name, uc.Endpoint)
|
||||
continue
|
||||
}
|
||||
if _, err := resolver.Resolve(context.Background(), msg); err != nil {
|
||||
mainLog.Load().Warn().Err(err).Msgf("could not send DNS loop check query for upstream: %q, endpoint: %q", uc.Name, uc.Endpoint)
|
||||
p.Warn().Err(err).Msgf("Could not send DNS loop check query for upstream: %q, endpoint: %q", uc.Name, uc.Endpoint)
|
||||
}
|
||||
}
|
||||
mainLog.Load().Debug().Msg("end checking DNS loop")
|
||||
p.Debug().Msg("End checking DNS loop")
|
||||
}
|
||||
|
||||
// checkDnsLoopTicker performs p.checkDnsLoop every minute.
|
||||
@@ -137,7 +138,7 @@ func (p *prog) checkDnsLoopTicker(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// loopTestMsg generates DNS message for checking loop.
|
||||
// loopTestMsg creates a DNS test message for loop detection
|
||||
func loopTestMsg(uid string) *dns.Msg {
|
||||
msg := new(dns.Msg)
|
||||
msg.SetQuestion(dns.Fqdn(uid+loopTestDomain), loopTestQtype)
|
||||
|
||||
+122
-84
@@ -10,78 +10,91 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/kardianos/service"
|
||||
"github.com/rs/zerolog"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
)
|
||||
|
||||
// Global variables for CLI configuration and state management
|
||||
// These are used across multiple commands and need to persist throughout the application lifecycle
|
||||
var (
|
||||
configPath string
|
||||
configBase64 string
|
||||
daemon bool
|
||||
listenAddress string
|
||||
primaryUpstream string
|
||||
secondaryUpstream string
|
||||
domains []string
|
||||
logPath string
|
||||
homedir string
|
||||
cacheSize int
|
||||
cfg ctrld.Config
|
||||
verbose int
|
||||
silent bool
|
||||
cdUID string
|
||||
cdOrg string
|
||||
customHostname string
|
||||
cdDev bool
|
||||
iface string
|
||||
ifaceStartStop string
|
||||
nextdns string
|
||||
cdUpstreamProto string
|
||||
deactivationPin int64
|
||||
skipSelfChecks bool
|
||||
cleanup bool
|
||||
startOnly bool
|
||||
rfc1918 bool
|
||||
interceptMode string // "", "dns", or "hard" — set via --intercept-mode flag or config
|
||||
dnsIntercept bool // derived: interceptMode == "dns" || interceptMode == "hard"
|
||||
hardIntercept bool // derived: interceptMode == "hard"
|
||||
configPath string
|
||||
configBase64 string
|
||||
daemon bool
|
||||
listenAddress string
|
||||
primaryUpstream string
|
||||
secondaryUpstream string
|
||||
domains []string
|
||||
logPath string
|
||||
homedir string
|
||||
cacheSize int
|
||||
cfg ctrld.Config
|
||||
verbose int
|
||||
silent bool
|
||||
cdUID string
|
||||
cdOrg string
|
||||
customHostname string
|
||||
cdDev bool
|
||||
iface string
|
||||
ifaceStartStop string
|
||||
nextdns string
|
||||
cdUpstreamProto string
|
||||
deactivationPin int64
|
||||
skipSelfChecks bool
|
||||
cleanup bool
|
||||
startOnly bool
|
||||
rfc1918 bool
|
||||
interceptMode string // "", "dns", or "hard" — set via --intercept-mode flag or config
|
||||
dnsIntercept bool // derived: interceptMode == "dns" || interceptMode == "hard"
|
||||
hardIntercept bool // derived: interceptMode == "hard"
|
||||
firewallMode string // "off" or "on" — set via --firewall-mode flag or config
|
||||
firewallModeFlagChanged bool // true when --firewall-mode was explicitly provided
|
||||
|
||||
mainLog atomic.Pointer[zerolog.Logger]
|
||||
consoleWriter zerolog.ConsoleWriter
|
||||
noConfigStart bool
|
||||
mainLog atomic.Pointer[ctrld.Logger]
|
||||
consoleWriter zapcore.Core
|
||||
consoleWriterLevel zapcore.Level
|
||||
noConfigStart bool
|
||||
)
|
||||
|
||||
// Flag name constants for consistent reference across the codebase
|
||||
// Using constants prevents typos and makes refactoring easier
|
||||
const (
|
||||
cdUidFlagName = "cd"
|
||||
cdOrgFlagName = "cd-org"
|
||||
customHostnameFlagName = "custom-hostname"
|
||||
nextdnsFlagName = "nextdns"
|
||||
|
||||
// autoIface is the sentinel --iface value meaning "use the default gateway interface".
|
||||
autoIface = "auto"
|
||||
)
|
||||
|
||||
// init initializes the default logger before any CLI commands are executed
|
||||
// This ensures logging is available even during early initialization phases
|
||||
func init() {
|
||||
l := zerolog.New(io.Discard)
|
||||
mainLog.Store(&l)
|
||||
l := zap.NewNop()
|
||||
mainLog.Store(&ctrld.Logger{Logger: l})
|
||||
}
|
||||
|
||||
// Main is the entry point for the CLI application
|
||||
// It initializes configuration, sets up the CLI structure, and executes the root command
|
||||
func Main() {
|
||||
// Fast path for pf interception probe subprocess. This runs before cobra
|
||||
// initialization to minimize startup time. The parent process spawns us with
|
||||
// "pf-probe-send <host> <hex-dns-packet>" and a non-_ctrld GID so pf
|
||||
// intercepts the DNS query. If pf rdr is working, the query reaches ctrld's
|
||||
// listener; if not, it goes to the real DNS server and ctrld detects the miss.
|
||||
// Fast path for pf interception probe subprocess.
|
||||
if len(os.Args) >= 4 && os.Args[1] == "pf-probe-send" {
|
||||
pfProbeSend(os.Args[2], os.Args[3])
|
||||
return
|
||||
}
|
||||
|
||||
ctrld.InitConfig(v, "ctrld")
|
||||
initCLI()
|
||||
rootCmd := initCLI()
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
mainLog.Load().Error().Msg(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeLogFilePath converts relative log file paths to absolute paths
|
||||
// This ensures log files are created in predictable locations regardless of working directory
|
||||
func normalizeLogFilePath(logFilePath string) string {
|
||||
if logFilePath == "" || filepath.IsAbs(logFilePath) || service.Interactive() {
|
||||
return logFilePath
|
||||
@@ -97,40 +110,36 @@ func normalizeLogFilePath(logFilePath string) string {
|
||||
}
|
||||
|
||||
// initConsoleLogging initializes console logging, then storing to mainLog.
|
||||
// This sets up human-readable logging output for interactive use
|
||||
func initConsoleLogging() {
|
||||
consoleWriter = zerolog.NewConsoleWriter(func(w *zerolog.ConsoleWriter) {
|
||||
w.TimeFormat = time.StampMilli
|
||||
})
|
||||
multi := zerolog.MultiLevelWriter(consoleWriter)
|
||||
l := mainLog.Load().Output(multi).With().Timestamp().Logger()
|
||||
mainLog.Store(&l)
|
||||
|
||||
consoleWriterLevel = ctrld.NoticeLevel
|
||||
switch {
|
||||
case silent:
|
||||
zerolog.SetGlobalLevel(zerolog.NoLevel)
|
||||
// For silent mode, use a no-op logger to suppress all output
|
||||
l := zap.NewNop()
|
||||
mainLog.Store(&ctrld.Logger{Logger: l})
|
||||
case verbose == 1:
|
||||
ctrld.ProxyLogger.Store(&l)
|
||||
zerolog.SetGlobalLevel(zerolog.InfoLevel)
|
||||
// Info level provides basic operational information
|
||||
consoleWriterLevel = zapcore.InfoLevel
|
||||
case verbose > 1:
|
||||
ctrld.ProxyLogger.Store(&l)
|
||||
zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
default:
|
||||
zerolog.SetGlobalLevel(zerolog.NoticeLevel)
|
||||
// Debug level provides detailed diagnostic information
|
||||
consoleWriterLevel = zapcore.DebugLevel
|
||||
}
|
||||
consoleWriter = newHumanReadableZapCore(os.Stdout, consoleWriterLevel)
|
||||
l := zap.New(consoleWriter)
|
||||
mainLog.Store(&ctrld.Logger{Logger: l})
|
||||
}
|
||||
|
||||
// initInteractiveLogging is like initLogging, but the ProxyLogger is discarded
|
||||
// to be used for all interactive commands.
|
||||
//
|
||||
// Current log file config will also be ignored.
|
||||
// This prevents log file conflicts during interactive command execution
|
||||
func initInteractiveLogging() {
|
||||
old := cfg.Service.LogPath
|
||||
cfg.Service.LogPath = ""
|
||||
zerolog.TimeFieldFormat = time.RFC3339 + ".000"
|
||||
initLoggingWithBackup(false)
|
||||
cfg.Service.LogPath = old
|
||||
l := zerolog.New(io.Discard)
|
||||
ctrld.ProxyLogger.Store(&l)
|
||||
}
|
||||
|
||||
// initLoggingWithBackup initializes log setup base on current config.
|
||||
@@ -139,77 +148,107 @@ func initInteractiveLogging() {
|
||||
// This is only used in runCmd for special handling in case of logging config
|
||||
// change in cd mode. Without special reason, the caller should use initLogging
|
||||
// wrapper instead of calling this function directly.
|
||||
func initLoggingWithBackup(doBackup bool) []io.Writer {
|
||||
func initLoggingWithBackup(doBackup bool) []zapcore.Core {
|
||||
var writers []io.Writer
|
||||
if logFilePath := normalizeLogFilePath(cfg.Service.LogPath); logFilePath != "" {
|
||||
// Create parent directory if necessary.
|
||||
// This ensures log files can be created even if the directory doesn't exist
|
||||
if err := os.MkdirAll(filepath.Dir(logFilePath), 0750); err != nil {
|
||||
mainLog.Load().Error().Msgf("failed to create log path: %v", err)
|
||||
mainLog.Load().Error().Msgf("Failed to create log path: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Default open log file in append mode.
|
||||
// This preserves existing log entries across restarts
|
||||
flags := os.O_CREATE | os.O_RDWR | os.O_APPEND
|
||||
if doBackup {
|
||||
// Backup old log file with .1 suffix.
|
||||
// This prevents log file corruption during rotation
|
||||
if err := os.Rename(logFilePath, logFilePath+oldLogSuffix); err != nil && !os.IsNotExist(err) {
|
||||
mainLog.Load().Error().Msgf("could not backup old log file: %v", err)
|
||||
mainLog.Load().Error().Msgf("Could not backup old log file: %v", err)
|
||||
} else {
|
||||
// Backup was created, set flags for truncating old log file.
|
||||
// This ensures a clean start for the new log file
|
||||
flags = os.O_CREATE | os.O_RDWR
|
||||
}
|
||||
}
|
||||
logFile, err := openLogFile(logFilePath, flags)
|
||||
if err != nil {
|
||||
mainLog.Load().Error().Msgf("failed to create log file: %v", err)
|
||||
mainLog.Load().Error().Msgf("Failed to create log file: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
writers = append(writers, logFile)
|
||||
}
|
||||
writers = append(writers, consoleWriter)
|
||||
multi := zerolog.MultiLevelWriter(writers...)
|
||||
l := mainLog.Load().Output(multi).With().Logger()
|
||||
mainLog.Store(&l)
|
||||
// TODO: find a better way.
|
||||
ctrld.ProxyLogger.Store(&l)
|
||||
|
||||
zerolog.SetGlobalLevel(zerolog.NoticeLevel)
|
||||
// Create zap cores for different writers
|
||||
// Multiple cores allow logging to both console and file simultaneously
|
||||
var cores []zapcore.Core
|
||||
cores = append(cores, consoleWriter)
|
||||
|
||||
// Determine log level based on verbosity and configuration
|
||||
// This provides flexible logging control for different use cases
|
||||
logLevel := cfg.Service.LogLevel
|
||||
switch {
|
||||
case silent:
|
||||
zerolog.SetGlobalLevel(zerolog.NoLevel)
|
||||
return writers
|
||||
// For silent mode, use a no-op logger to suppress all output
|
||||
l := zap.NewNop()
|
||||
mainLog.Store(&ctrld.Logger{Logger: l})
|
||||
return cores
|
||||
case verbose == 1:
|
||||
logLevel = "info"
|
||||
case verbose > 1:
|
||||
logLevel = "debug"
|
||||
}
|
||||
if logLevel == "" {
|
||||
return writers
|
||||
|
||||
// Parse log level string to zapcore.Level
|
||||
// This provides human-readable log level configuration
|
||||
var level zapcore.Level
|
||||
switch logLevel {
|
||||
case "debug":
|
||||
level = zapcore.DebugLevel
|
||||
case "info":
|
||||
level = zapcore.InfoLevel
|
||||
case "notice":
|
||||
level = ctrld.NoticeLevel
|
||||
case "warn":
|
||||
level = zapcore.WarnLevel
|
||||
case "error":
|
||||
level = zapcore.ErrorLevel
|
||||
default:
|
||||
level = zapcore.InfoLevel // default level
|
||||
}
|
||||
level, err := zerolog.ParseLevel(logLevel)
|
||||
if err != nil {
|
||||
mainLog.Load().Warn().Err(err).Msg("could not set log level")
|
||||
return writers
|
||||
|
||||
consoleWriter.Enabled(level)
|
||||
// Add cores for all writers
|
||||
// This enables multi-destination logging (console + file)
|
||||
for _, writer := range writers {
|
||||
core := newMachineFriendlyZapCore(writer, level)
|
||||
cores = append(cores, core)
|
||||
}
|
||||
zerolog.SetGlobalLevel(level)
|
||||
return writers
|
||||
|
||||
// Create a multi-core logger
|
||||
// This allows simultaneous logging to multiple destinations
|
||||
multiCore := zapcore.NewTee(cores...)
|
||||
logger := zap.New(multiCore)
|
||||
mainLog.Store(&ctrld.Logger{Logger: logger})
|
||||
|
||||
return cores
|
||||
}
|
||||
|
||||
// initCache initializes DNS cache configuration
|
||||
// This improves performance by caching frequently requested DNS responses
|
||||
func initCache() {
|
||||
if !cfg.Service.CacheEnable {
|
||||
return
|
||||
}
|
||||
if cfg.Service.CacheSize == 0 {
|
||||
// Default cache size provides good balance between memory usage and performance
|
||||
cfg.Service.CacheSize = 4096
|
||||
}
|
||||
}
|
||||
|
||||
// pfProbeSend is a minimal subprocess that sends a pre-built DNS query packet
|
||||
// to the specified host on port 53. It's invoked by probePFIntercept() with a
|
||||
// non-_ctrld GID so pf interception applies to the query.
|
||||
//
|
||||
// Usage: ctrld pf-probe-send <host> <hex-encoded-dns-packet>
|
||||
// to the specified host on port 53.
|
||||
func pfProbeSend(host, hexPacket string) {
|
||||
packet, err := hex.DecodeString(hexPacket)
|
||||
if err != nil {
|
||||
@@ -222,7 +261,6 @@ func pfProbeSend(host, hexPacket string) {
|
||||
defer conn.Close()
|
||||
conn.SetDeadline(time.Now().Add(time.Second))
|
||||
_, _ = conn.Write(packet)
|
||||
// Read response (don't care about result, just need the send to happen)
|
||||
buf := make([]byte, 512)
|
||||
_, _ = conn.Read(buf)
|
||||
}
|
||||
|
||||
+18
-3
@@ -6,14 +6,29 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
)
|
||||
|
||||
var logOutput strings.Builder
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
l := zerolog.New(&logOutput)
|
||||
mainLog.Store(&l)
|
||||
// Create a custom writer that writes to logOutput
|
||||
writer := zapcore.AddSync(&logOutput)
|
||||
|
||||
// Create zap encoder
|
||||
encoderConfig := zap.NewDevelopmentEncoderConfig()
|
||||
encoder := zapcore.NewConsoleEncoder(encoderConfig)
|
||||
|
||||
// Create core that writes to our string builder
|
||||
core := zapcore.NewCore(encoder, writer, zap.DebugLevel)
|
||||
|
||||
// Create logger
|
||||
l := zap.New(core)
|
||||
|
||||
mainLog.Store(&ctrld.Logger{Logger: l})
|
||||
|
||||
// Stub the self-upgrade command builder for the whole test binary. The real
|
||||
// builder execs os.Executable() — which under `go test` IS this test binary
|
||||
|
||||
+11
-4
@@ -15,6 +15,7 @@ import (
|
||||
)
|
||||
|
||||
// metricsServer represents a server to expose Prometheus metrics via HTTP.
|
||||
// This provides monitoring and observability for the DNS proxy service
|
||||
type metricsServer struct {
|
||||
server *http.Server
|
||||
mux *http.ServeMux
|
||||
@@ -24,6 +25,7 @@ type metricsServer struct {
|
||||
}
|
||||
|
||||
// newMetricsServer returns new metrics server.
|
||||
// This initializes the HTTP server for exposing Prometheus metrics
|
||||
func newMetricsServer(addr string, reg *prometheus.Registry) (*metricsServer, error) {
|
||||
mux := http.NewServeMux()
|
||||
ms := &metricsServer{
|
||||
@@ -37,11 +39,13 @@ func newMetricsServer(addr string, reg *prometheus.Registry) (*metricsServer, er
|
||||
}
|
||||
|
||||
// register adds handlers for given pattern.
|
||||
// This provides a clean interface for adding HTTP endpoints to the metrics server
|
||||
func (ms *metricsServer) register(pattern string, handler http.Handler) {
|
||||
ms.mux.Handle(pattern, handler)
|
||||
}
|
||||
|
||||
// registerMetricsServerHandler adds handlers for metrics server.
|
||||
// This sets up both Prometheus format and JSON format endpoints for metrics
|
||||
func (ms *metricsServer) registerMetricsServerHandler() {
|
||||
ms.register("/metrics", promhttp.HandlerFor(
|
||||
ms.reg,
|
||||
@@ -74,6 +78,7 @@ func (ms *metricsServer) registerMetricsServerHandler() {
|
||||
}
|
||||
|
||||
// start runs the metricsServer.
|
||||
// This starts the HTTP server for metrics exposure
|
||||
func (ms *metricsServer) start() error {
|
||||
listener, err := net.Listen("tcp", ms.addr)
|
||||
if err != nil {
|
||||
@@ -85,6 +90,7 @@ func (ms *metricsServer) start() error {
|
||||
}
|
||||
|
||||
// stop shutdowns the metricsServer within 2 seconds timeout.
|
||||
// This ensures graceful shutdown of the metrics server
|
||||
func (ms *metricsServer) stop() error {
|
||||
if !ms.started {
|
||||
return nil
|
||||
@@ -95,6 +101,7 @@ func (ms *metricsServer) stop() error {
|
||||
}
|
||||
|
||||
// runMetricsServer initializes metrics stats and runs the metrics server if enabled.
|
||||
// This sets up the complete metrics infrastructure including Prometheus collectors
|
||||
func (p *prog) runMetricsServer(ctx context.Context, reloadCh chan struct{}) {
|
||||
if !p.metricsEnabled() {
|
||||
return
|
||||
@@ -115,7 +122,7 @@ func (p *prog) runMetricsServer(ctx context.Context, reloadCh chan struct{}) {
|
||||
addr := p.cfg.Service.MetricsListener
|
||||
ms, err := newMetricsServer(addr, reg)
|
||||
if err != nil {
|
||||
mainLog.Load().Warn().Err(err).Msg("could not create new metrics server")
|
||||
mainLog.Load().Warn().Err(err).Msg("Could not create new metrics server")
|
||||
return
|
||||
}
|
||||
// Only start listener address if defined.
|
||||
@@ -130,9 +137,9 @@ func (p *prog) runMetricsServer(ctx context.Context, reloadCh chan struct{}) {
|
||||
statsVersion.WithLabelValues(commit, runtime.Version(), curVersion()).Inc()
|
||||
reg.MustRegister(statsTimeStart)
|
||||
statsTimeStart.Set(float64(time.Now().Unix()))
|
||||
mainLog.Load().Debug().Msgf("starting metrics server on: %s", addr)
|
||||
mainLog.Load().Debug().Msgf("Starting metrics server on: %s", addr)
|
||||
if err := ms.start(); err != nil {
|
||||
mainLog.Load().Warn().Err(err).Msg("could not start metrics server")
|
||||
mainLog.Load().Warn().Err(err).Msg("Could not start metrics server")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -144,7 +151,7 @@ func (p *prog) runMetricsServer(ctx context.Context, reloadCh chan struct{}) {
|
||||
}
|
||||
|
||||
if err := ms.stop(); err != nil {
|
||||
mainLog.Load().Warn().Err(err).Msg("could not stop metrics server")
|
||||
mainLog.Load().Warn().Err(err).Msg("Could not stop metrics server")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,28 +49,3 @@ func validInterface(iface *net.Interface, validIfacesMap map[string]struct{}) bo
|
||||
_, ok := validIfacesMap[iface.Name]
|
||||
return ok
|
||||
}
|
||||
|
||||
// validInterfacesMap returns a set of all valid hardware ports.
|
||||
func validInterfacesMap() map[string]struct{} {
|
||||
b, err := exec.Command("networksetup", "-listallhardwareports").Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return parseListAllHardwarePorts(bytes.NewReader(b))
|
||||
}
|
||||
|
||||
// parseListAllHardwarePorts parses output of "networksetup -listallhardwareports"
|
||||
// and returns map presents all hardware ports.
|
||||
func parseListAllHardwarePorts(r io.Reader) map[string]struct{} {
|
||||
m := make(map[string]struct{})
|
||||
scanner := bufio.NewScanner(r)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
after, ok := strings.CutPrefix(line, "Device: ")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
m[after] = struct{}{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
+3
-38
@@ -2,51 +2,16 @@ package cli
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"tailscale.com/net/netmon"
|
||||
)
|
||||
|
||||
// patchNetIfaceName patches network interface names on Linux
|
||||
// This is a no-op on Linux as interface names don't need special handling
|
||||
func patchNetIfaceName(iface *net.Interface) (bool, error) { return true, nil }
|
||||
|
||||
// validInterface reports whether the *net.Interface is a valid one.
|
||||
// Only non-virtual interfaces are considered valid.
|
||||
// This prevents DNS configuration on virtual interfaces like docker, veth, etc.
|
||||
func validInterface(iface *net.Interface, validIfacesMap map[string]struct{}) bool {
|
||||
_, ok := validIfacesMap[iface.Name]
|
||||
return ok
|
||||
}
|
||||
|
||||
// validInterfacesMap returns a set containing non virtual interfaces.
|
||||
func validInterfacesMap() map[string]struct{} {
|
||||
m := make(map[string]struct{})
|
||||
vis := virtualInterfaces()
|
||||
netmon.ForeachInterface(func(i netmon.Interface, prefixes []netip.Prefix) {
|
||||
if _, existed := vis[i.Name]; existed {
|
||||
return
|
||||
}
|
||||
m[i.Name] = struct{}{}
|
||||
})
|
||||
// Fallback to default route interface if found nothing.
|
||||
if len(m) == 0 {
|
||||
defaultRoute, err := netmon.DefaultRoute()
|
||||
if err != nil {
|
||||
return m
|
||||
}
|
||||
m[defaultRoute.InterfaceName] = struct{}{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// virtualInterfaces returns a map of virtual interfaces on current machine.
|
||||
func virtualInterfaces() map[string]struct{} {
|
||||
s := make(map[string]struct{})
|
||||
entries, _ := os.ReadDir("/sys/devices/virtual/net")
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
s[strings.TrimSpace(entry.Name())] = struct{}{}
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
+2
-11
@@ -4,19 +4,10 @@ package cli
|
||||
|
||||
import (
|
||||
"net"
|
||||
|
||||
"tailscale.com/net/netmon"
|
||||
)
|
||||
|
||||
// patchNetIfaceName patches network interface names on non-Linux/Darwin platforms
|
||||
func patchNetIfaceName(iface *net.Interface) (bool, error) { return true, nil }
|
||||
|
||||
// validInterface checks if an interface is valid on non-Linux/Darwin platforms
|
||||
func validInterface(iface *net.Interface, validIfacesMap map[string]struct{}) bool { return true }
|
||||
|
||||
// validInterfacesMap returns a set containing only default route interfaces.
|
||||
func validInterfacesMap() map[string]struct{} {
|
||||
defaultRoute, err := netmon.DefaultRoute()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]struct{}{defaultRoute.InterfaceName: {}}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
|
||||
"github.com/microsoft/wmi/pkg/base/host"
|
||||
"github.com/microsoft/wmi/pkg/base/instance"
|
||||
"github.com/microsoft/wmi/pkg/base/query"
|
||||
"github.com/microsoft/wmi/pkg/constant"
|
||||
"github.com/microsoft/wmi/pkg/hardware/network/netadapter"
|
||||
)
|
||||
|
||||
func patchNetIfaceName(iface *net.Interface) (bool, error) {
|
||||
@@ -23,71 +14,3 @@ func validInterface(iface *net.Interface, validIfacesMap map[string]struct{}) bo
|
||||
_, ok := validIfacesMap[iface.Name]
|
||||
return ok
|
||||
}
|
||||
|
||||
// validInterfacesMap returns a set of all physical interfaces.
|
||||
func validInterfacesMap() map[string]struct{} {
|
||||
m := make(map[string]struct{})
|
||||
for _, ifaceName := range validInterfaces() {
|
||||
m[ifaceName] = struct{}{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// validInterfaces returns a list of all physical interfaces.
|
||||
func validInterfaces() []string {
|
||||
log.SetOutput(io.Discard)
|
||||
defer log.SetOutput(os.Stderr)
|
||||
whost := host.NewWmiLocalHost()
|
||||
q := query.NewWmiQuery("MSFT_NetAdapter")
|
||||
instances, err := instance.GetWmiInstancesFromHost(whost, string(constant.StadardCimV2), q)
|
||||
if instances != nil {
|
||||
defer instances.Close()
|
||||
}
|
||||
if err != nil {
|
||||
mainLog.Load().Warn().Err(err).Msg("failed to get wmi network adapter")
|
||||
return nil
|
||||
}
|
||||
var adapters []string
|
||||
for _, i := range instances {
|
||||
adapter, err := netadapter.NewNetworkAdapter(i)
|
||||
if err != nil {
|
||||
mainLog.Load().Warn().Err(err).Msg("failed to get network adapter")
|
||||
continue
|
||||
}
|
||||
|
||||
name, err := adapter.GetPropertyName()
|
||||
if err != nil {
|
||||
mainLog.Load().Warn().Err(err).Msg("failed to get interface name")
|
||||
continue
|
||||
}
|
||||
|
||||
// From: https://learn.microsoft.com/en-us/previous-versions/windows/desktop/legacy/hh968170(v=vs.85)
|
||||
//
|
||||
// "Indicates if a connector is present on the network adapter. This value is set to TRUE
|
||||
// if this is a physical adapter or FALSE if this is not a physical adapter."
|
||||
physical, err := adapter.GetPropertyConnectorPresent()
|
||||
if err != nil {
|
||||
mainLog.Load().Debug().Str("method", "validInterfaces").Str("interface", name).Msg("failed to get network adapter connector present property")
|
||||
continue
|
||||
}
|
||||
if !physical {
|
||||
mainLog.Load().Debug().Str("method", "validInterfaces").Str("interface", name).Msg("skipping non-physical adapter")
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if it's a hardware interface. Checking only for connector present is not enough
|
||||
// because some interfaces are not physical but have a connector.
|
||||
hardware, err := adapter.GetPropertyHardwareInterface()
|
||||
if err != nil {
|
||||
mainLog.Load().Debug().Str("method", "validInterfaces").Str("interface", name).Msg("failed to get network adapter hardware interface property")
|
||||
continue
|
||||
}
|
||||
if !hardware {
|
||||
mainLog.Load().Debug().Str("method", "validInterfaces").Str("interface", name).Msg("skipping non-hardware interface")
|
||||
continue
|
||||
}
|
||||
|
||||
adapters = append(adapters, name)
|
||||
}
|
||||
return adapters
|
||||
}
|
||||
|
||||
@@ -3,18 +3,23 @@ package cli
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
)
|
||||
|
||||
func Test_validInterfaces(t *testing.T) {
|
||||
verbose = 3
|
||||
initConsoleLogging()
|
||||
start := time.Now()
|
||||
ifaces := validInterfaces()
|
||||
im := ctrld.ValidInterfaces(ctrld.LoggerCtx(context.Background(), mainLog.Load()))
|
||||
t.Logf("Using Windows API takes: %d", time.Since(start).Milliseconds())
|
||||
ifaces := slices.Collect(maps.Keys(im))
|
||||
|
||||
start = time.Now()
|
||||
ifacesPowershell := validInterfacesPowershell()
|
||||
|
||||
@@ -23,66 +23,67 @@ systemd-resolved=false
|
||||
var networkManagerCtrldConfFile = filepath.Join(nmConfDir, nmCtrldConfFilename)
|
||||
|
||||
// hasNetworkManager reports whether NetworkManager executable found.
|
||||
// hasNetworkManager checks if NetworkManager is available on the system
|
||||
func hasNetworkManager() bool {
|
||||
exe, _ := exec.LookPath("NetworkManager")
|
||||
return exe != ""
|
||||
}
|
||||
|
||||
func setupNetworkManager() error {
|
||||
func (p *prog) setupNetworkManager() error {
|
||||
if !hasNetworkManager() {
|
||||
return nil
|
||||
}
|
||||
if content, _ := os.ReadFile(nmCtrldConfContent); string(content) == nmCtrldConfContent {
|
||||
mainLog.Load().Debug().Msg("NetworkManager already setup, nothing to do")
|
||||
p.Debug().Msg("NetworkManager already setup, nothing to do")
|
||||
return nil
|
||||
}
|
||||
err := os.WriteFile(networkManagerCtrldConfFile, []byte(nmCtrldConfContent), os.FileMode(0644))
|
||||
if os.IsNotExist(err) {
|
||||
mainLog.Load().Debug().Msg("NetworkManager is not available")
|
||||
p.Debug().Msg("NetworkManager is not available")
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
mainLog.Load().Debug().Err(err).Msg("could not write NetworkManager ctrld config file")
|
||||
p.Debug().Err(err).Msg("Could not write NetworkManager ctrld config file")
|
||||
return err
|
||||
}
|
||||
|
||||
reloadNetworkManager()
|
||||
mainLog.Load().Debug().Msg("setup NetworkManager done")
|
||||
p.reloadNetworkManager()
|
||||
p.Debug().Msg("Setup NetworkManager done")
|
||||
return nil
|
||||
}
|
||||
|
||||
func restoreNetworkManager() error {
|
||||
func (p *prog) restoreNetworkManager() error {
|
||||
if !hasNetworkManager() {
|
||||
return nil
|
||||
}
|
||||
err := os.Remove(networkManagerCtrldConfFile)
|
||||
if os.IsNotExist(err) {
|
||||
mainLog.Load().Debug().Msg("NetworkManager is not available")
|
||||
p.Debug().Msg("NetworkManager is not available")
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
mainLog.Load().Debug().Err(err).Msg("could not remove NetworkManager ctrld config file")
|
||||
p.Debug().Err(err).Msg("Could not remove NetworkManager ctrld config file")
|
||||
return err
|
||||
}
|
||||
|
||||
reloadNetworkManager()
|
||||
mainLog.Load().Debug().Msg("restore NetworkManager done")
|
||||
p.reloadNetworkManager()
|
||||
p.Debug().Msg("Restore NetworkManager done")
|
||||
return nil
|
||||
}
|
||||
|
||||
func reloadNetworkManager() {
|
||||
func (p *prog) reloadNetworkManager() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
|
||||
defer cancel()
|
||||
conn, err := dbus.NewSystemConnectionContext(ctx)
|
||||
if err != nil {
|
||||
mainLog.Load().Error().Err(err).Msg("could not create new system connection")
|
||||
p.Error().Err(err).Msg("Could not create new system connection")
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
waitCh := make(chan string)
|
||||
if _, err := conn.ReloadUnitContext(ctx, nmSystemdUnitName, "ignore-dependencies", waitCh); err != nil {
|
||||
mainLog.Load().Debug().Err(err).Msg("could not reload NetworkManager")
|
||||
p.Debug().Err(err).Msg("Could not reload NetworkManager")
|
||||
return
|
||||
}
|
||||
<-waitCh
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
package cli
|
||||
|
||||
func setupNetworkManager() error {
|
||||
reloadNetworkManager()
|
||||
func (p *prog) setupNetworkManager() error {
|
||||
p.reloadNetworkManager()
|
||||
return nil
|
||||
}
|
||||
|
||||
func restoreNetworkManager() error {
|
||||
reloadNetworkManager()
|
||||
func (p *prog) restoreNetworkManager() error {
|
||||
p.reloadNetworkManager()
|
||||
return nil
|
||||
}
|
||||
|
||||
func reloadNetworkManager() {}
|
||||
func (p *prog) reloadNetworkManager() {}
|
||||
|
||||
+2
-1
@@ -8,11 +8,12 @@ import (
|
||||
|
||||
const nextdnsURL = "https://dns.nextdns.io"
|
||||
|
||||
// generateNextDNSConfig generates NextDNS configuration for the given UID
|
||||
func generateNextDNSConfig(uid string) {
|
||||
if uid == "" {
|
||||
return
|
||||
}
|
||||
mainLog.Load().Info().Msg("generating ctrld config for NextDNS resolver")
|
||||
mainLog.Load().Info().Msg("Generating ctrld config for NextDNS resolver")
|
||||
cfg = ctrld.Config{
|
||||
Listener: map[string]*ctrld.ListenerConfig{
|
||||
"0": {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
//go:build windows
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
)
|
||||
|
||||
const (
|
||||
// Default to current behavior: keep recovering indefinitely unless configured.
|
||||
defaultNRPTRecoveryMaxAttempts = 0
|
||||
defaultNRPTRecoveryCooldown = 30 * time.Minute
|
||||
|
||||
// Require more than one good health tick before clearing the circuit. A probe can
|
||||
// pass briefly after delete/re-add even when another agent recreates broken NRPT state.
|
||||
nrptRecoveryStableSuccessesToReset = 2
|
||||
)
|
||||
|
||||
type nrptRecoveryLimiter struct {
|
||||
mu sync.Mutex
|
||||
attempts int
|
||||
stableSuccesses int
|
||||
cooldownUntil time.Time
|
||||
lastSkipLog time.Time
|
||||
}
|
||||
|
||||
func nrptRecoveryMaxAttempts(cfg *ctrld.Config) int {
|
||||
if cfg != nil && cfg.Service.NRPTRecoveryMaxAttempts != nil {
|
||||
return *cfg.Service.NRPTRecoveryMaxAttempts
|
||||
}
|
||||
return defaultNRPTRecoveryMaxAttempts
|
||||
}
|
||||
|
||||
func nrptRecoveryCooldown(cfg *ctrld.Config) time.Duration {
|
||||
if cfg != nil && cfg.Service.NRPTRecoveryCooldown != nil {
|
||||
return *cfg.Service.NRPTRecoveryCooldown
|
||||
}
|
||||
return defaultNRPTRecoveryCooldown
|
||||
}
|
||||
|
||||
func (l *nrptRecoveryLimiter) allow(now time.Time, cfg *ctrld.Config) (bool, time.Duration) {
|
||||
maxAttempts := nrptRecoveryMaxAttempts(cfg)
|
||||
if maxAttempts <= 0 {
|
||||
return true, 0
|
||||
}
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
if now.Before(l.cooldownUntil) {
|
||||
return false, l.cooldownUntil.Sub(now)
|
||||
}
|
||||
return true, 0
|
||||
}
|
||||
|
||||
func (l *nrptRecoveryLimiter) recordRecoveryFlow(now time.Time, cfg *ctrld.Config) {
|
||||
maxAttempts := nrptRecoveryMaxAttempts(cfg)
|
||||
if maxAttempts <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
cooldown := nrptRecoveryCooldown(cfg)
|
||||
if cooldown <= 0 {
|
||||
cooldown = defaultNRPTRecoveryCooldown
|
||||
}
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
l.stableSuccesses = 0
|
||||
l.attempts++
|
||||
if l.attempts >= maxAttempts {
|
||||
l.cooldownUntil = now.Add(cooldown)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *nrptRecoveryLimiter) recordStableSuccess() {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
l.stableSuccesses++
|
||||
if l.stableSuccesses >= nrptRecoveryStableSuccessesToReset {
|
||||
l.attempts = 0
|
||||
l.cooldownUntil = time.Time{}
|
||||
l.lastSkipLog = time.Time{}
|
||||
}
|
||||
}
|
||||
|
||||
func (l *nrptRecoveryLimiter) shouldLogSkip(now time.Time) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
if l.lastSkipLog.IsZero() || now.Sub(l.lastSkipLog) >= 5*time.Minute {
|
||||
l.lastSkipLog = now
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//go:build windows
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
)
|
||||
|
||||
func TestNRPTRecoveryLimiterCooldownAndStableReset(t *testing.T) {
|
||||
maxAttempts := 2
|
||||
cooldown := 10 * time.Minute
|
||||
cfg := &ctrld.Config{}
|
||||
cfg.Service.NRPTRecoveryMaxAttempts = &maxAttempts
|
||||
cfg.Service.NRPTRecoveryCooldown = &cooldown
|
||||
|
||||
limiter := &nrptRecoveryLimiter{}
|
||||
now := time.Unix(100, 0)
|
||||
|
||||
if ok, wait := limiter.allow(now, cfg); !ok || wait != 0 {
|
||||
t.Fatalf("initial allow = %v, %v; want true, 0", ok, wait)
|
||||
}
|
||||
|
||||
limiter.recordRecoveryFlow(now, cfg)
|
||||
if ok, wait := limiter.allow(now.Add(time.Second), cfg); !ok || wait != 0 {
|
||||
t.Fatalf("allow after first flow = %v, %v; want true, 0", ok, wait)
|
||||
}
|
||||
|
||||
limiter.recordRecoveryFlow(now.Add(2*time.Second), cfg)
|
||||
if ok, wait := limiter.allow(now.Add(3*time.Second), cfg); ok || wait <= 0 {
|
||||
t.Fatalf("allow after max flows = %v, %v; want false, positive wait", ok, wait)
|
||||
}
|
||||
|
||||
limiter.recordStableSuccess()
|
||||
if ok, _ := limiter.allow(now.Add(4*time.Second), cfg); ok {
|
||||
t.Fatal("one stable success cleared cooldown; want cooldown to remain")
|
||||
}
|
||||
|
||||
limiter.recordStableSuccess()
|
||||
if ok, wait := limiter.allow(now.Add(5*time.Second), cfg); !ok || wait != 0 {
|
||||
t.Fatalf("allow after stable reset = %v, %v; want true, 0", ok, wait)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNRPTRecoveryLimiterDefaultIsUnlimited(t *testing.T) {
|
||||
cfg := &ctrld.Config{}
|
||||
limiter := &nrptRecoveryLimiter{}
|
||||
now := time.Unix(100, 0)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
limiter.recordRecoveryFlow(now.Add(time.Duration(i)*time.Second), cfg)
|
||||
}
|
||||
if ok, wait := limiter.allow(now.Add(time.Hour), cfg); !ok || wait != 0 {
|
||||
t.Fatalf("default allow after recovery flows = %v, %v; want true, 0", ok, wait)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNRPTRecoveryLimiterUnlimited(t *testing.T) {
|
||||
maxAttempts := 0
|
||||
cfg := &ctrld.Config{}
|
||||
cfg.Service.NRPTRecoveryMaxAttempts = &maxAttempts
|
||||
|
||||
limiter := &nrptRecoveryLimiter{}
|
||||
now := time.Unix(100, 0)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
limiter.recordRecoveryFlow(now.Add(time.Duration(i)*time.Second), cfg)
|
||||
}
|
||||
if ok, wait := limiter.allow(now.Add(time.Hour), cfg); !ok || wait != 0 {
|
||||
t.Fatalf("unlimited allow = %v, %v; want true, 0", ok, wait)
|
||||
}
|
||||
}
|
||||
+20
-6
@@ -8,26 +8,31 @@ import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld/internal/resolvconffile"
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
)
|
||||
|
||||
// allocate loopback ip
|
||||
// allocateIP allocates an IP address on the specified interface
|
||||
// sudo ifconfig lo0 alias 127.0.0.2 up
|
||||
func allocateIP(ip string) error {
|
||||
mainLog.Load().Debug().Str("ip", ip).Msg("Allocating IP address")
|
||||
cmd := exec.Command("ifconfig", "lo0", "alias", ip, "up")
|
||||
if err := cmd.Run(); err != nil {
|
||||
mainLog.Load().Error().Err(err).Msg("allocateIP failed")
|
||||
mainLog.Load().Error().Err(err).Msg("AllocateIP failed")
|
||||
return err
|
||||
}
|
||||
mainLog.Load().Debug().Str("ip", ip).Msg("IP address allocated successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// deAllocateIP deallocates an IP address from the specified interface
|
||||
func deAllocateIP(ip string) error {
|
||||
mainLog.Load().Debug().Str("ip", ip).Msg("Deallocating IP address")
|
||||
cmd := exec.Command("ifconfig", "lo0", "-alias", ip)
|
||||
if err := cmd.Run(); err != nil {
|
||||
mainLog.Load().Error().Err(err).Msg("deAllocateIP failed")
|
||||
mainLog.Load().Error().Err(err).Msg("DeAllocateIP failed")
|
||||
return err
|
||||
}
|
||||
mainLog.Load().Debug().Str("ip", ip).Msg("IP address deallocated successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -47,6 +52,8 @@ func setDnsIgnoreUnusableInterface(iface *net.Interface, nameservers []string) e
|
||||
// networksetup -setdnsservers Wi-Fi 8.8.8.8 1.1.1.1
|
||||
// TODO(cuonglm): use system API
|
||||
func setDNS(iface *net.Interface, nameservers []string) error {
|
||||
mainLog.Load().Debug().Str("interface", iface.Name).Strs("nameservers", nameservers).Msg("Setting DNS configuration")
|
||||
|
||||
// Note that networksetup won't modify search domains settings,
|
||||
// This assignment is just a placeholder to silent linter.
|
||||
_ = searchDomains
|
||||
@@ -56,6 +63,8 @@ func setDNS(iface *net.Interface, nameservers []string) error {
|
||||
if out, err := exec.Command(cmd, args...).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("%v: %w", string(out), err)
|
||||
}
|
||||
|
||||
mainLog.Load().Debug().Str("interface", iface.Name).Msg("DNS configuration set successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -73,25 +82,30 @@ func resetDnsIgnoreUnusableInterface(iface *net.Interface) error {
|
||||
|
||||
// TODO(cuonglm): use system API
|
||||
func resetDNS(iface *net.Interface) error {
|
||||
mainLog.Load().Debug().Str("interface", iface.Name).Msg("Resetting DNS configuration")
|
||||
|
||||
cmd := "networksetup"
|
||||
args := []string{"-setdnsservers", iface.Name, "empty"}
|
||||
if out, err := exec.Command(cmd, args...).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("%v: %w", string(out), err)
|
||||
}
|
||||
|
||||
mainLog.Load().Debug().Str("interface", iface.Name).Msg("DNS configuration reset successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// restoreDNS restores the DNS settings of the given interface.
|
||||
// this should only be executed upon turning off the ctrld service.
|
||||
func restoreDNS(iface *net.Interface) (err error) {
|
||||
if ns := savedStaticNameservers(iface); len(ns) > 0 {
|
||||
if ns := ctrld.SavedStaticNameservers(iface); len(ns) > 0 {
|
||||
err = setDNS(iface, ns)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// currentDNS returns the current DNS servers for the specified interface
|
||||
func currentDNS(_ *net.Interface) []string {
|
||||
return resolvconffile.NameServers()
|
||||
return ctrld.CurrentNameserversFromResolvconf()
|
||||
}
|
||||
|
||||
// currentStaticDNS returns the current static DNS settings of given interface.
|
||||
|
||||
+23
-8
@@ -9,27 +9,32 @@ import (
|
||||
"tailscale.com/health"
|
||||
"tailscale.com/util/dnsname"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
"github.com/Control-D-Inc/ctrld/internal/dns"
|
||||
"github.com/Control-D-Inc/ctrld/internal/resolvconffile"
|
||||
)
|
||||
|
||||
// allocate loopback ip
|
||||
// allocateIP allocates an IP address on the specified interface
|
||||
// sudo ifconfig lo0 127.0.0.53 alias
|
||||
func allocateIP(ip string) error {
|
||||
mainLog.Load().Debug().Str("ip", ip).Msg("Allocating IP address")
|
||||
cmd := exec.Command("ifconfig", "lo0", ip, "alias")
|
||||
if err := cmd.Run(); err != nil {
|
||||
mainLog.Load().Error().Err(err).Msg("allocateIP failed")
|
||||
return err
|
||||
}
|
||||
mainLog.Load().Debug().Str("ip", ip).Msg("IP address allocated successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// deAllocateIP deallocates an IP address from the specified interface
|
||||
func deAllocateIP(ip string) error {
|
||||
mainLog.Load().Debug().Str("ip", ip).Msg("Deallocating IP address")
|
||||
cmd := exec.Command("ifconfig", "lo0", ip, "-alias")
|
||||
if err := cmd.Run(); err != nil {
|
||||
mainLog.Load().Error().Err(err).Msg("deAllocateIP failed")
|
||||
return err
|
||||
}
|
||||
mainLog.Load().Debug().Str("ip", ip).Msg("IP address deallocated successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -40,9 +45,11 @@ func setDnsIgnoreUnusableInterface(iface *net.Interface, nameservers []string) e
|
||||
|
||||
// set the dns server for the provided network interface
|
||||
func setDNS(iface *net.Interface, nameservers []string) error {
|
||||
mainLog.Load().Debug().Str("interface", iface.Name).Strs("nameservers", nameservers).Msg("Setting DNS configuration")
|
||||
|
||||
r, err := dns.NewOSConfigurator(logf, &health.Tracker{}, &controlknobs.Knobs{}, iface.Name)
|
||||
if err != nil {
|
||||
mainLog.Load().Error().Err(err).Msg("failed to create DNS OS configurator")
|
||||
mainLog.Load().Error().Err(err).Msg("Failed to create DNS OS configurator")
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -58,13 +65,15 @@ func setDNS(iface *net.Interface, nameservers []string) error {
|
||||
if sds, err := searchDomains(); err == nil {
|
||||
osConfig.SearchDomains = sds
|
||||
} else {
|
||||
mainLog.Load().Debug().Err(err).Msg("failed to get search domains list")
|
||||
mainLog.Load().Debug().Err(err).Msg("Failed to get search domains list")
|
||||
}
|
||||
|
||||
if err := r.SetDNS(osConfig); err != nil {
|
||||
mainLog.Load().Error().Err(err).Msg("failed to set DNS")
|
||||
mainLog.Load().Error().Err(err).Msg("Failed to set DNS")
|
||||
return err
|
||||
}
|
||||
|
||||
mainLog.Load().Debug().Str("interface", iface.Name).Msg("DNS configuration set successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -73,17 +82,22 @@ func resetDnsIgnoreUnusableInterface(iface *net.Interface) error {
|
||||
return resetDNS(iface)
|
||||
}
|
||||
|
||||
// resetDNS resets DNS servers for the specified interface
|
||||
func resetDNS(iface *net.Interface) error {
|
||||
mainLog.Load().Debug().Str("interface", iface.Name).Msg("Resetting DNS configuration")
|
||||
|
||||
r, err := dns.NewOSConfigurator(logf, &health.Tracker{}, &controlknobs.Knobs{}, iface.Name)
|
||||
if err != nil {
|
||||
mainLog.Load().Error().Err(err).Msg("failed to create DNS OS configurator")
|
||||
mainLog.Load().Error().Err(err).Msg("Failed to create DNS OS configurator")
|
||||
return err
|
||||
}
|
||||
|
||||
if err := r.Close(); err != nil {
|
||||
mainLog.Load().Error().Err(err).Msg("failed to rollback DNS setting")
|
||||
mainLog.Load().Error().Err(err).Msg("Failed to rollback DNS setting")
|
||||
return err
|
||||
}
|
||||
|
||||
mainLog.Load().Debug().Str("interface", iface.Name).Msg("DNS configuration reset successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -93,8 +107,9 @@ func restoreDNS(iface *net.Interface) (err error) {
|
||||
return err
|
||||
}
|
||||
|
||||
// currentDNS returns the current DNS servers for the specified interface
|
||||
func currentDNS(_ *net.Interface) []string {
|
||||
return resolvconffile.NameServers()
|
||||
return ctrld.CurrentNameserversFromResolvconf()
|
||||
}
|
||||
|
||||
// currentStaticDNS returns the current static DNS settings of given interface.
|
||||
|
||||
+23
-19
@@ -21,30 +21,36 @@ import (
|
||||
"tailscale.com/health"
|
||||
"tailscale.com/util/dnsname"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
"github.com/Control-D-Inc/ctrld/internal/dns"
|
||||
ctrldnet "github.com/Control-D-Inc/ctrld/internal/net"
|
||||
"github.com/Control-D-Inc/ctrld/internal/resolvconffile"
|
||||
)
|
||||
|
||||
const resolvConfBackupFailedMsg = "open /etc/resolv.pre-ctrld-backup.conf: read-only file system"
|
||||
|
||||
type getDNS func(iface string) []string
|
||||
|
||||
// allocate loopback ip
|
||||
// sudo ip a add 127.0.0.2/24 dev lo
|
||||
func allocateIP(ip string) error {
|
||||
mainLog.Load().Debug().Str("ip", ip).Msg("Allocating IP address")
|
||||
cmd := exec.Command("ip", "a", "add", ip+"/24", "dev", "lo")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
mainLog.Load().Error().Err(err).Msgf("allocateIP failed: %s", string(out))
|
||||
mainLog.Load().Error().Err(err).Msgf("AllocateIP failed: %s", string(out))
|
||||
return err
|
||||
}
|
||||
mainLog.Load().Debug().Str("ip", ip).Msg("IP address allocated successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
func deAllocateIP(ip string) error {
|
||||
mainLog.Load().Debug().Str("ip", ip).Msg("Deallocating IP address")
|
||||
cmd := exec.Command("ip", "a", "del", ip+"/24", "dev", "lo")
|
||||
if err := cmd.Run(); err != nil {
|
||||
mainLog.Load().Error().Err(err).Msg("deAllocateIP failed")
|
||||
mainLog.Load().Error().Err(err).Msg("DeAllocateIP failed")
|
||||
return err
|
||||
}
|
||||
mainLog.Load().Debug().Str("ip", ip).Msg("IP address deallocated successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -56,9 +62,11 @@ func setDnsIgnoreUnusableInterface(iface *net.Interface, nameservers []string) e
|
||||
}
|
||||
|
||||
func setDNS(iface *net.Interface, nameservers []string) error {
|
||||
mainLog.Load().Debug().Str("interface", iface.Name).Strs("nameservers", nameservers).Msg("Setting DNS configuration")
|
||||
|
||||
r, err := dns.NewOSConfigurator(logf, &health.Tracker{}, &controlknobs.Knobs{}, iface.Name)
|
||||
if err != nil {
|
||||
mainLog.Load().Error().Err(err).Msg("failed to create DNS OS configurator")
|
||||
mainLog.Load().Error().Err(err).Msg("Failed to create dns os configurator")
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -72,17 +80,9 @@ func setDNS(iface *net.Interface, nameservers []string) error {
|
||||
SearchDomains: []dnsname.FQDN{},
|
||||
}
|
||||
if sds, err := searchDomains(); err == nil {
|
||||
// Filter the root domain, since it's not allowed by systemd.
|
||||
// See https://github.com/systemd/systemd/issues/9515
|
||||
filteredSds := slices.DeleteFunc(sds, func(s dnsname.FQDN) bool {
|
||||
return s == "" || s == "."
|
||||
})
|
||||
if len(filteredSds) != len(sds) {
|
||||
mainLog.Load().Debug().Msg(`Removed root domain "." from search domains list`)
|
||||
}
|
||||
osConfig.SearchDomains = filteredSds
|
||||
osConfig.SearchDomains = sds
|
||||
} else {
|
||||
mainLog.Load().Debug().Err(err).Msg("failed to get search domains list")
|
||||
mainLog.Load().Debug().Err(err).Msg("Failed to get search domains list")
|
||||
}
|
||||
trySystemdResolve := false
|
||||
if err := r.SetDNS(osConfig); err != nil {
|
||||
@@ -125,6 +125,8 @@ systemdResolve:
|
||||
}
|
||||
mainLog.Load().Debug().Msg("DNS was not set for some reason")
|
||||
}
|
||||
|
||||
mainLog.Load().Debug().Str("interface", iface.Name).Msg("DNS configuration set successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -134,6 +136,8 @@ func resetDnsIgnoreUnusableInterface(iface *net.Interface) error {
|
||||
}
|
||||
|
||||
func resetDNS(iface *net.Interface) (err error) {
|
||||
mainLog.Load().Debug().Str("interface", iface.Name).Msg("Resetting DNS configuration")
|
||||
|
||||
defer func() {
|
||||
if err == nil {
|
||||
return
|
||||
@@ -145,7 +149,7 @@ func resetDNS(iface *net.Interface) (err error) {
|
||||
if r, oerr := dns.NewOSConfigurator(logf, &health.Tracker{}, &controlknobs.Knobs{}, iface.Name); oerr == nil {
|
||||
_ = r.SetDNS(dns.OSConfig{})
|
||||
if err := r.Close(); err != nil {
|
||||
mainLog.Load().Error().Err(err).Msg("failed to rollback DNS setting")
|
||||
mainLog.Load().Error().Err(err).Msg("Failed to rollback dns setting")
|
||||
return
|
||||
}
|
||||
err = nil
|
||||
@@ -173,18 +177,18 @@ func resetDNS(iface *net.Interface) (err error) {
|
||||
}
|
||||
|
||||
// TODO(cuonglm): handle DHCPv6 properly.
|
||||
mainLog.Load().Debug().Msg("checking for IPv6 availability")
|
||||
mainLog.Load().Debug().Msg("Checking for ipv6 availability")
|
||||
if ctrldnet.IPv6Available(ctx) {
|
||||
c := client6.NewClient()
|
||||
conversation, err := c.Exchange(iface.Name)
|
||||
if err != nil && !errAddrInUse(err) {
|
||||
mainLog.Load().Debug().Err(err).Msg("could not exchange DHCPv6")
|
||||
mainLog.Load().Debug().Err(err).Msg("Could not exchange dhcpv6")
|
||||
}
|
||||
for _, packet := range conversation {
|
||||
if packet.Type() == dhcpv6.MessageTypeReply {
|
||||
msg, err := packet.GetInnerMessage()
|
||||
if err != nil {
|
||||
mainLog.Load().Debug().Err(err).Msg("could not get inner DHCPv6 message")
|
||||
mainLog.Load().Debug().Err(err).Msg("Could not get inner dhcpv6 message")
|
||||
return nil
|
||||
}
|
||||
nameservers := msg.Options.DNS()
|
||||
@@ -209,7 +213,7 @@ func restoreDNS(iface *net.Interface) (err error) {
|
||||
}
|
||||
|
||||
func currentDNS(iface *net.Interface) []string {
|
||||
resolvconfFunc := func(_ string) []string { return resolvconffile.NameServers() }
|
||||
resolvconfFunc := func(_ string) []string { return ctrld.CurrentNameserversFromResolvconf() }
|
||||
for _, fn := range []getDNS{getDNSByResolvectl, getDNSBySystemdResolved, getDNSByNmcli, resolvconfFunc} {
|
||||
if ns := fn(iface.Name); len(ns) > 0 {
|
||||
return ns
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
package cli
|
||||
|
||||
// TODO(cuonglm): implement.
|
||||
// allocateIP allocates an IP address on the specified interface
|
||||
func allocateIP(ip string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO(cuonglm): implement.
|
||||
// deAllocateIP deallocates an IP address from the specified interface
|
||||
func deAllocateIP(ip string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
+14
-126
@@ -1,21 +1,18 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
"golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
ctrldnet "github.com/Control-D-Inc/ctrld/internal/net"
|
||||
)
|
||||
|
||||
@@ -24,11 +21,6 @@ const (
|
||||
v6InterfaceKeyPathFormat = `SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters\Interfaces\`
|
||||
)
|
||||
|
||||
var (
|
||||
setDNSOnce sync.Once
|
||||
resetDNSOnce sync.Once
|
||||
)
|
||||
|
||||
// setDnsIgnoreUnusableInterface likes setDNS, but return a nil error if the interface is not usable.
|
||||
func setDnsIgnoreUnusableInterface(iface *net.Interface, nameservers []string) error {
|
||||
return setDNS(iface, nameservers)
|
||||
@@ -39,49 +31,7 @@ func setDNS(iface *net.Interface, nameservers []string) error {
|
||||
if len(nameservers) == 0 {
|
||||
return errors.New("empty DNS nameservers")
|
||||
}
|
||||
setDNSOnce.Do(func() {
|
||||
// If there's a Dns server running, that means we are on AD with Dns feature enabled.
|
||||
// Configuring the Dns server to forward queries to ctrld instead.
|
||||
if hasLocalDnsServerRunning() {
|
||||
mainLog.Load().Debug().Msg("Local DNS server detected, configuring forwarders")
|
||||
|
||||
file := absHomeDir(windowsForwardersFilename)
|
||||
mainLog.Load().Debug().Msgf("Using forwarders file: %s", file)
|
||||
|
||||
oldForwardersContent, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
mainLog.Load().Debug().Err(err).Msg("Could not read existing forwarders file")
|
||||
} else {
|
||||
mainLog.Load().Debug().Msgf("Existing forwarders content: %s", string(oldForwardersContent))
|
||||
}
|
||||
|
||||
hasLocalIPv6Listener := needLocalIPv6Listener(interceptMode)
|
||||
mainLog.Load().Debug().Bool("has_ipv6_listener", hasLocalIPv6Listener).Msg("IPv6 listener status")
|
||||
|
||||
forwarders := slices.DeleteFunc(slices.Clone(nameservers), func(s string) bool {
|
||||
if !hasLocalIPv6Listener {
|
||||
return false
|
||||
}
|
||||
return s == "::1"
|
||||
})
|
||||
mainLog.Load().Debug().Strs("forwarders", forwarders).Msg("Filtered forwarders list")
|
||||
|
||||
if err := os.WriteFile(file, []byte(strings.Join(forwarders, ",")), 0600); err != nil {
|
||||
mainLog.Load().Warn().Err(err).Msg("could not save forwarders settings")
|
||||
} else {
|
||||
mainLog.Load().Debug().Msg("Successfully wrote new forwarders file")
|
||||
}
|
||||
|
||||
oldForwarders := strings.Split(string(oldForwardersContent), ",")
|
||||
mainLog.Load().Debug().Strs("old_forwarders", oldForwarders).Msg("Previous forwarders")
|
||||
|
||||
if err := addDnsServerForwarders(forwarders, oldForwarders); err != nil {
|
||||
mainLog.Load().Warn().Err(err).Msg("could not set forwarders settings")
|
||||
} else {
|
||||
mainLog.Load().Debug().Msg("Successfully configured DNS server forwarders")
|
||||
}
|
||||
}
|
||||
})
|
||||
luid, err := winipcfg.LUIDFromIndex(uint32(iface.Index))
|
||||
if err != nil {
|
||||
return fmt.Errorf("setDNS: %w", err)
|
||||
@@ -125,25 +75,8 @@ func resetDnsIgnoreUnusableInterface(iface *net.Interface) error {
|
||||
return resetDNS(iface)
|
||||
}
|
||||
|
||||
// TODO(cuonglm): should we use system API?
|
||||
// resetDNS resets DNS servers for the specified interface
|
||||
func resetDNS(iface *net.Interface) error {
|
||||
resetDNSOnce.Do(func() {
|
||||
// See corresponding comment in setDNS.
|
||||
if hasLocalDnsServerRunning() {
|
||||
file := absHomeDir(windowsForwardersFilename)
|
||||
content, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
mainLog.Load().Error().Err(err).Msg("could not read forwarders settings")
|
||||
return
|
||||
}
|
||||
nameservers := strings.Split(string(content), ",")
|
||||
if err := removeDnsServerForwarders(nameservers); err != nil {
|
||||
mainLog.Load().Error().Err(err).Msg("could not remove forwarders settings")
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
luid, err := winipcfg.LUIDFromIndex(uint32(iface.Index))
|
||||
if err != nil {
|
||||
return fmt.Errorf("resetDNS: %w", err)
|
||||
@@ -161,7 +94,7 @@ func resetDNS(iface *net.Interface) error {
|
||||
// restoreDNS restores the DNS settings of the given interface.
|
||||
// this should only be executed upon turning off the ctrld service.
|
||||
func restoreDNS(iface *net.Interface) (err error) {
|
||||
if nss := savedStaticNameservers(iface); len(nss) > 0 {
|
||||
if nss := ctrld.SavedStaticNameservers(iface); len(nss) > 0 {
|
||||
v4ns := make([]string, 0, 2)
|
||||
v6ns := make([]string, 0, 2)
|
||||
for _, ns := range nss {
|
||||
@@ -178,24 +111,24 @@ func restoreDNS(iface *net.Interface) (err error) {
|
||||
}
|
||||
|
||||
if len(v4ns) > 0 {
|
||||
mainLog.Load().Debug().Msgf("restoring IPv4 static DNS for interface %q: %v", iface.Name, v4ns)
|
||||
mainLog.Load().Debug().Msgf("Restoring IPv4 static DNS for interface %q: %v", iface.Name, v4ns)
|
||||
if err := setDNS(iface, v4ns); err != nil {
|
||||
return fmt.Errorf("restoreDNS (IPv4): %w", err)
|
||||
}
|
||||
} else {
|
||||
mainLog.Load().Debug().Msgf("restoring IPv4 DHCP for interface %q", iface.Name)
|
||||
mainLog.Load().Debug().Msgf("Restoring IPv4 DHCP for interface %q", iface.Name)
|
||||
if err := luid.SetDNS(windows.AF_INET, nil, nil); err != nil {
|
||||
return fmt.Errorf("restoreDNS (IPv4 clear): %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(v6ns) > 0 {
|
||||
mainLog.Load().Debug().Msgf("restoring IPv6 static DNS for interface %q: %v", iface.Name, v6ns)
|
||||
mainLog.Load().Debug().Msgf("Restoring IPv6 static DNS for interface %q: %v", iface.Name, v6ns)
|
||||
if err := setDNS(iface, v6ns); err != nil {
|
||||
return fmt.Errorf("restoreDNS (IPv6): %w", err)
|
||||
}
|
||||
} else {
|
||||
mainLog.Load().Debug().Msgf("restoring IPv6 DHCP for interface %q", iface.Name)
|
||||
mainLog.Load().Debug().Msgf("Restoring IPv6 DHCP for interface %q", iface.Name)
|
||||
if err := luid.SetDNS(windows.AF_INET6, nil, nil); err != nil {
|
||||
return fmt.Errorf("restoreDNS (IPv6 clear): %w", err)
|
||||
}
|
||||
@@ -204,15 +137,16 @@ func restoreDNS(iface *net.Interface) (err error) {
|
||||
return err
|
||||
}
|
||||
|
||||
// currentDNS returns the current DNS servers for the specified interface
|
||||
func currentDNS(iface *net.Interface) []string {
|
||||
luid, err := winipcfg.LUIDFromIndex(uint32(iface.Index))
|
||||
if err != nil {
|
||||
mainLog.Load().Error().Err(err).Msg("failed to get interface LUID")
|
||||
mainLog.Load().Error().Err(err).Msg("Failed to get interface LUID")
|
||||
return nil
|
||||
}
|
||||
nameservers, err := luid.DNS()
|
||||
if err != nil {
|
||||
mainLog.Load().Error().Err(err).Msg("failed to get interface DNS")
|
||||
mainLog.Load().Error().Err(err).Msg("Failed to get interface DNS")
|
||||
return nil
|
||||
}
|
||||
ns := make([]string, 0, len(nameservers))
|
||||
@@ -240,7 +174,7 @@ func currentStaticDNS(iface *net.Interface) ([]string, error) {
|
||||
interfaceKeyPath := path + guid.String()
|
||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE, interfaceKeyPath, registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
mainLog.Load().Debug().Err(err).Msgf("failed to open registry key %q for interface %q; trying next key", interfaceKeyPath, iface.Name)
|
||||
mainLog.Load().Debug().Err(err).Msgf("Failed to open registry key %q for interface %q; trying next key", interfaceKeyPath, iface.Name)
|
||||
continue
|
||||
}
|
||||
func() {
|
||||
@@ -248,11 +182,11 @@ func currentStaticDNS(iface *net.Interface) ([]string, error) {
|
||||
for _, keyName := range []string{"NameServer", "ProfileNameServer"} {
|
||||
value, _, err := k.GetStringValue(keyName)
|
||||
if err != nil && !errors.Is(err, registry.ErrNotExist) {
|
||||
mainLog.Load().Debug().Err(err).Msgf("error reading %s registry key", keyName)
|
||||
mainLog.Load().Debug().Err(err).Msgf("Error reading %s registry key", keyName)
|
||||
continue
|
||||
}
|
||||
if len(value) > 0 {
|
||||
mainLog.Load().Debug().Msgf("found static DNS for interface %q: %s", iface.Name, value)
|
||||
mainLog.Load().Debug().Msgf("Found static DNS for interface %q: %s", iface.Name, value)
|
||||
parsed := parseDNSServers(value)
|
||||
for _, pns := range parsed {
|
||||
if !slices.Contains(ns, pns) {
|
||||
@@ -264,7 +198,7 @@ func currentStaticDNS(iface *net.Interface) ([]string, error) {
|
||||
}()
|
||||
}
|
||||
if len(ns) == 0 {
|
||||
mainLog.Load().Debug().Msgf("no static DNS values found for interface %q", iface.Name)
|
||||
mainLog.Load().Debug().Msgf("No static DNS values found for interface %q", iface.Name)
|
||||
}
|
||||
return ns, nil
|
||||
}
|
||||
@@ -284,49 +218,3 @@ func parseDNSServers(val string) []string {
|
||||
}
|
||||
return servers
|
||||
}
|
||||
|
||||
// addDnsServerForwarders adds given nameservers to DNS server forwarders list,
|
||||
// and also removing old forwarders if provided.
|
||||
func addDnsServerForwarders(nameservers, old []string) error {
|
||||
newForwardersMap := make(map[string]struct{})
|
||||
newForwarders := make([]string, len(nameservers))
|
||||
for i := range nameservers {
|
||||
newForwardersMap[nameservers[i]] = struct{}{}
|
||||
newForwarders[i] = fmt.Sprintf("%q", nameservers[i])
|
||||
}
|
||||
oldForwarders := old[:0]
|
||||
for _, fwd := range old {
|
||||
if _, ok := newForwardersMap[fwd]; !ok {
|
||||
oldForwarders = append(oldForwarders, fwd)
|
||||
}
|
||||
}
|
||||
// NOTE: It is important to add new forwarder before removing old one.
|
||||
// Testing on Windows Server 2022 shows that removing forwarder1
|
||||
// then adding forwarder2 sometimes ends up adding both of them
|
||||
// to the forwarders list.
|
||||
cmd := fmt.Sprintf("Add-DnsServerForwarder -IPAddress %s", strings.Join(newForwarders, ","))
|
||||
if len(oldForwarders) > 0 {
|
||||
cmd = fmt.Sprintf("%s ; Remove-DnsServerForwarder -IPAddress %s -Force", cmd, strings.Join(oldForwarders, ","))
|
||||
}
|
||||
if out, err := powershell(cmd); err != nil {
|
||||
return fmt.Errorf("%w: %s", err, string(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeDnsServerForwarders removes given nameservers from DNS server forwarders list.
|
||||
func removeDnsServerForwarders(nameservers []string) error {
|
||||
for _, ns := range nameservers {
|
||||
cmd := fmt.Sprintf("Remove-DnsServerForwarder -IPAddress %s -Force", ns)
|
||||
if out, err := powershell(cmd); err != nil {
|
||||
return fmt.Errorf("%w: %s", err, string(out))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// powershell runs the given powershell command.
|
||||
func powershell(cmd string) ([]byte, error) {
|
||||
out, err := exec.Command("powershell", "-Command", cmd).CombinedOutput()
|
||||
return bytes.TrimSpace(out), err
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -66,3 +68,9 @@ func currentStaticDnsPowershell(iface *net.Interface) ([]string, error) {
|
||||
}
|
||||
return ns, nil
|
||||
}
|
||||
|
||||
// powershell runs the given powershell command.
|
||||
func powershell(cmd string) ([]byte, error) {
|
||||
out, err := exec.Command("powershell", "-Command", cmd).CombinedOutput()
|
||||
return bytes.TrimSpace(out), err
|
||||
}
|
||||
|
||||
+277
-293
File diff suppressed because it is too large
Load Diff
@@ -4,8 +4,10 @@ import (
|
||||
"github.com/kardianos/service"
|
||||
)
|
||||
|
||||
// setDependencies sets service dependencies for Darwin
|
||||
func setDependencies(svc *service.Config) {}
|
||||
|
||||
// setWorkingDirectory sets the working directory for the service
|
||||
func setWorkingDirectory(svc *service.Config, dir string) {
|
||||
svc.WorkingDirectory = dir
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@ import (
|
||||
"github.com/kardianos/service"
|
||||
)
|
||||
|
||||
// setDependencies sets service dependencies for FreeBSD
|
||||
func setDependencies(svc *service.Config) {
|
||||
// TODO(cuonglm): remove once https://github.com/kardianos/service/issues/359 fixed.
|
||||
_ = os.MkdirAll("/usr/local/etc/rc.d", 0755)
|
||||
}
|
||||
|
||||
// setWorkingDirectory sets the working directory for the service
|
||||
func setWorkingDirectory(svc *service.Config, dir string) {}
|
||||
|
||||
@@ -9,8 +9,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/kardianos/service"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld/internal/router"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -23,6 +21,7 @@ func init() {
|
||||
}
|
||||
}
|
||||
|
||||
// setDependencies sets service dependencies for Linux
|
||||
func setDependencies(svc *service.Config) {
|
||||
svc.Dependencies = []string{
|
||||
"Wants=network-online.target",
|
||||
@@ -37,11 +36,9 @@ func setDependencies(svc *service.Config) {
|
||||
svc.Dependencies = append(svc.Dependencies, "Wants=systemd-networkd-wait-online.service")
|
||||
}
|
||||
}
|
||||
if routerDeps := router.ServiceDependencies(); len(routerDeps) > 0 {
|
||||
svc.Dependencies = append(svc.Dependencies, routerDeps...)
|
||||
}
|
||||
}
|
||||
|
||||
// setWorkingDirectory sets the working directory for the service
|
||||
func setWorkingDirectory(svc *service.Config, dir string) {
|
||||
svc.WorkingDirectory = dir
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package cli
|
||||
|
||||
import "github.com/Control-D-Inc/ctrld"
|
||||
|
||||
// Debug starts a new message with debug level.
|
||||
func (p *prog) Debug() *ctrld.LogEvent {
|
||||
return p.logger.Load().Debug()
|
||||
}
|
||||
|
||||
// Warn starts a new message with warn level.
|
||||
func (p *prog) Warn() *ctrld.LogEvent {
|
||||
return p.logger.Load().Warn()
|
||||
}
|
||||
|
||||
// Info starts a new message with info level.
|
||||
func (p *prog) Info() *ctrld.LogEvent {
|
||||
return p.logger.Load().Info()
|
||||
}
|
||||
|
||||
// Fatal starts a new message with fatal level.
|
||||
func (p *prog) Fatal() *ctrld.LogEvent {
|
||||
return p.logger.Load().Fatal()
|
||||
}
|
||||
|
||||
// Error starts a new message with error level.
|
||||
func (p *prog) Error() *ctrld.LogEvent {
|
||||
return p.logger.Load().Error()
|
||||
}
|
||||
|
||||
// Notice starts a new message with notice level.
|
||||
func (p *prog) Notice() *ctrld.LogEvent {
|
||||
return p.logger.Load().Notice()
|
||||
}
|
||||
@@ -4,8 +4,10 @@ package cli
|
||||
|
||||
import "github.com/kardianos/service"
|
||||
|
||||
// setDependencies sets service dependencies for other platforms
|
||||
func setDependencies(svc *service.Config) {}
|
||||
|
||||
// setWorkingDirectory sets the working directory for the service
|
||||
func setWorkingDirectory(svc *service.Config, dir string) {
|
||||
// WorkingDirectory is not supported on Windows.
|
||||
svc.WorkingDirectory = dir
|
||||
|
||||
+38
-15
@@ -1,17 +1,46 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"context"
|
||||
"net"
|
||||
"net/url"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/semver/v3"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
)
|
||||
|
||||
func TestErrNetworkErrorTreatsNoRouteAsNetworkError(t *testing.T) {
|
||||
err := &net.OpError{Op: "dial", Net: "tcp", Err: syscall.EHOSTUNREACH}
|
||||
assert.True(t, errNetworkError(err))
|
||||
assert.True(t, errUrlNetworkError(&url.Error{Op: "Get", URL: "https://dns.controld.com", Err: err}))
|
||||
}
|
||||
|
||||
func TestSleepWithContext(t *testing.T) {
|
||||
assert.True(t, sleepWithContext(context.Background(), time.Millisecond))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
start := time.Now()
|
||||
assert.False(t, sleepWithContext(ctx, time.Minute))
|
||||
assert.Less(t, time.Since(start), 100*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestUnreachableRecoveryBackoff(t *testing.T) {
|
||||
// Streak starts at the base cadence and doubles each attempt, capped at the max.
|
||||
assert.Equal(t, checkUpstreamBackoffSleep, unreachableRecoveryBackoff(0))
|
||||
assert.Equal(t, checkUpstreamBackoffSleep, unreachableRecoveryBackoff(1))
|
||||
assert.Equal(t, 2*checkUpstreamBackoffSleep, unreachableRecoveryBackoff(2))
|
||||
assert.Equal(t, 4*checkUpstreamBackoffSleep, unreachableRecoveryBackoff(3))
|
||||
assert.Equal(t, checkUpstreamUnreachableBackoffMax, unreachableRecoveryBackoff(100))
|
||||
}
|
||||
|
||||
func Test_prog_dnsWatchdogEnabled(t *testing.T) {
|
||||
p := &prog{cfg: &ctrld.Config{}}
|
||||
|
||||
@@ -174,10 +203,10 @@ func Test_shouldUpgrade(t *testing.T) {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Create test logger
|
||||
testLogger := zerolog.New(zerolog.NewTestWriter(t)).With().Logger()
|
||||
testLogger := &ctrld.Logger{Logger: zap.NewNop()}
|
||||
|
||||
// Call the function and capture the result
|
||||
result := shouldUpgrade(tc.versionTarget, tc.currentVersion, &testLogger)
|
||||
result := shouldUpgrade(tc.versionTarget, tc.currentVersion, testLogger)
|
||||
|
||||
// Assert the expected result
|
||||
assert.Equal(t, tc.shouldUpgrade, result, tc.description)
|
||||
@@ -186,10 +215,6 @@ func Test_shouldUpgrade(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_selfUpgradeCheck(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("skipped due to Windows file locking issue on Github Action runners")
|
||||
}
|
||||
|
||||
// Helper function to create a version
|
||||
makeVersion := func(v string) *semver.Version {
|
||||
ver, err := semver.NewVersion(v)
|
||||
@@ -226,10 +251,10 @@ func Test_selfUpgradeCheck(t *testing.T) {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Create test logger
|
||||
testLogger := zerolog.New(zerolog.NewTestWriter(t)).With().Logger()
|
||||
testLogger := &ctrld.Logger{Logger: zap.NewNop()}
|
||||
|
||||
// Call the function and capture the result
|
||||
result := selfUpgradeCheck(tc.versionTarget, tc.currentVersion, &testLogger)
|
||||
result := selfUpgradeCheck(tc.versionTarget, tc.currentVersion, testLogger)
|
||||
|
||||
// Assert the expected result
|
||||
assert.Equal(t, tc.shouldUpgrade, result, tc.description)
|
||||
@@ -238,10 +263,6 @@ func Test_selfUpgradeCheck(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_performUpgrade(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("skipped due to Windows file locking issue on Github Action runners")
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
versionTarget string
|
||||
@@ -267,8 +288,10 @@ func Test_performUpgrade(t *testing.T) {
|
||||
for _, tc := range tests {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Create test logger
|
||||
testLogger := &ctrld.Logger{Logger: zap.NewNop()}
|
||||
// Call the function and capture the result
|
||||
result := performUpgrade(tc.versionTarget)
|
||||
result := performUpgrade(tc.versionTarget, testLogger)
|
||||
assert.Equal(t, tc.expectedResult, result, tc.description)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,12 +2,10 @@ package cli
|
||||
|
||||
import "github.com/kardianos/service"
|
||||
|
||||
func setDependencies(svc *service.Config) {
|
||||
if hasLocalDnsServerRunning() {
|
||||
svc.Dependencies = []string{"DNS"}
|
||||
}
|
||||
}
|
||||
// setDependencies sets service dependencies for Windows
|
||||
func setDependencies(svc *service.Config) {}
|
||||
|
||||
// setWorkingDirectory sets the working directory for the service
|
||||
func setWorkingDirectory(svc *service.Config, dir string) {
|
||||
// WorkingDirectory is not supported on Windows.
|
||||
svc.WorkingDirectory = dir
|
||||
|
||||
@@ -2,6 +2,8 @@ package cli
|
||||
|
||||
import "github.com/prometheus/client_golang/prometheus"
|
||||
|
||||
// Prometheus metrics label constants for consistent labeling across all metrics
|
||||
// These ensure standardized metric labeling for monitoring and alerting
|
||||
const (
|
||||
metricsLabelListener = "listener"
|
||||
metricsLabelClientSourceIP = "client_source_ip"
|
||||
@@ -13,17 +15,21 @@ const (
|
||||
)
|
||||
|
||||
// statsVersion represent ctrld version.
|
||||
// This metric provides version information for monitoring and debugging
|
||||
var statsVersion = prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "ctrld_build_info",
|
||||
Help: "Version of ctrld process.",
|
||||
}, []string{"gitref", "goversion", "version"})
|
||||
|
||||
// statsTimeStart represents start time of ctrld service.
|
||||
// This metric tracks service uptime and helps with monitoring service restarts
|
||||
var statsTimeStart = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ctrld_time_seconds",
|
||||
Help: "Start time of the ctrld process since unix epoch in seconds.",
|
||||
})
|
||||
|
||||
// statsQueriesCountLabels defines the labels for query count metrics
|
||||
// These labels provide detailed breakdown of DNS query statistics
|
||||
var statsQueriesCountLabels = []string{
|
||||
metricsLabelListener,
|
||||
metricsLabelClientSourceIP,
|
||||
@@ -35,6 +41,7 @@ var statsQueriesCountLabels = []string{
|
||||
}
|
||||
|
||||
// statsQueriesCount counts total number of queries.
|
||||
// This provides comprehensive DNS query statistics for monitoring and alerting
|
||||
var statsQueriesCount = prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "ctrld_queries_count",
|
||||
Help: "Total number of queries.",
|
||||
@@ -44,12 +51,14 @@ var statsQueriesCount = prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
//
|
||||
// The labels "client_source_ip", "client_mac", "client_hostname" are unbounded,
|
||||
// thus this stat is highly inefficient if there are many devices.
|
||||
// This metric should be used carefully in high-client environments
|
||||
var statsClientQueriesCount = prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "ctrld_client_queries_count",
|
||||
Help: "Total number queries of a client.",
|
||||
}, []string{metricsLabelClientSourceIP, metricsLabelClientMac, metricsLabelClientHostname})
|
||||
|
||||
// WithLabelValuesInc increases prometheus counter by 1 if query stats is enabled.
|
||||
// This provides conditional metric collection to avoid performance impact when metrics are disabled
|
||||
func (p *prog) WithLabelValuesInc(c *prometheus.CounterVec, lvs ...string) {
|
||||
if p.metricsQueryStats.Load() {
|
||||
c.WithLabelValues(lvs...).Inc()
|
||||
|
||||
@@ -8,10 +8,12 @@ import (
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// notifyReloadSigCh sends reload signal to the channel
|
||||
func notifyReloadSigCh(ch chan os.Signal) {
|
||||
signal.Notify(ch, syscall.SIGUSR1)
|
||||
}
|
||||
|
||||
// sendReloadSignal sends a reload signal to the current process
|
||||
func (p *prog) sendReloadSignal() error {
|
||||
return syscall.Kill(syscall.Getpid(), syscall.SIGUSR1)
|
||||
}
|
||||
|
||||
@@ -6,8 +6,10 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// notifyReloadSigCh is a no-op on Windows platforms
|
||||
func notifyReloadSigCh(ch chan os.Signal) {}
|
||||
|
||||
// sendReloadSignal sends a reload signal to the program
|
||||
func (p *prog) sendReloadSignal() error {
|
||||
select {
|
||||
case p.reloadCh <- struct{}{}:
|
||||
|
||||
+23
-34
@@ -3,59 +3,45 @@ package cli
|
||||
import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld/internal/resolvconffile"
|
||||
)
|
||||
|
||||
// parseResolvConfNameservers reads the resolv.conf file and returns the nameservers found.
|
||||
// Returns nil if no nameservers are found.
|
||||
// This function parses the system DNS configuration to understand current nameserver settings
|
||||
func (p *prog) parseResolvConfNameservers(path string) ([]string, error) {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse the file for "nameserver" lines
|
||||
var currentNS []string
|
||||
lines := strings.Split(string(content), "\n")
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "nameserver") {
|
||||
parts := strings.Fields(trimmed)
|
||||
if len(parts) >= 2 {
|
||||
currentNS = append(currentNS, parts[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return currentNS, nil
|
||||
return resolvconffile.NameserversFromFile(path)
|
||||
}
|
||||
|
||||
// watchResolvConf watches any changes to /etc/resolv.conf file,
|
||||
// and reverting to the original config set by ctrld.
|
||||
// This ensures that DNS settings are not overridden by other applications or system processes
|
||||
func (p *prog) watchResolvConf(iface *net.Interface, ns []netip.Addr, setDnsFn func(iface *net.Interface, ns []netip.Addr) error) {
|
||||
resolvConfPath := "/etc/resolv.conf"
|
||||
// Evaluating symbolics link to watch the target file that /etc/resolv.conf point to.
|
||||
// This handles systems where resolv.conf is a symlink to another location
|
||||
if rp, _ := filepath.EvalSymlinks(resolvConfPath); rp != "" {
|
||||
resolvConfPath = rp
|
||||
}
|
||||
mainLog.Load().Debug().Msgf("start watching %s file", resolvConfPath)
|
||||
p.Debug().Msgf("Start watching %s file", resolvConfPath)
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
mainLog.Load().Warn().Err(err).Msg("could not create watcher for /etc/resolv.conf")
|
||||
p.Warn().Err(err).Msg("Could not create watcher for /etc/resolv.conf")
|
||||
return
|
||||
}
|
||||
defer watcher.Close()
|
||||
|
||||
// We watch /etc instead of /etc/resolv.conf directly,
|
||||
// see: https://github.com/fsnotify/fsnotify#watching-a-file-doesnt-work-well
|
||||
// This is necessary because some systems don't properly notify on file changes
|
||||
watchDir := filepath.Dir(resolvConfPath)
|
||||
if err := watcher.Add(watchDir); err != nil {
|
||||
mainLog.Load().Warn().Err(err).Msgf("could not add %s to watcher list", watchDir)
|
||||
p.Warn().Err(err).Msgf("Could not add %s to watcher list", watchDir)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -64,7 +50,7 @@ func (p *prog) watchResolvConf(iface *net.Interface, ns []netip.Addr, setDnsFn f
|
||||
case <-p.dnsWatcherStopCh:
|
||||
return
|
||||
case <-p.stopCh:
|
||||
mainLog.Load().Debug().Msgf("stopping watcher for %s", resolvConfPath)
|
||||
p.Debug().Msgf("Stopping watcher for %s", resolvConfPath)
|
||||
return
|
||||
case event, ok := <-watcher.Events:
|
||||
if p.recoveryRunning.Load() {
|
||||
@@ -77,9 +63,10 @@ func (p *prog) watchResolvConf(iface *net.Interface, ns []netip.Addr, setDnsFn f
|
||||
continue
|
||||
}
|
||||
if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) {
|
||||
mainLog.Load().Debug().Msgf("/etc/resolv.conf changes detected, reading changes...")
|
||||
p.Debug().Msgf("/etc/resolv.conf changes detected, reading changes...")
|
||||
|
||||
// Convert expected nameservers to strings for comparison
|
||||
// This allows us to detect when the resolv.conf has been modified
|
||||
expectedNS := make([]string, len(ns))
|
||||
for i, addr := range ns {
|
||||
expectedNS[i] = addr.String()
|
||||
@@ -92,18 +79,20 @@ func (p *prog) watchResolvConf(iface *net.Interface, ns []netip.Addr, setDnsFn f
|
||||
for retry := 0; retry < maxRetries; retry++ {
|
||||
foundNS, err = p.parseResolvConfNameservers(resolvConfPath)
|
||||
if err != nil {
|
||||
mainLog.Load().Error().Err(err).Msg("failed to read resolv.conf content")
|
||||
p.Error().Err(err).Msg("Failed to read resolv.conf content")
|
||||
break
|
||||
}
|
||||
|
||||
// If we found nameservers, break out of retry loop
|
||||
// This handles cases where the file is being written but not yet complete
|
||||
if len(foundNS) > 0 {
|
||||
break
|
||||
}
|
||||
|
||||
// Only retry if we found no nameservers
|
||||
// This handles temporary file states during updates
|
||||
if retry < maxRetries-1 {
|
||||
mainLog.Load().Debug().Msgf("resolv.conf has no nameserver entries, retry %d/%d in 2 seconds", retry+1, maxRetries)
|
||||
p.Debug().Msgf("resolv.conf has no nameserver entries, retry %d/%d in 2 seconds", retry+1, maxRetries)
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
@@ -113,7 +102,7 @@ func (p *prog) watchResolvConf(iface *net.Interface, ns []netip.Addr, setDnsFn f
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
mainLog.Load().Debug().Msg("resolv.conf remained empty after all retries")
|
||||
p.Debug().Msg("resolv.conf remained empty after all retries")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +119,7 @@ func (p *prog) watchResolvConf(iface *net.Interface, ns []netip.Addr, setDnsFn f
|
||||
}
|
||||
}
|
||||
|
||||
mainLog.Load().Debug().
|
||||
p.Debug().
|
||||
Strs("found", foundNS).
|
||||
Strs("expected", expectedNS).
|
||||
Bool("matches", matches).
|
||||
@@ -139,16 +128,16 @@ func (p *prog) watchResolvConf(iface *net.Interface, ns []netip.Addr, setDnsFn f
|
||||
// Only revert if the nameservers don't match
|
||||
if !matches {
|
||||
if err := watcher.Remove(watchDir); err != nil {
|
||||
mainLog.Load().Error().Err(err).Msg("failed to pause watcher")
|
||||
p.Error().Err(err).Msg("Failed to pause watcher")
|
||||
continue
|
||||
}
|
||||
|
||||
if err := setDnsFn(iface, ns); err != nil {
|
||||
mainLog.Load().Error().Err(err).Msg("failed to revert /etc/resolv.conf changes")
|
||||
p.Error().Err(err).Msg("Failed to revert /etc/resolv.conf changes")
|
||||
}
|
||||
|
||||
if err := watcher.Add(watchDir); err != nil {
|
||||
mainLog.Load().Error().Err(err).Msg("failed to continue running watcher")
|
||||
p.Error().Err(err).Msg("Failed to continue running watcher")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -158,7 +147,7 @@ func (p *prog) watchResolvConf(iface *net.Interface, ns []netip.Addr, setDnsFn f
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
mainLog.Load().Err(err).Msg("could not get event for /etc/resolv.conf")
|
||||
p.Error().Err(err).Msg("Could not get event for /etc/resolv.conf")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
const resolvConfPath = "/etc/resolv.conf"
|
||||
|
||||
// setResolvConf sets the content of resolv.conf file using the given nameservers list.
|
||||
func setResolvConf(iface *net.Interface, ns []netip.Addr) error {
|
||||
func (p *prog) setResolvConf(iface *net.Interface, ns []netip.Addr) error {
|
||||
servers := make([]string, len(ns))
|
||||
for i := range ns {
|
||||
servers[i] = ns[i].String()
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
)
|
||||
|
||||
// setResolvConf sets the content of the resolv.conf file using the given nameservers list.
|
||||
func setResolvConf(iface *net.Interface, ns []netip.Addr) error {
|
||||
func (p *prog) setResolvConf(iface *net.Interface, ns []netip.Addr) error {
|
||||
r, err := newLoopbackOSConfigurator()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -27,7 +27,7 @@ func setResolvConf(iface *net.Interface, ns []netip.Addr) error {
|
||||
if sds, err := searchDomains(); err == nil {
|
||||
oc.SearchDomains = sds
|
||||
} else {
|
||||
mainLog.Load().Debug().Err(err).Msg("failed to get search domains list when reverting resolv.conf file")
|
||||
p.Debug().Err(err).Msg("Failed to get search domains list when reverting resolv.conf file")
|
||||
}
|
||||
return r.SetDNS(oc)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
//go:build unix
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld/internal/dns/resolvconffile"
|
||||
)
|
||||
|
||||
func oldParseResolvConfNameservers(path string) ([]string, error) {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse the file for "nameserver" lines
|
||||
var currentNS []string
|
||||
lines := strings.Split(string(content), "\n")
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "nameserver") {
|
||||
parts := strings.Fields(trimmed)
|
||||
if len(parts) >= 2 {
|
||||
currentNS = append(currentNS, parts[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return currentNS, nil
|
||||
}
|
||||
|
||||
// Test_prog_parseResolvConfNameservers tests the parsing of nameservers from resolv.conf content.
|
||||
// Note: The previous implementation was removed to reduce code duplication and consolidate
|
||||
// the resolv.conf handling logic into a single unified approach. All resolv.conf parsing
|
||||
// is now handled by the resolvconffile package, which provides a consistent interface
|
||||
// for both reading and modifying resolv.conf files across different platforms.
|
||||
func Test_prog_parseResolvConfNameservers(t *testing.T) {
|
||||
oldNss, _ := oldParseResolvConfNameservers(resolvconffile.Path)
|
||||
p := &prog{}
|
||||
nss, _ := p.parseResolvConfNameservers(resolvconffile.Path)
|
||||
slices.Sort(oldNss)
|
||||
slices.Sort(nss)
|
||||
if !slices.Equal(oldNss, nss) {
|
||||
t.Errorf("result mismatched, old: %v, new: %v", oldNss, nss)
|
||||
}
|
||||
t.Logf("result: %v", nss)
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
)
|
||||
|
||||
// setResolvConf sets the content of resolv.conf file using the given nameservers list.
|
||||
func setResolvConf(_ *net.Interface, _ []netip.Addr) error {
|
||||
func (p *prog) setResolvConf(_ *net.Interface, _ []netip.Addr) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ func searchDomains() ([]dnsname.FQDN, error) {
|
||||
for a := aa.FirstDNSSuffix; a != nil; a = a.Next {
|
||||
d, err := dnsname.ToFQDN(a.String())
|
||||
if err != nil {
|
||||
mainLog.Load().Debug().Err(err).Msgf("failed to parse domain: %s", a.String())
|
||||
mainLog.Load().Debug().Err(err).Msgf("Failed to parse domain: %s", a.String())
|
||||
continue
|
||||
}
|
||||
sds = append(sds, d)
|
||||
|
||||
@@ -4,4 +4,5 @@ package cli
|
||||
|
||||
var supportedSelfDelete = true
|
||||
|
||||
// selfDeleteExe performs self-deletion on non-Windows platforms
|
||||
func selfDeleteExe() error { return nil }
|
||||
|
||||
@@ -33,6 +33,7 @@ type FILE_DISPOSITION_INFO struct {
|
||||
DeleteFile bool
|
||||
}
|
||||
|
||||
// dsOpenHandle opens a handle to the specified file with DELETE access
|
||||
func dsOpenHandle(pwPath *uint16) (windows.Handle, error) {
|
||||
handle, err := windows.CreateFile(
|
||||
pwPath,
|
||||
@@ -51,6 +52,7 @@ func dsOpenHandle(pwPath *uint16) (windows.Handle, error) {
|
||||
return handle, nil
|
||||
}
|
||||
|
||||
// dsRenameHandle renames a file handle to a stream name
|
||||
func dsRenameHandle(hHandle windows.Handle) error {
|
||||
var fRename FILE_RENAME_INFO
|
||||
DS_STREAM_RENAME, err := windows.UTF16FromString(":deadbeef")
|
||||
@@ -82,6 +84,7 @@ func dsRenameHandle(hHandle windows.Handle) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// dsDepositeHandle marks a file handle for deletion
|
||||
func dsDepositeHandle(hHandle windows.Handle) error {
|
||||
var fDelete FILE_DISPOSITION_INFO
|
||||
fDelete.DeleteFile = true
|
||||
@@ -100,6 +103,7 @@ func dsDepositeHandle(hHandle windows.Handle) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// selfDeleteExe performs self-deletion on Windows platforms
|
||||
func selfDeleteExe() error {
|
||||
var wcPath [windows.MAX_PATH + 1]uint16
|
||||
var hCurrent windows.Handle
|
||||
|
||||
@@ -5,12 +5,13 @@ package cli
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
)
|
||||
|
||||
func selfUninstall(p *prog, logger zerolog.Logger) {
|
||||
// selfUninstall performs self-uninstallation on non-Unix platforms
|
||||
func selfUninstall(p *prog, logger *ctrld.Logger) {
|
||||
if uninstallInvalidCdUID(p, logger, false) {
|
||||
logger.Warn().Msgf("service was uninstalled because device %q does not exist", cdUID)
|
||||
logger.Warn().Msgf("Service was uninstalled because device %q does not exist", cdUID)
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,17 +9,18 @@ import (
|
||||
"runtime"
|
||||
"syscall"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
)
|
||||
|
||||
func selfUninstall(p *prog, logger zerolog.Logger) {
|
||||
// selfUninstall performs self-uninstallation on Unix platforms
|
||||
func selfUninstall(p *prog, logger *ctrld.Logger) {
|
||||
if runtime.GOOS == "linux" {
|
||||
selfUninstallLinux(p, logger)
|
||||
}
|
||||
|
||||
bin, err := os.Executable()
|
||||
if err != nil {
|
||||
logger.Fatal().Err(err).Msg("could not determine executable")
|
||||
logger.Fatal().Err(err).Msg("Could not determine executable")
|
||||
}
|
||||
args := []string{"uninstall"}
|
||||
if deactivationPinSet() {
|
||||
@@ -28,18 +29,19 @@ func selfUninstall(p *prog, logger zerolog.Logger) {
|
||||
cmd := exec.Command(bin, args...)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
if err := cmd.Start(); err != nil {
|
||||
logger.Fatal().Err(err).Msg("could not start self uninstall command")
|
||||
logger.Fatal().Err(err).Msg("Could not start self uninstall command")
|
||||
}
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
logger.Warn().Msgf("service was uninstalled because device %q does not exist", cdUID)
|
||||
logger.Warn().Msgf("Service was uninstalled because device %q does not exist", cdUID)
|
||||
_ = cmd.Wait()
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func selfUninstallLinux(p *prog, logger zerolog.Logger) {
|
||||
// selfUninstallLinux performs self-uninstallation on Linux platforms
|
||||
func selfUninstallLinux(p *prog, logger *ctrld.Logger) {
|
||||
if uninstallInvalidCdUID(p, logger, true) {
|
||||
logger.Warn().Msgf("service was uninstalled because device %q does not exist", cdUID)
|
||||
logger.Warn().Msgf("Service was uninstalled because device %q does not exist", cdUID)
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
package cli
|
||||
|
||||
// semaphore provides a simple synchronization mechanism
|
||||
type semaphore interface {
|
||||
acquire()
|
||||
release()
|
||||
}
|
||||
|
||||
// noopSemaphore is a no-operation implementation of semaphore
|
||||
type noopSemaphore struct{}
|
||||
|
||||
// acquire performs a no-operation for the noop semaphore
|
||||
func (n noopSemaphore) acquire() {}
|
||||
|
||||
// release performs a no-operation for the noop semaphore
|
||||
func (n noopSemaphore) release() {}
|
||||
|
||||
// chanSemaphore is a channel-based implementation of semaphore
|
||||
type chanSemaphore struct {
|
||||
ready chan struct{}
|
||||
}
|
||||
|
||||
// acquire blocks until a slot is available in the semaphore
|
||||
func (c *chanSemaphore) acquire() {
|
||||
c.ready <- struct{}{}
|
||||
}
|
||||
|
||||
// release signals that a slot has been freed in the semaphore
|
||||
func (c *chanSemaphore) release() {
|
||||
<-c.ready
|
||||
}
|
||||
|
||||
+10
-51
@@ -11,9 +11,6 @@ import (
|
||||
|
||||
"github.com/coreos/go-systemd/v22/unit"
|
||||
"github.com/kardianos/service"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld/internal/router"
|
||||
"github.com/Control-D-Inc/ctrld/internal/router/openwrt"
|
||||
)
|
||||
|
||||
// newService wraps service.New call to return service.Service
|
||||
@@ -24,10 +21,6 @@ func newService(i service.Interface, c *service.Config) (service.Service, error)
|
||||
return nil, err
|
||||
}
|
||||
switch {
|
||||
case router.IsOldOpenwrt(), router.IsNetGearOrbi():
|
||||
return &procd{sysV: &sysV{s}, svcConfig: c}, nil
|
||||
case router.IsGLiNet():
|
||||
return &sysV{s}, nil
|
||||
case s.Platform() == "unix-systemv":
|
||||
return &sysV{s}, nil
|
||||
case s.Platform() == "linux-systemd":
|
||||
@@ -42,7 +35,7 @@ func newService(i service.Interface, c *service.Config) (service.Service, error)
|
||||
// sysV wraps a service.Service, and provide start/stop/status command
|
||||
// base on "/etc/init.d/<service_name>".
|
||||
//
|
||||
// Use this on system where "service" command is not available, like GL.iNET router.
|
||||
// Use this on system where "service" command is not available.
|
||||
type sysV struct {
|
||||
service.Service
|
||||
}
|
||||
@@ -89,37 +82,6 @@ func (s *sysV) Status() (service.Status, error) {
|
||||
return unixSystemVServiceStatus()
|
||||
}
|
||||
|
||||
// procd wraps a service.Service, and provide start/stop command
|
||||
// base on "/etc/init.d/<service_name>", status command base on parsing "ps" command output.
|
||||
//
|
||||
// Use this on system where "/etc/init.d/<service_name> status" command is not available,
|
||||
// like old GL.iNET Opal router.
|
||||
type procd struct {
|
||||
*sysV
|
||||
svcConfig *service.Config
|
||||
}
|
||||
|
||||
func (s *procd) Status() (service.Status, error) {
|
||||
if !s.installed() {
|
||||
return service.StatusUnknown, service.ErrNotInstalled
|
||||
}
|
||||
bin := s.svcConfig.Executable
|
||||
if bin == "" {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return service.StatusUnknown, nil
|
||||
}
|
||||
bin = exe
|
||||
}
|
||||
|
||||
// Looking for something like "/sbin/ctrld run ".
|
||||
shellCmd := fmt.Sprintf("ps | grep -q %q", bin+" [r]un ")
|
||||
if err := exec.Command("sh", "-c", shellCmd).Run(); err != nil {
|
||||
return service.StatusStopped, nil
|
||||
}
|
||||
return service.StatusRunning, nil
|
||||
}
|
||||
|
||||
// systemd wraps a service.Service, and provide status command to
|
||||
// report the status correctly.
|
||||
type systemd struct {
|
||||
@@ -153,7 +115,7 @@ func (s *systemd) Start() error {
|
||||
if out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("systemctl daemon-reload failed: %w\n%s", err, string(out))
|
||||
}
|
||||
mainLog.Load().Debug().Msg("set KillMode=process successfully")
|
||||
mainLog.Load().Debug().Msg("Set KillMode=process successfully")
|
||||
}
|
||||
return s.Service.Start()
|
||||
}
|
||||
@@ -163,7 +125,7 @@ func (s *systemd) Start() error {
|
||||
func ensureSystemdKillMode(r io.Reader) (opts []*unit.UnitOption, change bool) {
|
||||
opts, err := unit.DeserializeOptions(r)
|
||||
if err != nil {
|
||||
mainLog.Load().Error().Err(err).Msg("failed to deserialize options")
|
||||
mainLog.Load().Error().Err(err).Msg("Failed to deserialize options")
|
||||
return
|
||||
}
|
||||
change = true
|
||||
@@ -187,6 +149,7 @@ func ensureSystemdKillMode(r io.Reader) (opts []*unit.UnitOption, change bool) {
|
||||
return opts, change
|
||||
}
|
||||
|
||||
// newLaunchd creates a new launchd service wrapper
|
||||
func newLaunchd(s service.Service) *launchd {
|
||||
return &launchd{
|
||||
Service: s,
|
||||
@@ -216,28 +179,30 @@ type task struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
// doTasks executes a list of tasks and returns success status
|
||||
func doTasks(tasks []task) bool {
|
||||
for _, task := range tasks {
|
||||
mainLog.Load().Debug().Msgf("Running task %s", task.Name)
|
||||
if err := task.f(); err != nil {
|
||||
if task.abortOnError {
|
||||
mainLog.Load().Error().Msgf("error running task %s: %v", task.Name, err)
|
||||
mainLog.Load().Error().Msgf("Error running task %s: %v", task.Name, err)
|
||||
return false
|
||||
}
|
||||
// if this is darwin stop command, dont print debug
|
||||
// since launchctl complains on every start
|
||||
if runtime.GOOS != "darwin" || task.Name != "Stop" {
|
||||
mainLog.Load().Debug().Msgf("error running task %s: %v", task.Name, err)
|
||||
mainLog.Load().Debug().Msgf("Error running task %s: %v", task.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// checkHasElevatedPrivilege checks if the process has elevated privileges and exits if not
|
||||
func checkHasElevatedPrivilege() {
|
||||
ok, err := hasElevatedPrivilege()
|
||||
if err != nil {
|
||||
mainLog.Load().Error().Msgf("could not detect user privilege: %v", err)
|
||||
mainLog.Load().Error().Msgf("Could not detect user privilege: %v", err)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
@@ -246,16 +211,10 @@ func checkHasElevatedPrivilege() {
|
||||
}
|
||||
}
|
||||
|
||||
// unixSystemVServiceStatus checks the status of a Unix System V service
|
||||
func unixSystemVServiceStatus() (service.Status, error) {
|
||||
out, err := exec.Command("/etc/init.d/ctrld", "status").CombinedOutput()
|
||||
if err != nil {
|
||||
// Specific case for openwrt >= 24.10, it returns non-success code
|
||||
// for above status command, which may not right.
|
||||
if router.Name() == openwrt.Name {
|
||||
if string(bytes.ToLower(bytes.TrimSpace(out))) == "inactive" {
|
||||
return service.StatusStopped, nil
|
||||
}
|
||||
}
|
||||
return service.StatusUnknown, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -6,17 +6,15 @@ import (
|
||||
"os"
|
||||
)
|
||||
|
||||
// hasElevatedPrivilege checks if the current process has elevated privileges
|
||||
func hasElevatedPrivilege() (bool, error) {
|
||||
return os.Geteuid() == 0, nil
|
||||
}
|
||||
|
||||
// openLogFile opens a log file with the specified flags
|
||||
func openLogFile(path string, flags int) (*os.File, error) {
|
||||
return os.OpenFile(path, flags, os.FileMode(0o600))
|
||||
}
|
||||
|
||||
// hasLocalDnsServerRunning reports whether we are on Windows and having Dns server running.
|
||||
func hasLocalDnsServerRunning() bool { return false }
|
||||
|
||||
// ConfigureWindowsServiceFailureActions is a no-op on non-Windows platforms
|
||||
func ConfigureWindowsServiceFailureActions(serviceName string) error { return nil }
|
||||
|
||||
func isRunningOnDomainControllerWindows() (bool, int) { return false, 0 }
|
||||
|
||||
@@ -2,22 +2,16 @@ package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/microsoft/wmi/pkg/base/host"
|
||||
"github.com/microsoft/wmi/pkg/base/instance"
|
||||
"github.com/microsoft/wmi/pkg/base/query"
|
||||
"github.com/microsoft/wmi/pkg/constant"
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/svc/mgr"
|
||||
)
|
||||
|
||||
// hasElevatedPrivilege checks if the current process has elevated privileges on Windows
|
||||
func hasElevatedPrivilege() (bool, error) {
|
||||
var sid *windows.SID
|
||||
if err := windows.AllocateAndInitializeSid(
|
||||
@@ -100,6 +94,7 @@ func ConfigureWindowsServiceFailureActions(serviceName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// openLogFile opens a log file with the specified mode on Windows
|
||||
func openLogFile(path string, mode int) (*os.File, error) {
|
||||
if len(path) == 0 {
|
||||
return nil, &os.PathError{Path: path, Op: "open", Err: syscall.ERROR_FILE_NOT_FOUND}
|
||||
@@ -151,78 +146,3 @@ func openLogFile(path string, mode int) (*os.File, error) {
|
||||
|
||||
return os.NewFile(uintptr(handle), path), nil
|
||||
}
|
||||
|
||||
const processEntrySize = uint32(unsafe.Sizeof(windows.ProcessEntry32{}))
|
||||
|
||||
// hasLocalDnsServerRunning reports whether we are on Windows and having Dns server running.
|
||||
func hasLocalDnsServerRunning() bool {
|
||||
h, e := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
|
||||
if e != nil {
|
||||
return false
|
||||
}
|
||||
defer windows.CloseHandle(h)
|
||||
p := windows.ProcessEntry32{Size: processEntrySize}
|
||||
for {
|
||||
e := windows.Process32Next(h, &p)
|
||||
if e != nil {
|
||||
return false
|
||||
}
|
||||
if strings.ToLower(windows.UTF16ToString(p.ExeFile[:])) == "dns.exe" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isRunningOnDomainControllerWindows() (bool, int) {
|
||||
whost := host.NewWmiLocalHost()
|
||||
q := query.NewWmiQuery("Win32_ComputerSystem")
|
||||
instances, err := instance.GetWmiInstancesFromHost(whost, string(constant.CimV2), q)
|
||||
if err != nil {
|
||||
mainLog.Load().Debug().Err(err).Msg("WMI query failed")
|
||||
return false, 0
|
||||
}
|
||||
if instances == nil {
|
||||
mainLog.Load().Debug().Msg("WMI query returned nil instances")
|
||||
return false, 0
|
||||
}
|
||||
defer instances.Close()
|
||||
|
||||
if len(instances) == 0 {
|
||||
mainLog.Load().Debug().Msg("no rows returned from Win32_ComputerSystem")
|
||||
return false, 0
|
||||
}
|
||||
|
||||
val, err := instances[0].GetProperty("DomainRole")
|
||||
if err != nil {
|
||||
mainLog.Load().Debug().Err(err).Msg("failed to get DomainRole property")
|
||||
return false, 0
|
||||
}
|
||||
if val == nil {
|
||||
mainLog.Load().Debug().Msg("DomainRole property is nil")
|
||||
return false, 0
|
||||
}
|
||||
|
||||
// Safely handle varied types: string or integer
|
||||
var roleInt int
|
||||
switch v := val.(type) {
|
||||
case string:
|
||||
// "4", "5", etc.
|
||||
parsed, parseErr := strconv.Atoi(v)
|
||||
if parseErr != nil {
|
||||
mainLog.Load().Debug().Err(parseErr).Msgf("failed to parse DomainRole value %q", v)
|
||||
return false, 0
|
||||
}
|
||||
roleInt = parsed
|
||||
case int8, int16, int32, int64:
|
||||
roleInt = int(reflect.ValueOf(v).Int())
|
||||
case uint8, uint16, uint32, uint64:
|
||||
roleInt = int(reflect.ValueOf(v).Uint())
|
||||
default:
|
||||
mainLog.Load().Debug().Msgf("unexpected DomainRole type: %T value=%v", v, v)
|
||||
return false, 0
|
||||
}
|
||||
|
||||
// Check if role indicates a domain controller
|
||||
isDC := roleInt == BackupDomainController || roleInt == PrimaryDomainController
|
||||
return isDC, roleInt
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func Test_hasLocalDnsServerRunning(t *testing.T) {
|
||||
start := time.Now()
|
||||
hasDns := hasLocalDnsServerRunning()
|
||||
t.Logf("Using Windows API takes: %d", time.Since(start).Milliseconds())
|
||||
|
||||
start = time.Now()
|
||||
hasDnsPowershell := hasLocalDnsServerRunningPowershell()
|
||||
t.Logf("Using Powershell takes: %d", time.Since(start).Milliseconds())
|
||||
|
||||
if hasDns != hasDnsPowershell {
|
||||
t.Fatalf("result mismatch, want: %v, got: %v", hasDnsPowershell, hasDns)
|
||||
}
|
||||
}
|
||||
|
||||
func hasLocalDnsServerRunningPowershell() bool {
|
||||
_, err := powershell("Get-Process -Name DNS")
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package cli
|
||||
|
||||
import "testing"
|
||||
|
||||
// Test_ensureRunningIfaceForInvalidUninstall is a regression test for issue-556:
|
||||
// after a reboot, the invalid-device self-uninstall path could run before the
|
||||
// running interface was known. Because resetDNS (via resetDNSForRunningIface)
|
||||
// silently skips DNS restoration when p.runningIface is empty, the OS was left
|
||||
// pointed at ctrld's local listener with no internet after the service was
|
||||
// removed. ensureRunningIfaceForInvalidUninstall must populate p.runningIface
|
||||
// before resetDNS runs.
|
||||
func Test_ensureRunningIfaceForInvalidUninstall(t *testing.T) {
|
||||
// preRun mutates the package-level iface global; restore it after the test.
|
||||
origIface := iface
|
||||
t.Cleanup(func() { iface = origIface })
|
||||
|
||||
// newService needs the package service config; it is safe to build here
|
||||
// because ensureRunningIfaceForInvalidUninstall only queries the (absent)
|
||||
// control socket via runningIface, which returns nil when ctrld is not
|
||||
// running, and performs no DNS or service mutation.
|
||||
sc := NewServiceCommand()
|
||||
s, err := sc.newService(&prog{}, sc.createServiceConfig())
|
||||
if err != nil {
|
||||
t.Fatalf("newService: %v", err)
|
||||
}
|
||||
|
||||
t.Run("iface flag already resolved but not yet copied", func(t *testing.T) {
|
||||
iface = "eth-test"
|
||||
p := &prog{}
|
||||
|
||||
// Precondition mirrors the buggy post-reboot state: an empty running
|
||||
// interface would make resetDNS skip restoration entirely.
|
||||
if p.runningIface != "" {
|
||||
t.Fatalf("precondition: runningIface = %q, want empty", p.runningIface)
|
||||
}
|
||||
|
||||
ensureRunningIfaceForInvalidUninstall(p, s)
|
||||
|
||||
if p.runningIface == "" {
|
||||
t.Fatal("runningIface still empty after prepare: resetDNS would skip DNS " +
|
||||
"restoration and leave the OS pointed at ctrld's local listener")
|
||||
}
|
||||
if p.runningIface != "eth-test" {
|
||||
t.Fatalf("runningIface = %q, want the resolved iface %q", p.runningIface, "eth-test")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("iface unset falls back to auto-detected interface", func(t *testing.T) {
|
||||
iface = ""
|
||||
p := &prog{}
|
||||
|
||||
ensureRunningIfaceForInvalidUninstall(p, s)
|
||||
|
||||
// With iface unset the prep resolves "auto" to the default interface
|
||||
// (defaultIfaceName never returns empty on the supported platforms), so
|
||||
// resetDNS has a concrete interface to restore.
|
||||
if p.runningIface == "" {
|
||||
t.Fatal("runningIface still empty after prepare with iface unset: " +
|
||||
"resetDNS would skip DNS restoration")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package cli
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
@@ -12,11 +13,31 @@ const (
|
||||
maxFailureRequest = 50
|
||||
// checkUpstreamBackoffSleep is the time interval between each upstream checks.
|
||||
checkUpstreamBackoffSleep = 2 * time.Second
|
||||
// checkUpstreamUnreachableBackoffMax caps the recovery retry interval for an
|
||||
// endpoint that keeps failing with a network-unreachable error. It bounds
|
||||
// the backoff so an unroutable endpoint is still re-probed periodically and
|
||||
// recovers once the route returns.
|
||||
checkUpstreamUnreachableBackoffMax = 60 * time.Second
|
||||
)
|
||||
|
||||
// unreachableRecoveryBackoff returns the retry interval for the given streak of
|
||||
// consecutive network-unreachable failures. It starts at checkUpstreamBackoffSleep
|
||||
// and doubles each attempt, capped at checkUpstreamUnreachableBackoffMax.
|
||||
func unreachableRecoveryBackoff(streak int) time.Duration {
|
||||
d := checkUpstreamBackoffSleep
|
||||
for i := 1; i < streak; i++ {
|
||||
d *= 2
|
||||
if d >= checkUpstreamUnreachableBackoffMax {
|
||||
return checkUpstreamUnreachableBackoffMax
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// upstreamMonitor performs monitoring upstreams health.
|
||||
type upstreamMonitor struct {
|
||||
cfg *ctrld.Config
|
||||
cfg *ctrld.Config
|
||||
logger atomic.Pointer[ctrld.Logger]
|
||||
|
||||
mu sync.RWMutex
|
||||
checking map[string]bool
|
||||
@@ -28,7 +49,8 @@ type upstreamMonitor struct {
|
||||
failureTimerActive map[string]bool
|
||||
}
|
||||
|
||||
func newUpstreamMonitor(cfg *ctrld.Config) *upstreamMonitor {
|
||||
// newUpstreamMonitor creates a new upstream monitor instance
|
||||
func newUpstreamMonitor(cfg *ctrld.Config, logger *ctrld.Logger) *upstreamMonitor {
|
||||
um := &upstreamMonitor{
|
||||
cfg: cfg,
|
||||
checking: make(map[string]bool),
|
||||
@@ -37,6 +59,7 @@ func newUpstreamMonitor(cfg *ctrld.Config) *upstreamMonitor {
|
||||
recovered: make(map[string]bool),
|
||||
failureTimerActive: make(map[string]bool),
|
||||
}
|
||||
um.logger.Store(logger)
|
||||
for n := range cfg.Upstream {
|
||||
upstream := upstreamPrefix + n
|
||||
um.reset(upstream)
|
||||
@@ -53,7 +76,7 @@ func (um *upstreamMonitor) increaseFailureCount(upstream string) {
|
||||
defer um.mu.Unlock()
|
||||
|
||||
if um.recovered[upstream] {
|
||||
mainLog.Load().Debug().Msgf("upstream %q is recovered, skipping failure count increase", upstream)
|
||||
um.logger.Load().Debug().Msgf("Upstream %q is recovered, skipping failure count increase", upstream)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -61,7 +84,7 @@ func (um *upstreamMonitor) increaseFailureCount(upstream string) {
|
||||
failedCount := um.failureReq[upstream]
|
||||
|
||||
// Log the updated failure count.
|
||||
mainLog.Load().Debug().Msgf("upstream %q failure count updated to %d", upstream, failedCount)
|
||||
um.logger.Load().Debug().Msgf("Upstream %q failure count updated to %d", upstream, failedCount)
|
||||
|
||||
// If this is the first failure and no timer is running, start a 10-second timer.
|
||||
if failedCount == 1 && !um.failureTimerActive[upstream] {
|
||||
@@ -74,7 +97,7 @@ func (um *upstreamMonitor) increaseFailureCount(upstream string) {
|
||||
// and the upstream is not in a recovered state, mark it as down.
|
||||
if um.failureReq[upstream] > 0 && !um.recovered[upstream] {
|
||||
um.down[upstream] = true
|
||||
mainLog.Load().Warn().Msgf("upstream %q marked as down after 10 seconds (failure count: %d)", upstream, um.failureReq[upstream])
|
||||
um.logger.Load().Warn().Msgf("Upstream %q marked as down after 10 seconds (failure count: %d)", upstream, um.failureReq[upstream])
|
||||
}
|
||||
// Reset the timer flag so that a new timer can be spawned if needed.
|
||||
um.failureTimerActive[upstream] = false
|
||||
@@ -84,7 +107,7 @@ func (um *upstreamMonitor) increaseFailureCount(upstream string) {
|
||||
// If the failure count quickly reaches the threshold, mark the upstream as down immediately.
|
||||
if failedCount >= maxFailureRequest {
|
||||
um.down[upstream] = true
|
||||
mainLog.Load().Warn().Msgf("upstream %q marked as down immediately (failure count: %d)", upstream, failedCount)
|
||||
um.logger.Load().Warn().Msgf("Upstream %q marked as down immediately (failure count: %d)", upstream, failedCount)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+63
-34
@@ -6,8 +6,8 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"tailscale.com/net/netmon"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
@@ -19,7 +19,9 @@ var vpnDNSSettlingEnabled = runtime.GOOS == "windows"
|
||||
// including the interface it was discovered on. The interface is used on macOS
|
||||
// to create interface-scoped pf exemptions that allow the VPN's local DNS
|
||||
// handler (e.g., Tailscale's MagicDNS Network Extension) to receive queries
|
||||
// from all processes — not just ctrld.
|
||||
// from all processes — not just ctrld. Without the interface scope, VPN DNS
|
||||
// handlers that operate at the packet level (Network Extensions) never see
|
||||
// the queries because pf intercepts them first.
|
||||
type vpnDNSExemption struct {
|
||||
Server string // DNS server IP (e.g., "100.100.100.100")
|
||||
Interface string // Interface name from scutil (e.g., "utun11"), may be empty
|
||||
@@ -28,6 +30,8 @@ type vpnDNSExemption struct {
|
||||
|
||||
// vpnDNSExemptFunc is called when VPN DNS servers change, to update
|
||||
// the intercept layer (WFP/pf) to permit VPN DNS traffic.
|
||||
// On macOS, exemptions are interface-scoped to allow VPN local DNS handlers
|
||||
// (e.g., Tailscale MagicDNS) to receive queries from all processes.
|
||||
type vpnDNSExemptFunc func(exemptions []vpnDNSExemption) error
|
||||
|
||||
// vpnDNSManager tracks active VPN DNS configurations and provides
|
||||
@@ -37,6 +41,7 @@ type vpnDNSManager struct {
|
||||
configs []ctrld.VPNDNSConfig
|
||||
// Map of domain suffix → DNS servers for fast lookup
|
||||
routes map[string][]string
|
||||
logger *atomic.Pointer[ctrld.Logger]
|
||||
// DNS servers from VPN interfaces that have no domain/suffix config.
|
||||
// These are NOT added to the global OS resolver. They're only used
|
||||
// as additional nameservers for queries that match split-DNS rules
|
||||
@@ -50,6 +55,9 @@ type vpnDNSManager struct {
|
||||
// discoverVPNDNS is injected for tests so Refresh does not depend on the
|
||||
// runner host's real VPN/virtual adapter state.
|
||||
discoverVPNDNS func(context.Context) []ctrld.VPNDNSConfig
|
||||
// refreshRunning keeps noisy network-change storms from running overlapping
|
||||
// scutil/networksetup VPN DNS discovery work.
|
||||
refreshRunning atomic.Bool
|
||||
// Called when VPN DNS server list changes, to update intercept exemptions.
|
||||
onServersChanged vpnDNSExemptFunc
|
||||
}
|
||||
@@ -57,9 +65,10 @@ type vpnDNSManager struct {
|
||||
// newVPNDNSManager creates a new manager. Only call when dnsIntercept is active.
|
||||
// exemptFunc is called whenever VPN DNS servers are discovered/changed, to update
|
||||
// the OS-level intercept rules to permit ctrld's outbound queries to those IPs.
|
||||
func newVPNDNSManager(exemptFunc vpnDNSExemptFunc) *vpnDNSManager {
|
||||
func newVPNDNSManager(logger *atomic.Pointer[ctrld.Logger], exemptFunc vpnDNSExemptFunc) *vpnDNSManager {
|
||||
return &vpnDNSManager{
|
||||
routes: make(map[string][]string),
|
||||
logger: logger,
|
||||
discoverVPNDNS: ctrld.DiscoverVPNDNS,
|
||||
onServersChanged: exemptFunc,
|
||||
}
|
||||
@@ -67,25 +76,31 @@ func newVPNDNSManager(exemptFunc vpnDNSExemptFunc) *vpnDNSManager {
|
||||
|
||||
// Refresh re-discovers VPN DNS configs from the OS.
|
||||
// Called on network change events.
|
||||
func (m *vpnDNSManager) Refresh(guardAgainstNoNameservers bool) {
|
||||
logger := mainLog.Load()
|
||||
func (m *vpnDNSManager) Refresh(ctx context.Context, guardAgainstNoNameservers ...bool) {
|
||||
logger := ctrld.LoggerFromCtx(ctx)
|
||||
guardedRefresh := len(guardAgainstNoNameservers) > 0 && guardAgainstNoNameservers[0]
|
||||
if !m.refreshRunning.CompareAndSwap(false, true) {
|
||||
ctrld.Log(ctx, logger.Debug(), "VPN DNS refresh already running, skipping duplicate")
|
||||
return
|
||||
}
|
||||
defer m.refreshRunning.Store(false)
|
||||
|
||||
logger.Debug().Msg("Refreshing VPN DNS configurations")
|
||||
ctrld.Log(ctx, logger.Debug(), "Refreshing VPN DNS configurations")
|
||||
discoverVPNDNS := m.discoverVPNDNS
|
||||
if discoverVPNDNS == nil {
|
||||
discoverVPNDNS = ctrld.DiscoverVPNDNS
|
||||
}
|
||||
configs := discoverVPNDNS(context.Background())
|
||||
configs := discoverVPNDNS(ctx)
|
||||
|
||||
// Detect exit mode: if the default route goes through a VPN DNS interface,
|
||||
// the VPN is routing ALL traffic (exit node / full tunnel). This is more
|
||||
// reliable than scutil flag parsing because the routing table is the ground
|
||||
// truth for traffic flow, regardless of how the VPN presents itself in scutil.
|
||||
// truth for traffic flow.
|
||||
if dri, err := netmon.DefaultRouteInterface(); err == nil && dri != "" {
|
||||
for i := range configs {
|
||||
if configs[i].InterfaceName == dri {
|
||||
if !configs[i].IsExitMode {
|
||||
logger.Info().Msgf("VPN DNS on %s: default route interface match — EXIT MODE (route-based detection)", dri)
|
||||
ctrld.Log(ctx, logger.Info(), "VPN DNS on %s: default route interface match — EXIT MODE (route-based detection)", dri)
|
||||
}
|
||||
configs[i].IsExitMode = true
|
||||
}
|
||||
@@ -97,21 +112,21 @@ func (m *vpnDNSManager) Refresh(guardAgainstNoNameservers bool) {
|
||||
|
||||
previousExemptions := m.currentExemptionsLocked()
|
||||
|
||||
if vpnDNSSettlingEnabled && len(configs) == 0 && guardAgainstNoNameservers && m.hasVPNDNSStateLocked() {
|
||||
if vpnDNSSettlingEnabled && len(configs) == 0 && guardedRefresh && m.hasVPNDNSStateLocked() {
|
||||
if !m.retainedAfterEmptyDiscovery {
|
||||
exemptions := m.currentExemptionsLocked()
|
||||
m.retainedAfterEmptyDiscovery = true
|
||||
logger.Debug().Msgf(
|
||||
ctrld.Log(ctx, logger.Debug(),
|
||||
"VPN DNS discovery empty; retaining last-known VPN DNS state for one guarded refresh (%d domainless servers, %d exemptions)",
|
||||
len(m.domainlessServers), len(exemptions))
|
||||
if m.onServersChanged != nil {
|
||||
if err := m.onServersChanged(exemptions); err != nil {
|
||||
logger.Error().Err(err).Msg("Failed to re-apply retained VPN DNS exemptions")
|
||||
ctrld.Log(ctx, logger.Error().Err(err), "Failed to re-apply retained VPN DNS exemptions")
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
logger.Debug().Msgf(
|
||||
ctrld.Log(ctx, logger.Debug(),
|
||||
"VPN DNS discovery still empty on next guarded refresh; clearing retained VPN DNS state (%d domainless servers)",
|
||||
len(m.domainlessServers))
|
||||
}
|
||||
@@ -125,24 +140,26 @@ func (m *vpnDNSManager) Refresh(guardAgainstNoNameservers bool) {
|
||||
|
||||
// Build domain -> DNS servers mapping
|
||||
for _, config := range configs {
|
||||
logger.Debug().Msgf("Processing VPN interface %s with %d domains and %d servers",
|
||||
ctrld.Log(ctx, logger.Debug(), "Processing VPN interface %s with %d domains and %d servers",
|
||||
config.InterfaceName, len(config.Domains), len(config.Servers))
|
||||
|
||||
for _, domain := range config.Domains {
|
||||
// Normalize domain: remove leading dot, Linux routing domain prefix (~),
|
||||
// and convert to lowercase.
|
||||
domain = strings.TrimPrefix(domain, "~")
|
||||
domain = strings.TrimPrefix(domain, "~") // Linux resolvectl routing domain prefix
|
||||
domain = strings.TrimPrefix(domain, ".")
|
||||
domain = strings.ToLower(domain)
|
||||
|
||||
if domain != "" {
|
||||
m.routes[domain] = append([]string{}, config.Servers...)
|
||||
logger.Debug().Msgf("Added VPN DNS route: %s -> %v", domain, config.Servers)
|
||||
ctrld.Log(ctx, logger.Debug(), "Added VPN DNS route: %s -> %v", domain, config.Servers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect unique VPN DNS exemptions (server + interface) for pf/WFP rules.
|
||||
// We track server+interface pairs because the same server IP on different
|
||||
// interfaces needs separate exemptions (interface-scoped on macOS).
|
||||
type exemptionKey struct{ server, iface string }
|
||||
seen := make(map[exemptionKey]bool)
|
||||
var exemptions []vpnDNSExemption
|
||||
@@ -165,22 +182,22 @@ func (m *vpnDNSManager) Refresh(guardAgainstNoNameservers bool) {
|
||||
// they're stored separately and only used for queries that match existing
|
||||
// split-DNS rules (from ctrld config, AD domain, or VPN suffix config).
|
||||
var domainlessServers []string
|
||||
seen2 := make(map[string]bool)
|
||||
seenDomainless := make(map[string]bool)
|
||||
for _, config := range configs {
|
||||
if len(config.Domains) == 0 && len(config.Servers) > 0 {
|
||||
logger.Debug().Msgf("VPN interface %s has DNS servers but no domains, storing as split-rule fallback: %v",
|
||||
ctrld.Log(ctx, logger.Debug(), "VPN interface %s has DNS servers but no domains, storing as split-rule fallback: %v",
|
||||
config.InterfaceName, config.Servers)
|
||||
for _, s := range config.Servers {
|
||||
if !seen2[s] {
|
||||
seen2[s] = true
|
||||
domainlessServers = append(domainlessServers, s)
|
||||
for _, server := range config.Servers {
|
||||
if !seenDomainless[server] {
|
||||
seenDomainless[server] = true
|
||||
domainlessServers = append(domainlessServers, server)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
m.domainlessServers = domainlessServers
|
||||
|
||||
logger.Debug().Msgf("VPN DNS refresh completed: %d configs, %d routes, %d domainless servers, %d unique exemptions",
|
||||
ctrld.Log(ctx, logger.Debug(), "VPN DNS refresh completed: %d configs, %d routes, %d domainless servers, %d unique exemptions",
|
||||
len(m.configs), len(m.routes), len(m.domainlessServers), len(exemptions))
|
||||
|
||||
// Update intercept rules to permit VPN DNS traffic only when the exemption set
|
||||
@@ -189,19 +206,19 @@ func (m *vpnDNSManager) Refresh(guardAgainstNoNameservers bool) {
|
||||
// a self-triggering network-change loop. Empty exemptions are still applied
|
||||
// when they differ from the previous set, so stale VPN exemptions are cleared
|
||||
// on disconnect.
|
||||
m.updateInterceptExemptionsIfChanged(logger, previousExemptions, exemptions, "VPN DNS")
|
||||
m.updateInterceptExemptionsIfChanged(ctx, logger, previousExemptions, exemptions, "VPN DNS")
|
||||
}
|
||||
|
||||
func (m *vpnDNSManager) updateInterceptExemptionsIfChanged(logger *zerolog.Logger, before, after []vpnDNSExemption, reason string) {
|
||||
func (m *vpnDNSManager) updateInterceptExemptionsIfChanged(ctx context.Context, logger *ctrld.Logger, before, after []vpnDNSExemption, reason string) {
|
||||
if m.onServersChanged == nil {
|
||||
return
|
||||
}
|
||||
if vpnDNSExemptionsEqual(before, after) {
|
||||
logger.Debug().Msgf("VPN DNS exemptions unchanged after %s refresh; skipping intercept rule update", reason)
|
||||
ctrld.Log(ctx, logger.Debug(), "VPN DNS exemptions unchanged after %s refresh; skipping intercept rule update", reason)
|
||||
return
|
||||
}
|
||||
if err := m.onServersChanged(after); err != nil {
|
||||
logger.Error().Err(err).Msg("Failed to update intercept exemptions for VPN DNS servers")
|
||||
ctrld.Log(ctx, logger.Error().Err(err), "Failed to update intercept exemptions for VPN DNS servers")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,9 +320,12 @@ func (m *vpnDNSManager) ShouldFailClosedAfterVPNDNSTransportFailure(domain strin
|
||||
return false
|
||||
}
|
||||
|
||||
mainLog.Load().Debug().Msgf(
|
||||
"VPN DNS transport failed for %s while retained VPN DNS state is active; suppressing OS fallback for this query (servers=%v)",
|
||||
domain, servers)
|
||||
logger := m.logger.Load()
|
||||
if logger != nil {
|
||||
logger.Debug().Msgf(
|
||||
"VPN DNS transport failed for %s while retained VPN DNS state is active; suppressing OS fallback for this query (servers=%v)",
|
||||
domain, servers)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -316,13 +336,17 @@ func (m *vpnDNSManager) VPNDNSReachable() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.retainedAfterEmptyDiscovery {
|
||||
mainLog.Load().Debug().Msg("VPN DNS transport recovered; clearing retained-empty-discovery state")
|
||||
logger := m.logger.Load()
|
||||
if logger != nil {
|
||||
logger.Debug().Msg("VPN DNS transport recovered; clearing retained-empty-discovery state")
|
||||
}
|
||||
}
|
||||
m.retainedAfterEmptyDiscovery = false
|
||||
}
|
||||
|
||||
// UpstreamForDomain checks if the domain matches any VPN search domain.
|
||||
// Returns VPN DNS servers if matched, nil otherwise.
|
||||
// Uses suffix matching: "foo.provisur.local" matches "provisur.local"
|
||||
func (m *vpnDNSManager) UpstreamForDomain(domain string) []string {
|
||||
if domain == "" {
|
||||
return nil
|
||||
@@ -331,16 +355,19 @@ func (m *vpnDNSManager) UpstreamForDomain(domain string) []string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
// Normalize domain (remove trailing dot, convert to lowercase)
|
||||
domain = strings.TrimSuffix(domain, ".")
|
||||
domain = strings.ToLower(domain)
|
||||
|
||||
// First try exact match
|
||||
if servers, ok := m.routes[domain]; ok {
|
||||
return append([]string{}, servers...)
|
||||
return append([]string{}, servers...) // Return copy to avoid race conditions
|
||||
}
|
||||
|
||||
// Try suffix matching - check if domain ends with any of our VPN domains
|
||||
for vpnDomain, servers := range m.routes {
|
||||
if strings.HasSuffix(domain, "."+vpnDomain) {
|
||||
return append([]string{}, servers...)
|
||||
return append([]string{}, servers...) // Return copy
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,6 +384,7 @@ func (m *vpnDNSManager) DomainlessServers() []string {
|
||||
}
|
||||
|
||||
// CurrentServers returns the current set of unique VPN DNS server IPs.
|
||||
// Used by pf anchor rebuild to include VPN DNS exemptions without a full Refresh().
|
||||
func (m *vpnDNSManager) CurrentServers() []string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
@@ -375,6 +403,7 @@ func (m *vpnDNSManager) CurrentServers() []string {
|
||||
}
|
||||
|
||||
// CurrentExemptions returns VPN DNS server + interface pairs for pf exemption rules.
|
||||
// Used by pf anchor rebuild paths that need interface-scoped exemptions.
|
||||
func (m *vpnDNSManager) CurrentExemptions() []vpnDNSExemption {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
@@ -406,6 +435,6 @@ func (m *vpnDNSManager) upstreamConfigFor(server string) *ctrld.UpstreamConfig {
|
||||
Name: "VPN DNS",
|
||||
Type: ctrld.ResolverTypeLegacy,
|
||||
Endpoint: endpoint,
|
||||
Timeout: 2000,
|
||||
Timeout: 2000, // 2 second timeout for VPN DNS queries
|
||||
}
|
||||
}
|
||||
|
||||
+40
-8
@@ -2,6 +2,8 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
@@ -14,10 +16,40 @@ func withVPNDNSSettlingEnabled(t *testing.T) {
|
||||
t.Cleanup(func() { vpnDNSSettlingEnabled = old })
|
||||
}
|
||||
|
||||
func TestVPNDNSRefreshSkipsConcurrentDuplicate(t *testing.T) {
|
||||
m := newVPNDNSManager(&mainLog, nil)
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
done := make(chan struct{})
|
||||
var once sync.Once
|
||||
var calls atomic.Int32
|
||||
|
||||
m.discoverVPNDNS = func(context.Context) []ctrld.VPNDNSConfig {
|
||||
calls.Add(1)
|
||||
once.Do(func() { close(started) })
|
||||
<-release
|
||||
return nil
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
m.Refresh(context.Background(), true)
|
||||
}()
|
||||
|
||||
<-started
|
||||
m.Refresh(context.Background(), true)
|
||||
close(release)
|
||||
<-done
|
||||
|
||||
if calls.Load() != 1 {
|
||||
t.Fatalf("expected overlapping refresh to be skipped, got %d discovery calls", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVPNDNSRefreshRetainsStateForOneGuardedEmptyDiscovery(t *testing.T) {
|
||||
withVPNDNSSettlingEnabled(t)
|
||||
var gotExemptions []vpnDNSExemption
|
||||
m := newVPNDNSManager(func(exemptions []vpnDNSExemption) error {
|
||||
m := newVPNDNSManager(&mainLog, func(exemptions []vpnDNSExemption) error {
|
||||
gotExemptions = exemptions
|
||||
return nil
|
||||
})
|
||||
@@ -28,7 +60,7 @@ func TestVPNDNSRefreshRetainsStateForOneGuardedEmptyDiscovery(t *testing.T) {
|
||||
}}
|
||||
m.domainlessServers = []string{"10.25.37.21", "10.25.37.22"}
|
||||
|
||||
m.Refresh(true)
|
||||
m.Refresh(context.Background(), true)
|
||||
|
||||
if got := m.DomainlessServers(); len(got) != 2 {
|
||||
t.Fatalf("expected retained domainless servers, got %v", got)
|
||||
@@ -44,7 +76,7 @@ func TestVPNDNSRefreshRetainsStateForOneGuardedEmptyDiscovery(t *testing.T) {
|
||||
func TestVPNDNSRefreshClearsOnSecondGuardedEmptyDiscovery(t *testing.T) {
|
||||
withVPNDNSSettlingEnabled(t)
|
||||
var gotExemptions []vpnDNSExemption
|
||||
m := newVPNDNSManager(func(exemptions []vpnDNSExemption) error {
|
||||
m := newVPNDNSManager(&mainLog, func(exemptions []vpnDNSExemption) error {
|
||||
gotExemptions = exemptions
|
||||
return nil
|
||||
})
|
||||
@@ -56,7 +88,7 @@ func TestVPNDNSRefreshClearsOnSecondGuardedEmptyDiscovery(t *testing.T) {
|
||||
m.domainlessServers = []string{"10.25.37.21"}
|
||||
m.retainedAfterEmptyDiscovery = true
|
||||
|
||||
m.Refresh(true)
|
||||
m.Refresh(context.Background(), true)
|
||||
|
||||
if got := m.DomainlessServers(); len(got) != 0 {
|
||||
t.Fatalf("expected domainless servers to be cleared on second empty discovery, got %v", got)
|
||||
@@ -71,7 +103,7 @@ func TestVPNDNSRefreshClearsOnSecondGuardedEmptyDiscovery(t *testing.T) {
|
||||
|
||||
func TestVPNDNSRefreshSkipsUnchangedInterceptExemptions(t *testing.T) {
|
||||
var updates [][]vpnDNSExemption
|
||||
m := newVPNDNSManager(func(exemptions []vpnDNSExemption) error {
|
||||
m := newVPNDNSManager(&mainLog, func(exemptions []vpnDNSExemption) error {
|
||||
updates = append(updates, append([]vpnDNSExemption{}, exemptions...))
|
||||
return nil
|
||||
})
|
||||
@@ -83,8 +115,8 @@ func TestVPNDNSRefreshSkipsUnchangedInterceptExemptions(t *testing.T) {
|
||||
}}
|
||||
}
|
||||
|
||||
m.Refresh(true)
|
||||
m.Refresh(true)
|
||||
m.Refresh(context.Background(), true)
|
||||
m.Refresh(context.Background(), true)
|
||||
|
||||
if len(updates) != 1 {
|
||||
t.Fatalf("expected exactly one intercept exemption update for unchanged VPN DNS state, got %d", len(updates))
|
||||
@@ -96,7 +128,7 @@ func TestVPNDNSRefreshSkipsUnchangedInterceptExemptions(t *testing.T) {
|
||||
|
||||
func TestVPNDNSTransportFailureSuppressesFallbackOnlyWhileRetainingState(t *testing.T) {
|
||||
withVPNDNSSettlingEnabled(t)
|
||||
m := newVPNDNSManager(nil)
|
||||
m := newVPNDNSManager(&mainLog, nil)
|
||||
m.domainlessServers = []string{"10.25.37.21"}
|
||||
|
||||
if m.ShouldFailClosedAfterVPNDNSTransportFailure("splunk.aws.arena.net.", []string{"10.25.37.21"}) {
|
||||
|
||||
@@ -45,7 +45,7 @@ func (c *Controller) Start(CdUID string, ProvisionID string, CustomHostname stri
|
||||
}
|
||||
}
|
||||
|
||||
// As workaround to avoid circular dependency between cli and ctrld_library module
|
||||
// mapCallback maps the AppCallback interface to cli.AppCallback to avoid circular dependency
|
||||
func mapCallback(callback AppCallback) cli.AppCallback {
|
||||
return cli.AppCallback{
|
||||
HostName: func() string {
|
||||
|
||||
@@ -117,6 +117,10 @@ func SetConfigNameWithPath(v *viper.Viper, name, configPath string) {
|
||||
|
||||
// InitConfig initializes default config values for given *viper.Viper instance.
|
||||
func InitConfig(v *viper.Viper, name string) {
|
||||
ctx := context.Background()
|
||||
logger := LoggerFromCtx(ctx)
|
||||
Log(ctx, logger.Debug(), "Config initialization started")
|
||||
|
||||
v.SetDefault("listener", map[string]*ListenerConfig{
|
||||
"0": {
|
||||
IP: "",
|
||||
@@ -155,6 +159,8 @@ func InitConfig(v *viper.Viper, name string) {
|
||||
Timeout: 3000,
|
||||
},
|
||||
})
|
||||
|
||||
Log(ctx, logger.Debug(), "Config initialization completed")
|
||||
}
|
||||
|
||||
// Config represents ctrld supported configuration.
|
||||
@@ -241,8 +247,16 @@ type ServiceConfig struct {
|
||||
ForceRefetchWaitTime *int `mapstructure:"force_refetch_wait_time" toml:"force_refetch_wait_time,omitempty"`
|
||||
LeakOnUpstreamFailure *bool `mapstructure:"leak_on_upstream_failure" toml:"leak_on_upstream_failure,omitempty"`
|
||||
InterceptMode string `mapstructure:"intercept_mode" toml:"intercept_mode,omitempty" validate:"omitempty,oneof=off dns hard"`
|
||||
Daemon bool `mapstructure:"-" toml:"-"`
|
||||
AllocateIP bool `mapstructure:"-" toml:"-"`
|
||||
NRPTRecoveryMaxAttempts *int `mapstructure:"nrpt_recovery_max_attempts" toml:"nrpt_recovery_max_attempts,omitempty" validate:"omitempty,gte=0"`
|
||||
NRPTRecoveryCooldown *time.Duration `mapstructure:"nrpt_recovery_cooldown" toml:"nrpt_recovery_cooldown,omitempty"`
|
||||
// FirewallMode controls the DNS-resolved IP allowlist. When "on", only IPs
|
||||
// that were successfully resolved by ctrld are allowed for outbound connections.
|
||||
// This closes the "DNS gap" where apps bypass DNS policy using hardcoded IPs.
|
||||
// Requires intercept mode to be active for enforcement on desktop platforms.
|
||||
// On mobile, the netstack layer uses the allowlist directly.
|
||||
FirewallMode string `mapstructure:"firewall_mode" toml:"firewall_mode,omitempty" validate:"omitempty,oneof=off on"`
|
||||
Daemon bool `mapstructure:"-" toml:"-"`
|
||||
AllocateIP bool `mapstructure:"-" toml:"-"`
|
||||
}
|
||||
|
||||
// NetworkConfig specifies configuration for networks where ctrld will handle requests.
|
||||
@@ -319,14 +333,20 @@ func (lc *ListenerConfig) IsDirectDnsListener() bool {
|
||||
}
|
||||
}
|
||||
|
||||
// MatchingConfig defines the configuration for rule matching behavior
|
||||
type MatchingConfig struct {
|
||||
Order []string `mapstructure:"order" toml:"order,omitempty"`
|
||||
}
|
||||
|
||||
// ListenerPolicyConfig specifies the policy rules for ctrld to filter incoming requests.
|
||||
type ListenerPolicyConfig struct {
|
||||
Name string `mapstructure:"name" toml:"name,omitempty"`
|
||||
Networks []Rule `mapstructure:"networks" toml:"networks,omitempty,inline,multiline" validate:"dive,len=1"`
|
||||
Rules []Rule `mapstructure:"rules" toml:"rules,omitempty,inline,multiline" validate:"dive,len=1"`
|
||||
Macs []Rule `mapstructure:"macs" toml:"macs,omitempty,inline,multiline" validate:"dive,len=1"`
|
||||
FailoverRcodes []string `mapstructure:"failover_rcodes" toml:"failover_rcodes,omitempty" validate:"dive,dnsrcode"`
|
||||
FailoverRcodeNumbers []int `mapstructure:"-" toml:"-"`
|
||||
Name string `mapstructure:"name" toml:"name,omitempty"`
|
||||
Networks []Rule `mapstructure:"networks" toml:"networks,omitempty,inline,multiline" validate:"dive,len=1"`
|
||||
Rules []Rule `mapstructure:"rules" toml:"rules,omitempty,inline,multiline" validate:"dive,len=1"`
|
||||
Macs []Rule `mapstructure:"macs" toml:"macs,omitempty,inline,multiline" validate:"dive,len=1"`
|
||||
FailoverRcodes []string `mapstructure:"failover_rcodes" toml:"failover_rcodes,omitempty" validate:"dive,dnsrcode"`
|
||||
FailoverRcodeNumbers []int `mapstructure:"-" toml:"-"`
|
||||
Matching *MatchingConfig `mapstructure:"-" toml:"-"`
|
||||
}
|
||||
|
||||
// Rule is a map from source to list of upstreams.
|
||||
@@ -335,12 +355,13 @@ type ListenerPolicyConfig struct {
|
||||
type Rule map[string][]string
|
||||
|
||||
// Init initialized necessary values for an UpstreamConfig.
|
||||
func (uc *UpstreamConfig) Init() {
|
||||
func (uc *UpstreamConfig) Init(ctx context.Context) {
|
||||
logger := LoggerFromCtx(ctx)
|
||||
if err := uc.initDnsStamps(); err != nil {
|
||||
ProxyLogger.Load().Fatal().Err(err).Msg("invalid DNS Stamps")
|
||||
logger.Fatal().Err(err).Msg("Invalid dns stamps")
|
||||
}
|
||||
uc.initDoHScheme()
|
||||
uc.uid = upstreamUID()
|
||||
uc.uid = upstreamUID(ctx)
|
||||
if u, err := url.Parse(uc.Endpoint); err == nil {
|
||||
uc.Domain = u.Hostname()
|
||||
switch uc.Type {
|
||||
@@ -360,6 +381,9 @@ func (uc *UpstreamConfig) Init() {
|
||||
}
|
||||
}
|
||||
if uc.IPStack == "" {
|
||||
// Set default IP stack based on upstream type
|
||||
// Control-D upstreams use split stack for better IPv4/IPv6 handling,
|
||||
// while other upstreams use both stacks for maximum compatibility
|
||||
if uc.IsControlD() {
|
||||
uc.IPStack = IpStackSplit
|
||||
} else {
|
||||
@@ -421,7 +445,7 @@ func (uc *UpstreamConfig) IsDiscoverable() bool {
|
||||
return *uc.Discoverable
|
||||
}
|
||||
switch uc.Type {
|
||||
case ResolverTypeOS, ResolverTypeLegacy, ResolverTypePrivate, ResolverTypeLocal:
|
||||
case ResolverTypeOS, ResolverTypeLegacy, ResolverTypePrivate:
|
||||
if ip, err := netip.ParseAddr(uc.Domain); err == nil {
|
||||
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || tsaddr.CGNATRange().Contains(ip)
|
||||
}
|
||||
@@ -444,21 +468,18 @@ func (uc *UpstreamConfig) UID() string {
|
||||
return uc.uid
|
||||
}
|
||||
|
||||
// SetupBootstrapIP manually find all available IPs of the upstream.
|
||||
// The first usable IP will be used as bootstrap IP of the upstream.
|
||||
// The upstream domain will be looked up using following orders:
|
||||
//
|
||||
// - Current system DNS settings.
|
||||
// - Direct IPs table for ControlD upstreams.
|
||||
// - ControlD Bootstrap DNS 76.76.2.22
|
||||
//
|
||||
// SetupBootstrapIP sets up bootstrap IPs for the upstream config.
|
||||
// The setup process will block until there's usable IPs found.
|
||||
func (uc *UpstreamConfig) SetupBootstrapIP() {
|
||||
func (uc *UpstreamConfig) SetupBootstrapIP(ctx context.Context) {
|
||||
logger := LoggerFromCtx(ctx)
|
||||
Log(ctx, logger.Debug(), "Setting up bootstrap IPs for upstream: %s", uc.Name)
|
||||
|
||||
b := backoff.NewBackoff("setupBootstrapIP", func(format string, args ...any) {}, 10*time.Second)
|
||||
isControlD := uc.IsControlD()
|
||||
nss := initDefaultOsResolver()
|
||||
nss := initDefaultOsResolver(ctx)
|
||||
for {
|
||||
uc.bootstrapIPs = lookupIP(uc.Domain, uc.Timeout, nss)
|
||||
Log(ctx, logger.Debug(), "Looking up bootstrap IPs for domain: %s", uc.Domain)
|
||||
uc.bootstrapIPs = lookupIP(ctx, uc.Domain, uc.Timeout, nss)
|
||||
// For ControlD upstream, the bootstrap IPs could not be RFC 1918 addresses,
|
||||
// filtering them out here to prevent weird behavior.
|
||||
if isControlD {
|
||||
@@ -473,18 +494,18 @@ func (uc *UpstreamConfig) SetupBootstrapIP() {
|
||||
uc.bootstrapIPs = uc.bootstrapIPs[:n]
|
||||
if len(uc.bootstrapIPs) == 0 {
|
||||
uc.bootstrapIPs = bootstrapIPsFromControlDDomain(uc.Domain)
|
||||
ProxyLogger.Load().Warn().Msgf("no record found for %q, lookup from direct IP table", uc.Domain)
|
||||
logger.Warn().Msgf("No record found for %q, lookup from direct ip table", uc.Domain)
|
||||
}
|
||||
}
|
||||
if len(uc.bootstrapIPs) == 0 {
|
||||
ProxyLogger.Load().Warn().Msgf("no record found for %q, using bootstrap server: %s", uc.Domain, PremiumDNSBoostrapIP)
|
||||
uc.bootstrapIPs = lookupIP(uc.Domain, uc.Timeout, []string{net.JoinHostPort(PremiumDNSBoostrapIP, "53")})
|
||||
logger.Warn().Msgf("No record found for %q, using bootstrap server: %s", uc.Domain, PremiumDNSBoostrapIP)
|
||||
uc.bootstrapIPs = lookupIP(ctx, uc.Domain, uc.Timeout, []string{net.JoinHostPort(PremiumDNSBoostrapIP, "53")})
|
||||
|
||||
}
|
||||
if len(uc.bootstrapIPs) > 0 {
|
||||
break
|
||||
}
|
||||
ProxyLogger.Load().Warn().Msg("could not resolve bootstrap IPs, retrying...")
|
||||
logger.Warn().Msg("Could not resolve bootstrap ips, retrying...")
|
||||
b.BackOff(context.Background(), errors.New("no bootstrap IPs"))
|
||||
}
|
||||
for _, ip := range uc.bootstrapIPs {
|
||||
@@ -494,11 +515,12 @@ func (uc *UpstreamConfig) SetupBootstrapIP() {
|
||||
uc.bootstrapIPs4 = append(uc.bootstrapIPs4, ip)
|
||||
}
|
||||
}
|
||||
ProxyLogger.Load().Debug().Msgf("bootstrap IPs: %v", uc.bootstrapIPs)
|
||||
logger.Debug().Msgf("Bootstrap ips: %v", uc.bootstrapIPs)
|
||||
Log(ctx, logger.Debug(), "Bootstrap IP setup completed for upstream: %s", uc.Name)
|
||||
}
|
||||
|
||||
// ReBootstrap re-setup the bootstrap IP and the transport.
|
||||
func (uc *UpstreamConfig) ReBootstrap() {
|
||||
func (uc *UpstreamConfig) ReBootstrap(ctx context.Context) {
|
||||
switch uc.Type {
|
||||
case ResolverTypeDOH, ResolverTypeDOH3, ResolverTypeDOQ, ResolverTypeDOT:
|
||||
default:
|
||||
@@ -506,90 +528,47 @@ func (uc *UpstreamConfig) ReBootstrap() {
|
||||
}
|
||||
_, _, _ = uc.g.Do("ReBootstrap", func() (any, error) {
|
||||
if uc.rebootstrap.CompareAndSwap(rebootstrapNotStarted, rebootstrapStarted) {
|
||||
ProxyLogger.Load().Debug().Msgf("re-bootstrapping upstream ip for %v", uc)
|
||||
logger := LoggerFromCtx(ctx)
|
||||
Log(ctx, logger.Debug(), "Re-bootstrapping upstream: %s", uc.Name)
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ForceReBootstrap immediately replaces the upstream transport, closing old
|
||||
// connections and creating new ones synchronously. Unlike ReBootstrap() which
|
||||
// sets a lazy flag (new transport created on next query), this ensures the
|
||||
// transport is ready before any queries arrive. Use when external events
|
||||
// (e.g. firewall state flush) are known to have killed existing connections.
|
||||
func (uc *UpstreamConfig) ForceReBootstrap() {
|
||||
// ForceReBootstrap immediately creates a new transport (closing old idle
|
||||
// connections first) without waiting for the lazy re-bootstrap mechanism.
|
||||
// Used after pf state table flushes where existing TCP/QUIC connections
|
||||
// are dead and we need fresh connections immediately.
|
||||
func (uc *UpstreamConfig) ForceReBootstrap(ctx context.Context) {
|
||||
switch uc.Type {
|
||||
case ResolverTypeDOH, ResolverTypeDOH3, ResolverTypeDOQ, ResolverTypeDOT:
|
||||
default:
|
||||
return
|
||||
}
|
||||
ProxyLogger.Load().Debug().Msgf("force re-bootstrapping upstream transport for %v", uc)
|
||||
uc.SetupTransport()
|
||||
logger := LoggerFromCtx(ctx)
|
||||
Log(ctx, logger.Debug(), "force re-bootstrapping upstream transport for %v", uc)
|
||||
uc.closeTransports()
|
||||
uc.SetupTransport(ctx)
|
||||
// Clear any pending lazy re-bootstrap flag so ensureSetupTransport()
|
||||
// doesn't redundantly recreate the transport we just built.
|
||||
uc.rebootstrap.Store(rebootstrapNotStarted)
|
||||
}
|
||||
|
||||
// closeTransports closes idle connections on all existing transports.
|
||||
// This is called before creating new transports during re-bootstrap to
|
||||
// force in-flight requests on stale connections to fail quickly, rather
|
||||
// than waiting for the full context deadline (e.g. 5s) after a firewall
|
||||
// state table flush kills the underlying TCP/QUIC connections.
|
||||
func (uc *UpstreamConfig) closeTransports() {
|
||||
if t := uc.transport; t != nil {
|
||||
t.CloseIdleConnections()
|
||||
}
|
||||
if t := uc.transport4; t != nil {
|
||||
t.CloseIdleConnections()
|
||||
}
|
||||
if t := uc.transport6; t != nil {
|
||||
t.CloseIdleConnections()
|
||||
}
|
||||
if p := uc.doqConnPool; p != nil {
|
||||
p.CloseIdleConnections()
|
||||
}
|
||||
if p := uc.doqConnPool4; p != nil {
|
||||
p.CloseIdleConnections()
|
||||
}
|
||||
if p := uc.doqConnPool6; p != nil {
|
||||
p.CloseIdleConnections()
|
||||
}
|
||||
if p := uc.dotClientPool; p != nil {
|
||||
p.CloseIdleConnections()
|
||||
}
|
||||
if p := uc.dotClientPool4; p != nil {
|
||||
p.CloseIdleConnections()
|
||||
}
|
||||
if p := uc.dotClientPool6; p != nil {
|
||||
p.CloseIdleConnections()
|
||||
}
|
||||
// http3RoundTripper is stored as http.RoundTripper but the concrete type
|
||||
// (*http3.Transport) exposes CloseIdleConnections via this interface.
|
||||
type idleCloser interface {
|
||||
CloseIdleConnections()
|
||||
}
|
||||
for _, rt := range []http.RoundTripper{uc.http3RoundTripper, uc.http3RoundTripper4, uc.http3RoundTripper6} {
|
||||
if c, ok := rt.(idleCloser); ok {
|
||||
c.CloseIdleConnections()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetupTransport initializes the network transport used to connect to upstream servers.
|
||||
// For now, DoH/DoH3/DoQ/DoT upstreams are supported.
|
||||
func (uc *UpstreamConfig) SetupTransport() {
|
||||
func (uc *UpstreamConfig) SetupTransport(ctx context.Context) {
|
||||
switch uc.Type {
|
||||
case ResolverTypeDOH, ResolverTypeDOH3, ResolverTypeDOQ, ResolverTypeDOT:
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
// Close existing transport connections before creating new ones.
|
||||
// This forces in-flight requests on stale connections (e.g. after a
|
||||
// firewall state table flush) to fail fast instead of waiting for
|
||||
// the full context deadline timeout.
|
||||
uc.closeTransports()
|
||||
|
||||
ips := uc.bootstrapIPs
|
||||
switch uc.IPStack {
|
||||
case IpStackV4:
|
||||
@@ -597,21 +576,20 @@ func (uc *UpstreamConfig) SetupTransport() {
|
||||
case IpStackV6:
|
||||
ips = uc.bootstrapIPs6
|
||||
}
|
||||
|
||||
uc.transport = uc.newDOHTransport(ips)
|
||||
uc.http3RoundTripper = uc.newDOH3Transport(ips)
|
||||
uc.doqConnPool = uc.newDOQConnPool(ips)
|
||||
uc.dotClientPool = uc.newDOTClientPool(ips)
|
||||
uc.transport = uc.newDOHTransport(ctx, ips)
|
||||
uc.http3RoundTripper = uc.newDOH3Transport(ctx, ips)
|
||||
uc.doqConnPool = uc.newDOQConnPool(ctx, ips)
|
||||
uc.dotClientPool = uc.newDOTClientPool(ctx, ips)
|
||||
if uc.IPStack == IpStackSplit {
|
||||
uc.transport4 = uc.newDOHTransport(uc.bootstrapIPs4)
|
||||
uc.http3RoundTripper4 = uc.newDOH3Transport(uc.bootstrapIPs4)
|
||||
uc.doqConnPool4 = uc.newDOQConnPool(uc.bootstrapIPs4)
|
||||
uc.dotClientPool4 = uc.newDOTClientPool(uc.bootstrapIPs4)
|
||||
if HasIPv6() {
|
||||
uc.transport6 = uc.newDOHTransport(uc.bootstrapIPs6)
|
||||
uc.http3RoundTripper6 = uc.newDOH3Transport(uc.bootstrapIPs6)
|
||||
uc.doqConnPool6 = uc.newDOQConnPool(uc.bootstrapIPs6)
|
||||
uc.dotClientPool6 = uc.newDOTClientPool(uc.bootstrapIPs6)
|
||||
uc.transport4 = uc.newDOHTransport(ctx, uc.bootstrapIPs4)
|
||||
uc.http3RoundTripper4 = uc.newDOH3Transport(ctx, uc.bootstrapIPs4)
|
||||
uc.doqConnPool4 = uc.newDOQConnPool(ctx, uc.bootstrapIPs4)
|
||||
uc.dotClientPool4 = uc.newDOTClientPool(ctx, uc.bootstrapIPs4)
|
||||
if HasIPv6(ctx) {
|
||||
uc.transport6 = uc.newDOHTransport(ctx, uc.bootstrapIPs6)
|
||||
uc.http3RoundTripper6 = uc.newDOH3Transport(ctx, uc.bootstrapIPs6)
|
||||
uc.doqConnPool6 = uc.newDOQConnPool(ctx, uc.bootstrapIPs6)
|
||||
uc.dotClientPool6 = uc.newDOTClientPool(ctx, uc.bootstrapIPs6)
|
||||
} else {
|
||||
uc.transport6 = uc.transport4
|
||||
uc.http3RoundTripper6 = uc.http3RoundTripper4
|
||||
@@ -621,17 +599,18 @@ func (uc *UpstreamConfig) SetupTransport() {
|
||||
}
|
||||
}
|
||||
|
||||
func (uc *UpstreamConfig) ensureSetupTransport() {
|
||||
func (uc *UpstreamConfig) ensureSetupTransport(ctx context.Context) {
|
||||
uc.transportOnce.Do(func() {
|
||||
uc.SetupTransport()
|
||||
uc.SetupTransport(ctx)
|
||||
})
|
||||
|
||||
if uc.rebootstrap.CompareAndSwap(rebootstrapStarted, rebootstrapInProgress) {
|
||||
uc.SetupTransport()
|
||||
uc.SetupTransport(ctx)
|
||||
uc.rebootstrap.Store(rebootstrapNotStarted)
|
||||
}
|
||||
}
|
||||
|
||||
func (uc *UpstreamConfig) newDOHTransport(addrs []string) *http.Transport {
|
||||
func (uc *UpstreamConfig) newDOHTransport(ctx context.Context, addrs []string) *http.Transport {
|
||||
if uc.Type != ResolverTypeDOH {
|
||||
return nil
|
||||
}
|
||||
@@ -655,12 +634,13 @@ func (uc *UpstreamConfig) newDOHTransport(addrs []string) *http.Transport {
|
||||
dialerTimeoutMs = uc.Timeout
|
||||
}
|
||||
dialerTimeout := time.Duration(dialerTimeoutMs) * time.Millisecond
|
||||
logger := LoggerFromCtx(ctx)
|
||||
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
_, port, _ := net.SplitHostPort(addr)
|
||||
if uc.BootstrapIP != "" {
|
||||
dialer := net.Dialer{Timeout: dialerTimeout, KeepAlive: dialerTimeout}
|
||||
addr := net.JoinHostPort(uc.BootstrapIP, port)
|
||||
Log(ctx, ProxyLogger.Load().Debug(), "sending doh request to: %s", addr)
|
||||
Log(ctx, logger.Debug(), "Sending doh request to: %s", addr)
|
||||
return dialer.DialContext(ctx, network, addr)
|
||||
}
|
||||
pd := &ctrldnet.ParallelDialer{}
|
||||
@@ -670,11 +650,11 @@ func (uc *UpstreamConfig) newDOHTransport(addrs []string) *http.Transport {
|
||||
for i := range addrs {
|
||||
dialAddrs[i] = net.JoinHostPort(addrs[i], port)
|
||||
}
|
||||
conn, err := pd.DialContext(ctx, network, dialAddrs, ProxyLogger.Load())
|
||||
conn, err := pd.DialContext(ctx, network, dialAddrs, logger.Logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
Log(ctx, ProxyLogger.Load().Debug(), "sending doh request to: %s", conn.RemoteAddr())
|
||||
Log(ctx, logger.Debug(), "Sending doh request to: %s", conn.RemoteAddr())
|
||||
return conn, nil
|
||||
}
|
||||
runtime.SetFinalizer(transport, func(transport *http.Transport) {
|
||||
@@ -684,19 +664,20 @@ func (uc *UpstreamConfig) newDOHTransport(addrs []string) *http.Transport {
|
||||
}
|
||||
|
||||
// Ping warms up the connection to DoH/DoH3 upstream.
|
||||
func (uc *UpstreamConfig) Ping() {
|
||||
if err := uc.ping(); err != nil {
|
||||
ProxyLogger.Load().Debug().Err(err).Msgf("upstream ping failed: %s", uc.Endpoint)
|
||||
_ = uc.FallbackToDirectIP()
|
||||
func (uc *UpstreamConfig) Ping(ctx context.Context) {
|
||||
if err := uc.ping(ctx); err != nil {
|
||||
logger := LoggerFromCtx(ctx)
|
||||
logger.Debug().Err(err).Msgf("Upstream ping failed: %s", uc.Endpoint)
|
||||
_ = uc.FallbackToDirectIP(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// ErrorPing is like Ping, but return an error if any.
|
||||
func (uc *UpstreamConfig) ErrorPing() error {
|
||||
return uc.ping()
|
||||
func (uc *UpstreamConfig) ErrorPing(ctx context.Context) error {
|
||||
return uc.ping(ctx)
|
||||
}
|
||||
|
||||
func (uc *UpstreamConfig) ping() error {
|
||||
func (uc *UpstreamConfig) ping(ctx context.Context) error {
|
||||
switch uc.Type {
|
||||
case ResolverTypeDOH, ResolverTypeDOH3, ResolverTypeDOQ:
|
||||
default:
|
||||
@@ -725,21 +706,21 @@ func (uc *UpstreamConfig) ping() error {
|
||||
for _, typ := range []uint16{dns.TypeA, dns.TypeAAAA} {
|
||||
switch uc.Type {
|
||||
case ResolverTypeDOH:
|
||||
if err := ping(uc.dohTransport(typ)); err != nil {
|
||||
if err := ping(uc.dohTransport(ctx, typ)); err != nil {
|
||||
return err
|
||||
}
|
||||
case ResolverTypeDOH3:
|
||||
if err := ping(uc.doh3Transport(typ)); err != nil {
|
||||
if err := ping(uc.doh3Transport(ctx, typ)); err != nil {
|
||||
return err
|
||||
}
|
||||
case ResolverTypeDOQ:
|
||||
// For DoQ, we just ensure transport is set up by calling doqTransport
|
||||
// DoQ doesn't use HTTP, so we can't ping it the same way
|
||||
_ = uc.doqTransport(typ)
|
||||
_ = uc.doqTransport(ctx, typ)
|
||||
case ResolverTypeDOT:
|
||||
// For DoT, we just ensure transport is set up by calling dotTransport
|
||||
// DoT doesn't use HTTP, so we can't ping it the same way
|
||||
_ = uc.dotTransport(typ)
|
||||
_ = uc.dotTransport(ctx, typ)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -772,12 +753,12 @@ func (uc *UpstreamConfig) isNextDNS() bool {
|
||||
return domain == "dns.nextdns.io"
|
||||
}
|
||||
|
||||
func (uc *UpstreamConfig) dohTransport(dnsType uint16) http.RoundTripper {
|
||||
uc.ensureSetupTransport()
|
||||
func (uc *UpstreamConfig) dohTransport(ctx context.Context, dnsType uint16) http.RoundTripper {
|
||||
uc.ensureSetupTransport(ctx)
|
||||
return transportByIpStack(uc.IPStack, dnsType, uc.transport, uc.transport4, uc.transport6)
|
||||
}
|
||||
|
||||
func (uc *UpstreamConfig) netForDNSType(dnsType uint16) (string, string) {
|
||||
func (uc *UpstreamConfig) netForDNSType(ctx context.Context, dnsType uint16) (string, string) {
|
||||
switch uc.IPStack {
|
||||
case IpStackBoth:
|
||||
return "tcp-tls", "udp"
|
||||
@@ -790,7 +771,7 @@ func (uc *UpstreamConfig) netForDNSType(dnsType uint16) (string, string) {
|
||||
case dns.TypeA:
|
||||
return "tcp4-tls", "udp4"
|
||||
default:
|
||||
if HasIPv6() {
|
||||
if HasIPv6(ctx) {
|
||||
return "tcp6-tls", "udp6"
|
||||
}
|
||||
return "tcp4-tls", "udp4"
|
||||
@@ -871,7 +852,7 @@ func (uc *UpstreamConfig) Context(ctx context.Context) (context.Context, context
|
||||
}
|
||||
|
||||
// FallbackToDirectIP changes ControlD upstream endpoint to use direct IP instead of domain.
|
||||
func (uc *UpstreamConfig) FallbackToDirectIP() bool {
|
||||
func (uc *UpstreamConfig) FallbackToDirectIP(ctx context.Context) bool {
|
||||
if !uc.IsControlD() {
|
||||
return false
|
||||
}
|
||||
@@ -890,7 +871,8 @@ func (uc *UpstreamConfig) FallbackToDirectIP() bool {
|
||||
default:
|
||||
return
|
||||
}
|
||||
ProxyLogger.Load().Warn().Msgf("using direct IP for %q: %s", uc.Endpoint, ip)
|
||||
logger := LoggerFromCtx(ctx)
|
||||
Log(ctx, logger.Warn(), "Using direct IP for %q: %s", uc.Endpoint, ip)
|
||||
uc.u.Host = ip
|
||||
done = true
|
||||
})
|
||||
@@ -899,12 +881,18 @@ func (uc *UpstreamConfig) FallbackToDirectIP() bool {
|
||||
|
||||
// Init initialized necessary values for an ListenerConfig.
|
||||
func (lc *ListenerConfig) Init() {
|
||||
logger := LoggerFromCtx(context.Background())
|
||||
Log(context.Background(), logger.Debug(), "Initializing listener config")
|
||||
|
||||
if lc.Policy != nil {
|
||||
lc.Policy.FailoverRcodeNumbers = make([]int, len(lc.Policy.FailoverRcodes))
|
||||
for i, rcode := range lc.Policy.FailoverRcodes {
|
||||
lc.Policy.FailoverRcodeNumbers[i] = dnsrcode.FromString(rcode)
|
||||
}
|
||||
Log(context.Background(), logger.Debug(), "Listener policy initialized with %d failover rcodes", len(lc.Policy.FailoverRcodes))
|
||||
}
|
||||
|
||||
Log(context.Background(), logger.Debug(), "Listener config initialization completed")
|
||||
}
|
||||
|
||||
// ValidateConfig validates the given config.
|
||||
@@ -1020,11 +1008,12 @@ func ResolverTypeFromEndpoint(endpoint string) string {
|
||||
}
|
||||
|
||||
// upstreamUID generates an unique identifier for an upstream.
|
||||
func upstreamUID() string {
|
||||
func upstreamUID(ctx context.Context) string {
|
||||
logger := LoggerFromCtx(ctx)
|
||||
b := make([]byte, 4)
|
||||
for {
|
||||
if _, err := crand.Read(b); err != nil {
|
||||
ProxyLogger.Load().Warn().Err(err).Msg("could not generate uid for upstream, retrying...")
|
||||
logger.Warn().Err(err).Msg("Could not generate uid for upstream, retrying...")
|
||||
continue
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
|
||||
+14
-13
@@ -1,6 +1,7 @@
|
||||
package ctrld
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -37,10 +38,10 @@ func TestUpstreamConfig_SetupBootstrapIP(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Enable parallel tests once https://github.com/microsoft/wmi/issues/165 fixed.
|
||||
// t.Parallel()
|
||||
tc.uc.Init()
|
||||
tc.uc.SetupBootstrapIP()
|
||||
tc.uc.Init(context.Background())
|
||||
tc.uc.SetupBootstrapIP(context.Background())
|
||||
if len(tc.uc.bootstrapIPs) == 0 {
|
||||
t.Log(defaultNameservers())
|
||||
t.Log(defaultNameservers(context.Background()))
|
||||
t.Fatalf("could not bootstrap ip: %s", tc.uc.String())
|
||||
}
|
||||
})
|
||||
@@ -356,7 +357,7 @@ func TestUpstreamConfig_Init(t *testing.T) {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
tc.uc.Init()
|
||||
tc.uc.Init(context.Background())
|
||||
tc.uc.uid = "" // we don't care about the uid.
|
||||
assert.Equal(t, tc.expected, tc.uc)
|
||||
})
|
||||
@@ -498,7 +499,7 @@ func TestUpstreamConfig_IsDiscoverable(t *testing.T) {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
tc.uc.Init()
|
||||
tc.uc.Init(context.Background())
|
||||
if got := tc.uc.IsDiscoverable(); got != tc.discoverable {
|
||||
t.Errorf("unexpected result, want: %v, got: %v", tc.discoverable, got)
|
||||
}
|
||||
@@ -515,7 +516,9 @@ func TestRebootstrapRace(t *testing.T) {
|
||||
bootstrapIPs: []string{"1.1.1.1", "1.0.0.1"},
|
||||
}
|
||||
|
||||
uc.SetupTransport()
|
||||
ctx := LoggerCtx(context.Background(), NopLogger)
|
||||
|
||||
uc.SetupTransport(ctx)
|
||||
|
||||
if uc.transport == nil {
|
||||
t.Fatal("initial transport should be set")
|
||||
@@ -523,7 +526,7 @@ func TestRebootstrapRace(t *testing.T) {
|
||||
|
||||
const goroutines = 100
|
||||
|
||||
uc.ReBootstrap()
|
||||
uc.ReBootstrap(ctx)
|
||||
|
||||
started := make(chan struct{})
|
||||
go func() {
|
||||
@@ -531,7 +534,7 @@ func TestRebootstrapRace(t *testing.T) {
|
||||
for {
|
||||
switch uc.rebootstrap.Load() {
|
||||
case rebootstrapStarted, rebootstrapInProgress:
|
||||
uc.ReBootstrap()
|
||||
uc.ReBootstrap(ctx)
|
||||
default:
|
||||
return
|
||||
}
|
||||
@@ -541,12 +544,10 @@ func TestRebootstrapRace(t *testing.T) {
|
||||
<-started
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(goroutines)
|
||||
for range goroutines {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
uc.ensureSetupTransport()
|
||||
}()
|
||||
wg.Go(func() {
|
||||
uc.ensureSetupTransport(ctx)
|
||||
})
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
+14
-13
@@ -13,18 +13,19 @@ import (
|
||||
"github.com/quic-go/quic-go/http3"
|
||||
)
|
||||
|
||||
func (uc *UpstreamConfig) newDOH3Transport(addrs []string) http.RoundTripper {
|
||||
func (uc *UpstreamConfig) newDOH3Transport(ctx context.Context, addrs []string) http.RoundTripper {
|
||||
if uc.Type != ResolverTypeDOH3 {
|
||||
return nil
|
||||
}
|
||||
rt := &http3.Transport{}
|
||||
rt.TLSClientConfig = &tls.Config{RootCAs: uc.certPool, MinVersion: tls.VersionTLS12}
|
||||
logger := LoggerFromCtx(ctx)
|
||||
rt.Dial = func(ctx context.Context, addr string, tlsCfg *tls.Config, cfg *quic.Config) (*quic.Conn, error) {
|
||||
_, port, _ := net.SplitHostPort(addr)
|
||||
// if we have a bootstrap ip set, use it to avoid DNS lookup
|
||||
if uc.BootstrapIP != "" {
|
||||
addr = net.JoinHostPort(uc.BootstrapIP, port)
|
||||
ProxyLogger.Load().Debug().Msgf("sending doh3 request to: %s", addr)
|
||||
Log(ctx, logger.Debug(), "Sending doh3 request to: %s", addr)
|
||||
udpConn, err := net.ListenUDP("udp", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -44,7 +45,7 @@ func (uc *UpstreamConfig) newDOH3Transport(addrs []string) http.RoundTripper {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ProxyLogger.Load().Debug().Msgf("sending doh3 request to: %s", conn.RemoteAddr())
|
||||
Log(ctx, logger.Debug(), "Sending doh3 request to: %s", conn.RemoteAddr())
|
||||
return conn, err
|
||||
}
|
||||
runtime.SetFinalizer(rt, func(rt *http3.Transport) {
|
||||
@@ -53,18 +54,18 @@ func (uc *UpstreamConfig) newDOH3Transport(addrs []string) http.RoundTripper {
|
||||
return rt
|
||||
}
|
||||
|
||||
func (uc *UpstreamConfig) doh3Transport(dnsType uint16) http.RoundTripper {
|
||||
uc.ensureSetupTransport()
|
||||
func (uc *UpstreamConfig) doh3Transport(ctx context.Context, dnsType uint16) http.RoundTripper {
|
||||
uc.ensureSetupTransport(ctx)
|
||||
return transportByIpStack(uc.IPStack, dnsType, uc.http3RoundTripper, uc.http3RoundTripper4, uc.http3RoundTripper6)
|
||||
}
|
||||
|
||||
func (uc *UpstreamConfig) doqTransport(dnsType uint16) *doqConnPool {
|
||||
uc.ensureSetupTransport()
|
||||
func (uc *UpstreamConfig) doqTransport(ctx context.Context, dnsType uint16) *doqConnPool {
|
||||
uc.ensureSetupTransport(ctx)
|
||||
return transportByIpStack(uc.IPStack, dnsType, uc.doqConnPool, uc.doqConnPool4, uc.doqConnPool6)
|
||||
}
|
||||
|
||||
func (uc *UpstreamConfig) dotTransport(dnsType uint16) *dotConnPool {
|
||||
uc.ensureSetupTransport()
|
||||
func (uc *UpstreamConfig) dotTransport(ctx context.Context, dnsType uint16) *dotConnPool {
|
||||
uc.ensureSetupTransport(ctx)
|
||||
return transportByIpStack(uc.IPStack, dnsType, uc.dotClientPool, uc.dotClientPool4, uc.dotClientPool6)
|
||||
}
|
||||
|
||||
@@ -158,16 +159,16 @@ func (d *quicParallelDialer) Dial(ctx context.Context, addrs []string, tlsCfg *t
|
||||
return nil, errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (uc *UpstreamConfig) newDOQConnPool(addrs []string) *doqConnPool {
|
||||
func (uc *UpstreamConfig) newDOQConnPool(ctx context.Context, addrs []string) *doqConnPool {
|
||||
if uc.Type != ResolverTypeDOQ {
|
||||
return nil
|
||||
}
|
||||
return newDOQConnPool(uc, addrs)
|
||||
return newDOQConnPool(ctx, uc, addrs)
|
||||
}
|
||||
|
||||
func (uc *UpstreamConfig) newDOTClientPool(addrs []string) *dotConnPool {
|
||||
func (uc *UpstreamConfig) newDOTClientPool(ctx context.Context, addrs []string) *dotConnPool {
|
||||
if uc.Type != ResolverTypeDOT {
|
||||
return nil
|
||||
}
|
||||
return newDOTClientPool(uc, addrs)
|
||||
return newDOTClientPool(ctx, uc, addrs)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user