mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-09-04 13:36:35 +02:00
Compare commits
186
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c48220a404 | ||
|
|
dc39073a55 | ||
|
|
e8e04ae094 | ||
|
|
0f821a7907 | ||
|
|
dd77d865b6 | ||
|
|
d8994cc7d8 | ||
|
|
80d1acdfd5 | ||
|
|
f96868c266 | ||
|
|
4113064680 | ||
|
|
f0b60f3efa | ||
|
|
47dd64b901 | ||
|
|
5eb2db549b | ||
|
|
a40eb70ce8 | ||
|
|
0dde645a8f | ||
|
|
fffa621797 | ||
|
|
0f310594b0 | ||
|
|
0db5ee44be | ||
|
|
062dcbfb12 | ||
|
|
88e076abe7 | ||
|
|
052b057756 | ||
|
|
891d5d9821 | ||
|
|
7de6298fa4 | ||
|
|
52b7aaab87 | ||
|
|
e5f2506199 | ||
|
|
0c8281c879 | ||
|
|
24fc9085fa | ||
|
|
e4e1c0abb9 | ||
|
|
58a5ea56af | ||
|
|
171dd0a7e6 | ||
|
|
cbef8b911c | ||
|
|
06238e5b12 | ||
|
|
8ffe8d4aaf | ||
|
|
19b222f6a8 | ||
|
|
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 |
@@ -9,18 +9,18 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: ["windows-latest", "ubuntu-latest", "macOS-latest"]
|
||||
go: ["1.24.x"]
|
||||
go: ["1.25.x"]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- uses: WillAbides/setup-go-faster@v1.8.0
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: ${{ matrix.go }}
|
||||
- run: "go test -race ./..."
|
||||
- uses: dominikh/staticcheck-action@v1.3.1
|
||||
- uses: dominikh/staticcheck-action@v1.4.0
|
||||
with:
|
||||
version: "2025.1"
|
||||
version: "2025.1.1"
|
||||
install-go: false
|
||||
cache-key: ${{ matrix.go }}
|
||||
|
||||
@@ -12,3 +12,9 @@ ctrld-*
|
||||
|
||||
# generated file
|
||||
cmd/cli/rsrc_*.syso
|
||||
ctrld
|
||||
ctrld.exe
|
||||
|
||||
# Local planning artifacts - never commit
|
||||
/SPEC.md
|
||||
/tasks/
|
||||
|
||||
@@ -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=client)"'
|
||||
```
|
||||
|
||||
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=client' -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,8 +80,8 @@ docker build -t controldns/ctrld . -f docker/Dockerfile
|
||||
```
|
||||
|
||||
|
||||
# Usage
|
||||
The cli is self documenting, so free free to run `--help` on any sub-command to get specific usages.
|
||||
## Usage
|
||||
The cli is self documenting, so feel free to run `--help` on any sub-command to get specific usages.
|
||||
|
||||
## Arguments
|
||||
```
|
||||
@@ -112,7 +93,7 @@ _/ ___\ __\_ __ \ | / __ |
|
||||
\/ dns forwarding proxy \/
|
||||
|
||||
Usage:
|
||||
ctrld [command]
|
||||
ctrld-client [command]
|
||||
|
||||
Available Commands:
|
||||
run Run the DNS proxy server
|
||||
@@ -128,12 +109,12 @@ Available Commands:
|
||||
log Manage runtime debug logs
|
||||
|
||||
Flags:
|
||||
-h, --help help for ctrld
|
||||
-h, --help help for ctrld-client
|
||||
-s, --silent do not write any log output
|
||||
-v, --verbose count verbose log output, "-v" basic logging, "-vv" debug level logging
|
||||
--version version for ctrld
|
||||
-v, --verbose count verbose log output, "-v" basic logging, "-vv" debug logging
|
||||
--version version for ctrld-client
|
||||
|
||||
Use "ctrld [command] --help" for more information about a command.
|
||||
Use "ctrld-client [command] --help" for more information about a command.
|
||||
```
|
||||
|
||||
## Basic Run Mode
|
||||
@@ -143,12 +124,12 @@ This is the most basic way to run `ctrld`, in foreground mode. Unless you alread
|
||||
|
||||
Windows (Admin Shell)
|
||||
```shell
|
||||
ctrld.exe run
|
||||
ctrld-client.exe run
|
||||
```
|
||||
|
||||
Linux or Macos
|
||||
```shell
|
||||
sudo ctrld run
|
||||
sudo ctrld-client run
|
||||
```
|
||||
|
||||
You can then run a test query using a DNS client, for example, `dig`:
|
||||
@@ -161,25 +142,23 @@ 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
|
||||
|
||||
Windows (Admin Shell)
|
||||
```shell
|
||||
ctrld.exe start
|
||||
ctrld-client.exe start
|
||||
```
|
||||
|
||||
Linux or Macos
|
||||
```
|
||||
sudo ctrld start
|
||||
sudo ctrld-client start
|
||||
```
|
||||
|
||||
If `ctrld` is not in your system path (you installed it manually), you will need to run the above commands from the directory where you installed `ctrld`.
|
||||
If `ctrld-client` is not in your system path (you installed it manually), you will need to run the above commands from the directory where you installed it.
|
||||
|
||||
In order to stop the service, and restore your DNS to original state, simply run `ctrld stop`. If you wish to stop and uninstall the service permanently, run `ctrld uninstall`.
|
||||
In order to stop the service, and restore your DNS to original state, simply run `ctrld-client stop`. If you wish to stop and uninstall the service permanently, run `ctrld-client uninstall`.
|
||||
|
||||
## Unmanaged Service Mode
|
||||
This mode functions similarly to the "Service Mode" above except it will simply start a system service and the config defined listeners, but **will not make any changes to any network interfaces**. You can then set the `ctrld` listener(s) IP on the desired network interfaces manually.
|
||||
@@ -188,36 +167,36 @@ This mode functions similarly to the "Service Mode" above except it will simply
|
||||
|
||||
Windows (Admin Shell)
|
||||
```shell
|
||||
ctrld.exe service start
|
||||
ctrld-client.exe service start
|
||||
```
|
||||
|
||||
Linux or Macos
|
||||
```shell
|
||||
sudo ctrld service start
|
||||
sudo ctrld-client 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.
|
||||
|
||||
Windows (Admin Shell)
|
||||
```shell
|
||||
ctrld.exe start --cd abcd1234
|
||||
ctrld-client.exe start --cd abcd1234
|
||||
```
|
||||
|
||||
Linux or Macos
|
||||
```shell
|
||||
sudo ctrld start --cd abcd1234
|
||||
sudo ctrld-client 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
|
||||
@@ -257,7 +236,7 @@ If you're unable to use a config file, `ctrld` can be be supplied with basic con
|
||||
|
||||
### Example
|
||||
```
|
||||
ctrld run --listen=127.0.0.1:53 --primary_upstream=https://freedns.controld.com/p2 --secondary_upstream=10.0.10.1:53 --domains=*.company.int,very-secure.local --log /path/to/log.log
|
||||
ctrld-client run --listen=127.0.0.1:53 --primary_upstream=https://freedns.controld.com/p2 --secondary_upstream=10.0.10.1:53 --domains=*.company.int,very-secure.local --log /path/to/log.log
|
||||
```
|
||||
|
||||
The above will start a foreground process and:
|
||||
@@ -266,5 +245,67 @@ The above will start a foreground process and:
|
||||
- Excluding `*.company.int` and `very-secure.local` matching queries, that are forwarded to `10.0.10.1:53`
|
||||
- Write a debug log to `/path/to/log.log`
|
||||
|
||||
## DNS Intercept Mode
|
||||
When running `ctrld` alongside VPN software, DNS conflicts can cause intermittent failures, bypassed filtering, or configuration loops. DNS Intercept Mode prevents these issues by transparently capturing all DNS traffic on the system and routing it through `ctrld`, without modifying network adapter DNS settings.
|
||||
|
||||
### When to Use
|
||||
Enable DNS Intercept Mode if you:
|
||||
- Use corporate VPN software (F5, Cisco AnyConnect, Palo Alto GlobalProtect, Zscaler)
|
||||
- Run overlay networks like Tailscale or WireGuard
|
||||
- Experience random DNS failures when VPN connects/disconnects
|
||||
- See gaps in your Control D analytics when VPN is active
|
||||
- Have endpoint security software that also manages DNS
|
||||
|
||||
### Command
|
||||
|
||||
Windows (Admin Shell)
|
||||
```shell
|
||||
ctrld-client.exe start --intercept-mode dns --cd RESOLVER_ID_HERE
|
||||
```
|
||||
|
||||
macOS
|
||||
```shell
|
||||
sudo ctrld-client start --intercept-mode dns --cd RESOLVER_ID_HERE
|
||||
```
|
||||
|
||||
`--intercept-mode dns` automatically detects VPN internal domains and routes them to the VPN's DNS server, while Control D handles everything else.
|
||||
|
||||
To disable intercept mode on a service that already has it enabled:
|
||||
|
||||
Windows (Admin Shell)
|
||||
```shell
|
||||
ctrld-client.exe start --intercept-mode off
|
||||
```
|
||||
|
||||
macOS
|
||||
```shell
|
||||
sudo ctrld-client start --intercept-mode off
|
||||
```
|
||||
|
||||
This removes the intercept rules and reverts to standard interface-based DNS configuration.
|
||||
|
||||
### Platform Support
|
||||
| Platform | Supported | Mechanism |
|
||||
|----------|-----------|-----------|
|
||||
| Windows | ✅ | NRPT (Name Resolution Policy Table) |
|
||||
| macOS | ✅ | pf (packet filter) redirect |
|
||||
| Linux | ❌ | Not currently supported |
|
||||
|
||||
### Features
|
||||
- **VPN split routing** — VPN-specific domains are automatically detected and forwarded to the VPN's DNS server
|
||||
- **Captive portal recovery** — Wi-Fi login pages (hotels, airports, coffee shops) work automatically
|
||||
- **No network adapter changes** — DNS settings stay untouched, eliminating conflicts entirely
|
||||
- **Automatic port 53 conflict resolution** — if another process (e.g., `mDNSResponder` on macOS) is already using port 53, `ctrld` automatically listens on a different port. OS-level packet interception redirects all DNS traffic to `ctrld` transparently, so no manual configuration is needed. This only applies to intercept mode.
|
||||
|
||||
### Tested VPN Software
|
||||
- F5 BIG-IP APM
|
||||
- Cisco AnyConnect
|
||||
- Palo Alto GlobalProtect
|
||||
- Tailscale (including Exit Nodes)
|
||||
- Windscribe
|
||||
- WireGuard
|
||||
|
||||
For more details, see the [DNS Intercept Mode documentation](https://docs.controld.com/docs/dns-intercept).
|
||||
|
||||
## Contributing
|
||||
See [Contribution Guideline](./docs/contributing.md)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+6
-33
@@ -1,26 +1,21 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// addExtraSplitDnsRule adds split DNS rule for domain if it's part of active directory.
|
||||
func addExtraSplitDnsRule(cfg *ctrld.Config) bool {
|
||||
domain, err := getActiveDirectoryDomain()
|
||||
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,
|
||||
@@ -40,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
|
||||
}
|
||||
|
||||
@@ -5,14 +5,16 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
"github.com/Control-D-Inc/ctrld/testhelper"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
"github.com/Control-D-Inc/ctrld/internal/system"
|
||||
"github.com/Control-D-Inc/ctrld/testhelper"
|
||||
)
|
||||
|
||||
func Test_getActiveDirectoryDomain(t *testing.T) {
|
||||
start := time.Now()
|
||||
domain, err := getActiveDirectoryDomain()
|
||||
domain, err := system.GetActiveDirectoryDomain()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
"github.com/Control-D-Inc/ctrld/internal/controld"
|
||||
)
|
||||
|
||||
// progForRefresh builds a prog that can run a configuration refresh: Firewall
|
||||
// Mode on, and a buffered reload channel so a refresh that decides ctrld must
|
||||
// reload does not block on a listener that does not exist in a test.
|
||||
func progForRefresh() *prog {
|
||||
p := progWithAllowList()
|
||||
p.rc = &controld.ResolverConfig{}
|
||||
p.apiReloadCh = make(chan *ctrld.Config, 1)
|
||||
return p
|
||||
}
|
||||
|
||||
// refresh runs one configuration refresh through the handler apiConfigReload
|
||||
// uses, which is the point: a test that called applyAllowedDestinations directly
|
||||
// would still pass if the refresh path stopped calling it.
|
||||
func refresh(t *testing.T, p *prog, forced bool, rc *controld.ResolverConfig) {
|
||||
t.Helper()
|
||||
p.applyFetchedResolverConfig(context.Background(), discardLogger(), rc, forced, time.Now().Unix())
|
||||
}
|
||||
|
||||
// TestRefreshAppliesAllowedDestinations drives the real refresh handler for both
|
||||
// the scheduled and the forced path, in the case where nothing else about the
|
||||
// configuration changed - no custom config, unchanged exclusions - so the
|
||||
// refresh takes its early return. That is where an allowed-destination update is
|
||||
// easiest to lose, because the refresh has no other work to do.
|
||||
func TestRefreshAppliesAllowedDestinations(t *testing.T) {
|
||||
direct := netip.MustParseAddr("203.0.113.10")
|
||||
inRange := netip.MustParseAddr("198.51.100.7")
|
||||
|
||||
for _, forced := range []bool{false, true} {
|
||||
name := "scheduled refresh"
|
||||
if forced {
|
||||
name = "forced refresh"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
p := progForRefresh()
|
||||
|
||||
refresh(t, p, forced, &controld.ResolverConfig{
|
||||
DestinationIPs: []string{"203.0.113.10", "198.51.100.0/24"},
|
||||
})
|
||||
if !p.allowList.Contains(direct) || !p.allowList.Contains(inRange) {
|
||||
t.Fatalf("refresh did not apply the organization list: %s=%v %s=%v",
|
||||
direct, p.allowList.Contains(direct), inRange, p.allowList.Contains(inRange))
|
||||
}
|
||||
select {
|
||||
case cfg := <-p.apiReloadCh:
|
||||
t.Fatalf("unchanged configuration signaled a reload (%v)", cfg)
|
||||
default:
|
||||
}
|
||||
|
||||
// A later refresh withdraws one entry and keeps the other.
|
||||
refresh(t, p, forced, &controld.ResolverConfig{
|
||||
DestinationIPs: []string{"203.0.113.10"},
|
||||
})
|
||||
if p.allowList.Contains(inRange) {
|
||||
t.Fatalf("%s still allowed after the refresh that withdrew it", inRange)
|
||||
}
|
||||
if !p.allowList.Contains(direct) {
|
||||
t.Fatalf("%s should still be allowed", direct)
|
||||
}
|
||||
|
||||
// And one clears the list entirely.
|
||||
refresh(t, p, forced, &controld.ResolverConfig{})
|
||||
if p.allowList.Contains(direct) {
|
||||
t.Fatalf("%s still allowed after the refresh that cleared the list", direct)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshAppliesAllowedDestinationsWhenReloading covers the other branch of
|
||||
// the same handler: a refresh that also changes the exclusion list, and so
|
||||
// signals a ctrld reload, must still apply the destinations - and they must
|
||||
// survive the allowlist flush that the reload performs.
|
||||
func TestRefreshAppliesAllowedDestinationsWhenReloading(t *testing.T) {
|
||||
p := progForRefresh()
|
||||
p.rc = &controld.ResolverConfig{Exclude: []string{"example.com"}}
|
||||
direct := netip.MustParseAddr("203.0.113.10")
|
||||
|
||||
refresh(t, p, false, &controld.ResolverConfig{
|
||||
Exclude: []string{"example.com", "example.net"},
|
||||
DestinationIPs: []string{"203.0.113.10"},
|
||||
})
|
||||
|
||||
select {
|
||||
case <-p.apiReloadCh:
|
||||
default:
|
||||
t.Fatal("exclusion list change did not signal a reload")
|
||||
}
|
||||
if !p.allowList.Contains(direct) {
|
||||
t.Fatalf("%s not allowed after a refresh that reloaded ctrld", direct)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshKeepsAllowedDestinationsPendingUntilMirrored pins that the refresh
|
||||
// path reports honestly: while platform enforcement is rejecting the change, the
|
||||
// refresh leaves it pending and retries it, instead of recording it as applied.
|
||||
func TestRefreshKeepsAllowedDestinationsPendingUntilMirrored(t *testing.T) {
|
||||
p := progForRefresh()
|
||||
var calls []mirrorCall
|
||||
failing := true
|
||||
stubMirror(t, &calls, &failing)
|
||||
|
||||
rc := &controld.ResolverConfig{DestinationIPs: []string{"203.0.113.10"}}
|
||||
refresh(t, p, false, rc)
|
||||
if got := p.pendingDestinations(p.allowList); got != 1 {
|
||||
t.Fatalf("pendingDestinations = %d after a rejected mirror, want 1", got)
|
||||
}
|
||||
|
||||
refresh(t, p, false, rc)
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("refresh did not retry the rejected change: calls = %v", calls)
|
||||
}
|
||||
|
||||
failing = false
|
||||
refresh(t, p, false, rc)
|
||||
if got := p.pendingDestinations(p.allowList); got != 0 {
|
||||
t.Fatalf("pendingDestinations = %d after the mirror succeeded, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// stubResolverConfigFetch replaces the refresh loop's API call for the duration
|
||||
// of a test, and points cdUID at a device so apiConfigReload does not return
|
||||
// immediately. Each fetch returns the config the test currently wants and
|
||||
// reports on fetched, which is how a test knows a refresh cycle has run.
|
||||
func stubResolverConfigFetch(t *testing.T, config func() *controld.ResolverConfig, fetched chan<- struct{}) {
|
||||
t.Helper()
|
||||
origFetch, origUID := fetchResolverConfigFn, cdUID
|
||||
t.Cleanup(func() { fetchResolverConfigFn, cdUID = origFetch, origUID })
|
||||
|
||||
cdUID = "test-uid"
|
||||
fetchResolverConfigFn = func(context.Context, *controld.ResolverConfigRequest, bool) (*controld.ResolverConfig, error) {
|
||||
rc := config()
|
||||
select {
|
||||
case fetched <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return rc, nil
|
||||
}
|
||||
}
|
||||
|
||||
// startRefreshLoop runs apiConfigReload in the background and joins it before the
|
||||
// test finishes. The loop must not outlive the test: it calls through the same
|
||||
// package-level stubs the next test replaces, so a leaked one would both race
|
||||
// those globals and act on another test's prog.
|
||||
func startRefreshLoop(t *testing.T, p *prog) {
|
||||
t.Helper()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
p.apiConfigReload()
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
close(p.stopCh)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Error("apiConfigReload did not stop")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// waitForCondition polls until cond holds, failing the test if it never does. The
|
||||
// refresh loop runs in its own goroutine, so its effects land asynchronously.
|
||||
func waitForCondition(t *testing.T, what string, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("timed out waiting for %s", what)
|
||||
}
|
||||
|
||||
// TestApiConfigReloadAppliesAllowedDestinations drives apiConfigReload itself -
|
||||
// the loop that owns the refresh ticker and the forced-reload channel - rather
|
||||
// than the handler it calls, so the wiring between them is covered too: removing
|
||||
// the handler call from the loop must fail a test, not just removing the work
|
||||
// inside the handler.
|
||||
func TestApiConfigReloadAppliesAllowedDestinations(t *testing.T) {
|
||||
direct := netip.MustParseAddr("203.0.113.10")
|
||||
|
||||
for _, forced := range []bool{true, false} {
|
||||
name := "forced reload"
|
||||
if !forced {
|
||||
name = "refresh ticker"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
p := progForRefresh()
|
||||
p.cfg = &ctrld.Config{}
|
||||
refetch := 1 // seconds; only the ticker path waits for it
|
||||
p.cfg.Service.RefetchTime = &refetch
|
||||
p.stopCh = make(chan struct{})
|
||||
p.apiForceReloadCh = make(chan struct{})
|
||||
|
||||
var mu sync.Mutex
|
||||
destinations := []string{"203.0.113.10"}
|
||||
fetched := make(chan struct{}, 1)
|
||||
stubResolverConfigFetch(t, func() *controld.ResolverConfig {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return &controld.ResolverConfig{DestinationIPs: append([]string(nil), destinations...)}
|
||||
}, fetched)
|
||||
|
||||
startRefreshLoop(t, p)
|
||||
|
||||
if forced {
|
||||
p.apiForceReloadCh <- struct{}{}
|
||||
}
|
||||
waitForCondition(t, "the destination to be applied", func() bool {
|
||||
return p.allowList.Contains(direct)
|
||||
})
|
||||
|
||||
// The organization withdraws it; the next cycle must take it away.
|
||||
mu.Lock()
|
||||
destinations = nil
|
||||
mu.Unlock()
|
||||
|
||||
if forced {
|
||||
p.apiForceReloadCh <- struct{}{}
|
||||
}
|
||||
waitForCondition(t, "the withdrawn destination to stop being allowed", func() bool {
|
||||
return !p.allowList.Contains(direct)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
+1075
-364
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,116 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
)
|
||||
|
||||
func TestIsExplicitInterceptListener(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ip string
|
||||
port int
|
||||
want bool
|
||||
}{
|
||||
{name: "empty", ip: "", port: 0, want: false},
|
||||
{name: "wildcard", ip: "0.0.0.0", port: 53, want: false},
|
||||
{name: "zero port", ip: "127.0.0.1", port: 0, want: false},
|
||||
{name: "default intercept listener", ip: "127.0.0.1", port: 53, want: false},
|
||||
{name: "fallback port explicit", ip: "127.0.0.1", port: 5354, want: true},
|
||||
{name: "custom loopback explicit", ip: "127.0.0.2", port: 53, want: true},
|
||||
{name: "custom address explicit", ip: "192.0.2.10", port: 53, want: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isExplicitInterceptListener(tt.ip, tt.port); got != tt.want {
|
||||
t.Fatalf("isExplicitInterceptListener(%q, %d) = %v, want %v", tt.ip, tt.port, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
"github.com/Control-D-Inc/ctrld/internal/controld"
|
||||
)
|
||||
|
||||
func TestContextFromStopCh(t *testing.T) {
|
||||
t.Run("cancels when stopCh closes", func(t *testing.T) {
|
||||
stopCh := make(chan struct{})
|
||||
ctx, cancel := contextFromStopCh(stopCh)
|
||||
defer cancel()
|
||||
|
||||
if ctx.Err() != nil {
|
||||
t.Fatalf("context cancelled before the stop request: %v", ctx.Err())
|
||||
}
|
||||
close(stopCh)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("context was not cancelled after stopCh closed")
|
||||
}
|
||||
if !errors.Is(ctx.Err(), context.Canceled) {
|
||||
t.Errorf("ctx.Err() = %v, want %v", ctx.Err(), context.Canceled)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cancel releases the watcher", func(t *testing.T) {
|
||||
// stopCh is never closed: cancel() must still end the goroutine watching it.
|
||||
ctx, cancel := contextFromStopCh(make(chan struct{}))
|
||||
cancel()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("context was not cancelled by cancel()")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil stopCh is usable", func(t *testing.T) {
|
||||
// Mobile callers have no stop channel; preflight must still run.
|
||||
ctx, cancel := contextFromStopCh(nil)
|
||||
defer cancel()
|
||||
if ctx.Err() != nil {
|
||||
t.Fatalf("context cancelled immediately: %v", ctx.Err())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// retryableNetworkErr is the shape processCDFlags treats as "retry with bootstrap
|
||||
// DNS": a url.Error wrapping a network failure.
|
||||
func retryableNetworkErr() error {
|
||||
return &url.Error{
|
||||
Op: "Post",
|
||||
URL: "https://api.controld.com/utility",
|
||||
Err: &net.OpError{Op: "dial", Net: "tcp", Err: syscall.ECONNREFUSED},
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCDFlagsStopsWhenCancelled(t *testing.T) {
|
||||
oldFetch := fetchResolverConfig
|
||||
oldUID := cdUID
|
||||
t.Cleanup(func() {
|
||||
fetchResolverConfig = oldFetch
|
||||
cdUID = oldUID
|
||||
})
|
||||
cdUID = "testuid"
|
||||
|
||||
var calls atomic.Int64
|
||||
fetchResolverConfig = func(ctx context.Context, req *controld.ResolverConfigRequest, dev bool) (*controld.ResolverConfig, error) {
|
||||
calls.Add(1)
|
||||
return nil, retryableNetworkErr()
|
||||
}
|
||||
|
||||
// A stop request arriving while the API is unreachable. Before this was
|
||||
// cancellable, the retry loop kept running after the service reported itself
|
||||
// stopped, which is what kept the incident's process alive and enforcing.
|
||||
stopCh := make(chan struct{})
|
||||
ctx, cancel := contextFromStopCh(stopCh)
|
||||
defer cancel()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
cfg := ctrld.Config{}
|
||||
_, err := processCDFlags(ctx, &cfg)
|
||||
done <- err
|
||||
}()
|
||||
|
||||
// Let it fail at least once and settle into backoff before stopping.
|
||||
deadline := time.After(10 * time.Second)
|
||||
for calls.Load() == 0 {
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatal("resolver config was never fetched")
|
||||
case err := <-done:
|
||||
t.Fatalf("processCDFlags returned before any fetch: %v", err)
|
||||
default:
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
close(stopCh)
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("processCDFlags err = %v, want it to report %v", err, context.Canceled)
|
||||
}
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("processCDFlags did not return after the stop request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCDFlagsReturnsImmediatelyWhenAlreadyCancelled(t *testing.T) {
|
||||
oldFetch := fetchResolverConfig
|
||||
oldUID := cdUID
|
||||
t.Cleanup(func() {
|
||||
fetchResolverConfig = oldFetch
|
||||
cdUID = oldUID
|
||||
})
|
||||
cdUID = "testuid"
|
||||
|
||||
var calls atomic.Int64
|
||||
fetchResolverConfig = func(ctx context.Context, req *controld.ResolverConfigRequest, dev bool) (*controld.ResolverConfig, error) {
|
||||
calls.Add(1)
|
||||
return nil, retryableNetworkErr()
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
cfg := ctrld.Config{}
|
||||
_, err := processCDFlags(ctx, &cfg)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("processCDFlags err = %v, want %v", err, context.Canceled)
|
||||
}
|
||||
// One attempt is made before the loop notices; it must not retry past that.
|
||||
if got := calls.Load(); got > 1 {
|
||||
t.Errorf("fetched %d times with a cancelled context, want at most 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunAPIPreflightClassification is the regression guard for classifying a preflight
|
||||
// failure as an operator stop.
|
||||
//
|
||||
// runAPIPreflight cancels the context it derived from stopCh. Sampling the stop state
|
||||
// from that context afterwards reports "stopped" unconditionally, because
|
||||
// context.CancelFunc sets ctx.Err() whether or not anyone asked to stop. run() then
|
||||
// takes the stop branch for every failure, which skips self-uninstalling a deleted
|
||||
// device, skips the mobile exit callback, and tells the service manager a failed start
|
||||
// was a clean exit.
|
||||
func TestRunAPIPreflightClassification(t *testing.T) {
|
||||
oldFetch := fetchResolverConfig
|
||||
oldUID := cdUID
|
||||
t.Cleanup(func() {
|
||||
fetchResolverConfig = oldFetch
|
||||
cdUID = oldUID
|
||||
})
|
||||
cdUID = "testuid"
|
||||
|
||||
// A deleted ControlD device: non-retryable, so preflight returns promptly.
|
||||
deletedDevice := func() error {
|
||||
e := &controld.ErrorResponse{}
|
||||
e.ErrorField.Code = controld.InvalidConfigCode
|
||||
e.ErrorField.Message = "device does not exist"
|
||||
return e
|
||||
}
|
||||
|
||||
openCh := make(chan struct{})
|
||||
closedCh := make(chan struct{})
|
||||
close(closedCh)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
stopCh <-chan struct{}
|
||||
fetchErr func() error
|
||||
wantStop bool
|
||||
}{
|
||||
{
|
||||
// The P1: no stop was requested, so this must reach the failure branch.
|
||||
name: "api error with no stop request",
|
||||
stopCh: openCh,
|
||||
fetchErr: deletedDevice,
|
||||
},
|
||||
{
|
||||
// Mobile passes no stop channel at all, so it could never have stopped.
|
||||
name: "api error with a nil stop channel",
|
||||
stopCh: nil,
|
||||
fetchErr: deletedDevice,
|
||||
},
|
||||
{
|
||||
name: "stop requested during preflight",
|
||||
stopCh: closedCh,
|
||||
fetchErr: func() error { return retryableNetworkErr() },
|
||||
wantStop: true,
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
fetchResolverConfig = func(context.Context, *controld.ResolverConfigRequest, bool) (*controld.ResolverConfig, error) {
|
||||
return nil, tc.fetchErr()
|
||||
}
|
||||
cfg := ctrld.Config{}
|
||||
pf := runAPIPreflight(tc.stopCh, &cfg)
|
||||
|
||||
if pf.err == nil {
|
||||
t.Fatal("expected preflight to fail")
|
||||
}
|
||||
if pf.stopRequested != tc.wantStop {
|
||||
t.Errorf("stopRequested = %v, want %v", pf.stopRequested, tc.wantStop)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunAPIPreflightPreservesAPIError verifies the error reaches the caller in a form
|
||||
// the failure branch can still act on: self-uninstall keys off an *ErrorResponse with
|
||||
// InvalidConfigCode, and it only runs if that error is both classified as a failure and
|
||||
// still unwrappable.
|
||||
func TestRunAPIPreflightPreservesAPIError(t *testing.T) {
|
||||
oldFetch := fetchResolverConfig
|
||||
oldUID := cdUID
|
||||
t.Cleanup(func() {
|
||||
fetchResolverConfig = oldFetch
|
||||
cdUID = oldUID
|
||||
})
|
||||
cdUID = "testuid"
|
||||
|
||||
want := &controld.ErrorResponse{}
|
||||
want.ErrorField.Code = controld.InvalidConfigCode
|
||||
fetchResolverConfig = func(context.Context, *controld.ResolverConfigRequest, bool) (*controld.ResolverConfig, error) {
|
||||
return nil, want
|
||||
}
|
||||
|
||||
cfg := ctrld.Config{}
|
||||
pf := runAPIPreflight(make(chan struct{}), &cfg)
|
||||
|
||||
if pf.stopRequested {
|
||||
t.Error("a device-deleted failure must not be reported as an operator stop")
|
||||
}
|
||||
var got *controld.ErrorResponse
|
||||
if !errors.As(pf.err, &got) {
|
||||
t.Fatalf("error no longer unwraps to *controld.ErrorResponse: %v", pf.err)
|
||||
}
|
||||
if got.ErrorField.Code != controld.InvalidConfigCode {
|
||||
t.Errorf("code = %d, want %d (self-uninstall would not trigger)", got.ErrorField.Code, controld.InvalidConfigCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPermanentAPIRejectionNarrowsToClientErrors is the regression guard for the clean
|
||||
// exit added above.
|
||||
//
|
||||
// controld builds an *ErrorResponse for any non-200 whose body decodes, so the Go type
|
||||
// says nothing about whether the API's answer will change on a retry. Keying the clean
|
||||
// exit off the type alone meant a 502 from a load balancer, or an API having a bad ten
|
||||
// minutes, stopped ctrld on every affected host with no service-manager retry behind it -
|
||||
// worse than the abnormal exit it replaced, because a Fatal at least gets restarted.
|
||||
//
|
||||
// Only a client-error status may take that path.
|
||||
func TestPermanentAPIRejectionNarrowsToClientErrors(t *testing.T) {
|
||||
rejection := func(status, code int) error {
|
||||
e := &controld.ErrorResponse{StatusCode: status}
|
||||
e.ErrorField.Code = code
|
||||
e.ErrorField.Message = "api said no"
|
||||
return e
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
wantPermanent bool
|
||||
}{
|
||||
{
|
||||
// The case the clean exit exists for: the device is gone, and every restart
|
||||
// will be told the same thing.
|
||||
name: "deleted device",
|
||||
err: rejection(http.StatusNotFound, controld.InvalidConfigCode),
|
||||
wantPermanent: true,
|
||||
},
|
||||
{"revoked credentials", rejection(http.StatusUnauthorized, 0), true},
|
||||
{"forbidden", rejection(http.StatusForbidden, 0), true},
|
||||
{"malformed request", rejection(http.StatusBadRequest, 0), true},
|
||||
|
||||
// Server-side trouble. These must keep the abnormal exit so the service
|
||||
// manager's recovery policy retries.
|
||||
{"bad gateway", rejection(http.StatusBadGateway, 0), false},
|
||||
{"internal error", rejection(http.StatusInternalServerError, 0), false},
|
||||
{"service unavailable", rejection(http.StatusServiceUnavailable, 0), false},
|
||||
|
||||
// 4xx, but both are the API asking for a later attempt rather than refusing
|
||||
// this configuration.
|
||||
{"request timeout", rejection(http.StatusRequestTimeout, 0), false},
|
||||
{"rate limited", rejection(http.StatusTooManyRequests, 0), false},
|
||||
|
||||
// An *ErrorResponse built without a recorded status carries no verdict. A
|
||||
// hand-constructed one, or a decode path that forgets to record the status,
|
||||
// must not silently gain the clean exit.
|
||||
{"no recorded status", rejection(0, controld.InvalidConfigCode), false},
|
||||
|
||||
// Not an API answer at all: the incident's denied socket reaches Fatal.
|
||||
{"network failure", retryableNetworkErr(), false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, ok := permanentAPIRejection(tc.err)
|
||||
if ok != tc.wantPermanent {
|
||||
t.Errorf("permanentAPIRejection() = %v, want %v", ok, tc.wantPermanent)
|
||||
}
|
||||
if ok && got == nil {
|
||||
t.Error("a permanent rejection must return the rejection for reporting")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The wrapped form matters too: preflight composes the fetch error, and errors.As has
|
||||
// to reach through that for either branch to be chosen correctly.
|
||||
wrapped := fmt.Errorf("processCDFlags: %w", rejection(http.StatusNotFound, controld.InvalidConfigCode))
|
||||
if _, ok := permanentAPIRejection(wrapped); !ok {
|
||||
t.Error("a wrapped API rejection must still be recognised")
|
||||
}
|
||||
wrappedTransient := fmt.Errorf("processCDFlags: %w", rejection(http.StatusBadGateway, 0))
|
||||
if _, ok := permanentAPIRejection(wrappedTransient); ok {
|
||||
t.Error("a wrapped 502 must not be treated as a permanent rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopRequested(t *testing.T) {
|
||||
closedCh := make(chan struct{})
|
||||
close(closedCh)
|
||||
|
||||
if stopRequested(nil) {
|
||||
t.Error("a nil stop channel must read as no stop (mobile passes none)")
|
||||
}
|
||||
if stopRequested(make(chan struct{})) {
|
||||
t.Error("an open stop channel must read as no stop")
|
||||
}
|
||||
if !stopRequested(closedCh) {
|
||||
t.Error("a closed stop channel must read as a stop")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReloadFetchIsBoundedByServiceLifetime covers the reload path's stop wiring.
|
||||
//
|
||||
// Reload fetches the ControlD config too, and it used to build the bounded context
|
||||
// itself. Nothing tested that: the wrong channel, or a dropped cancel, would have left a
|
||||
// reload retrying against an unreachable API after "Service stopped" was logged, and no
|
||||
// test would have failed. Both paths now go through one bounded fetch, so this pins it.
|
||||
func TestReloadFetchIsBoundedByServiceLifetime(t *testing.T) {
|
||||
original := processCDFlagsFn
|
||||
t.Cleanup(func() { processCDFlagsFn = original })
|
||||
|
||||
t.Run("a stop request cancels the reload fetch", func(t *testing.T) {
|
||||
stopCh := make(chan struct{})
|
||||
close(stopCh)
|
||||
|
||||
var sawCancelled bool
|
||||
processCDFlagsFn = func(ctx context.Context, _ *ctrld.Config) (*controld.ResolverConfig, error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
sawCancelled = true
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
p := &prog{stopCh: stopCh}
|
||||
if _, err := p.fetchCDConfigBoundedByLifetime(&ctrld.Config{}); !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("reload fetch err = %v, want %v", err, context.Canceled)
|
||||
}
|
||||
if !sawCancelled {
|
||||
t.Error("the reload fetch did not observe the stop request: it is not bound to the service lifetime")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the derived context is always released", func(t *testing.T) {
|
||||
// stopCh stays open: the fetch's own cancel is what must end the watcher, or
|
||||
// every reload leaks a goroutine.
|
||||
var captured context.Context
|
||||
processCDFlagsFn = func(ctx context.Context, _ *ctrld.Config) (*controld.ResolverConfig, error) {
|
||||
captured = ctx
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
p := &prog{stopCh: make(chan struct{})}
|
||||
if _, err := p.fetchCDConfigBoundedByLifetime(&ctrld.Config{}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-captured.Done():
|
||||
case <-time.After(time.Second):
|
||||
t.Error("the reload fetch left its context uncancelled")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
"github.com/Control-D-Inc/ctrld/internal/controld"
|
||||
)
|
||||
|
||||
// TestApiFailureCode covers the preflight-error mapping: a deleted device
|
||||
// gets its own code (it drives self-uninstall), other permanent rejections
|
||||
// are generic, anything else is retryable reachability trouble.
|
||||
func TestApiFailureCode(t *testing.T) {
|
||||
rejection := func(status, code int) error {
|
||||
e := &controld.ErrorResponse{StatusCode: status}
|
||||
e.ErrorField.Code = code
|
||||
e.ErrorField.Message = "api said no"
|
||||
return e
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
wantCode provisionFailureCode
|
||||
wantOk bool
|
||||
}{
|
||||
{name: "nil error", err: nil, wantCode: "", wantOk: false},
|
||||
{
|
||||
name: "deleted device maps to device invalid",
|
||||
err: rejection(http.StatusNotFound, controld.InvalidConfigCode),
|
||||
wantCode: provisionCodeAPIDeviceInvalid,
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
name: "revoked credentials map to rejected",
|
||||
err: rejection(http.StatusUnauthorized, 0),
|
||||
wantCode: provisionCodeAPIRejected,
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
name: "server error maps to unreachable",
|
||||
err: rejection(http.StatusBadGateway, 0),
|
||||
wantCode: provisionCodeAPIUnreachable,
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
name: "network failure maps to unreachable",
|
||||
err: retryableNetworkErr(),
|
||||
wantCode: provisionCodeAPIUnreachable,
|
||||
wantOk: true,
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
code, ok := apiFailureCode(tc.err)
|
||||
if ok != tc.wantOk {
|
||||
t.Fatalf("apiFailureCode() ok = %v, want %v", ok, tc.wantOk)
|
||||
}
|
||||
if code != tc.wantCode {
|
||||
t.Errorf("apiFailureCode() code = %s, want %s", code, tc.wantCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestApiFailureCodeMapsRejectionReason covers the token_* reasons the API sends in
|
||||
// error.metadata.reason on a provisioning-token rejection. An absent or unknown
|
||||
// reason must fall back to the generic API_REJECTED exactly as before this reason
|
||||
// field existed.
|
||||
func TestApiFailureCodeMapsRejectionReason(t *testing.T) {
|
||||
rejectionWithReason := func(reason string) error {
|
||||
e := &controld.ErrorResponse{StatusCode: http.StatusBadRequest}
|
||||
e.ErrorField.Code = 40003
|
||||
e.ErrorField.Message = "invalid token"
|
||||
e.ErrorField.Metadata.Reason = reason
|
||||
return e
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
reason string
|
||||
wantCode provisionFailureCode
|
||||
}{
|
||||
{name: "token_invalid", reason: "token_invalid", wantCode: provisionCodeTokenInvalid},
|
||||
{name: "token_expired", reason: "token_expired", wantCode: provisionCodeTokenExpired},
|
||||
{name: "token_limit_reached", reason: "token_limit_reached", wantCode: provisionCodeTokenLimitReached},
|
||||
{name: "token_disabled", reason: "token_disabled", wantCode: provisionCodeTokenDisabled},
|
||||
{name: "reason absent falls back", reason: "", wantCode: provisionCodeAPIRejected},
|
||||
{name: "unknown reason falls back", reason: "some_future_reason", wantCode: provisionCodeAPIRejected},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
code, ok := apiFailureCode(rejectionWithReason(tc.reason))
|
||||
if !ok {
|
||||
t.Fatal("apiFailureCode() ok = false, want true")
|
||||
}
|
||||
if code != tc.wantCode {
|
||||
t.Errorf("apiFailureCode() code = %s, want %s", code, tc.wantCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestApiFailureCodeSurvivesMalformedReasonType covers a rejection body whose
|
||||
// metadata.reason is the wrong JSON type end to end: decode it exactly as
|
||||
// internal/controld does (json.Unmarshal into the same exported type), then
|
||||
// classify it. Before the metadata decode fix, this body failed the whole
|
||||
// decode and apiFailureCode never saw an *ErrorResponse at all, so it fell
|
||||
// back to API_UNREACHABLE - the retryable bootstrap code - instead of the
|
||||
// permanent rejection this HTTP 400 with a known error code actually is.
|
||||
func TestApiFailureCodeSurvivesMalformedReasonType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{name: "reason as a number", body: `{"error":{"message":"invalid token","code":40003,"metadata":{"reason":12345}}}`},
|
||||
{name: "reason as an object", body: `{"error":{"message":"invalid token","code":40003,"metadata":{"reason":{"inner":"value"}}}}`},
|
||||
{name: "reason as null", body: `{"error":{"message":"invalid token","code":40003,"metadata":{"reason":null}}}`},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
e := &controld.ErrorResponse{StatusCode: http.StatusBadRequest}
|
||||
if err := json.Unmarshal([]byte(tc.body), e); err != nil {
|
||||
t.Fatalf("a malformed reason must not fail the whole decode: %v", err)
|
||||
}
|
||||
code, ok := apiFailureCode(e)
|
||||
if !ok {
|
||||
t.Fatal("apiFailureCode() ok = false, want true")
|
||||
}
|
||||
if code != provisionCodeAPIRejected {
|
||||
t.Errorf("apiFailureCode() code = %s, want %s (not %s)", code, provisionCodeAPIRejected, provisionCodeAPIUnreachable)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCdUIDFromProvTokenReasonCodes covers the full path from an API rejection
|
||||
// reason to a persisted result file: each known reason gets its own code, exit
|
||||
// code, and stage, with a message naming the field and a next action but never
|
||||
// echoing the token. Absent and unknown reasons keep the generic rejection.
|
||||
func TestCdUIDFromProvTokenReasonCodes(t *testing.T) {
|
||||
const secretToken = "org-secret-token-999"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
reason string
|
||||
wantCode provisionFailureCode
|
||||
wantContains string
|
||||
}{
|
||||
{name: "token_invalid", reason: "token_invalid", wantCode: provisionCodeTokenInvalid, wantContains: "provisioning code"},
|
||||
{name: "token_expired", reason: "token_expired", wantCode: provisionCodeTokenExpired, wantContains: "expired"},
|
||||
{name: "token_limit_reached", reason: "token_limit_reached", wantCode: provisionCodeTokenLimitReached, wantContains: "limit"},
|
||||
{name: "token_disabled", reason: "token_disabled", wantCode: provisionCodeTokenDisabled, wantContains: "invalidated"},
|
||||
{name: "reason absent", reason: "", wantCode: provisionCodeAPIRejected, wantContains: ""},
|
||||
{name: "unknown reason", reason: "brand_new_reason", wantCode: provisionCodeAPIRejected, wantContains: ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
exitCode, _ := stubProvisionGlobals(t)
|
||||
oldFetch, oldHostname := fetchResolverUIDFn, customHostname
|
||||
t.Cleanup(func() { fetchResolverUIDFn, customHostname = oldFetch, oldHostname })
|
||||
cdUID = ""
|
||||
cdOrg = secretToken
|
||||
customHostname = ""
|
||||
|
||||
rejected := &controld.ErrorResponse{StatusCode: http.StatusBadRequest}
|
||||
rejected.ErrorField.Code = 40003
|
||||
rejected.ErrorField.Message = "invalid token " + secretToken
|
||||
rejected.ErrorField.Metadata.Reason = tc.reason
|
||||
fetchResolverUIDFn = func(context.Context, *controld.UtilityOrgRequest, string, bool) (*controld.ResolverConfig, error) {
|
||||
return nil, rejected
|
||||
}
|
||||
|
||||
if got := cdUIDFromProvToken(); got != "" {
|
||||
t.Errorf("cdUIDFromProvToken() = %q, want empty on failure", got)
|
||||
}
|
||||
if *exitCode != provisionExitCodeForCode[tc.wantCode] {
|
||||
t.Errorf("exit = %d, want %d", *exitCode, provisionExitCodeForCode[tc.wantCode])
|
||||
}
|
||||
r, err := readProvisionResult()
|
||||
if err != nil {
|
||||
t.Fatalf("no provision result written: %v", err)
|
||||
}
|
||||
if r.Code != string(tc.wantCode) {
|
||||
t.Errorf("code = %q, want %q", r.Code, tc.wantCode)
|
||||
}
|
||||
if r.Stage != string(provisionStageBootstrap) {
|
||||
t.Errorf("stage = %q, want bootstrap", r.Stage)
|
||||
}
|
||||
if tc.wantContains != "" && !strings.Contains(r.Message, tc.wantContains) {
|
||||
t.Errorf("message = %q, want it to contain %q", r.Message, tc.wantContains)
|
||||
}
|
||||
if strings.Contains(r.Message, secretToken) {
|
||||
t.Errorf("token leaked into result message: %q", r.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func stubProvisionGlobals(t *testing.T) (exitCode *int, notified *bool) {
|
||||
t.Helper()
|
||||
oldCdUID, oldCdOrg := cdUID, cdOrg
|
||||
oldExit, oldUninstall := provisionExit, uninstallInvalidCdUIDFn
|
||||
t.Cleanup(func() {
|
||||
cdUID, cdOrg = oldCdUID, oldCdOrg
|
||||
provisionExit, uninstallInvalidCdUIDFn = oldExit, oldUninstall
|
||||
})
|
||||
overrideProvisionResultPath(t)
|
||||
code := -1
|
||||
provisionExit = func(c int) { code = c }
|
||||
n := false
|
||||
return &code, &n
|
||||
}
|
||||
|
||||
func TestHandleAPIPreflightFailure(t *testing.T) {
|
||||
deviceInvalid := func() error {
|
||||
e := &controld.ErrorResponse{StatusCode: http.StatusNotFound}
|
||||
e.ErrorField.Code = controld.InvalidConfigCode
|
||||
e.ErrorField.Message = "device does not exist"
|
||||
return e
|
||||
}
|
||||
rejected := func() error {
|
||||
e := &controld.ErrorResponse{StatusCode: http.StatusUnauthorized}
|
||||
e.ErrorField.Message = "bad token"
|
||||
return e
|
||||
}
|
||||
|
||||
t.Run("permanent rejection returns cleanly", func(t *testing.T) {
|
||||
exitCode, notified := stubProvisionGlobals(t)
|
||||
handleAPIPreflightFailure(&prog{}, rejected(), func() { *notified = true })
|
||||
if *exitCode != -1 {
|
||||
t.Errorf("provisionExit called with %d, want a clean return", *exitCode)
|
||||
}
|
||||
if !*notified {
|
||||
t.Error("notify not called")
|
||||
}
|
||||
r, err := readProvisionResult()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.Code != string(provisionCodeAPIRejected) {
|
||||
t.Errorf("code = %q, want API_REJECTED", r.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("deleted device self-uninstalls and returns cleanly", func(t *testing.T) {
|
||||
exitCode, notified := stubProvisionGlobals(t)
|
||||
uninstalled := false
|
||||
uninstallInvalidCdUIDFn = func(_ *prog, _ *ctrld.Logger, _ bool) bool {
|
||||
uninstalled = true
|
||||
return true
|
||||
}
|
||||
handleAPIPreflightFailure(&prog{}, deviceInvalid(), func() { *notified = true })
|
||||
if *exitCode != -1 {
|
||||
t.Errorf("provisionExit called with %d, want a clean return", *exitCode)
|
||||
}
|
||||
if !uninstalled {
|
||||
t.Error("self-uninstall not attempted")
|
||||
}
|
||||
if !*notified {
|
||||
t.Error("notify not called")
|
||||
}
|
||||
r, err := readProvisionResult()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.Code != string(provisionCodeAPIDeviceInvalid) {
|
||||
t.Errorf("code = %q, want API_DEVICE_INVALID", r.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unreachable exits nonzero", func(t *testing.T) {
|
||||
exitCode, notified := stubProvisionGlobals(t)
|
||||
handleAPIPreflightFailure(&prog{}, retryableNetworkErr(), func() { *notified = true })
|
||||
if *exitCode != provisionExitCodeForCode[provisionCodeAPIUnreachable] {
|
||||
t.Errorf("exit = %d, want %d", *exitCode, provisionExitCodeForCode[provisionCodeAPIUnreachable])
|
||||
}
|
||||
if !*notified {
|
||||
t.Error("notify not called")
|
||||
}
|
||||
r, err := readProvisionResult()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.Code != string(provisionCodeAPIUnreachable) {
|
||||
t.Errorf("code = %q, want API_UNREACHABLE", r.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bare uid from a composite --cd value is redacted", func(t *testing.T) {
|
||||
_, _ = stubProvisionGlobals(t)
|
||||
cdUID = "deviceabc/clientxyz"
|
||||
cdOrg = ""
|
||||
err := fmt.Errorf("failed: api says deviceabc is unknown")
|
||||
handleAPIPreflightFailure(&prog{}, err, func() {})
|
||||
r, rerr := readProvisionResult()
|
||||
if rerr != nil {
|
||||
t.Fatal(rerr)
|
||||
}
|
||||
if strings.Contains(r.Message, "deviceabc") {
|
||||
t.Errorf("bare uid leaked into message: %q", r.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestDoValidateCdRemoteConfigClassifiesAPIFailure covers the direct
|
||||
// "--cd <uid>" install path (fatal=true): a fetch failure must classify on
|
||||
// the provisioning boundary with the same per-class codes as the daemon-side
|
||||
// preflight, instead of a bare fatal.
|
||||
func TestDoValidateCdRemoteConfigClassifiesAPIFailure(t *testing.T) {
|
||||
exitCode, _ := stubProvisionGlobals(t)
|
||||
oldFetch := fetchResolverConfig
|
||||
t.Cleanup(func() { fetchResolverConfig = oldFetch })
|
||||
|
||||
deviceInvalid := func() error {
|
||||
e := &controld.ErrorResponse{StatusCode: http.StatusNotFound}
|
||||
e.ErrorField.Code = controld.InvalidConfigCode
|
||||
e.ErrorField.Message = "device does not exist"
|
||||
return e
|
||||
}
|
||||
rejected := func() error {
|
||||
e := &controld.ErrorResponse{StatusCode: http.StatusUnauthorized}
|
||||
e.ErrorField.Message = "bad token"
|
||||
return e
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
wantCode provisionFailureCode
|
||||
}{
|
||||
{name: "device invalid", err: deviceInvalid(), wantCode: provisionCodeAPIDeviceInvalid},
|
||||
{name: "permanent rejection", err: rejected(), wantCode: provisionCodeAPIRejected},
|
||||
{name: "unreachable", err: retryableNetworkErr(), wantCode: provisionCodeAPIUnreachable},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
fetchResolverConfig = func(context.Context, *controld.ResolverConfigRequest, bool) (*controld.ResolverConfig, error) {
|
||||
return nil, tc.err
|
||||
}
|
||||
if err := doValidateCdRemoteConfig("device-uid-123", true); err == nil {
|
||||
t.Error("doValidateCdRemoteConfig() error = nil, want the fetch error back")
|
||||
}
|
||||
if *exitCode != provisionExitCodeForCode[tc.wantCode] {
|
||||
t.Errorf("exit = %d, want %d", *exitCode, provisionExitCodeForCode[tc.wantCode])
|
||||
}
|
||||
r, err := readProvisionResult()
|
||||
if err != nil {
|
||||
t.Fatalf("no provision result written: %v", err)
|
||||
}
|
||||
if r.Code != string(tc.wantCode) {
|
||||
t.Errorf("code = %q, want %q", r.Code, tc.wantCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoValidateCdRemoteConfigNonFatalReturnsError proves the restart path
|
||||
// (fatal=false) is unaffected: it still just warns and hands the error back,
|
||||
// with no process exit and no result file.
|
||||
func TestDoValidateCdRemoteConfigNonFatalReturnsError(t *testing.T) {
|
||||
exitCode, _ := stubProvisionGlobals(t)
|
||||
oldFetch := fetchResolverConfig
|
||||
t.Cleanup(func() { fetchResolverConfig = oldFetch })
|
||||
|
||||
wantErr := errors.New("network unreachable")
|
||||
fetchResolverConfig = func(context.Context, *controld.ResolverConfigRequest, bool) (*controld.ResolverConfig, error) {
|
||||
return nil, wantErr
|
||||
}
|
||||
|
||||
if err := doValidateCdRemoteConfig("device-uid-123", false); !errors.Is(err, wantErr) {
|
||||
t.Errorf("doValidateCdRemoteConfig() error = %v, want %v", err, wantErr)
|
||||
}
|
||||
if *exitCode != -1 {
|
||||
t.Errorf("provisionExit called with %d, want no exit", *exitCode)
|
||||
}
|
||||
if _, err := readProvisionResult(); err == nil {
|
||||
t.Error("expected no provision result written for the non-fatal path")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoValidateCdRemoteConfigDoesNotSelfUninstall proves the direct-cd
|
||||
// install path never triggers self-uninstall on a device-invalid failure:
|
||||
// this runs before the service is installed, so there is nothing to remove.
|
||||
func TestDoValidateCdRemoteConfigDoesNotSelfUninstall(t *testing.T) {
|
||||
_, _ = stubProvisionGlobals(t)
|
||||
oldFetch, oldUninstall := fetchResolverConfig, uninstallInvalidCdUIDFn
|
||||
t.Cleanup(func() { fetchResolverConfig, uninstallInvalidCdUIDFn = oldFetch, oldUninstall })
|
||||
|
||||
uninstallCalled := false
|
||||
uninstallInvalidCdUIDFn = func(*prog, *ctrld.Logger, bool) bool {
|
||||
uninstallCalled = true
|
||||
return true
|
||||
}
|
||||
e := &controld.ErrorResponse{StatusCode: http.StatusNotFound}
|
||||
e.ErrorField.Code = controld.InvalidConfigCode
|
||||
fetchResolverConfig = func(context.Context, *controld.ResolverConfigRequest, bool) (*controld.ResolverConfig, error) {
|
||||
return nil, e
|
||||
}
|
||||
|
||||
_ = doValidateCdRemoteConfig("device-uid-123", true)
|
||||
|
||||
if uninstallCalled {
|
||||
t.Error("doValidateCdRemoteConfig triggered self-uninstall; nothing is installed yet on this path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCdUIDFromProvTokenFailureEmitsCode(t *testing.T) {
|
||||
exitCode, _ := stubProvisionGlobals(t)
|
||||
oldFetch, oldHostname := fetchResolverUIDFn, customHostname
|
||||
t.Cleanup(func() { fetchResolverUIDFn, customHostname = oldFetch, oldHostname })
|
||||
cdUID = ""
|
||||
cdOrg = "org-secret-token-123"
|
||||
customHostname = ""
|
||||
|
||||
rejected := &controld.ErrorResponse{StatusCode: http.StatusUnauthorized}
|
||||
rejected.ErrorField.Message = "bad provision token org-secret-token-123"
|
||||
fetchResolverUIDFn = func(context.Context, *controld.UtilityOrgRequest, string, bool) (*controld.ResolverConfig, error) {
|
||||
return nil, rejected
|
||||
}
|
||||
|
||||
if got := cdUIDFromProvToken(); got != "" {
|
||||
t.Errorf("cdUIDFromProvToken() = %q, want empty on failure", got)
|
||||
}
|
||||
if *exitCode != provisionExitCodeForCode[provisionCodeAPIRejected] {
|
||||
t.Errorf("exit = %d, want API_REJECTED exit %d", *exitCode, provisionExitCodeForCode[provisionCodeAPIRejected])
|
||||
}
|
||||
r, err := readProvisionResult()
|
||||
if err != nil {
|
||||
t.Fatalf("no provision result written: %v", err)
|
||||
}
|
||||
if r.Code != string(provisionCodeAPIRejected) {
|
||||
t.Errorf("code = %q, want API_REJECTED", r.Code)
|
||||
}
|
||||
if strings.Contains(r.Message, cdOrg) {
|
||||
t.Errorf("token leaked into result message: %q", r.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression test: an explicit ip:port that fails to bind used to die with a
|
||||
// bare fatal log automation could not tell apart from any other crash. It
|
||||
// must report a stable code through the provisioning result instead.
|
||||
func TestTryUpdateListenerConfigConfiguredAddrUnavailable(t *testing.T) {
|
||||
// Occupy one localhost port on both udp and tcp, and hold both for the
|
||||
// whole test so ctrld's own bind attempt is guaranteed to fail.
|
||||
udpConn, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("could not reserve a udp port: %v", err)
|
||||
}
|
||||
defer udpConn.Close()
|
||||
|
||||
host, portStr, err := net.SplitHostPort(udpConn.LocalAddr().String())
|
||||
if err != nil {
|
||||
t.Fatalf("could not parse reserved address: %v", err)
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
t.Fatalf("could not parse reserved port: %v", err)
|
||||
}
|
||||
|
||||
tcpLn, err := net.Listen("tcp", net.JoinHostPort(host, portStr))
|
||||
if err != nil {
|
||||
t.Fatalf("could not reserve the same port on tcp: %v", err)
|
||||
}
|
||||
defer tcpLn.Close()
|
||||
|
||||
oldCdUID, oldCdOrg, oldNextdns, oldIntercept := cdUID, cdOrg, nextdns, interceptMode
|
||||
oldPath, oldExit := provisionResultPath, provisionExit
|
||||
t.Cleanup(func() {
|
||||
cdUID, cdOrg, nextdns, interceptMode = oldCdUID, oldCdOrg, oldNextdns, oldIntercept
|
||||
provisionResultPath, provisionExit = oldPath, oldExit
|
||||
})
|
||||
// Non-cd, non-nextdns mode with an explicit ip:port: no fallback checks,
|
||||
// the path that used to reach the fatal exit directly.
|
||||
cdUID = ""
|
||||
cdOrg = ""
|
||||
nextdns = ""
|
||||
interceptMode = ""
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
provisionResultPath = func() string { return filepath.Join(tmpDir, "provision_result.json") }
|
||||
|
||||
var exitCode int
|
||||
var exited bool
|
||||
provisionExit = func(code int) { exitCode = code; exited = true }
|
||||
|
||||
cfg := &ctrld.Config{
|
||||
Listener: map[string]*ctrld.ListenerConfig{
|
||||
"0": {IP: host, Port: port},
|
||||
},
|
||||
}
|
||||
|
||||
notified := false
|
||||
_, ok := tryUpdateListenerConfig(cfg, func() { notified = true }, true)
|
||||
|
||||
if ok {
|
||||
t.Error("tryUpdateListenerConfig ok = true, want false")
|
||||
}
|
||||
if !notified {
|
||||
t.Error("expected notifyFunc to run before the recorded exit")
|
||||
}
|
||||
if !exited {
|
||||
t.Fatal("expected provisionExit to be called")
|
||||
}
|
||||
if exitCode != 42 {
|
||||
t.Errorf("exit code = %d, want 42 (LISTENER_CONFIGURED_ADDR_UNAVAILABLE)", exitCode)
|
||||
}
|
||||
|
||||
result, err := readProvisionResult()
|
||||
if err != nil {
|
||||
t.Fatalf("could not read provision result: %v", err)
|
||||
}
|
||||
if result.Code != string(provisionCodeListenerAddrUnavail) {
|
||||
t.Errorf("result code = %s, want %s", result.Code, provisionCodeListenerAddrUnavail)
|
||||
}
|
||||
if result.Stage != string(provisionStageListener) {
|
||||
t.Errorf("result stage = %s, want %s", result.Stage, provisionStageListener)
|
||||
}
|
||||
if result.ExitCode != 42 {
|
||||
t.Errorf("result exit code = %d, want 42", result.ExitCode)
|
||||
}
|
||||
if result.Detail == nil || len(result.Detail.Attempts) == 0 {
|
||||
t.Fatal("expected the occupied address to appear as a recorded bind attempt")
|
||||
}
|
||||
|
||||
occupiedAddr := net.JoinHostPort(host, portStr)
|
||||
// Windows words WSAEADDRINUSE differently, so only require the canonical
|
||||
// message on platforms that produce it.
|
||||
requireInUseText := runtime.GOOS != "windows"
|
||||
var sawUDP, sawTCP bool
|
||||
for _, a := range result.Detail.Attempts {
|
||||
if a.Addr != occupiedAddr || a.OSError == "" {
|
||||
continue
|
||||
}
|
||||
if requireInUseText && !strings.Contains(strings.ToLower(a.OSError), "address already in use") {
|
||||
continue
|
||||
}
|
||||
switch a.Proto {
|
||||
case "udp":
|
||||
sawUDP = true
|
||||
case "tcp":
|
||||
sawTCP = true
|
||||
}
|
||||
}
|
||||
if !sawUDP {
|
||||
t.Error("expected a udp attempt on the occupied address with a bind error")
|
||||
}
|
||||
if !sawTCP {
|
||||
t.Error("expected a tcp attempt on the occupied address with a bind error")
|
||||
}
|
||||
}
|
||||
|
||||
// The exhaustion path (exit 41) is not covered: forcing every fallback,
|
||||
// including a freshly randomized ip/port, to fail has no deterministic seam,
|
||||
// so a test would race whatever ports are free on the host.
|
||||
@@ -44,3 +44,55 @@ func Test_isStableVersion(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test_displayVersion pins the tag-to-display transform. master carries v2.x.x
|
||||
// tags so its releases can be tracked next to the v1.x.x line still cut from the
|
||||
// v1.0 branch, and the client reports the tag with its major decremented.
|
||||
//
|
||||
// The v1.0-branch cases are the ones that make the split safe without a
|
||||
// build-time branch signal: a major of 1 has to pass through untouched, or the
|
||||
// v1.0 line would start reporting v0.x.x.
|
||||
func Test_displayVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"master tag", "v2.0.0", "v1.0.0"},
|
||||
{"master tag with minor and patch", "v2.3.1", "v1.3.1"},
|
||||
{"master prerelease keeps its suffix", "v2.1.0-rc1", "v1.1.0-rc1"},
|
||||
{"master tag with build metadata", "v2.1.0+build.5", "v1.1.0+build.5"},
|
||||
{"a later major still decrements by one", "v3.2.1", "v2.2.1"},
|
||||
// v1.0 branch: untouched, which is what scopes the transform to master.
|
||||
{"v1.0 branch tag", "v1.3.5", "v1.3.5"},
|
||||
{"v1.0 branch prerelease", "v1.3.5-next", "v1.3.5-next"},
|
||||
// Not semantic versions: dev and commit-suffixed builds pass through.
|
||||
{"dev", "dev", "dev"},
|
||||
{"dev with commit", "dev-abc1234", "dev-abc1234"},
|
||||
{"empty", "", ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := displayVersion(tc.in); got != tc.want {
|
||||
t.Errorf("displayVersion(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test_displayVersionKeepsStabilityClassification guards the coupling between the
|
||||
// transform and isStableVersion, which selects the self-upgrade channel: a tag
|
||||
// must not change from prerelease to stable (or back) by being renumbered.
|
||||
func Test_displayVersionKeepsStabilityClassification(t *testing.T) {
|
||||
for _, ver := range []string{"v2.0.0", "v2.1.0-rc1", "v1.3.5", "v1.3.5-next", "dev"} {
|
||||
ver := ver
|
||||
t.Run(ver, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got, want := isStableVersion(displayVersion(ver)), isStableVersion(ver); got != want {
|
||||
t.Errorf("isStableVersion(displayVersion(%q)) = %v, want %v", ver, got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
-1397
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,466 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/kardianos/service"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld/internal/controld"
|
||||
)
|
||||
|
||||
// diag reports the facts that support asks for on every provisioning
|
||||
// ticket. A customer or admin can then paste the output of one command
|
||||
// instead of a hunt through logs and preference panes. It runs without
|
||||
// root. A section that needs data the current user cannot read reports
|
||||
// that, instead of a failure of the whole command. On an installed device
|
||||
// those are the provisioning-result and the service-state sections, because
|
||||
// the service wrote the file as root and the service manager answers only
|
||||
// root.
|
||||
const diagCmdLong = `Collect diagnostics for a provisioning failure.
|
||||
|
||||
Reports the client version, MDM-managed preferences (macOS only), the last
|
||||
provisioning result, service state, and whether the Control D API is
|
||||
reachable. Safe to paste into a support ticket: it never prints the
|
||||
provisioning token itself, only whether one is present.
|
||||
|
||||
Run it with root or administrator rights on an installed device to include
|
||||
the last provisioning result and the service state. Without those rights,
|
||||
the two sections report permission denied.`
|
||||
|
||||
// diagAPIProbeTimeout bounds the API reachability check. diag must return
|
||||
// promptly even when the network is unreachable.
|
||||
const diagAPIProbeTimeout = 5 * time.Second
|
||||
|
||||
// diagOverallTimeout bounds the whole report. No single probe, however
|
||||
// wedged, may keep "ctrld diag" from returning.
|
||||
const diagOverallTimeout = 15 * time.Second
|
||||
|
||||
// diagServiceStateTimeout bounds one service-state probe. systemctl or
|
||||
// launchctl can hang against a wedged service manager; the kardianos
|
||||
// service package gives us no way to cancel that call, so we race it
|
||||
// against this timer in a goroutine instead.
|
||||
const diagServiceStateTimeout = 5 * time.Second
|
||||
|
||||
// diagFieldMaxLen bounds any single field pulled from outside ctrld's own
|
||||
// control (a managed-preferences value), so a misconfigured profile cannot
|
||||
// blow up the report's size.
|
||||
const diagFieldMaxLen = 256
|
||||
|
||||
// managedPrefsDomain is the MDM-managed preferences domain ctrld reads its
|
||||
// provisioning settings from.
|
||||
const managedPrefsDomain = "/Library/Managed Preferences/com.controld.ctrld"
|
||||
|
||||
// managedPrefsBin is invoked with an absolute path so diag never depends on
|
||||
// PATH.
|
||||
const managedPrefsBin = "/usr/bin/defaults"
|
||||
|
||||
// managedPrefsSupported reports whether this platform has managed
|
||||
// preferences to read. A var so tests can exercise the macOS-shaped report
|
||||
// on any OS.
|
||||
var managedPrefsSupported = func() bool { return runtime.GOOS == "darwin" }
|
||||
|
||||
// managedPrefsRead runs `defaults read <domain> [key]` and returns the
|
||||
// trimmed value, or ok=false if the domain or key does not exist. A var so
|
||||
// tests seam it instead of shelling out for real. It honors the caller's
|
||||
// ctx so a near-expired overall deadline cuts this short too.
|
||||
var managedPrefsRead = func(ctx context.Context, domain, key string) (string, bool) {
|
||||
args := []string{"read", domain}
|
||||
if key != "" {
|
||||
args = append(args, key)
|
||||
}
|
||||
probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
out, err := exec.CommandContext(probeCtx, managedPrefsBin, args...).Output()
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimSpace(string(out)), true
|
||||
}
|
||||
|
||||
// diagProbeReachability makes the one HTTPS probe api_reachability reports
|
||||
// on. A var so tests replace it instead of hitting the network.
|
||||
var diagProbeReachability = controld.ProbeReachability
|
||||
|
||||
type diagReport struct {
|
||||
ClientVersion string `json:"client_version"`
|
||||
Commit string `json:"commit"`
|
||||
ManagedPreferences diagManagedPreferences `json:"managed_preferences"`
|
||||
ProvisionResult diagProvisionResult `json:"provision_result"`
|
||||
ServiceState diagServiceState `json:"service_state"`
|
||||
APIReachability diagAPIReachability `json:"api_reachability"`
|
||||
}
|
||||
|
||||
type diagManagedPreferences struct {
|
||||
Applicable bool `json:"applicable"`
|
||||
ProfilePresent bool `json:"profile_present"`
|
||||
ProvisionToken string `json:"provision_token"` // "present" or "absent"; never the value
|
||||
CustomHostname string `json:"custom_hostname"`
|
||||
InterceptMode string `json:"intercept_mode"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
// diagProvisionResult mirrors the on-disk provision_result.json for report
|
||||
// purposes. AgeSeconds is -1 when no age applies (nothing recorded, or the
|
||||
// timestamp did not parse). Message and Attempts are re-bounded on read: the
|
||||
// file was written bounded, but diag must not trust that a file on disk
|
||||
// still is (a mismatched version, or hand-edited).
|
||||
type diagProvisionResult struct {
|
||||
Status string `json:"status"` // none, recorded, untrusted, unreadable, corrupt
|
||||
Stage string `json:"stage"`
|
||||
Code string `json:"code"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
Message string `json:"message"`
|
||||
Attempts []provisionBindAttempt `json:"attempts,omitempty"`
|
||||
AgeSeconds int64 `json:"age_seconds"`
|
||||
}
|
||||
|
||||
type diagServiceState struct {
|
||||
Status string `json:"status"` // running, stopped, not_installed, unknown
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
type diagAPIReachability struct {
|
||||
Reachable bool `json:"reachable"`
|
||||
ErrorClass string `json:"error_class"` // empty when reachable
|
||||
}
|
||||
|
||||
// diagProvisionResultPath locates the result file where the service wrote
|
||||
// it, with the same homedir override the writer obeys. The user home-dir
|
||||
// resolver has a fallback to the home directory of the current user when
|
||||
// /etc/controld is not writable, and every run without root hits that
|
||||
// fallback. A diag that used it would report "none recorded" for a file it
|
||||
// never opened. A var so tests can point it at a temp dir.
|
||||
var diagProvisionResultPath = func() string {
|
||||
if homedir != "" {
|
||||
return filepath.Join(homedir, provisionResultFileName)
|
||||
}
|
||||
dir, err := serviceHomeDir()
|
||||
if err != nil {
|
||||
return provisionResultPath()
|
||||
}
|
||||
return filepath.Join(dir, provisionResultFileName)
|
||||
}
|
||||
|
||||
// diagServiceStateFn collects service_state. A var so tests can supply a
|
||||
// fixed state instead of depending on whatever service happens to be
|
||||
// installed on the machine running the tests.
|
||||
var diagServiceStateFn = collectServiceStateReal
|
||||
|
||||
// buildDiagReport bounds the whole report at diagOverallTimeout: whatever
|
||||
// deadline the caller passed in, a single probe wedging past this must not
|
||||
// keep "ctrld diag" from returning.
|
||||
func buildDiagReport(ctx context.Context) diagReport {
|
||||
ctx, cancel := context.WithTimeout(ctx, diagOverallTimeout)
|
||||
defer cancel()
|
||||
return diagReport{
|
||||
ClientVersion: appVersion,
|
||||
Commit: commit,
|
||||
ManagedPreferences: collectManagedPreferences(ctx),
|
||||
ProvisionResult: collectProvisionResultDiag(),
|
||||
ServiceState: collectServiceStateBounded(ctx),
|
||||
APIReachability: collectAPIReachability(ctx),
|
||||
}
|
||||
}
|
||||
|
||||
func collectManagedPreferences(ctx context.Context) diagManagedPreferences {
|
||||
if !managedPrefsSupported() {
|
||||
return diagManagedPreferences{Note: "not applicable on this platform"}
|
||||
}
|
||||
m := diagManagedPreferences{Applicable: true}
|
||||
if _, ok := managedPrefsRead(ctx, managedPrefsDomain, ""); !ok {
|
||||
m.Note = "configuration profile not found"
|
||||
return m
|
||||
}
|
||||
m.ProfilePresent = true
|
||||
// An empty value reads as absent: the postinstall refuses to provision
|
||||
// on an empty ProvisionToken, so "present" would send support the wrong
|
||||
// way.
|
||||
if v, ok := managedPrefsRead(ctx, managedPrefsDomain, "ProvisionToken"); ok && v != "" {
|
||||
m.ProvisionToken = "present"
|
||||
} else {
|
||||
m.ProvisionToken = "absent"
|
||||
}
|
||||
if v, ok := managedPrefsRead(ctx, managedPrefsDomain, "CustomHostname"); ok {
|
||||
m.CustomHostname = boundedDiagField(v)
|
||||
}
|
||||
if v, ok := managedPrefsRead(ctx, managedPrefsDomain, "InterceptMode"); ok {
|
||||
m.InterceptMode = boundedDiagField(v)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// boundedDiagField caps a value from outside ctrld's control to a size that
|
||||
// keeps the report bounded, cutting on a rune boundary so it never splits a
|
||||
// multi-byte character.
|
||||
func boundedDiagField(s string) string {
|
||||
if utf8.RuneCountInString(s) <= diagFieldMaxLen {
|
||||
return s
|
||||
}
|
||||
runes := []rune(s)
|
||||
return string(runes[:diagFieldMaxLen])
|
||||
}
|
||||
|
||||
// collectProvisionResultDiag reads provision_result.json from the service
|
||||
// home through the same trusted reader "ctrld start" uses, so diag never
|
||||
// reports a code that contract validation would reject. A read the current
|
||||
// user is not permitted to make is its own status: the file is not
|
||||
// corrupt, the reader lacks root.
|
||||
func collectProvisionResultDiag() diagProvisionResult {
|
||||
r, err := readProvisionResultAt(diagProvisionResultPath())
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return diagProvisionResult{Status: "none", AgeSeconds: -1}
|
||||
}
|
||||
if os.IsPermission(err) {
|
||||
return diagProvisionResult{Status: "unreadable", AgeSeconds: -1}
|
||||
}
|
||||
return diagProvisionResult{Status: "corrupt", AgeSeconds: -1}
|
||||
}
|
||||
if !provisionResultTrusted(r) {
|
||||
return diagProvisionResult{Status: "untrusted", AgeSeconds: -1}
|
||||
}
|
||||
age := int64(-1)
|
||||
if ts, parseErr := time.Parse(time.RFC3339, r.Timestamp); parseErr == nil {
|
||||
if d := time.Since(ts); d >= 0 {
|
||||
age = int64(d.Round(time.Second).Seconds())
|
||||
} else {
|
||||
age = 0
|
||||
}
|
||||
}
|
||||
var attempts []provisionBindAttempt
|
||||
if r.Detail != nil {
|
||||
attempts = boundedDiagAttempts(r.Detail.Attempts)
|
||||
}
|
||||
return diagProvisionResult{
|
||||
Status: "recorded",
|
||||
Stage: r.Stage,
|
||||
Code: r.Code,
|
||||
ExitCode: r.ExitCode,
|
||||
Message: boundedDiagField(r.Message),
|
||||
Attempts: attempts,
|
||||
AgeSeconds: age,
|
||||
}
|
||||
}
|
||||
|
||||
// boundedDiagAttempts re-applies the same attempt-count cap the file was
|
||||
// written with, and bounds each attempt's fields, so a result file from a
|
||||
// mismatched or tampered version cannot make the report unbounded.
|
||||
func boundedDiagAttempts(attempts []provisionBindAttempt) []provisionBindAttempt {
|
||||
if len(attempts) > maxProvisionBindAttempts {
|
||||
attempts = attempts[:maxProvisionBindAttempts]
|
||||
}
|
||||
bounded := make([]provisionBindAttempt, len(attempts))
|
||||
for i, a := range attempts {
|
||||
bounded[i] = provisionBindAttempt{
|
||||
Addr: boundedDiagField(a.Addr),
|
||||
Proto: boundedDiagField(a.Proto),
|
||||
OSError: boundedDiagField(a.OSError),
|
||||
}
|
||||
}
|
||||
return bounded
|
||||
}
|
||||
|
||||
// collectServiceStateReal is diagServiceStateFn's production implementation.
|
||||
// It reuses the same service-manager wrapper "ctrld status" does, so a
|
||||
// permission-limited run reports "requires elevated privileges" rather than
|
||||
// a wrong status (see the launchd wrapper in service.go).
|
||||
func collectServiceStateReal() diagServiceState {
|
||||
sc := NewServiceCommand()
|
||||
s, _, err := sc.initializeServiceManager()
|
||||
if err != nil {
|
||||
return diagServiceState{Status: "unknown", Note: "could not set up the service manager"}
|
||||
}
|
||||
status, statusErr := s.Status()
|
||||
switch {
|
||||
case errors.Is(statusErr, service.ErrNotInstalled):
|
||||
return diagServiceState{Status: "not_installed"}
|
||||
case statusErr != nil:
|
||||
return diagServiceState{Status: "unknown", Note: boundedDiagField(statusErr.Error())}
|
||||
case status == service.StatusRunning:
|
||||
return diagServiceState{Status: "running"}
|
||||
case status == service.StatusStopped:
|
||||
return diagServiceState{Status: "stopped"}
|
||||
default:
|
||||
return diagServiceState{Status: "unknown"}
|
||||
}
|
||||
}
|
||||
|
||||
// collectServiceStateBounded runs diagServiceStateFn in the background and
|
||||
// races it against ctx and diagServiceStateTimeout, so a wedged service
|
||||
// manager reports "timed out" instead of hanging the whole diag report. The
|
||||
// goroutine is left running if the probe never returns; that is harmless
|
||||
// since the process exits shortly after diag prints its report.
|
||||
func collectServiceStateBounded(ctx context.Context) diagServiceState {
|
||||
done := make(chan diagServiceState, 1)
|
||||
go func() { done <- diagServiceStateFn() }()
|
||||
|
||||
timer := time.NewTimer(diagServiceStateTimeout)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case s := <-done:
|
||||
return s
|
||||
case <-ctx.Done():
|
||||
return diagServiceState{Status: "unknown", Note: "timed out"}
|
||||
case <-timer.C:
|
||||
return diagServiceState{Status: "unknown", Note: "timed out"}
|
||||
}
|
||||
}
|
||||
|
||||
func collectAPIReachability(ctx context.Context) diagAPIReachability {
|
||||
probeCtx, cancel := context.WithTimeout(ctx, diagAPIProbeTimeout)
|
||||
defer cancel()
|
||||
if err := diagProbeReachability(probeCtx, cdDev); err != nil {
|
||||
return diagAPIReachability{Reachable: false, ErrorClass: classifyReachabilityError(err)}
|
||||
}
|
||||
return diagAPIReachability{Reachable: true}
|
||||
}
|
||||
|
||||
// classifyReachabilityError turns a probe failure into a coarse class safe
|
||||
// to print: no host, no address, no request details, just what kind of
|
||||
// failure it was.
|
||||
func classifyReachabilityError(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return "timeout"
|
||||
}
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) && netErr.Timeout() {
|
||||
return "timeout"
|
||||
}
|
||||
var dnsErr *net.DNSError
|
||||
if errors.As(err, &dnsErr) {
|
||||
return "dns"
|
||||
}
|
||||
var certErr *tls.CertificateVerificationError
|
||||
if errors.As(err, &certErr) {
|
||||
return "tls"
|
||||
}
|
||||
var opErr *net.OpError
|
||||
if errors.As(err, &opErr) {
|
||||
return "connection"
|
||||
}
|
||||
return "other"
|
||||
}
|
||||
|
||||
// diagElevateHint names the step that gets root or administrator rights on
|
||||
// this OS, for the text report.
|
||||
func diagElevateHint() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "run again from an administrator prompt"
|
||||
}
|
||||
return "run again with sudo"
|
||||
}
|
||||
|
||||
func displayOrDefault(s, def string) string {
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// renderDiagText writes the text-mode report in a fixed section order, so
|
||||
// output stays stable across runs and safe to diff or paste into a ticket.
|
||||
func renderDiagText(w io.Writer, r diagReport) {
|
||||
fmt.Fprintf(w, "client version: %s (commit %s)\n\n", r.ClientVersion, r.Commit)
|
||||
|
||||
fmt.Fprintln(w, "managed preferences:")
|
||||
switch {
|
||||
case !r.ManagedPreferences.Applicable:
|
||||
fmt.Fprintf(w, " %s\n", displayOrDefault(r.ManagedPreferences.Note, "not applicable"))
|
||||
case !r.ManagedPreferences.ProfilePresent:
|
||||
fmt.Fprintf(w, " %s\n", r.ManagedPreferences.Note)
|
||||
default:
|
||||
fmt.Fprintf(w, " provision token: %s\n", r.ManagedPreferences.ProvisionToken)
|
||||
fmt.Fprintf(w, " custom hostname: %s\n", displayOrDefault(r.ManagedPreferences.CustomHostname, "(not set)"))
|
||||
fmt.Fprintf(w, " intercept mode: %s\n", displayOrDefault(r.ManagedPreferences.InterceptMode, "(not set)"))
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
|
||||
fmt.Fprintln(w, "last provisioning result:")
|
||||
switch r.ProvisionResult.Status {
|
||||
case "none":
|
||||
fmt.Fprintln(w, " none recorded")
|
||||
case "untrusted":
|
||||
fmt.Fprintln(w, " result file present but not trusted (contents ignored)")
|
||||
case "unreadable":
|
||||
fmt.Fprintf(w, " permission denied (%s)\n", diagElevateHint())
|
||||
case "corrupt":
|
||||
fmt.Fprintln(w, " result file present but could not be read (contents ignored)")
|
||||
default:
|
||||
fmt.Fprintf(w, " stage: %s\n", r.ProvisionResult.Stage)
|
||||
fmt.Fprintf(w, " code: %s\n", r.ProvisionResult.Code)
|
||||
fmt.Fprintf(w, " exit code: %d\n", r.ProvisionResult.ExitCode)
|
||||
fmt.Fprintf(w, " message: %s\n", r.ProvisionResult.Message)
|
||||
for _, a := range r.ProvisionResult.Attempts {
|
||||
fmt.Fprintf(w, " attempt: %s/%s: %s\n", a.Addr, a.Proto, a.OSError)
|
||||
}
|
||||
fmt.Fprintf(w, " age: %s\n", (time.Duration(r.ProvisionResult.AgeSeconds) * time.Second).String())
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
|
||||
fmt.Fprintln(w, "service state:")
|
||||
fmt.Fprintf(w, " status: %s\n", r.ServiceState.Status)
|
||||
if r.ServiceState.Note != "" {
|
||||
fmt.Fprintf(w, " note: %s\n", r.ServiceState.Note)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
|
||||
fmt.Fprintln(w, "api reachability:")
|
||||
fmt.Fprintf(w, " reachable: %t\n", r.APIReachability.Reachable)
|
||||
if r.APIReachability.ErrorClass != "" {
|
||||
fmt.Fprintf(w, " error class: %s\n", r.APIReachability.ErrorClass)
|
||||
}
|
||||
}
|
||||
|
||||
func writeDiagJSON(w io.Writer, r diagReport) error {
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(r)
|
||||
}
|
||||
|
||||
// InitDiagCmd registers "ctrld diag" alongside the other top-level commands.
|
||||
func InitDiagCmd(rootCmd *cobra.Command) *cobra.Command {
|
||||
var asJSON bool
|
||||
diagCmd := &cobra.Command{
|
||||
Use: "diag",
|
||||
Short: "Collect diagnostics for a provisioning failure",
|
||||
Long: diagCmdLong,
|
||||
Args: cobra.NoArgs,
|
||||
// diag always exits 0 once it ran: a failure it finds is reported,
|
||||
// not turned into a nonzero exit. RunE returning an error would exit
|
||||
// 1 (see Main), so every branch below reports instead of erroring.
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
report := buildDiagReport(context.Background())
|
||||
if asJSON {
|
||||
if err := writeDiagJSON(cmd.OutOrStdout(), report); err != nil {
|
||||
// A closed pipe (e.g. `ctrld diag --json | head -1`) must
|
||||
// not turn into a nonzero exit; the report already ran.
|
||||
mainLog.Load().Debug().Err(err).Msg("could not write diag JSON report")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
renderDiagText(cmd.OutOrStdout(), report)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
diagCmd.Flags().BoolVar(&asJSON, "json", false, "print the report as JSON")
|
||||
rootCmd.AddCommand(diagCmd)
|
||||
return diagCmd
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// fakeProvisionToken stands in for a real provisioning code. Tests assert it
|
||||
// never reaches either output mode.
|
||||
const fakeProvisionToken = "org-v1-FAKE00000000000000000000TOKEN"
|
||||
|
||||
func withManagedPrefsSeam(t *testing.T, supported bool, values map[string]string) {
|
||||
t.Helper()
|
||||
oldSupported := managedPrefsSupported
|
||||
oldRead := managedPrefsRead
|
||||
managedPrefsSupported = func() bool { return supported }
|
||||
managedPrefsRead = func(_ context.Context, _, key string) (string, bool) {
|
||||
v, ok := values[key]
|
||||
return v, ok
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
managedPrefsSupported = oldSupported
|
||||
managedPrefsRead = oldRead
|
||||
})
|
||||
}
|
||||
|
||||
// overrideDiagProvisionResultPath points the writer and diag at one temp
|
||||
// file, so a test can write a result and read it back through diag.
|
||||
func overrideDiagProvisionResultPath(t *testing.T) string {
|
||||
t.Helper()
|
||||
path := overrideProvisionResultPath(t)
|
||||
old := diagProvisionResultPath
|
||||
diagProvisionResultPath = func() string { return path }
|
||||
t.Cleanup(func() { diagProvisionResultPath = old })
|
||||
return path
|
||||
}
|
||||
|
||||
func withServiceStateSeam(t *testing.T, state diagServiceState) {
|
||||
t.Helper()
|
||||
old := diagServiceStateFn
|
||||
diagServiceStateFn = func() diagServiceState { return state }
|
||||
t.Cleanup(func() { diagServiceStateFn = old })
|
||||
}
|
||||
|
||||
func withAPIProbeSeam(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
old := diagProbeReachability
|
||||
diagProbeReachability = func(context.Context, bool) error { return err }
|
||||
t.Cleanup(func() { diagProbeReachability = old })
|
||||
}
|
||||
|
||||
func seedTrustedProvisionResult(t *testing.T, age time.Duration) {
|
||||
t.Helper()
|
||||
overrideDiagProvisionResultPath(t)
|
||||
r := newProvisionResult(provisionCodeTokenExpired, "the provisioning code has expired", nil)
|
||||
r.Timestamp = time.Now().Add(-age).UTC().Format(time.RFC3339)
|
||||
if err := writeProvisionResult(r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// seedOversizedProvisionResult writes a trusted result file straight to disk
|
||||
// (bypassing newProvisionResult's own bounding), standing in for a file left
|
||||
// by a mismatched or tampered version of ctrld.
|
||||
func seedOversizedProvisionResult(t *testing.T) {
|
||||
t.Helper()
|
||||
overrideDiagProvisionResultPath(t)
|
||||
attempts := make([]provisionBindAttempt, maxProvisionBindAttempts*3)
|
||||
for i := range attempts {
|
||||
attempts[i] = provisionBindAttempt{
|
||||
Addr: "0.0.0.0:53",
|
||||
Proto: "udp",
|
||||
OSError: strings.Repeat("e", diagFieldMaxLen*3),
|
||||
}
|
||||
}
|
||||
r := &provisionResult{
|
||||
Version: 1,
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
Stage: string(provisionStageListener),
|
||||
Code: string(provisionCodeListenerBindFailed),
|
||||
ExitCode: provisionExitCodeForCode[provisionCodeListenerBindFailed],
|
||||
Message: strings.Repeat("m", diagFieldMaxLen*3),
|
||||
Detail: &provisionDetail{Attempts: attempts},
|
||||
}
|
||||
if err := writeProvisionResult(r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagProvisionResultBoundsOversizedFields(t *testing.T) {
|
||||
seedOversizedProvisionResult(t)
|
||||
|
||||
got := collectProvisionResultDiag()
|
||||
|
||||
if n := utf8.RuneCountInString(got.Message); n > diagFieldMaxLen {
|
||||
t.Errorf("message length = %d, want <= %d", n, diagFieldMaxLen)
|
||||
}
|
||||
if len(got.Attempts) > maxProvisionBindAttempts {
|
||||
t.Errorf("attempts length = %d, want <= %d", len(got.Attempts), maxProvisionBindAttempts)
|
||||
}
|
||||
for _, a := range got.Attempts {
|
||||
if n := utf8.RuneCountInString(a.OSError); n > diagFieldMaxLen {
|
||||
t.Errorf("attempt os_error length = %d, want <= %d", n, diagFieldMaxLen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagTextReportNeverLeaksToken(t *testing.T) {
|
||||
withManagedPrefsSeam(t, true, map[string]string{
|
||||
"": "", // domain probe: profile present
|
||||
"ProvisionToken": fakeProvisionToken,
|
||||
"CustomHostname": "corp-laptop.example.com",
|
||||
"InterceptMode": "intercept-dns",
|
||||
})
|
||||
seedTrustedProvisionResult(t, 3*time.Minute+12*time.Second)
|
||||
withServiceStateSeam(t, diagServiceState{Status: "stopped"})
|
||||
withAPIProbeSeam(t, nil)
|
||||
|
||||
report := buildDiagReport(context.Background())
|
||||
var buf bytes.Buffer
|
||||
renderDiagText(&buf, report)
|
||||
out := buf.String()
|
||||
|
||||
if strings.Contains(out, fakeProvisionToken) {
|
||||
t.Fatalf("text output leaked the provision token: %s", out)
|
||||
}
|
||||
wantLines := []string{
|
||||
"provision token: present",
|
||||
"custom hostname: corp-laptop.example.com",
|
||||
"intercept mode: intercept-dns",
|
||||
"stage: bootstrap",
|
||||
"code: TOKEN_EXPIRED",
|
||||
"exit code: 34",
|
||||
"status: stopped",
|
||||
"reachable: true",
|
||||
}
|
||||
for _, want := range wantLines {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("text output missing %q, got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagJSONReportNeverLeaksToken(t *testing.T) {
|
||||
withManagedPrefsSeam(t, true, map[string]string{
|
||||
"": "",
|
||||
"ProvisionToken": fakeProvisionToken,
|
||||
"CustomHostname": "corp-laptop.example.com",
|
||||
"InterceptMode": "standard",
|
||||
})
|
||||
seedTrustedProvisionResult(t, time.Minute)
|
||||
withServiceStateSeam(t, diagServiceState{Status: "running"})
|
||||
withAPIProbeSeam(t, errors.New("dial tcp: connect: connection refused"))
|
||||
|
||||
report := buildDiagReport(context.Background())
|
||||
var buf bytes.Buffer
|
||||
if err := writeDiagJSON(&buf, report); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := buf.String()
|
||||
if strings.Contains(out, fakeProvisionToken) {
|
||||
t.Fatalf("JSON output leaked the provision token: %s", out)
|
||||
}
|
||||
|
||||
var decoded diagReport
|
||||
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("could not decode JSON report: %v", err)
|
||||
}
|
||||
if decoded.ManagedPreferences.ProvisionToken != "present" {
|
||||
t.Errorf("provision_token = %q, want present", decoded.ManagedPreferences.ProvisionToken)
|
||||
}
|
||||
if decoded.ManagedPreferences.CustomHostname != "corp-laptop.example.com" {
|
||||
t.Errorf("custom_hostname = %q", decoded.ManagedPreferences.CustomHostname)
|
||||
}
|
||||
if decoded.ProvisionResult.Status != "recorded" || decoded.ProvisionResult.Code != "TOKEN_EXPIRED" {
|
||||
t.Errorf("provision_result = %+v", decoded.ProvisionResult)
|
||||
}
|
||||
if decoded.ServiceState.Status != "running" {
|
||||
t.Errorf("service_state = %+v", decoded.ServiceState)
|
||||
}
|
||||
if decoded.APIReachability.Reachable {
|
||||
t.Error("api_reachability.reachable = true, want false")
|
||||
}
|
||||
if decoded.APIReachability.ErrorClass == "" {
|
||||
t.Error("api_reachability.error_class empty for an unreachable API")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagEmptyMachine(t *testing.T) {
|
||||
overrideDiagProvisionResultPath(t) // temp dir, no result file written
|
||||
withManagedPrefsSeam(t, false, nil)
|
||||
withServiceStateSeam(t, diagServiceState{Status: "not_installed"})
|
||||
withAPIProbeSeam(t, context.DeadlineExceeded)
|
||||
|
||||
report := buildDiagReport(context.Background())
|
||||
|
||||
if report.ManagedPreferences.Applicable {
|
||||
t.Error("managed preferences reported applicable with no profile on this platform")
|
||||
}
|
||||
if report.ProvisionResult.Status != "none" {
|
||||
t.Errorf("provision result status = %q, want none", report.ProvisionResult.Status)
|
||||
}
|
||||
if report.ServiceState.Status != "not_installed" {
|
||||
t.Errorf("service state = %q, want not_installed", report.ServiceState.Status)
|
||||
}
|
||||
if report.APIReachability.Reachable {
|
||||
t.Error("api reachability reported reachable with a forced timeout")
|
||||
}
|
||||
if report.APIReachability.ErrorClass != "timeout" {
|
||||
t.Errorf("error class = %q, want timeout", report.APIReachability.ErrorClass)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
renderDiagText(&buf, report)
|
||||
if !strings.Contains(buf.String(), "none recorded") {
|
||||
t.Errorf("text output missing 'none recorded': %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Without root, diag must look where the root-run service wrote the result
|
||||
// file, not in the home directory of the current user.
|
||||
func TestDiagProvisionResultPathIgnoresUserHome(t *testing.T) {
|
||||
want := "/etc/controld/" + provisionResultFileName
|
||||
if runtime.GOOS == "windows" {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want = filepath.Join(filepath.Dir(exe), provisionResultFileName)
|
||||
}
|
||||
if got := diagProvisionResultPath(); got != want {
|
||||
t.Errorf("diag provision result path = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Diag must read where a daemon started with --homedir wrote, so it obeys
|
||||
// the same override as the writer.
|
||||
func TestDiagProvisionResultPathHonorsHomedir(t *testing.T) {
|
||||
old := homedir
|
||||
homedir = t.TempDir()
|
||||
t.Cleanup(func() { homedir = old })
|
||||
want := filepath.Join(homedir, provisionResultFileName)
|
||||
if got := diagProvisionResultPath(); got != want {
|
||||
t.Errorf("diag provision result path = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A result file the current user cannot read must report that, not
|
||||
// "corrupt": the file is fine, the reader lacks root.
|
||||
func TestDiagProvisionResultUnreadable(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("file modes do not deny reads on Windows")
|
||||
}
|
||||
if os.Geteuid() == 0 {
|
||||
t.Skip("root can read a 0000 file")
|
||||
}
|
||||
path := overrideDiagProvisionResultPath(t)
|
||||
if err := os.WriteFile(path, []byte("{}"), 0o000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := collectProvisionResultDiag()
|
||||
if r.Status != "unreadable" {
|
||||
t.Errorf("status = %q, want unreadable", r.Status)
|
||||
}
|
||||
if r.AgeSeconds != -1 {
|
||||
t.Errorf("age_seconds = %d, want -1", r.AgeSeconds)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
renderDiagText(&buf, diagReport{ProvisionResult: r})
|
||||
if !strings.Contains(buf.String(), "permission denied ("+diagElevateHint()+")") {
|
||||
t.Errorf("text output does not name permission denied and the elevation step: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagServiceStateHangYieldsTimeout(t *testing.T) {
|
||||
old := diagServiceStateFn
|
||||
// started closes the instant the background probe goroutine reads and
|
||||
// invokes our stub. Cleanup waits for that before restoring the global:
|
||||
// otherwise a slow-to-schedule goroutine can still be reading
|
||||
// diagServiceStateFn when Cleanup writes to it, a data race on the shared
|
||||
// package var (this test's stub is left running past the test's own
|
||||
// return, same as production - see collectServiceStateBounded's doc).
|
||||
started := make(chan struct{})
|
||||
diagServiceStateFn = func() diagServiceState {
|
||||
close(started)
|
||||
time.Sleep(2 * time.Second) // stand in for a wedged systemctl/launchctl
|
||||
return diagServiceState{Status: "running"}
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
<-started
|
||||
diagServiceStateFn = old
|
||||
})
|
||||
|
||||
withManagedPrefsSeam(t, false, nil)
|
||||
overrideDiagProvisionResultPath(t)
|
||||
withAPIProbeSeam(t, nil)
|
||||
|
||||
// A short deadline stands in for the overall 15s budget already having
|
||||
// run low; the probe must still yield within it instead of hanging.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
report := buildDiagReport(ctx)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if elapsed > time.Second {
|
||||
t.Fatalf("buildDiagReport took %s, want bounded well under the 2s hang", elapsed)
|
||||
}
|
||||
if report.ServiceState.Status != "unknown" {
|
||||
t.Errorf("service state status = %q, want unknown", report.ServiceState.Status)
|
||||
}
|
||||
if !strings.Contains(report.ServiceState.Note, "timed out") {
|
||||
t.Errorf("service state note = %q, want it to mention timing out", report.ServiceState.Note)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagManagedPrefsProfileAbsent(t *testing.T) {
|
||||
withManagedPrefsSeam(t, true, map[string]string{}) // domain read fails: profile absent
|
||||
m := collectManagedPreferences(context.Background())
|
||||
if m.ProfilePresent {
|
||||
t.Error("profile reported present when the domain read failed")
|
||||
}
|
||||
if m.Note == "" {
|
||||
t.Error("expected a note explaining the absent profile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagManagedPrefsTokenAbsent(t *testing.T) {
|
||||
withManagedPrefsSeam(t, true, map[string]string{"": ""}) // profile present, no keys set
|
||||
m := collectManagedPreferences(context.Background())
|
||||
if !m.ProfilePresent {
|
||||
t.Fatal("profile should be present")
|
||||
}
|
||||
if m.ProvisionToken != "absent" {
|
||||
t.Errorf("provision token = %q, want absent", m.ProvisionToken)
|
||||
}
|
||||
}
|
||||
|
||||
// An empty ProvisionToken value must read as absent: the postinstall refuses
|
||||
// to provision on an empty token, so diag must not call it present.
|
||||
func TestDiagManagedPrefsTokenEmpty(t *testing.T) {
|
||||
withManagedPrefsSeam(t, true, map[string]string{"": "", "ProvisionToken": ""})
|
||||
m := collectManagedPreferences(context.Background())
|
||||
if !m.ProfilePresent {
|
||||
t.Fatal("profile should be present")
|
||||
}
|
||||
if m.ProvisionToken != "absent" {
|
||||
t.Errorf("provision token = %q, want absent", m.ProvisionToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyReachabilityError(t *testing.T) {
|
||||
if got := classifyReachabilityError(nil); got != "" {
|
||||
t.Errorf("nil error class = %q, want empty", got)
|
||||
}
|
||||
if got := classifyReachabilityError(context.DeadlineExceeded); got != "timeout" {
|
||||
t.Errorf("deadline exceeded class = %q, want timeout", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagCommandJSONFlag(t *testing.T) {
|
||||
withManagedPrefsSeam(t, false, nil)
|
||||
overrideDiagProvisionResultPath(t)
|
||||
withServiceStateSeam(t, diagServiceState{Status: "not_installed"})
|
||||
withAPIProbeSeam(t, nil)
|
||||
|
||||
rootCmd := &cobra.Command{Use: "ctrld"}
|
||||
InitDiagCmd(rootCmd)
|
||||
var buf bytes.Buffer
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetArgs([]string{"diag", "--json"})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("diag --json returned error: %v", err)
|
||||
}
|
||||
var decoded diagReport
|
||||
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("diag --json did not print valid JSON: %v\n%s", err, buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// writerFailingAfter accepts the first n bytes written to it, then fails
|
||||
// every write after that - standing in for a pipe closed by a downstream
|
||||
// reader (`ctrld diag --json | head -1`).
|
||||
type writerFailingAfter struct {
|
||||
n int
|
||||
written int
|
||||
}
|
||||
|
||||
func (w *writerFailingAfter) Write(p []byte) (int, error) {
|
||||
if w.written >= w.n {
|
||||
return 0, errors.New("write: broken pipe")
|
||||
}
|
||||
remaining := w.n - w.written
|
||||
if len(p) > remaining {
|
||||
w.written += remaining
|
||||
return remaining, errors.New("write: broken pipe")
|
||||
}
|
||||
w.written += len(p)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func TestDiagJSONWriteErrorStillExitsZero(t *testing.T) {
|
||||
withManagedPrefsSeam(t, false, nil)
|
||||
overrideDiagProvisionResultPath(t)
|
||||
withServiceStateSeam(t, diagServiceState{Status: "not_installed"})
|
||||
withAPIProbeSeam(t, nil)
|
||||
|
||||
rootCmd := &cobra.Command{Use: "ctrld"}
|
||||
InitDiagCmd(rootCmd)
|
||||
rootCmd.SetOut(&writerFailingAfter{n: 10})
|
||||
rootCmd.SetArgs([]string{"diag", "--json"})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("diag --json with a failing writer returned error %v, want nil per the always-exit-0 contract", err)
|
||||
}
|
||||
}
|
||||
@@ -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: 'off' (disable interception and clear a persisted intercept_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,318 @@
|
||||
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: ctrldServiceDisplayName,
|
||||
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",
|
||||
Long: statusCmdLong,
|
||||
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",
|
||||
Long: statusCmdLong,
|
||||
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: ctrldServiceDisplayName,
|
||||
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,569 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// serviceStageFailureCode maps an aborted service-manager task to its
|
||||
// provisioning code. Other abortOnError tasks (like config validation) keep
|
||||
// their own error paths.
|
||||
func serviceStageFailureCode(taskName string) (provisionFailureCode, bool) {
|
||||
switch taskName {
|
||||
case "Install":
|
||||
return provisionCodeServiceInstall, true
|
||||
case "Start":
|
||||
return provisionCodeServiceStartFailed, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// serviceTaskErrorSummary describes which service-manager task failed and why,
|
||||
// for use as a provisioning result message.
|
||||
func serviceTaskErrorSummary(taskName string, err error) string {
|
||||
return fmt.Sprintf("%s failed: %v", taskName, err)
|
||||
}
|
||||
|
||||
// resultStalenessTolerance absorbs clock granularity between "ctrld start"
|
||||
// recording its start time and the daemon writing its result file.
|
||||
const resultStalenessTolerance = 2 * time.Second
|
||||
|
||||
// reportStartFailure reports why "ctrld start" failed after install/start
|
||||
// looked fine. A result file the daemon wrote during this attempt names the
|
||||
// failure better than a generic self-check code, so it wins.
|
||||
func reportStartFailure(startedAt time.Time, fallbackMsg string) {
|
||||
if r, err := readProvisionResult(); err == nil && provisionResultTrusted(r) {
|
||||
if ts, err := time.Parse(time.RFC3339, r.Timestamp); err == nil {
|
||||
if !ts.Before(startedAt.Add(-resultStalenessTolerance)) {
|
||||
mainLog.Load().Error().Msg(r.failureLine())
|
||||
provisionExit(r.ExitCode)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
failProvision(newProvisionResult(provisionCodeServiceSelfCheck, fallbackMsg, nil, provisionSecrets()...), nil)
|
||||
}
|
||||
|
||||
// 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")
|
||||
|
||||
// Clear before any check runs, not just before doTasksE: a result from a
|
||||
// previous attempt must never survive to mislead diag/postinstall on this
|
||||
// one, even if this attempt fails before reaching doTasksE.
|
||||
clearProvisionResult()
|
||||
|
||||
firewallModeFlagChanged = cmd.Flags().Changed("firewall-mode")
|
||||
if !checkStrFlagEmpty(cmd, cdUidFlagName) {
|
||||
return nil
|
||||
}
|
||||
if !checkStrFlagEmpty(cmd, cdOrgFlagName) {
|
||||
return nil
|
||||
}
|
||||
if !validateCdAndNextDNSFlags() {
|
||||
return nil
|
||||
}
|
||||
|
||||
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 !validateInterceptModeFlag(interceptMode) {
|
||||
return nil
|
||||
}
|
||||
if !validateFirewallModeFlag(firewallModeFlagChanged, firewallMode, nil) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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")
|
||||
// A bare error return would exit 1 with no result file, so support
|
||||
// could not tell this failure from a start that never ran.
|
||||
failProvisionUnclassified("initialize service manager: "+err.Error(), nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
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 {
|
||||
// An explicit "off" argument must override a previously persisted config
|
||||
// value while the service clears that value on startup.
|
||||
if err := removeServiceFlag("--intercept-mode"); err != nil {
|
||||
failRunUnclassified(logger.Error().Err(err), fmt.Sprintf("failed to remove existing intercept mode from service arguments: %v", err), nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
if interceptMode == "off" {
|
||||
logger.Notice().Msg("Existing service detected — disabling intercept mode")
|
||||
} else {
|
||||
logger.Notice().Msgf("Existing service detected — appending --intercept-mode %s to service arguments", interceptMode)
|
||||
}
|
||||
if err := appendServiceFlag("--intercept-mode"); err != nil {
|
||||
failRunUnclassified(logger.Error().Err(err), fmt.Sprintf("failed to append intercept flag to service arguments: %v", err), nil)
|
||||
return nil
|
||||
}
|
||||
if err := appendServiceFlag(interceptMode); err != nil {
|
||||
failRunUnclassified(logger.Error().Err(err), fmt.Sprintf("failed to append intercept mode value to service arguments: %v", err), nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
failRunUnclassified(logger.Error(), fmt.Sprintf("failed to unmarshal config: %v", err), nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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"},
|
||||
}
|
||||
startAttemptAt := time.Now()
|
||||
logger.Notice().Msg("Starting existing ctrld service")
|
||||
failedTask, taskErr := doTasksE(tasks)
|
||||
if taskErr != nil {
|
||||
if code, ok := serviceStageFailureCode(failedTask); ok {
|
||||
failProvision(newProvisionResult(code, serviceTaskErrorSummary(failedTask, taskErr), nil, provisionSecrets()...), nil)
|
||||
return nil
|
||||
}
|
||||
failProvisionUnclassified(serviceTaskErrorSummary(failedTask, taskErr), nil)
|
||||
return nil
|
||||
}
|
||||
sockDir, err := socketDir()
|
||||
if err != nil {
|
||||
failRunUnclassified(logger.Error(), fmt.Sprintf("failed to get socket directory: %v", err), nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// The daemon can start and still fail provisioning (for example a
|
||||
// listener bind conflict). Self-check like a fresh install so this
|
||||
// path reports the daemon's failure code instead of a false
|
||||
// "Service started" — but never uninstall an existing service.
|
||||
time.Sleep(1 * time.Second)
|
||||
ok, status, err := selfCheckStatus(ctx, s, sockDir)
|
||||
if !ok || status != service.StatusRunning {
|
||||
fallbackMsg := "ctrld service did not pass its post-start self-check"
|
||||
if err != nil {
|
||||
fallbackMsg = fmt.Sprintf("An error occurred while performing test query: %s", err)
|
||||
logger.Error().Msg(fallbackMsg)
|
||||
}
|
||||
if status == service.StatusRunning && err == nil {
|
||||
fallbackMsg = "ctrld service was running, but a DNS query could not be sent to its listener; check firewall rules blocking/intercepting/redirecting DNS queries"
|
||||
logger.Error().Msg(fallbackMsg)
|
||||
}
|
||||
reportStartFailure(startAttemptAt, fallbackMsg)
|
||||
return nil
|
||||
}
|
||||
logger.Notice().Msg("Service started")
|
||||
clearProvisionResult()
|
||||
reportSetDnsOk(sockDir)
|
||||
// Verify service registration after successful start.
|
||||
if err := verifyServiceRegistration(); err != nil {
|
||||
logger.Warn().Err(err).Msg("Service registry verification failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if cdUID != "" {
|
||||
if err := doValidateCdRemoteConfig(cdUID, true); err != nil {
|
||||
return nil
|
||||
}
|
||||
} 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 != "" {
|
||||
if !validateCdUpstreamProtocol(nil) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if configPath != "" {
|
||||
v.SetConfigFile(configPath)
|
||||
}
|
||||
|
||||
tryReadingConfigWithNotice(writeDefaultConfig, true)
|
||||
|
||||
if err := v.Unmarshal(&cfg); err != nil {
|
||||
failRunUnclassified(logger.Error(), fmt.Sprintf("failed to unmarshal config: %v", err), nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
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, true, "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"},
|
||||
}
|
||||
startAttemptAt := time.Now()
|
||||
logger.Notice().Msg("Starting service")
|
||||
failedTask, taskErr := doTasksE(tasks)
|
||||
if taskErr != nil {
|
||||
if code, ok := serviceStageFailureCode(failedTask); ok {
|
||||
failProvision(newProvisionResult(code, serviceTaskErrorSummary(failedTask, taskErr), nil, provisionSecrets()...), nil)
|
||||
return nil
|
||||
}
|
||||
// Not a service-stage task. doTasksE already logged the cause; classify
|
||||
// UNCLASSIFIED instead of the old silent fall-through that exited 0.
|
||||
failProvisionUnclassified(serviceTaskErrorSummary(failedTask, taskErr), nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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")
|
||||
clearProvisionResult()
|
||||
default:
|
||||
marker := append(bytes.Repeat([]byte("="), 32), '\n')
|
||||
fallbackMsg := "ctrld service did not pass its post-start self-check"
|
||||
// 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))
|
||||
fallbackMsg = 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"))
|
||||
fallbackMsg = "ctrld service was running, but a DNS query could not be sent to its listener; check firewall rules blocking/intercepting/redirecting DNS queries"
|
||||
}
|
||||
|
||||
_, _ = logger.Write(marker)
|
||||
uninstall(p, s)
|
||||
reportStartFailure(startAttemptAt, fallbackMsg)
|
||||
return nil
|
||||
}
|
||||
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: 'off' (disable interception and clear a persisted intercept_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,153 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// startFunctionSource extracts the source text of ServiceCommand.Start's body
|
||||
// from commands_service_start.go. Driving Start() itself end-to-end for every
|
||||
// early-return branch is not practical in a unit test: within a few lines of
|
||||
// any check failing, Start() reaches into the real OS service manager. Some
|
||||
// invariants about its shape are cheaper and more reliable to pin by reading
|
||||
// the source than by executing it.
|
||||
func startFunctionSource(t *testing.T) string {
|
||||
t.Helper()
|
||||
file := packageSourcePath(t, "commands_service_start.go")
|
||||
fset := token.NewFileSet()
|
||||
node, err := parser.ParseFile(fset, file, nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("could not parse %s: %v", file, err)
|
||||
}
|
||||
for _, decl := range node.Decls {
|
||||
fn, ok := decl.(*ast.FuncDecl)
|
||||
if !ok || fn.Name.Name != "Start" || fn.Recv == nil {
|
||||
continue
|
||||
}
|
||||
src, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
t.Fatalf("could not read %s: %v", file, err)
|
||||
}
|
||||
start := fset.Position(fn.Body.Lbrace).Offset
|
||||
end := fset.Position(fn.Body.Rbrace).Offset
|
||||
return string(src[start:end])
|
||||
}
|
||||
t.Fatalf("ServiceCommand.Start not found in %s", file)
|
||||
return ""
|
||||
}
|
||||
|
||||
// TestServiceCommandStartClearsProvisionResultBeforeAnyCheck pins the
|
||||
// ordering fix: clearProvisionResult() must run before every check in
|
||||
// Start() that can fail or return early, not just before doTasksE. Without
|
||||
// this, a check between the top of Start() and the old call sites could
|
||||
// return early (whether by writing its own classified failure or, like the
|
||||
// "service already running" and service-manager-init-error paths, by writing
|
||||
// nothing at all) while a previous attempt's result file was still sitting
|
||||
// there to mislead diag/postinstall on retry.
|
||||
func TestServiceCommandStartClearsProvisionResultBeforeAnyCheck(t *testing.T) {
|
||||
body := startFunctionSource(t)
|
||||
|
||||
clearIdx := strings.Index(body, "clearProvisionResult()")
|
||||
if clearIdx == -1 {
|
||||
t.Fatal("Start() no longer calls clearProvisionResult()")
|
||||
}
|
||||
|
||||
// Every check or step that can return out of Start() before reaching
|
||||
// doTasksE. Each must appear after the entry clear.
|
||||
earlyChecks := []string{
|
||||
"checkStrFlagEmpty(",
|
||||
"validateCdAndNextDNSFlags(",
|
||||
"validateInterceptModeFlag(",
|
||||
"validateFirewallModeFlag(",
|
||||
"initializeServiceManagerWithServiceConfig(",
|
||||
"doTasksE(",
|
||||
}
|
||||
for _, check := range earlyChecks {
|
||||
idx := strings.Index(body, check)
|
||||
if idx == -1 {
|
||||
t.Fatalf("expected Start() to still call %s", check)
|
||||
}
|
||||
if idx < clearIdx {
|
||||
t.Errorf("%s appears before clearProvisionResult(): a failure there could leave a stale result file behind", check)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestServiceCommandStartClassifiesServiceManagerInitFailure pins the fix for
|
||||
// a bare error return: a service-manager init failure in Start() must fail
|
||||
// through failProvisionUnclassified, so a result file and the identifier line
|
||||
// exist, instead of returning the error for a plain exit 1.
|
||||
func TestServiceCommandStartClassifiesServiceManagerInitFailure(t *testing.T) {
|
||||
body := startFunctionSource(t)
|
||||
initIdx := strings.Index(body, "initializeServiceManagerWithServiceConfig(")
|
||||
if initIdx == -1 {
|
||||
t.Fatal("Start() no longer calls initializeServiceManagerWithServiceConfig")
|
||||
}
|
||||
branchEnd := strings.Index(body[initIdx:], "p.cfg = &cfg")
|
||||
if branchEnd == -1 {
|
||||
t.Fatal("could not find the end of the service-manager init branch")
|
||||
}
|
||||
branch := body[initIdx : initIdx+branchEnd]
|
||||
if !strings.Contains(branch, "failProvisionUnclassified(") {
|
||||
t.Error("service-manager init failure does not fail through failProvisionUnclassified")
|
||||
}
|
||||
if strings.Contains(branch, "return err") {
|
||||
t.Error("service-manager init failure still returns the bare error, which exits 1 with no result file")
|
||||
}
|
||||
}
|
||||
|
||||
// startTestCommand builds the minimal cobra.Command ServiceCommand.Start needs
|
||||
// before it can reach its early --intercept-mode check: the --cd/--cd-org
|
||||
// flags must exist (checkStrFlagEmpty looks them up unconditionally) but stay
|
||||
// unchanged, so neither Fatals.
|
||||
func startTestCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{}
|
||||
cmd.Flags().String(cdUidFlagName, "", "")
|
||||
cmd.Flags().String(cdOrgFlagName, "", "")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// TestServiceCommandStartReplacesStaleResultOnEarlyClassifiedFailure is a
|
||||
// behavioral companion to the structural test above: it drives the real
|
||||
// Start() through its earliest classified failure (an invalid
|
||||
// --intercept-mode) and checks the file left behind names the new attempt,
|
||||
// not a stale one seeded beforehand.
|
||||
func TestServiceCommandStartReplacesStaleResultOnEarlyClassifiedFailure(t *testing.T) {
|
||||
exitCode, _ := stubProvisionGlobals(t)
|
||||
oldIntercept, oldNextdns, oldFirewallChanged := interceptMode, nextdns, firewallModeFlagChanged
|
||||
t.Cleanup(func() {
|
||||
interceptMode, nextdns, firewallModeFlagChanged = oldIntercept, oldNextdns, oldFirewallChanged
|
||||
})
|
||||
cdUID, cdOrg, nextdns = "", "", ""
|
||||
interceptMode = "bogus" // fails validateInterceptModeFlag before any OS work
|
||||
|
||||
if err := writeProvisionResult(newProvisionResult(provisionCodeServiceStartFailed, "a previous failed attempt", nil)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sc := NewServiceCommand()
|
||||
if err := sc.Start(startTestCommand(), nil); err != nil {
|
||||
t.Fatalf("Start() error = %v", err)
|
||||
}
|
||||
|
||||
wantExit := provisionExitCodeForCode[provisionCodeInterceptModeInvalid]
|
||||
if *exitCode != wantExit {
|
||||
t.Fatalf("exit = %d, want %d (validateInterceptModeFlag should have run)", *exitCode, wantExit)
|
||||
}
|
||||
r, err := readProvisionResult()
|
||||
if err != nil {
|
||||
t.Fatalf("no provision result written: %v", err)
|
||||
}
|
||||
if r.Code == string(provisionCodeServiceStartFailed) {
|
||||
t.Fatal("stale result from a previous attempt survived the new attempt")
|
||||
}
|
||||
if r.Code != string(provisionCodeInterceptModeInvalid) {
|
||||
t.Errorf("code = %q, want %q", r.Code, provisionCodeInterceptModeInvalid)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/kardianos/service"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Exit codes reported by "ctrld status".
|
||||
const (
|
||||
statusExitRunning = 0
|
||||
statusExitStopped = 1
|
||||
statusExitUnknown = 2
|
||||
// statusExitNotReady means the service manager considers the service running,
|
||||
// but the process has not finished starting up, so it is not serving DNS or
|
||||
// applying policy. This is a distinct code because it needs a distinct response:
|
||||
// the process exists, so restarting the service is what recovers it, while a
|
||||
// stopped service needs starting and an unknown state needs investigation.
|
||||
statusExitNotReady = 3
|
||||
)
|
||||
|
||||
// serviceReadinessTimeout bounds the control-socket probe. Status must answer
|
||||
// quickly, and a service that cannot respond within this window is not usefully
|
||||
// "running" from a caller's point of view either way.
|
||||
const serviceReadinessTimeout = 3 * time.Second
|
||||
|
||||
// statusCmdLong documents what the reported states mean, including that a service the
|
||||
// OS calls running is not necessarily serving.
|
||||
const statusCmdLong = `Show status of the ctrld service.
|
||||
|
||||
Reports both what the OS service manager thinks and whether ctrld has finished
|
||||
starting up, since a service can be registered as running while its process is
|
||||
still in startup and serving nothing.
|
||||
|
||||
Exit codes:
|
||||
0 running and serving, or running with startup not verified
|
||||
1 stopped
|
||||
2 status unknown
|
||||
3 registered as running, but startup has not completed
|
||||
|
||||
Verifying startup requires reaching ctrld's control socket. On Linux, BSD and macOS
|
||||
that socket lives in a directory only the privileged user resolves, so an
|
||||
unprivileged "ctrld status" reports the service manager's view and says startup was
|
||||
not verified rather than claiming the service is unhealthy. Exit 3 is only reported
|
||||
when the check could actually be made.`
|
||||
|
||||
// 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(statusExitUnknown)
|
||||
case service.StatusRunning:
|
||||
// The service manager only knows a process was created. During the Windows
|
||||
// Firewall Mode incident it reported "running" for a process stuck in API
|
||||
// preflight that had never created its control socket, started a DNS
|
||||
// listener, or applied any policy - and "Service is running" sent the
|
||||
// investigation looking elsewhere. Probe readiness before claiming it.
|
||||
ready, probeErr := serviceReady()
|
||||
if probeErr != nil {
|
||||
logger.Debug().Err(probeErr).Msg("Readiness probe did not confirm startup")
|
||||
}
|
||||
r := classifyReadiness(ready, probeErr, readinessVerifiable())
|
||||
for _, msg := range r.messages {
|
||||
logger.Notice().Msg(msg)
|
||||
}
|
||||
os.Exit(r.exitCode)
|
||||
case service.StatusStopped:
|
||||
logger.Notice().Msg("Service is stopped")
|
||||
os.Exit(statusExitStopped)
|
||||
}
|
||||
|
||||
logger.Debug().Msg("Service status command completed")
|
||||
return nil
|
||||
}
|
||||
|
||||
// readiness is what "ctrld status" reports for a service the service manager
|
||||
// considers running.
|
||||
type readiness struct {
|
||||
messages []string
|
||||
exitCode int
|
||||
}
|
||||
|
||||
// readinessVerifiable reports whether a failed control-socket probe can be trusted to
|
||||
// mean "the service has not finished starting up".
|
||||
//
|
||||
// It can only mean that if this process resolves the same socket path the daemon
|
||||
// created, and socketDir() is caller-relative on unix: it returns the system directory
|
||||
// only when that is writable, and the caller's home directory otherwise. So a
|
||||
// root-owned daemon listens on /var/run/ctrld_control.sock while an unprivileged
|
||||
// "ctrld status" looks under $HOME, finds nothing, and gets ENOENT - which means "wrong
|
||||
// path", not "not ready". Reporting exit 3 there would tell a monitoring check to
|
||||
// restart a perfectly healthy daemon.
|
||||
//
|
||||
// On Windows and mobile socketDir() is the install/home directory for every caller, so
|
||||
// the probe is comparable - which matters because Windows is where the hung-start this
|
||||
// exit code exists for was seen. On Windows that only holds while this binary is the
|
||||
// installed one: a copy run from elsewhere resolves a different socket directory, so its
|
||||
// failed probe would say nothing about the service. installedServiceDirMatches() checks
|
||||
// that, and answers true when it cannot tell, preserving the previous behaviour.
|
||||
func readinessVerifiable() bool {
|
||||
if isMobile() {
|
||||
return true
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
return installedServiceDirMatches()
|
||||
}
|
||||
elevated, err := hasElevatedPrivilege()
|
||||
return err == nil && elevated
|
||||
}
|
||||
|
||||
// classifyReadiness turns a control-socket probe result into the report for a service
|
||||
// the service manager calls running.
|
||||
//
|
||||
// verifiable comes from readinessVerifiable: when it is false a failed probe says
|
||||
// nothing about the service, so the report falls back to the service manager's view.
|
||||
// A *successful* probe is still conclusive either way - reaching the socket at all is
|
||||
// positive evidence, whoever the caller is.
|
||||
func classifyReadiness(ready bool, err error, verifiable bool) readiness {
|
||||
switch {
|
||||
case ready:
|
||||
return readiness{
|
||||
messages: []string{"Service is running"},
|
||||
exitCode: statusExitRunning,
|
||||
}
|
||||
case !verifiable:
|
||||
return readiness{
|
||||
messages: []string{"Service is running (startup not verified: re-run with elevated privileges to check readiness)"},
|
||||
exitCode: statusExitRunning,
|
||||
}
|
||||
case errors.Is(err, errReadinessNotReported):
|
||||
// The service answered, just not with a verdict - an older daemon without the
|
||||
// /started route. It is alive and reachable, so the service manager's view is
|
||||
// the best available answer.
|
||||
return readiness{
|
||||
messages: []string{"Service is running (startup not verified: this ctrld build does not report readiness)"},
|
||||
exitCode: statusExitRunning,
|
||||
}
|
||||
case errors.Is(err, fs.ErrPermission):
|
||||
// Without access to the control socket there is nothing to report beyond the
|
||||
// service manager's view. Do not call a service unhealthy because the caller
|
||||
// lacks privilege.
|
||||
return readiness{
|
||||
messages: []string{"Service is running (startup not verified: control socket requires elevated privileges)"},
|
||||
exitCode: statusExitRunning,
|
||||
}
|
||||
default:
|
||||
return readiness{
|
||||
messages: []string{
|
||||
"Service is registered as running, but has not completed startup: it is not serving DNS",
|
||||
"Check the ctrld log for why startup did not finish, then restart the service",
|
||||
},
|
||||
exitCode: statusExitNotReady,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// serviceReady reports whether a running ctrld has finished starting up, by asking
|
||||
// its control server. The control server answers /started only once the onStarted
|
||||
// hooks have completed, which is after the DNS listeners are up, so a successful
|
||||
// probe means the process is actually serving rather than merely alive.
|
||||
//
|
||||
// An error means "could not confirm readiness" and is returned for the caller to
|
||||
// classify: a refused connection or missing socket is a process that never got that
|
||||
// far, while a permission error says nothing about the service's health.
|
||||
func serviceReady() (bool, error) {
|
||||
dir, err := socketDir()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return serviceReadyAt(filepath.Join(dir, ControlSocketName()), serviceReadinessTimeout)
|
||||
}
|
||||
|
||||
// errReadinessNotReported marks a control server that answered without a readiness
|
||||
// verdict.
|
||||
//
|
||||
// http.Client.Post returns (resp, nil) for any status, so a daemon with no /started
|
||||
// route answers 404 and an internal failure answers 5xx - neither says the service has
|
||||
// not started. Reporting "not ready" there tells a monitoring check to restart a healthy
|
||||
// service, and it happens in normal operation: after an upgrade replaces the binary on
|
||||
// disk but before the service restarts, and throughout a mixed-version rollout.
|
||||
var errReadinessNotReported = errors.New("control server did not report readiness")
|
||||
|
||||
// serviceReadyAt is serviceReady against an explicit socket path and timeout.
|
||||
func serviceReadyAt(sockPath string, timeout time.Duration) (bool, error) {
|
||||
cc := newControlClient(sockPath)
|
||||
cc.c.Timeout = timeout
|
||||
resp, err := cc.post(startedPath, nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
return true, nil
|
||||
case http.StatusRequestTimeout:
|
||||
// The daemon's own verdict: its onStarted hooks have not completed. This is the
|
||||
// hung start statusExitNotReady exists for.
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("%w: HTTP %d", errReadinessNotReported, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// startControlSocket serves handler on a unix socket and returns its path.
|
||||
func startControlSocket(t *testing.T, handler http.HandlerFunc) string {
|
||||
t.Helper()
|
||||
// Keep the path short: unix socket paths have a low length limit.
|
||||
dir, err := os.MkdirTemp("", "ctrldsock")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
||||
|
||||
sockPath := filepath.Join(dir, "s.sock")
|
||||
ln, err := net.Listen("unix", sockPath)
|
||||
if err != nil {
|
||||
t.Skipf("cannot listen on a unix socket: %v", err)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle(startedPath, handler)
|
||||
srv := &http.Server{Handler: mux}
|
||||
go func() { _ = srv.Serve(ln) }()
|
||||
t.Cleanup(func() { _ = srv.Close() })
|
||||
return sockPath
|
||||
}
|
||||
|
||||
func TestServiceReadyAt(t *testing.T) {
|
||||
t.Run("ready when the control server reports started", func(t *testing.T) {
|
||||
sock := startControlSocket(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
ready, err := serviceReadyAt(sock, time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ready {
|
||||
t.Error("ready = false, want true")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not ready when startup has not finished", func(t *testing.T) {
|
||||
// What /started returns when the onStarted hooks have not completed.
|
||||
sock := startControlSocket(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusRequestTimeout)
|
||||
})
|
||||
ready, err := serviceReadyAt(sock, time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ready {
|
||||
t.Error("ready = true for a control server that has not finished startup")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not ready when there is no control socket", func(t *testing.T) {
|
||||
// The incident: the process was alive but had never created the socket, so
|
||||
// every control request was refused.
|
||||
ready, err := serviceReadyAt(filepath.Join(t.TempDir(), "absent.sock"), time.Second)
|
||||
if ready {
|
||||
t.Error("ready = true with no control socket")
|
||||
}
|
||||
if err == nil {
|
||||
t.Error("expected an error when the control socket does not exist")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not ready when the probe times out", func(t *testing.T) {
|
||||
sock := startControlSocket(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(2 * time.Second)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
ready, err := serviceReadyAt(sock, 50*time.Millisecond)
|
||||
if ready {
|
||||
t.Error("ready = true for a probe that timed out")
|
||||
}
|
||||
if err == nil {
|
||||
t.Error("expected an error when the probe times out")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestClassifyReadiness(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ready bool
|
||||
err error
|
||||
verifiable bool
|
||||
wantCode int
|
||||
}{
|
||||
{
|
||||
name: "ready",
|
||||
ready: true,
|
||||
verifiable: true,
|
||||
wantCode: statusExitRunning,
|
||||
},
|
||||
{
|
||||
// The service manager says running, the process is not serving. This
|
||||
// must not report success.
|
||||
name: "running but never finished startup",
|
||||
err: errors.New("connect: connection refused"),
|
||||
verifiable: true,
|
||||
wantCode: statusExitNotReady,
|
||||
},
|
||||
{
|
||||
// A caller without privilege cannot probe; that is not evidence of a
|
||||
// broken service, so it must not be reported as one.
|
||||
name: "probe not permitted",
|
||||
err: fs.ErrPermission,
|
||||
verifiable: true,
|
||||
wantCode: statusExitRunning,
|
||||
},
|
||||
{
|
||||
name: "wrapped permission error",
|
||||
err: &net.OpError{Op: "dial", Err: fs.ErrPermission},
|
||||
verifiable: true,
|
||||
wantCode: statusExitRunning,
|
||||
},
|
||||
{
|
||||
// The P2: an unprivileged caller on unix resolves a socket path the
|
||||
// daemon never used, so the probe fails with ENOENT rather than a
|
||||
// permission error. That says nothing about the service and must not be
|
||||
// reported as unhealthy - a monitoring check acting on exit 3 would
|
||||
// restart a healthy daemon.
|
||||
name: "missing socket at an unverifiable path",
|
||||
err: &net.OpError{Op: "dial", Err: os.ErrNotExist},
|
||||
verifiable: false,
|
||||
wantCode: statusExitRunning,
|
||||
},
|
||||
{
|
||||
name: "connection refused at an unverifiable path",
|
||||
err: errors.New("connect: connection refused"),
|
||||
verifiable: false,
|
||||
wantCode: statusExitRunning,
|
||||
},
|
||||
{
|
||||
// A probe that actually reached the socket is conclusive whoever ran it.
|
||||
name: "successful probe is trusted even when unverifiable",
|
||||
ready: true,
|
||||
verifiable: false,
|
||||
wantCode: statusExitRunning,
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := classifyReadiness(tc.ready, tc.err, tc.verifiable)
|
||||
if got.exitCode != tc.wantCode {
|
||||
t.Errorf("exitCode = %d, want %d", got.exitCode, tc.wantCode)
|
||||
}
|
||||
if len(got.messages) == 0 {
|
||||
t.Error("no message to report")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadinessVerifiableMatchesSocketVisibility is the closure test for the P2: the
|
||||
// not-ready verdict must only be reachable when this process resolves the same socket
|
||||
// directory the daemon uses.
|
||||
//
|
||||
// On unix that is the privileged user's path, so an unprivileged run - which is how
|
||||
// "ctrld status" is normally invoked, since only darwin has an elevation PreRun and the
|
||||
// root-level alias has none - must not be able to reach exit 3.
|
||||
func TestReadinessVerifiableMatchesSocketVisibility(t *testing.T) {
|
||||
verifiable := readinessVerifiable()
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
if !verifiable {
|
||||
t.Error("on Windows every caller resolves the install directory, so the probe is always verifiable")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
elevated, err := hasElevatedPrivilege()
|
||||
if err != nil {
|
||||
t.Skipf("cannot determine privilege: %v", err)
|
||||
}
|
||||
if verifiable != elevated {
|
||||
t.Errorf("readinessVerifiable() = %v, want %v (elevated)", verifiable, elevated)
|
||||
}
|
||||
|
||||
if !elevated {
|
||||
// The shape the review asked to assert: unprivileged, healthy daemon, and a
|
||||
// probe that cannot see its socket must still report running.
|
||||
dir, err := socketDir()
|
||||
if err != nil {
|
||||
t.Fatalf("socketDir(): %v", err)
|
||||
}
|
||||
if dir == "/var/run" {
|
||||
t.Skip("unprivileged but /var/run is writable, so the probe path does match")
|
||||
}
|
||||
r := classifyReadiness(false, &net.OpError{Op: "dial", Err: os.ErrNotExist}, verifiable)
|
||||
if r.exitCode == statusExitNotReady {
|
||||
t.Errorf("unprivileged status probing %q reported not-ready (exit %d) for a healthy service", dir, r.exitCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every status must map to its own exit code: a caller that cannot tell a hung
|
||||
// service from a healthy or a stopped one is back to the incident's diagnostics.
|
||||
//
|
||||
// The literal values are the contract. statusCmdLong documents them and monitoring
|
||||
// scripts key off them, so asserting the constants against each other would let a
|
||||
// renumbering keep the suite green while silently breaking every caller.
|
||||
func TestStatusExitCodesAreDistinct(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
got int
|
||||
want int
|
||||
}{
|
||||
{"running", statusExitRunning, 0},
|
||||
{"stopped", statusExitStopped, 1},
|
||||
{"unknown", statusExitUnknown, 2},
|
||||
{"not ready", statusExitNotReady, 3},
|
||||
} {
|
||||
if tc.got != tc.want {
|
||||
t.Errorf("%s exit code = %d, want %d: statusCmdLong and monitoring scripts document this value", tc.name, tc.got, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
codes := map[int]string{
|
||||
statusExitRunning: "running",
|
||||
statusExitStopped: "stopped",
|
||||
statusExitUnknown: "unknown",
|
||||
statusExitNotReady: "not ready",
|
||||
}
|
||||
if len(codes) != 4 {
|
||||
t.Errorf("status exit codes collide, only %d distinct: %v", len(codes), codes)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadinessProbeStatusHandling covers what each control-server answer means.
|
||||
//
|
||||
// http.Client.Post returns (resp, nil) for any status code, so a daemon without the
|
||||
// /started route answers 404 and the probe must report "cannot confirm" rather than "not
|
||||
// started". That state is reached in normal operation - after an upgrade replaces the
|
||||
// binary but before the service restarts, and throughout a mixed-version rollout - and
|
||||
// reporting exit 3 there tells monitoring to restart a healthy service.
|
||||
func TestReadinessProbeStatusHandling(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status int
|
||||
wantReady bool
|
||||
wantReported bool // whether the answer carries a readiness verdict
|
||||
wantExitCode int
|
||||
}{
|
||||
{"started", http.StatusOK, true, true, statusExitRunning},
|
||||
{"still starting", http.StatusRequestTimeout, false, true, statusExitNotReady},
|
||||
{"no readiness route", http.StatusNotFound, false, false, statusExitRunning},
|
||||
{"control server error", http.StatusInternalServerError, false, false, statusExitRunning},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
status := tc.status
|
||||
sock := startControlSocket(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(status)
|
||||
})
|
||||
|
||||
ready, err := serviceReadyAt(sock, time.Second)
|
||||
if ready != tc.wantReady {
|
||||
t.Errorf("ready = %v, want %v", ready, tc.wantReady)
|
||||
}
|
||||
if reported := !errors.Is(err, errReadinessNotReported); reported != tc.wantReported {
|
||||
t.Errorf("readiness reported = %v, want %v (err: %v)", reported, tc.wantReported, err)
|
||||
}
|
||||
if got := classifyReadiness(ready, err, true).exitCode; got != tc.wantExitCode {
|
||||
t.Errorf("exit code = %d, want %d", got, tc.wantExitCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,122 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestServiceStageFailureCode(t *testing.T) {
|
||||
tests := []struct {
|
||||
taskName string
|
||||
wantCode provisionFailureCode
|
||||
wantOK bool
|
||||
}{
|
||||
{"Install", provisionCodeServiceInstall, true},
|
||||
{"Start", provisionCodeServiceStartFailed, true},
|
||||
{"Checking config", "", false},
|
||||
{"", "", false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
code, ok := serviceStageFailureCode(tc.taskName)
|
||||
if code != tc.wantCode || ok != tc.wantOK {
|
||||
t.Errorf("serviceStageFailureCode(%q) = (%q, %v), want (%q, %v)", tc.taskName, code, ok, tc.wantCode, tc.wantOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stubProvisionExit(t *testing.T) *int {
|
||||
t.Helper()
|
||||
exitCode := -1
|
||||
old := provisionExit
|
||||
provisionExit = func(code int) { exitCode = code }
|
||||
t.Cleanup(func() { provisionExit = old })
|
||||
return &exitCode
|
||||
}
|
||||
|
||||
func TestReportStartFailureUsesFreshDaemonResult(t *testing.T) {
|
||||
overrideProvisionResultPath(t)
|
||||
exitCode := stubProvisionExit(t)
|
||||
|
||||
startedAt := time.Now()
|
||||
daemonResult := newProvisionResult(provisionCodeAPIUnreachable, "daemon could not reach the API", nil)
|
||||
if err := writeProvisionResult(daemonResult); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reportStartFailure(startedAt, "generic self-check failure")
|
||||
|
||||
if *exitCode != provisionExitCodeForCode[provisionCodeAPIUnreachable] {
|
||||
t.Errorf("exit code = %d, want the daemon's own exit code %d", *exitCode, provisionExitCodeForCode[provisionCodeAPIUnreachable])
|
||||
}
|
||||
out, err := readProvisionResult()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Code != string(provisionCodeAPIUnreachable) {
|
||||
t.Errorf("persisted code = %q, want the daemon's own code untouched", out.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportStartFailureFallsBackOnStaleDaemonResult(t *testing.T) {
|
||||
overrideProvisionResultPath(t)
|
||||
exitCode := stubProvisionExit(t)
|
||||
|
||||
stale := newProvisionResult(provisionCodeAPIUnreachable, "an old failure", nil)
|
||||
stale.Timestamp = time.Now().Add(-1 * time.Hour).UTC().Format(time.RFC3339)
|
||||
if err := writeProvisionResult(stale); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
startedAt := time.Now()
|
||||
reportStartFailure(startedAt, "test query failed: timeout")
|
||||
|
||||
if *exitCode != provisionExitCodeForCode[provisionCodeServiceSelfCheck] {
|
||||
t.Errorf("exit code = %d, want SERVICE_SELFCHECK_FAILED exit %d", *exitCode, provisionExitCodeForCode[provisionCodeServiceSelfCheck])
|
||||
}
|
||||
out, err := readProvisionResult()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Code != string(provisionCodeServiceSelfCheck) {
|
||||
t.Errorf("persisted code = %q, want %q", out.Code, provisionCodeServiceSelfCheck)
|
||||
}
|
||||
if out.Message != "test query failed: timeout" {
|
||||
t.Errorf("persisted message = %q, want the fallback message", out.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportStartFailureRejectsUntrustedFile(t *testing.T) {
|
||||
overrideProvisionResultPath(t)
|
||||
exitCode := stubProvisionExit(t)
|
||||
|
||||
planted := newProvisionResult(provisionCodeAPIUnreachable, "planted", nil)
|
||||
planted.Code = "FAKE_CODE"
|
||||
planted.ExitCode = 99
|
||||
if err := writeProvisionResult(planted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reportStartFailure(time.Now().Add(-time.Minute), "self-check failed")
|
||||
|
||||
if *exitCode != provisionExitCodeForCode[provisionCodeServiceSelfCheck] {
|
||||
t.Errorf("exit = %d, want the fallback %d, never the planted 99", *exitCode, provisionExitCodeForCode[provisionCodeServiceSelfCheck])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportStartFailureFallsBackWhenResultFileMissing(t *testing.T) {
|
||||
overrideProvisionResultPath(t)
|
||||
exitCode := stubProvisionExit(t)
|
||||
|
||||
reportStartFailure(time.Now(), "firewall hint")
|
||||
|
||||
if *exitCode != provisionExitCodeForCode[provisionCodeServiceSelfCheck] {
|
||||
t.Errorf("exit code = %d, want SERVICE_SELFCHECK_FAILED exit %d", *exitCode, provisionExitCodeForCode[provisionCodeServiceSelfCheck])
|
||||
}
|
||||
out, err := readProvisionResult()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Message != "firewall hint" {
|
||||
t.Errorf("persisted message = %q, want the fallback message", out.Message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
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-client", 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, ctrldServiceDisplayName, config.DisplayName)
|
||||
// Windows requires service display names to be unique and rejects a second
|
||||
// registration with ERROR_DUPLICATE_SERVICE_NAME. Reusing the v1 service's
|
||||
// display name would make "ctrld-client start" unable to install on hosts
|
||||
// that still have v1 installed, so pin that they stay distinct (#565).
|
||||
assert.NotEqual(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-client",
|
||||
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,394 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"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, err := binaryVersion(bin)
|
||||
if err != nil {
|
||||
mainLog.Load().Warn().Err(err).Msg("Failed to get new binary version")
|
||||
ver = "unknown version"
|
||||
}
|
||||
mainLog.Load().Notice().Msgf("Upgrade successful - %s", ver)
|
||||
return nil
|
||||
}
|
||||
|
||||
stop := func() error {
|
||||
if !svcInstalled {
|
||||
return nil
|
||||
}
|
||||
if err := stopServiceAndWait(s, upgradeStopTimeout); err != nil {
|
||||
return err
|
||||
}
|
||||
// Mirror the Cleanup task in doRestart: leave DNS settings as the OS had them,
|
||||
// not as a half-started ctrld left them.
|
||||
p.resetDNS(false, true)
|
||||
return nil
|
||||
}
|
||||
return rollbackToPreviousBinary(bin, oldBin, stop, doRestart)
|
||||
}
|
||||
|
||||
// rollbackToPreviousBinary restores oldBin over bin after the replacement failed to
|
||||
// become ready, and restarts the service on the restored binary.
|
||||
//
|
||||
// stop must leave the replacement's process gone, because every step here modifies
|
||||
// the executable that process is running from. It is called first for that reason:
|
||||
// readiness failing does not mean the process exited - the service manager can report
|
||||
// a started service whose process never became operational, which is what the Windows
|
||||
// Firewall Mode incident produced. Windows holds an exclusive lock on a running
|
||||
// executable's image, so the previous code's os.Remove(bin) failed with "Access is
|
||||
// denied", and because that was fatal the restore never ran: the broken binary stayed
|
||||
// installed with the previous one stranded at its _previous name.
|
||||
//
|
||||
// Stopping first also puts the host back in a known state. A stopped ctrld holds no
|
||||
// WFP or NRPT enforcement, so a replacement that was blocking traffic stops blocking
|
||||
// it here instead of at the next reboot.
|
||||
func rollbackToPreviousBinary(bin, oldBin string, stop func() error, restart func() bool) error {
|
||||
if err := stop(); err != nil {
|
||||
mainLog.Load().Error().Err(err).Msg("Could not confirm the service stopped; not modifying its binary")
|
||||
return err
|
||||
}
|
||||
|
||||
// Only restore a previous binary that actually runs. During the incident the
|
||||
// _previous file existed but produced no version output; renaming that over the
|
||||
// current binary would have replaced a service that starts but hangs with one that
|
||||
// cannot start at all.
|
||||
//
|
||||
// The probe is retried for the same reason removeBinaryWithRetry is: on Windows a
|
||||
// single exec can fail transiently while antivirus scans the file or the disk is
|
||||
// busy, and treating that as "no usable previous binary" leaves the host stopped
|
||||
// with the broken binary installed and no enforcement - an end state worse than
|
||||
// restoring a binary that turns out to be bad, which the restart check below
|
||||
// catches.
|
||||
//
|
||||
// Running "--version" proves the file executes. It is not an authenticity check:
|
||||
// nothing here compares a signature or checksum before a file becomes the installed
|
||||
// service binary. That is acceptable only because the install directory is writable
|
||||
// by administrators alone, which is this command's standing assumption.
|
||||
prevVer, err := binaryVersionWithRetry(oldBin, upgradeStopTimeout)
|
||||
if err != nil {
|
||||
mainLog.Load().Error().Err(err).Msgf("Previous binary at %s is not usable, keeping it for inspection", oldBin)
|
||||
mainLog.Load().Notice().Msgf("Service is stopped and %s is still the installed binary - no ctrld enforcement is active", bin)
|
||||
return fmt.Errorf("upgrade failed and no usable previous binary to restore: %w", err)
|
||||
}
|
||||
|
||||
mainLog.Load().Warn().Msgf("Restoring previous binary: %s (%s)", oldBin, prevVer)
|
||||
if err := removeBinaryWithRetry(bin, upgradeStopTimeout); err != nil {
|
||||
mainLog.Load().Error().Err(err).Msg("Failed to remove new binary")
|
||||
mainLog.Load().Notice().Msg("Service is stopped - no ctrld enforcement is active")
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(oldBin, bin); err != nil {
|
||||
mainLog.Load().Error().Err(err).Msg("Failed to restore old binary")
|
||||
mainLog.Load().Notice().Msgf("Service is stopped and %s is missing; reinstall ctrld to recover", bin)
|
||||
return err
|
||||
}
|
||||
if restart() {
|
||||
mainLog.Load().Notice().Msgf("Restored previous binary successfully - %s", prevVer)
|
||||
return nil
|
||||
}
|
||||
|
||||
mainLog.Load().Error().Msg("Restored the previous binary but it did not become ready either")
|
||||
return errors.New("upgrade failed and the restored binary did not become ready")
|
||||
}
|
||||
|
||||
const (
|
||||
// upgradeStopTimeout bounds how long rollback waits for the replacement process to
|
||||
// exit, and for Windows to release the lock on its image afterwards.
|
||||
upgradeStopTimeout = 30 * time.Second
|
||||
// upgradeStopPollInterval is how often the service status is re-checked while
|
||||
// waiting for the process to exit.
|
||||
upgradeStopPollInterval = 500 * time.Millisecond
|
||||
// binaryVersionTimeout bounds the "--version" probe, so a binary that hangs on
|
||||
// startup cannot hang the upgrade.
|
||||
binaryVersionTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// stopServiceAndWait stops the service and waits until the service manager reports
|
||||
// it stopped. Rollback needs the process gone, not merely asked to stop: a stop
|
||||
// request returns before the process exits, and on Windows the executable stays
|
||||
// locked until it does.
|
||||
func stopServiceAndWait(s service.Service, timeout time.Duration) error {
|
||||
if err := s.Stop(); err != nil {
|
||||
// Not fatal: the service may already be stopped, or stopping may fail while
|
||||
// the process is exiting anyway. The status poll below decides.
|
||||
mainLog.Load().Debug().Err(err).Msg("Stop request failed, waiting for the process to exit anyway")
|
||||
}
|
||||
deadline := time.Now().Add(timeout)
|
||||
statusReadable := false
|
||||
var lastErr error
|
||||
for {
|
||||
status, err := s.Status()
|
||||
switch {
|
||||
case errors.Is(err, service.ErrNotInstalled):
|
||||
return nil
|
||||
case err == nil:
|
||||
statusReadable = true
|
||||
if status == service.StatusStopped {
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
lastErr = err
|
||||
}
|
||||
if !time.Now().Before(deadline) {
|
||||
if !statusReadable {
|
||||
// The status was never readable, so "did not stop" was never observed -
|
||||
// only "could not be observed". Refusing to continue here would leave the
|
||||
// broken binary installed with the service stopped and no enforcement,
|
||||
// which is the outcome rollback exists to avoid. Let the caller proceed:
|
||||
// the remove is retried while the image is locked, and the restart check
|
||||
// still has to pass before this reports success.
|
||||
mainLog.Load().Warn().Err(lastErr).Msgf("Could not read service status within %s; continuing with rollback", timeout)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("service did not stop within %s", timeout)
|
||||
}
|
||||
time.Sleep(upgradeStopPollInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// binaryVersionWithRetry probes a binary's version, retrying transient exec failures
|
||||
// until timeout. Only the last error is reported: the earlier attempts are noise once a
|
||||
// retry has been made.
|
||||
func binaryVersionWithRetry(path string, timeout time.Duration) (string, error) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
version, err := binaryVersionFn(path)
|
||||
if err == nil {
|
||||
return version, nil
|
||||
}
|
||||
if !time.Now().Before(deadline) {
|
||||
return "", err
|
||||
}
|
||||
mainLog.Load().Debug().Err(err).Msgf("Version probe of %s failed, retrying", path)
|
||||
time.Sleep(upgradeStopPollInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// removeBinaryWithRetry removes path, retrying while it is still locked. Windows
|
||||
// releases the lock on an executable's image asynchronously after its process exits,
|
||||
// so a remove issued immediately after the service reports stopped can still fail
|
||||
// with "Access is denied".
|
||||
func removeBinaryWithRetry(path string, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
err := os.Remove(path)
|
||||
if err == nil || errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if !time.Now().Before(deadline) {
|
||||
return fmt.Errorf("could not remove %s within %s: %w", path, timeout, err)
|
||||
}
|
||||
time.Sleep(upgradeStopPollInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// binaryVersionFn is indirected so rollback can be tested without staging a runnable
|
||||
// executable per platform. The probe itself is covered directly against the test
|
||||
// binary; see TestBinaryVersion.
|
||||
var binaryVersionFn = binaryVersion
|
||||
|
||||
// binaryVersion runs path with "--version" and returns the version it reports. It
|
||||
// answers "can this binary actually run on this host", which is what rollback needs
|
||||
// to know before making a file the installed ctrld.
|
||||
//
|
||||
// On Windows path is ctrld-client.exe_previous, whose extension is not in PATHEXT. That
|
||||
// resolves because os/exec only falls back to appending PATHEXT entries when the path
|
||||
// has no extension at all (lp_windows.go findExecutable): with one present and the
|
||||
// file on disk, it is used as-is. A suffix that left no extension - renaming
|
||||
// oldBinSuffix such that the result is "ctrld-client_previous" - would break this probe with
|
||||
// "executable file not found in %PATH%", and rollback would then refuse to restore a
|
||||
// perfectly good binary.
|
||||
func binaryVersion(path string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), binaryVersionTimeout)
|
||||
defer cancel()
|
||||
out, err := exec.CommandContext(ctx, path, "--version").CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("running %s --version: %w", path, err)
|
||||
}
|
||||
ver, ok := parseVersionOutput(string(out))
|
||||
if !ok {
|
||||
return "", fmt.Errorf("unexpected --version output from %s: %q", path, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return ver, nil
|
||||
}
|
||||
|
||||
// parseVersionOutput extracts the version from a binary's "--version" output.
|
||||
//
|
||||
// The expected prefix is built from cliName, which is also what the root command
|
||||
// is named, because Cobra renders "--version" as "<name> version <version>" from
|
||||
// that same name. Spelling the prefix out here instead would make a rename of the
|
||||
// client silently break this parser - and with it rollback, which refuses to
|
||||
// restore a previous binary whose version it cannot read. That failure mode is
|
||||
// the worst one this code has: the service is already stopped, so a wrongly
|
||||
// rejected previous binary leaves the host with no ctrld enforcement at all.
|
||||
func parseVersionOutput(out string) (string, bool) {
|
||||
// Not CutPrefix's own return: on a miss it hands back the whole input, which a
|
||||
// caller that forgot to check the bool would store as if it were a version.
|
||||
ver, ok := strings.CutPrefix(strings.TrimSpace(out), cliName+" version ")
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return ver, true
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kardianos/service"
|
||||
)
|
||||
|
||||
// fakeService implements the parts of service.Service that rollback uses. Any other
|
||||
// method panics, which keeps accidental dependencies visible.
|
||||
type fakeService struct {
|
||||
service.Service
|
||||
|
||||
stopErr error
|
||||
stopCalls int
|
||||
statuses []service.Status // consumed one per Status() call; the last repeats
|
||||
statusErr error
|
||||
onStopCall func()
|
||||
}
|
||||
|
||||
func (f *fakeService) Stop() error {
|
||||
f.stopCalls++
|
||||
if f.onStopCall != nil {
|
||||
f.onStopCall()
|
||||
}
|
||||
return f.stopErr
|
||||
}
|
||||
|
||||
func (f *fakeService) Status() (service.Status, error) {
|
||||
if f.statusErr != nil {
|
||||
return service.StatusUnknown, f.statusErr
|
||||
}
|
||||
if len(f.statuses) == 0 {
|
||||
return service.StatusStopped, nil
|
||||
}
|
||||
st := f.statuses[0]
|
||||
if len(f.statuses) > 1 {
|
||||
f.statuses = f.statuses[1:]
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func TestStopServiceAndWait(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
svc *fakeService
|
||||
timeout time.Duration
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "stops after a few polls",
|
||||
svc: &fakeService{statuses: []service.Status{service.StatusRunning, service.StatusRunning, service.StatusStopped}},
|
||||
timeout: 5 * time.Second,
|
||||
},
|
||||
{
|
||||
name: "already stopped",
|
||||
svc: &fakeService{statuses: []service.Status{service.StatusStopped}},
|
||||
timeout: 5 * time.Second,
|
||||
},
|
||||
{
|
||||
// A stop request that errors is not fatal on its own: the process may be
|
||||
// exiting anyway, so the status poll decides.
|
||||
name: "stop errors but service is stopped",
|
||||
svc: &fakeService{stopErr: errors.New("already stopped"), statuses: []service.Status{service.StatusStopped}},
|
||||
timeout: 5 * time.Second,
|
||||
},
|
||||
{
|
||||
name: "not installed",
|
||||
svc: &fakeService{statusErr: service.ErrNotInstalled},
|
||||
timeout: 5 * time.Second,
|
||||
},
|
||||
{
|
||||
// The process never exits. Rollback must be told so, because modifying a
|
||||
// running executable is what produced "Access is denied".
|
||||
name: "never stops",
|
||||
svc: &fakeService{statuses: []service.Status{service.StatusRunning}},
|
||||
timeout: time.Millisecond,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := stopServiceAndWait(tc.svc, tc.timeout)
|
||||
if tc.wantErr && err == nil {
|
||||
t.Fatal("expected an error, got nil")
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if tc.svc.stopCalls != 1 {
|
||||
t.Errorf("Stop() called %d times, want 1", tc.svc.stopCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveBinaryWithRetry(t *testing.T) {
|
||||
t.Run("removes an existing file", func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "ctrld")
|
||||
if err := os.WriteFile(path, []byte("binary"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := removeBinaryWithRetry(path, time.Second); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Errorf("file still exists after removal: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing file is not an error", func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "absent")
|
||||
if err := removeBinaryWithRetry(path, time.Second); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("gives up and reports when the path cannot be removed", func(t *testing.T) {
|
||||
// A non-empty directory stands in for a locked executable: os.Remove keeps
|
||||
// failing, so the retry loop must surface the error rather than hang.
|
||||
dir := filepath.Join(t.TempDir(), "locked")
|
||||
if err := os.Mkdir(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "child"), nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := removeBinaryWithRetry(dir, time.Millisecond); err == nil {
|
||||
t.Fatal("expected an error for a path that cannot be removed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBinaryVersion(t *testing.T) {
|
||||
t.Run("reports the version", func(t *testing.T) {
|
||||
t.Setenv(envFakeVersionOutput, cliName+" version dev-94fbd3f")
|
||||
got, err := binaryVersion(os.Args[0])
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "dev-94fbd3f" {
|
||||
t.Errorf("binaryVersion() = %q, want %q", got, "dev-94fbd3f")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects a binary that prints no version", func(t *testing.T) {
|
||||
// The incident's ctrld.exe_previous: the file exists and runs, but produces no
|
||||
// version output. Restoring it would have replaced a hung service with one
|
||||
// that cannot start at all.
|
||||
t.Setenv(envFakeVersionOutput, envFakeVersionSilent)
|
||||
if _, err := binaryVersion(os.Args[0]); err == nil {
|
||||
t.Fatal("expected an error for a binary with no version output")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects a missing binary", func(t *testing.T) {
|
||||
if _, err := binaryVersion(filepath.Join(t.TempDir(), "absent")); err == nil {
|
||||
t.Fatal("expected an error for a missing binary")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// stubBinaryVersion makes the version probe report ver for any path, so a rollback
|
||||
// test does not have to stage a runnable executable.
|
||||
//
|
||||
// Staging one is not portable: oldBin is bin+"_previous", so a fixture named "ctrld"
|
||||
// yields the extension-less "ctrld_previous", which Windows refuses to execute
|
||||
// ("executable file not found in %PATH%"), and a symlink to the test binary needs a
|
||||
// privilege Windows does not grant by default. The probe itself is covered against the
|
||||
// real test binary in TestBinaryVersion; these tests are about rollback's ordering.
|
||||
func stubBinaryVersion(t *testing.T, ver string, err error) {
|
||||
t.Helper()
|
||||
prev := binaryVersionFn
|
||||
binaryVersionFn = func(string) (string, error) { return ver, err }
|
||||
t.Cleanup(func() { binaryVersionFn = prev })
|
||||
}
|
||||
|
||||
func TestRollbackToPreviousBinaryStopsBeforeTouchingTheBinary(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
bin := filepath.Join(dir, "ctrld")
|
||||
oldBin := bin + oldBinSuffix
|
||||
if err := os.WriteFile(bin, []byte("replacement"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(oldBin, []byte("previous"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stubBinaryVersion(t, "dev-a75d669", nil)
|
||||
|
||||
// The invariant: when stop runs, the replacement's executable is still untouched.
|
||||
// Reversing these two is exactly the "Access is denied" defect.
|
||||
var stopped bool
|
||||
var binExistedAtStop bool
|
||||
stop := func() error {
|
||||
stopped = true
|
||||
_, err := os.Stat(bin)
|
||||
binExistedAtStop = err == nil
|
||||
return nil
|
||||
}
|
||||
restarted := false
|
||||
restart := func() bool { restarted = true; return true }
|
||||
|
||||
if err := rollbackToPreviousBinary(bin, oldBin, stop, restart); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !stopped {
|
||||
t.Error("rollback did not stop the service")
|
||||
}
|
||||
if !binExistedAtStop {
|
||||
t.Error("the binary was modified before the service was stopped")
|
||||
}
|
||||
if !restarted {
|
||||
t.Error("rollback did not restart the service")
|
||||
}
|
||||
if _, err := os.Stat(oldBin); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Errorf("previous binary was not moved into place: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(bin); err != nil {
|
||||
t.Errorf("restored binary is missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollbackToPreviousBinaryKeepsUnusablePrevious(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
bin := filepath.Join(dir, "ctrld")
|
||||
oldBin := bin + oldBinSuffix
|
||||
if err := os.WriteFile(bin, []byte("replacement"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A previous binary that exists but does not report a version, as in the incident.
|
||||
if err := os.WriteFile(oldBin, []byte("not a working binary"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Stubbed rather than left to the real probe: that would fail here for the right
|
||||
// reason on unix (not an executable) but the wrong one on Windows (the fixture's
|
||||
// name has no extension), so the assertion would not be about usability at all.
|
||||
stubBinaryVersion(t, "", errors.New("unexpected --version output"))
|
||||
|
||||
stopped := false
|
||||
restarted := false
|
||||
err := rollbackToPreviousBinary(bin, oldBin,
|
||||
func() error { stopped = true; return nil },
|
||||
func() bool { restarted = true; return true },
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error when the previous binary is unusable")
|
||||
}
|
||||
if !stopped {
|
||||
t.Error("the service must still be stopped: a broken replacement holds enforcement")
|
||||
}
|
||||
if restarted {
|
||||
t.Error("must not restart the service with an unusable binary")
|
||||
}
|
||||
// Nothing was swapped, and the previous file is kept for inspection.
|
||||
if _, err := os.Stat(oldBin); err != nil {
|
||||
t.Errorf("unusable previous binary was not preserved: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(bin); err != nil {
|
||||
t.Errorf("installed binary was removed despite having nothing to restore: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollbackToPreviousBinaryAbortsWhenStopFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
bin := filepath.Join(dir, "ctrld")
|
||||
oldBin := bin + oldBinSuffix
|
||||
for _, p := range []string{bin, oldBin} {
|
||||
if err := os.WriteFile(p, []byte("binary"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
stopErr := errors.New("service did not stop within 30s")
|
||||
err := rollbackToPreviousBinary(bin, oldBin,
|
||||
func() error { return stopErr },
|
||||
func() bool { t.Error("must not restart after a failed stop"); return false },
|
||||
)
|
||||
if !errors.Is(err, stopErr) {
|
||||
t.Fatalf("error = %v, want %v", err, stopErr)
|
||||
}
|
||||
// The executable of a process that may still be running must be left alone.
|
||||
if _, err := os.Stat(bin); err != nil {
|
||||
t.Errorf("binary was modified even though the stop failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVersionOutputParsesThroughRollbackProbe ties the "--version" output the root
|
||||
// command actually produces to the parser rollback reads it with.
|
||||
//
|
||||
// These are two halves of one contract that live in different files: Cobra renders
|
||||
// "<Use> version <Version>", and binaryVersion cuts a prefix off it. Renaming the
|
||||
// client moved the first half; if the second half had kept its literal, every
|
||||
// upgrade would have logged "unknown version" and - the part that matters -
|
||||
// rollbackToPreviousBinary would have judged a perfectly good previous binary
|
||||
// "not usable" and left the host stopped with the broken one installed.
|
||||
//
|
||||
// The version output is taken from the real root command rather than assembled
|
||||
// here, so a future change to the name, the template, or the parser has to keep
|
||||
// them agreeing.
|
||||
func TestVersionOutputParsesThroughRollbackProbe(t *testing.T) {
|
||||
rootCmd := initCLI()
|
||||
rootCmd.SetVersionTemplate(rootCmd.VersionTemplate())
|
||||
|
||||
var out bytes.Buffer
|
||||
rootCmd.SetOut(&out)
|
||||
rootCmd.SetErr(&out)
|
||||
rootCmd.SetArgs([]string{"--version"})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("running --version: %v", err)
|
||||
}
|
||||
|
||||
got := out.String()
|
||||
if strings.TrimSpace(got) == "" {
|
||||
t.Fatal("--version produced no output")
|
||||
}
|
||||
ver, ok := parseVersionOutput(got)
|
||||
if !ok {
|
||||
t.Fatalf("the version probe cannot parse the root command's own --version output %q; "+
|
||||
"rollback would reject a working previous binary as unusable", strings.TrimSpace(got))
|
||||
}
|
||||
if ver != appVersion {
|
||||
t.Errorf("parsed version = %q, want %q", ver, appVersion)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseVersionOutput covers the shapes the probe must accept and reject. The
|
||||
// rejected ones are what a genuinely broken previous binary produces - the
|
||||
// incident's ctrld.exe_previous printed nothing at all - and rollback depends on
|
||||
// telling those apart from a healthy binary under a new name.
|
||||
func TestParseVersionOutput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
out string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"current identity", cliName + " version v1.0.0", "v1.0.0", true},
|
||||
{"trailing newline", cliName + " version v1.0.0\n", "v1.0.0", true},
|
||||
{"dev build", cliName + " version dev-94fbd3f", "dev-94fbd3f", true},
|
||||
// The pre-rename identity: a v1-line binary is not a valid rollback target
|
||||
// for this client, and must not be read as one.
|
||||
{"previous identity", "ctrld version v1.3.5", "", false},
|
||||
{"no output", "", "", false},
|
||||
{"unrelated output", "some other program", "", false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, ok := parseVersionOutput(tc.out)
|
||||
if ok != tc.ok {
|
||||
t.Fatalf("parseVersionOutput(%q) ok = %v, want %v", tc.out, ok, tc.ok)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("parseVersionOutput(%q) = %q, want %q", tc.out, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
@@ -32,6 +34,12 @@ func (c *controlClient) post(path string, data io.Reader) (*http.Response, error
|
||||
return c.c.Post("http://unix"+path, contentTypeJson, data)
|
||||
}
|
||||
|
||||
// postStream sends a POST request with no timeout, suitable for long-lived streaming connections.
|
||||
func (c *controlClient) postStream(path string, data io.Reader) (*http.Response, error) {
|
||||
c.c.Timeout = 0
|
||||
return c.c.Post("http://unix"+path, contentTypeJson, data)
|
||||
}
|
||||
|
||||
// deactivationRequest represents request for validating deactivation pin.
|
||||
type deactivationRequest struct {
|
||||
Pin int64 `json:"pin"`
|
||||
|
||||
+246
-36
@@ -10,6 +10,7 @@ import (
|
||||
"os"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/kardianos/service"
|
||||
@@ -29,20 +30,24 @@ const (
|
||||
ifacePath = "/iface"
|
||||
viewLogsPath = "/log/view"
|
||||
sendLogsPath = "/log/send"
|
||||
tailLogsPath = "/log/tail"
|
||||
)
|
||||
|
||||
type ifaceResponse struct {
|
||||
Name string `json:"name"`
|
||||
All bool `json:"all"`
|
||||
OK bool `json:"ok"`
|
||||
Name string `json:"name"`
|
||||
All bool `json:"all"`
|
||||
OK bool `json:"ok"`
|
||||
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{
|
||||
@@ -56,12 +61,18 @@ func newControlServer(addr string) (*controlServer, error) {
|
||||
func (s *controlServer) start() error {
|
||||
_ = os.Remove(s.addr)
|
||||
unixListener, err := net.Listen("unix", s.addr)
|
||||
if l, ok := unixListener.(*net.UnixListener); ok {
|
||||
l.SetUnlinkOnClose(true)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Restrict socket permissions to owner-only (0600) so that only the
|
||||
// process owner (typically root) can connect. Defense-in-depth since
|
||||
// the control server endpoints carry no authentication of their own.
|
||||
if err := os.Chmod(s.addr, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
if l, ok := unixListener.(*net.UnixListener); ok {
|
||||
l.SetUnlinkOnClose(true)
|
||||
}
|
||||
go s.server.Serve(unixListener)
|
||||
return nil
|
||||
}
|
||||
@@ -79,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
|
||||
}
|
||||
|
||||
@@ -116,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 {
|
||||
@@ -175,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
|
||||
}
|
||||
|
||||
@@ -216,15 +227,38 @@ 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)
|
||||
return
|
||||
}
|
||||
|
||||
// Re-fetch pin code from API.
|
||||
if rc, err := controld.FetchResolverConfig(cdUID, rootCmd.Version, cdDev); rc != nil {
|
||||
rcReq := &controld.ResolverConfigRequest{
|
||||
RawUID: cdUID,
|
||||
Version: appVersion,
|
||||
Metadata: ctrld.SystemMetadataRuntime(context.Background()),
|
||||
}
|
||||
if rc, err := controld.FetchResolverConfig(loggerCtx, rcReq, cdDev); rc != nil {
|
||||
if rc.DeactivationPin != nil {
|
||||
cdDeactivationPin.Store(*rc.DeactivationPin)
|
||||
} else {
|
||||
cdDeactivationPin.Store(defaultDeactivationPin)
|
||||
}
|
||||
// Every resolver-config response carries the organization's allowed
|
||||
// destinations, including this one, so apply them rather than discarding
|
||||
// a fresher list until the next scheduled refresh converges.
|
||||
//
|
||||
// Only the destinations: p.rc is deliberately left alone. The scheduled
|
||||
// refresh decides whether to reload ctrld by comparing the response
|
||||
// against p.rc, so storing this one here would let an exclude-list change
|
||||
// be compared away and never reloaded. The destination set needs no
|
||||
// reload - it is enforced directly and applying it is idempotent.
|
||||
p.applyAllowedDestinations(p.firewallAllowList(), rc.DestinationIPs)
|
||||
} 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.
|
||||
@@ -236,7 +270,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
|
||||
}
|
||||
|
||||
@@ -244,6 +278,7 @@ func (p *prog) registerControlServerHandler() {
|
||||
switch req.Pin {
|
||||
case cdDeactivationPin.Load():
|
||||
code = http.StatusOK
|
||||
deactivationFailedAttempts.Store(0)
|
||||
select {
|
||||
case p.pinCodeValidCh <- struct{}{}:
|
||||
default:
|
||||
@@ -251,6 +286,11 @@ func (p *prog) registerControlServerHandler() {
|
||||
case defaultDeactivationPin:
|
||||
// If the pin code was set, but users do not provide --pin, return proper code to client.
|
||||
code = http.StatusBadRequest
|
||||
default:
|
||||
if deactivationFailedAttempts.Add(1) >= deactivationMaxFailedAttempts {
|
||||
deactivationLockedUntil.Store(time.Now().Unix() + deactivationLockoutSeconds)
|
||||
deactivationFailedAttempts.Store(0)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(code)
|
||||
}))
|
||||
@@ -271,6 +311,10 @@ func (p *prog) registerControlServerHandler() {
|
||||
res.Name = p.runningIface
|
||||
res.All = p.requiredMultiNICsConfig
|
||||
res.OK = true
|
||||
// Report intercept mode to the start command for proper log output.
|
||||
if interceptMode == "dns" || interceptMode == "hard" {
|
||||
res.InterceptMode = interceptMode
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(res); err != nil {
|
||||
@@ -280,7 +324,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
|
||||
@@ -306,7 +350,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
|
||||
@@ -319,14 +363,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 {
|
||||
@@ -334,8 +379,173 @@ func (p *prog) registerControlServerHandler() {
|
||||
}
|
||||
p.internalLogSent = time.Now()
|
||||
}))
|
||||
p.cs.register(tailLogsPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Determine logging mode and validate before starting the stream.
|
||||
var lw *logWriter
|
||||
useInternalLog := p.needInternalLogging()
|
||||
if useInternalLog {
|
||||
p.mu.Lock()
|
||||
lw = p.internalLogWriter
|
||||
p.mu.Unlock()
|
||||
if lw == nil {
|
||||
w.WriteHeader(http.StatusMovedPermanently)
|
||||
return
|
||||
}
|
||||
} else if p.cfg.Service.LogPath == "" {
|
||||
// No logging configured at all.
|
||||
w.WriteHeader(http.StatusMovedPermanently)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse optional "lines" query param for initial context.
|
||||
numLines := 10
|
||||
if v := request.URL.Query().Get("lines"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
||||
numLines = n
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Header().Set("Transfer-Encoding", "chunked")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
if useInternalLog {
|
||||
// Internal logging mode: subscribe to the logWriter.
|
||||
|
||||
// Send last N lines as initial context.
|
||||
if numLines > 0 {
|
||||
if tail := lw.tailLastLines(numLines); len(tail) > 0 {
|
||||
w.Write(tail)
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
ch, unsub := lw.Subscribe()
|
||||
defer unsub()
|
||||
for {
|
||||
select {
|
||||
case data, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, err := w.Write(data); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
case <-request.Context().Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// File-based logging mode: tail the log file.
|
||||
logFile := normalizeLogFilePath(p.cfg.Service.LogPath)
|
||||
f, err := os.Open(logFile)
|
||||
if err != nil {
|
||||
// Already committed 200, just return.
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Seek to show last N lines.
|
||||
if numLines > 0 {
|
||||
if tail := tailFileLastLines(f, numLines); len(tail) > 0 {
|
||||
w.Write(tail)
|
||||
flusher.Flush()
|
||||
}
|
||||
} else {
|
||||
// Seek to end.
|
||||
f.Seek(0, io.SeekEnd)
|
||||
}
|
||||
|
||||
// Poll for new data.
|
||||
buf := make([]byte, 4096)
|
||||
ticker := time.NewTicker(200 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
n, err := f.Read(buf)
|
||||
if n > 0 {
|
||||
if _, werr := w.Write(buf[:n]); werr != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
if err != nil && err != io.EOF {
|
||||
return
|
||||
}
|
||||
case <-request.Context().Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// tailFileLastLines reads the last n lines from a file and returns them.
|
||||
// The file position is left at the end of the file after this call.
|
||||
func tailFileLastLines(f *os.File, n int) []byte {
|
||||
stat, err := f.Stat()
|
||||
if err != nil || stat.Size() == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read from the end in chunks to find the last n lines.
|
||||
const chunkSize = 4096
|
||||
fileSize := stat.Size()
|
||||
var lines []byte
|
||||
offset := fileSize
|
||||
count := 0
|
||||
|
||||
for offset > 0 && count <= n {
|
||||
readSize := int64(chunkSize)
|
||||
if readSize > offset {
|
||||
readSize = offset
|
||||
}
|
||||
offset -= readSize
|
||||
buf := make([]byte, readSize)
|
||||
nRead, err := f.ReadAt(buf, offset)
|
||||
if err != nil && err != io.EOF {
|
||||
break
|
||||
}
|
||||
buf = buf[:nRead]
|
||||
lines = append(buf, lines...)
|
||||
|
||||
// Count newlines in this chunk.
|
||||
for _, b := range buf {
|
||||
if b == '\n' {
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Trim to last n lines.
|
||||
idx := 0
|
||||
nlCount := 0
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
if lines[i] == '\n' {
|
||||
nlCount++
|
||||
if nlCount == n+1 {
|
||||
idx = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
lines = lines[idx:]
|
||||
|
||||
// Seek to end of file for subsequent reads.
|
||||
f.Seek(0, io.SeekEnd)
|
||||
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
|
||||
@@ -0,0 +1,446 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
"tailscale.com/net/netmon"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
"github.com/Control-D-Inc/ctrld/internal/dnscache"
|
||||
)
|
||||
|
||||
// DNS64 synthesis for IPv6-only networks WITHOUT client-side 464XLAT (no
|
||||
// CLAT). On such networks the carrier's DNS64 resolver is load-bearing: it
|
||||
// synthesizes AAAA records mapping IPv4-only destinations into the NAT64
|
||||
// prefix, and there is no CLAT interface to carry real IPv4 traffic. When
|
||||
// ctrld answers with genuine A records there, IPv4-only destinations become
|
||||
// unreachable — DNS resolves but connectivity fails (issue companion to
|
||||
// #533; tethering is unaffected because Apple/Android always provide CLAT).
|
||||
//
|
||||
// ctrld therefore performs its own RFC 6147-style synthesis after filtering:
|
||||
// when the network is IPv6-only with no CLAT and a NAT64 prefix is known,
|
||||
// an AAAA query whose (policy-approved) answer contains no AAAA records is
|
||||
// re-resolved as an A query through the same upstream, and the A records are
|
||||
// mapped into the NAT64 prefix. Blocked answers are never synthesized —
|
||||
// synthesis runs on the answer the policy engine already approved.
|
||||
//
|
||||
// NAT64 prefix discovery uses RFC 7050: resolve AAAA for ipv4only.arpa
|
||||
// through the network's own resolvers and derive the prefix from the
|
||||
// embedded well-known IPv4 addresses (192.0.0.170/171). PREF64 router
|
||||
// advertisements (RFC 8781) are not parsed; RFC 7050 covers the same
|
||||
// networks without OS-specific RA plumbing.
|
||||
|
||||
const (
|
||||
// dns64RecheckInterval bounds how often network state (CLAT presence,
|
||||
// IPv4 availability, NAT64 prefix) is re-evaluated.
|
||||
dns64RecheckInterval = 5 * time.Minute
|
||||
// dns64WellKnownName is the RFC 7050 discovery name.
|
||||
dns64WellKnownName = "ipv4only.arpa."
|
||||
// dns64DiscoverTimeout bounds one background discovery attempt.
|
||||
dns64DiscoverTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
// rfc7050WellKnown are the IPv4 addresses embedded in ipv4only.arpa AAAA
|
||||
// answers on DNS64 networks (RFC 7050).
|
||||
var rfc7050WellKnown = []netip.Addr{
|
||||
netip.AddrFrom4([4]byte{192, 0, 0, 170}),
|
||||
netip.AddrFrom4([4]byte{192, 0, 0, 171}),
|
||||
}
|
||||
|
||||
var dns64WellKnownPrefix = netip.MustParsePrefix("64:ff9b::/96")
|
||||
|
||||
// clatPrefix is the RFC 7335 IPv4 service-continuity prefix used by
|
||||
// client-side translators (CLAT).
|
||||
var clatPrefix = netip.PrefixFrom(netip.AddrFrom4([4]byte{192, 0, 0, 0}), 29)
|
||||
|
||||
type dns64State struct {
|
||||
mu sync.Mutex
|
||||
checkedAt time.Time
|
||||
active bool // network is v6-only, no CLAT, prefix known
|
||||
prefix netip.Prefix // discovered NAT64 prefix (/96)
|
||||
discovering bool
|
||||
generation uint64
|
||||
}
|
||||
|
||||
// dns64NetworkClassFn is a seam for tests.
|
||||
var dns64NetworkClassFn = currentDNS64NetworkClass
|
||||
|
||||
func addrFromNetAddr(a net.Addr) (netip.Addr, bool) {
|
||||
var ip net.IP
|
||||
switch v := a.(type) {
|
||||
case *net.IPNet:
|
||||
ip = v.IP
|
||||
case *net.IPAddr:
|
||||
ip = v.IP
|
||||
default:
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
nip, ok := netip.AddrFromSlice(ip)
|
||||
if !ok {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
return nip.Unmap(), true
|
||||
}
|
||||
|
||||
// dns64NetworkClass classifies the host addressing state from interface
|
||||
// addresses. Only IPv4 on the default-route interface counts as usable, so
|
||||
// RFC1918 addresses owned by Docker, Parallels, VMware, and similar virtual
|
||||
// interfaces do not disable DNS64. CLAT is detected across all interfaces.
|
||||
func dns64NetworkClass(defaultRouteAddrs, allAddrs []net.Addr) (hasUsableIPv4, hasCLAT bool) {
|
||||
for _, a := range allAddrs {
|
||||
nip, ok := addrFromNetAddr(a)
|
||||
if !ok || !nip.Is4() {
|
||||
continue
|
||||
}
|
||||
if clatPrefix.Contains(nip) {
|
||||
hasCLAT = true
|
||||
}
|
||||
}
|
||||
for _, a := range defaultRouteAddrs {
|
||||
nip, ok := addrFromNetAddr(a)
|
||||
if !ok || !nip.Is4() || clatPrefix.Contains(nip) {
|
||||
continue
|
||||
}
|
||||
if nip.IsLoopback() || nip.IsLinkLocalUnicast() || nip.IsUnspecified() {
|
||||
continue
|
||||
}
|
||||
hasUsableIPv4 = true
|
||||
}
|
||||
return hasUsableIPv4, hasCLAT
|
||||
}
|
||||
|
||||
func currentDNS64NetworkClass() (hasUsableIPv4, hasCLAT bool, err error) {
|
||||
defaultRouteInterface, err := netmon.DefaultRouteInterface()
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
iface, err := net.InterfaceByName(defaultRouteInterface)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
defaultRouteAddrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
allAddrs, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
hasUsableIPv4, hasCLAT = dns64NetworkClass(defaultRouteAddrs, allAddrs)
|
||||
return hasUsableIPv4, hasCLAT, nil
|
||||
}
|
||||
|
||||
// nat64PrefixFromAnswer derives the NAT64 prefix from an ipv4only.arpa AAAA
|
||||
// answer per RFC 7050: find an AAAA embedding a well-known IPv4 address in
|
||||
// its last 4 bytes and take the leading /96.
|
||||
func nat64PrefixFromAnswer(answer *dns.Msg) (netip.Prefix, bool) {
|
||||
if answer == nil {
|
||||
return netip.Prefix{}, false
|
||||
}
|
||||
for _, rr := range answer.Answer {
|
||||
aaaa, ok := rr.(*dns.AAAA)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
v6, ok := netip.AddrFromSlice(aaaa.AAAA.To16())
|
||||
if !ok || v6.Is4() {
|
||||
continue
|
||||
}
|
||||
b := v6.As16()
|
||||
embedded := netip.AddrFrom4([4]byte{b[12], b[13], b[14], b[15]})
|
||||
for _, wk := range rfc7050WellKnown {
|
||||
if embedded == wk {
|
||||
var p [16]byte
|
||||
copy(p[:12], b[:12])
|
||||
return netip.PrefixFrom(netip.AddrFrom16(p), 96), true
|
||||
}
|
||||
}
|
||||
}
|
||||
return netip.Prefix{}, false
|
||||
}
|
||||
|
||||
// synthesizeAAAAFromA returns a copy of aAnswer converted into an AAAA
|
||||
// answer for the original AAAA request: every A record is mapped into the
|
||||
// NAT64 prefix; other records (CNAMEs etc.) are preserved.
|
||||
func synthesizeAAAAFromA(req *dns.Msg, aAnswer *dns.Msg, prefix netip.Prefix) *dns.Msg {
|
||||
if aAnswer == nil {
|
||||
return nil
|
||||
}
|
||||
out := aAnswer.Copy()
|
||||
out.SetReply(req)
|
||||
out.Rcode = aAnswer.Rcode
|
||||
out.Compress = true
|
||||
answers := make([]dns.RR, 0, len(aAnswer.Answer))
|
||||
pb := prefix.Addr().As16()
|
||||
for _, rr := range aAnswer.Answer {
|
||||
a, ok := rr.(*dns.A)
|
||||
if !ok {
|
||||
// Preserve CNAME chain records unchanged.
|
||||
answers = append(answers, dns.Copy(rr))
|
||||
continue
|
||||
}
|
||||
v4 := a.A.To4()
|
||||
if v4 == nil {
|
||||
continue
|
||||
}
|
||||
v4Addr, ok := netip.AddrFromSlice(v4)
|
||||
if !ok || !v4Addr.IsGlobalUnicast() || (prefix == dns64WellKnownPrefix && v4Addr.IsPrivate()) {
|
||||
continue
|
||||
}
|
||||
var b [16]byte
|
||||
copy(b[:12], pb[:12])
|
||||
copy(b[12:], v4)
|
||||
aaaa := &dns.AAAA{
|
||||
Hdr: dns.RR_Header{
|
||||
Name: a.Hdr.Name,
|
||||
Rrtype: dns.TypeAAAA,
|
||||
Class: a.Hdr.Class,
|
||||
Ttl: a.Hdr.Ttl,
|
||||
},
|
||||
AAAA: net.IP(b[:]),
|
||||
}
|
||||
answers = append(answers, aaaa)
|
||||
}
|
||||
out.Answer = answers
|
||||
return out
|
||||
}
|
||||
|
||||
// answerHasAAAA reports whether the answer section contains any AAAA record.
|
||||
func answerHasAAAA(answer *dns.Msg) bool {
|
||||
if answer == nil {
|
||||
return false
|
||||
}
|
||||
for _, rr := range answer.Answer {
|
||||
if _, ok := rr.(*dns.AAAA); ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// dns64Eligible reports whether an answer qualifies for DNS64 synthesis:
|
||||
// an AAAA query answered NOERROR with no AAAA records. NXDOMAIN and error
|
||||
// rcodes are never synthesized (RFC 6147 §5.1.2: the name genuinely does
|
||||
// not exist or the query failed).
|
||||
func dns64Eligible(req, answer *dns.Msg) bool {
|
||||
if req == nil || answer == nil || len(req.Question) == 0 || req.CheckingDisabled {
|
||||
return false
|
||||
}
|
||||
if req.Question[0].Qtype != dns.TypeAAAA {
|
||||
return false
|
||||
}
|
||||
if answer.Rcode != dns.RcodeSuccess {
|
||||
return false
|
||||
}
|
||||
return !answerHasAAAA(answer)
|
||||
}
|
||||
|
||||
// dns64Active reports whether synthesis should currently run, re-evaluating
|
||||
// network class and (if needed) kicking off background prefix discovery at
|
||||
// most every dns64RecheckInterval.
|
||||
func (p *prog) dns64Active() bool {
|
||||
s := &p.dns64
|
||||
s.mu.Lock()
|
||||
if time.Since(s.checkedAt) < dns64RecheckInterval {
|
||||
active := s.active
|
||||
s.mu.Unlock()
|
||||
return active
|
||||
}
|
||||
s.checkedAt = time.Now()
|
||||
generation := s.generation
|
||||
s.mu.Unlock()
|
||||
|
||||
hasV4, hasCLAT, err := dns64NetworkClassFn()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if generation != s.generation {
|
||||
return s.active
|
||||
}
|
||||
if err != nil {
|
||||
s.active = false
|
||||
return false
|
||||
}
|
||||
if hasV4 || hasCLAT {
|
||||
// Dual-stack or 464XLAT: the OS/CLAT handles IPv4 reachability;
|
||||
// synthesis would be unnecessary (and on CLAT networks, harmful —
|
||||
// real A records are preferable so traffic uses the CLAT).
|
||||
s.generation++
|
||||
s.active = false
|
||||
s.prefix = netip.Prefix{}
|
||||
s.discovering = false
|
||||
return false
|
||||
}
|
||||
s.active = s.prefix.IsValid()
|
||||
if !s.discovering {
|
||||
s.discovering = true
|
||||
generation := s.generation
|
||||
go p.discoverNAT64Prefix(generation)
|
||||
}
|
||||
return s.active
|
||||
}
|
||||
|
||||
func (p *prog) activeDNS64Prefix() (netip.Prefix, bool) {
|
||||
if !p.dns64Active() {
|
||||
return netip.Prefix{}, false
|
||||
}
|
||||
p.dns64.mu.Lock()
|
||||
defer p.dns64.mu.Unlock()
|
||||
return p.dns64.prefix, p.dns64.prefix.IsValid()
|
||||
}
|
||||
|
||||
func dns64CacheVariant(prefix netip.Prefix) string {
|
||||
return "dns64:" + prefix.String()
|
||||
}
|
||||
|
||||
func dns64CacheKey(msg *dns.Msg, upstream string, prefix netip.Prefix) dnscache.Key {
|
||||
return dnscache.NewVariantKey(msg, upstream, dns64CacheVariant(prefix))
|
||||
}
|
||||
|
||||
func (p *prog) resetDNS64State() {
|
||||
p.dns64.mu.Lock()
|
||||
p.dns64.generation++
|
||||
p.dns64.checkedAt = time.Time{}
|
||||
p.dns64.active = false
|
||||
p.dns64.prefix = netip.Prefix{}
|
||||
p.dns64.discovering = false
|
||||
p.dns64.mu.Unlock()
|
||||
}
|
||||
|
||||
func dns64RouteStateChanged(delta *netmon.ChangeDelta) bool {
|
||||
if delta == nil || delta.Old == nil || delta.New == nil {
|
||||
return true
|
||||
}
|
||||
if delta.Old.DefaultRouteInterface != delta.New.DefaultRouteInterface ||
|
||||
delta.Old.HaveV4 != delta.New.HaveV4 || delta.Old.HaveV6 != delta.New.HaveV6 {
|
||||
return true
|
||||
}
|
||||
if dns64StateHasCLAT(delta.Old) != dns64StateHasCLAT(delta.New) {
|
||||
return true
|
||||
}
|
||||
iface := delta.New.DefaultRouteInterface
|
||||
oldPrefixes := delta.Old.InterfaceIPs[iface]
|
||||
newPrefixes := delta.New.InterfaceIPs[iface]
|
||||
if len(oldPrefixes) != len(newPrefixes) {
|
||||
return true
|
||||
}
|
||||
newPrefixSet := make(map[netip.Prefix]struct{}, len(newPrefixes))
|
||||
for _, prefix := range newPrefixes {
|
||||
newPrefixSet[prefix] = struct{}{}
|
||||
}
|
||||
for _, prefix := range oldPrefixes {
|
||||
if _, ok := newPrefixSet[prefix]; !ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func dns64StateHasCLAT(state *netmon.State) bool {
|
||||
for _, prefixes := range state.InterfaceIPs {
|
||||
for _, prefix := range prefixes {
|
||||
if clatPrefix.Contains(prefix.Addr().Unmap()) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *prog) handleDNS64NetworkChange(delta *netmon.ChangeDelta, major bool) {
|
||||
if major || dns64RouteStateChanged(delta) {
|
||||
p.resetDNS64State()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *prog) storeDiscoveredNAT64Prefix(generation uint64, prefix netip.Prefix) bool {
|
||||
p.dns64.mu.Lock()
|
||||
defer p.dns64.mu.Unlock()
|
||||
if p.dns64.generation != generation {
|
||||
return false
|
||||
}
|
||||
p.dns64.prefix = prefix
|
||||
p.dns64.active = true
|
||||
p.dns64.checkedAt = time.Now()
|
||||
return true
|
||||
}
|
||||
|
||||
// discoverNAT64Prefix resolves ipv4only.arpa AAAA through the OS-discovered
|
||||
// resolvers (the network's own DNS64 resolver) and stores the derived
|
||||
// prefix. Runs in the background; failures leave synthesis inactive until
|
||||
// the next recheck window.
|
||||
func (p *prog) discoverNAT64Prefix(generation uint64) {
|
||||
defer func() {
|
||||
p.dns64.mu.Lock()
|
||||
if p.dns64.generation == generation {
|
||||
p.dns64.discovering = false
|
||||
}
|
||||
p.dns64.mu.Unlock()
|
||||
}()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), dns64DiscoverTimeout)
|
||||
defer cancel()
|
||||
ctx = ctrld.LoggerCtx(ctx, mainLog.Load())
|
||||
|
||||
msg := new(dns.Msg)
|
||||
msg.SetQuestion(dns64WellKnownName, dns.TypeAAAA)
|
||||
resolver, err := ctrld.NewResolver(ctx, osUpstreamConfig)
|
||||
if err != nil {
|
||||
mainLog.Load().Debug().Err(err).Msg("dns64: could not create OS resolver for NAT64 discovery")
|
||||
return
|
||||
}
|
||||
answer, err := resolver.Resolve(ctx, msg)
|
||||
if err != nil {
|
||||
mainLog.Load().Debug().Err(err).Msg("dns64: NAT64 prefix discovery query failed")
|
||||
return
|
||||
}
|
||||
prefix, ok := nat64PrefixFromAnswer(answer)
|
||||
if !ok {
|
||||
mainLog.Load().Debug().Msg("dns64: no NAT64 prefix present (not a DNS64 network)")
|
||||
return
|
||||
}
|
||||
if !p.storeDiscoveredNAT64Prefix(generation, prefix) {
|
||||
return
|
||||
}
|
||||
mainLog.Load().Info().Msgf("dns64: discovered NAT64 prefix %s; enabling AAAA synthesis for IPv6-only network without CLAT", prefix)
|
||||
}
|
||||
|
||||
// maybeDNS64 applies DNS64 synthesis to an already-filtered answer when the
|
||||
// network requires it. resolveA re-resolves the question as an A query
|
||||
// through the same upstream that produced the answer.
|
||||
func (p *prog) maybeDNS64(ctx context.Context, req *dns.Msg, answer *dns.Msg, resolveA func(*dns.Msg) *dns.Msg) (*dns.Msg, netip.Prefix) {
|
||||
if !dns64Eligible(req, answer) || !p.dns64Active() {
|
||||
return answer, netip.Prefix{}
|
||||
}
|
||||
p.dns64.mu.Lock()
|
||||
prefix := p.dns64.prefix
|
||||
generation := p.dns64.generation
|
||||
p.dns64.mu.Unlock()
|
||||
if !prefix.IsValid() {
|
||||
return answer, netip.Prefix{}
|
||||
}
|
||||
aReq := req.Copy()
|
||||
aReq.Question[0].Qtype = dns.TypeA
|
||||
aAnswer := resolveA(aReq)
|
||||
if aAnswer == nil || aAnswer.Rcode != dns.RcodeSuccess || !sameQuestion(aReq, aAnswer) {
|
||||
return answer, netip.Prefix{}
|
||||
}
|
||||
synth := synthesizeAAAAFromA(req, aAnswer, prefix)
|
||||
p.dns64.mu.Lock()
|
||||
current := p.dns64.active && p.dns64.generation == generation && p.dns64.prefix == prefix
|
||||
p.dns64.mu.Unlock()
|
||||
if !current {
|
||||
return answer, netip.Prefix{}
|
||||
}
|
||||
if synth == nil || !answerHasAAAA(synth) {
|
||||
// The companion A lookup completed successfully, so this passthrough
|
||||
// answer is definitive for the current prefix and may be cached in the
|
||||
// DNS64 variant to avoid repeating both upstream lookups.
|
||||
return answer, prefix
|
||||
}
|
||||
ctrld.Log(ctx, mainLog.Load().Debug(), "dns64: synthesized AAAA from A records via NAT64 prefix %s", prefix)
|
||||
return synth, prefix
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
"tailscale.com/net/netmon"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld/internal/dnscache"
|
||||
)
|
||||
|
||||
func mkAAAAReq(name string) *dns.Msg {
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion(dns.Fqdn(name), dns.TypeAAAA)
|
||||
return m
|
||||
}
|
||||
|
||||
func mkNetAddr(cidr string) net.Addr {
|
||||
ip, n, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
n.IP = ip
|
||||
return n
|
||||
}
|
||||
|
||||
func TestDNS64NetworkClass(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
defaultRouteAddrs []net.Addr
|
||||
allAddrs []net.Addr
|
||||
wantV4 bool
|
||||
wantCLAT bool
|
||||
}{
|
||||
{"dual stack", []net.Addr{mkNetAddr("10.0.11.61/23"), mkNetAddr("2605:8d80::1/64")}, []net.Addr{mkNetAddr("10.0.11.61/23"), mkNetAddr("2605:8d80::1/64")}, true, false},
|
||||
{"virtual rfc1918 does not imply ipv4 connectivity", []net.Addr{mkNetAddr("2605:8d80::1/64")}, []net.Addr{mkNetAddr("2605:8d80::1/64"), mkNetAddr("192.168.65.1/24")}, false, false},
|
||||
{"464xlat tether (customer case)", []net.Addr{mkNetAddr("2605:8d80:6b41:122::1/64")}, []net.Addr{mkNetAddr("192.0.0.2/32"), mkNetAddr("2605:8d80:6b41:122::1/64")}, false, true},
|
||||
{"v6 only no clat (dns64 network)", []net.Addr{mkNetAddr("2001:db8::1/64")}, []net.Addr{mkNetAddr("2001:db8::1/64")}, false, false},
|
||||
{"loopback only", []net.Addr{mkNetAddr("127.0.0.1/8"), mkNetAddr("::1/128")}, []net.Addr{mkNetAddr("127.0.0.1/8"), mkNetAddr("::1/128")}, false, false},
|
||||
{"link local v4 ignored", []net.Addr{mkNetAddr("169.254.10.1/16"), mkNetAddr("2001:db8::1/64")}, []net.Addr{mkNetAddr("169.254.10.1/16"), mkNetAddr("2001:db8::1/64")}, false, false},
|
||||
{"clat plus real v4", []net.Addr{mkNetAddr("10.0.0.5/24")}, []net.Addr{mkNetAddr("192.0.0.2/32"), mkNetAddr("10.0.0.5/24")}, true, true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
v4, clat := dns64NetworkClass(tc.defaultRouteAddrs, tc.allAddrs)
|
||||
if v4 != tc.wantV4 || clat != tc.wantCLAT {
|
||||
t.Errorf("dns64NetworkClass() = (v4=%v, clat=%v), want (v4=%v, clat=%v)", v4, clat, tc.wantV4, tc.wantCLAT)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNAT64PrefixFromAnswer(t *testing.T) {
|
||||
mkAnswer := func(v6 string) *dns.Msg {
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion(dns64WellKnownName, dns.TypeAAAA)
|
||||
r := new(dns.Msg)
|
||||
r.SetReply(m)
|
||||
if v6 != "" {
|
||||
r.Answer = append(r.Answer, &dns.AAAA{
|
||||
Hdr: dns.RR_Header{Name: dns64WellKnownName, Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: 300},
|
||||
AAAA: net.ParseIP(v6),
|
||||
})
|
||||
}
|
||||
return r
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
answer *dns.Msg
|
||||
wantPrefix string
|
||||
wantOK bool
|
||||
}{
|
||||
{"well-known prefix + 192.0.0.170", mkAnswer("64:ff9b::c000:aa"), "64:ff9b::/96", true},
|
||||
{"well-known prefix + 192.0.0.171", mkAnswer("64:ff9b::c000:ab"), "64:ff9b::/96", true},
|
||||
{"carrier-specific prefix", mkAnswer("2001:db8:64::c000:aa"), "2001:db8:64::/96", true},
|
||||
{"non-dns64 answer (real aaaa)", mkAnswer("2001:db8::1"), "", false},
|
||||
{"empty answer", mkAnswer(""), "", false},
|
||||
{"nil answer", nil, "", false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p, ok := nat64PrefixFromAnswer(tc.answer)
|
||||
if ok != tc.wantOK {
|
||||
t.Fatalf("nat64PrefixFromAnswer() ok = %v, want %v", ok, tc.wantOK)
|
||||
}
|
||||
if ok && p != netip.MustParsePrefix(tc.wantPrefix) {
|
||||
t.Errorf("nat64PrefixFromAnswer() = %s, want %s", p, tc.wantPrefix)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSynthesizeAAAAFromA(t *testing.T) {
|
||||
req := mkAAAAReq("legacy.example.com")
|
||||
aReq := req.Copy()
|
||||
aReq.Question[0].Qtype = dns.TypeA
|
||||
aAns := new(dns.Msg)
|
||||
aAns.SetReply(aReq)
|
||||
aAns.Answer = []dns.RR{
|
||||
&dns.CNAME{Hdr: dns.RR_Header{Name: "legacy.example.com.", Rrtype: dns.TypeCNAME, Class: dns.ClassINET, Ttl: 60}, Target: "cdn.example.net."},
|
||||
&dns.A{Hdr: dns.RR_Header{Name: "cdn.example.net.", Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60}, A: net.ParseIP("198.51.100.7")},
|
||||
}
|
||||
prefix := netip.MustParsePrefix("64:ff9b::/96")
|
||||
|
||||
out := synthesizeAAAAFromA(req, aAns, prefix)
|
||||
if out == nil {
|
||||
t.Fatal("synthesizeAAAAFromA returned nil")
|
||||
}
|
||||
var gotAAAA *dns.AAAA
|
||||
var gotCNAME *dns.CNAME
|
||||
for _, rr := range out.Answer {
|
||||
switch v := rr.(type) {
|
||||
case *dns.AAAA:
|
||||
gotAAAA = v
|
||||
case *dns.CNAME:
|
||||
gotCNAME = v
|
||||
case *dns.A:
|
||||
t.Error("synthesized answer still contains an A record")
|
||||
}
|
||||
}
|
||||
if gotCNAME == nil {
|
||||
t.Error("CNAME chain record not preserved")
|
||||
}
|
||||
if gotAAAA == nil {
|
||||
t.Fatal("no synthesized AAAA record")
|
||||
}
|
||||
want := net.ParseIP("64:ff9b::c633:6407") // 198.51.100.7 embedded
|
||||
if !gotAAAA.AAAA.Equal(want) {
|
||||
t.Errorf("synthesized AAAA = %s, want %s", gotAAAA.AAAA, want)
|
||||
}
|
||||
if gotAAAA.Hdr.Ttl != 60 {
|
||||
t.Errorf("TTL not preserved: got %d", gotAAAA.Hdr.Ttl)
|
||||
}
|
||||
if out.Question[0].Qtype != dns.TypeAAAA {
|
||||
t.Errorf("reply question type = %d, want AAAA", out.Question[0].Qtype)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSynthesizeAAAAFromAIPv4Eligibility(t *testing.T) {
|
||||
req := mkAAAAReq("blocked.example")
|
||||
tests := []struct {
|
||||
name string
|
||||
prefix netip.Prefix
|
||||
ip string
|
||||
want bool
|
||||
}{
|
||||
{"unspecified with well-known prefix", dns64WellKnownPrefix, "0.0.0.0", false},
|
||||
{"loopback with well-known prefix", dns64WellKnownPrefix, "127.0.0.1", false},
|
||||
{"link-local with well-known prefix", dns64WellKnownPrefix, "169.254.1.1", false},
|
||||
{"private with well-known prefix", dns64WellKnownPrefix, "10.0.0.1", false},
|
||||
{"private with network-specific prefix", netip.MustParsePrefix("2001:db8:64::/96"), "10.0.0.1", true},
|
||||
{"unspecified with network-specific prefix", netip.MustParsePrefix("2001:db8:64::/96"), "0.0.0.0", false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
aReq := req.Copy()
|
||||
aReq.Question[0].Qtype = dns.TypeA
|
||||
aAns := new(dns.Msg)
|
||||
aAns.SetReply(aReq)
|
||||
aAns.Answer = []dns.RR{&dns.A{Hdr: dns.RR_Header{Name: req.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60}, A: net.ParseIP(tc.ip)}}
|
||||
if got := answerHasAAAA(synthesizeAAAAFromA(req, aAns, tc.prefix)); got != tc.want {
|
||||
t.Fatalf("answerHasAAAA() = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNS64Eligible(t *testing.T) {
|
||||
emptyReply := func(req *dns.Msg, rcode int) *dns.Msg {
|
||||
r := new(dns.Msg)
|
||||
r.SetReply(req)
|
||||
r.Rcode = rcode
|
||||
return r
|
||||
}
|
||||
aaaaReq := mkAAAAReq("x.example.")
|
||||
cdReq := aaaaReq.Copy()
|
||||
cdReq.CheckingDisabled = true
|
||||
withAAAA := emptyReply(aaaaReq, dns.RcodeSuccess)
|
||||
withAAAA.Answer = []dns.RR{&dns.AAAA{Hdr: dns.RR_Header{Name: "x.example.", Rrtype: dns.TypeAAAA, Class: dns.ClassINET}, AAAA: net.ParseIP("2001:db8::1")}}
|
||||
aReq := new(dns.Msg)
|
||||
aReq.SetQuestion("x.example.", dns.TypeA)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
req *dns.Msg
|
||||
answer *dns.Msg
|
||||
want bool
|
||||
}{
|
||||
{"AAAA empty NOERROR -> eligible", aaaaReq, emptyReply(aaaaReq, dns.RcodeSuccess), true},
|
||||
{"AAAA with records -> not eligible", aaaaReq, withAAAA, false},
|
||||
{"CD query is not synthesized", cdReq, emptyReply(cdReq, dns.RcodeSuccess), false},
|
||||
{"NXDOMAIN never synthesized", aaaaReq, emptyReply(aaaaReq, dns.RcodeNameError), false},
|
||||
{"SERVFAIL never synthesized", aaaaReq, emptyReply(aaaaReq, dns.RcodeServerFailure), false},
|
||||
{"A query not eligible", aReq, emptyReply(aReq, dns.RcodeSuccess), false},
|
||||
{"nil answer", aaaaReq, nil, false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := dns64Eligible(tc.req, tc.answer); got != tc.want {
|
||||
t.Errorf("dns64Eligible() = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNS64ActiveGating(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
hasV4 bool
|
||||
hasCLAT bool
|
||||
prefix string
|
||||
want bool
|
||||
}{
|
||||
{"dual stack, prefix known", true, false, "64:ff9b::/96", false},
|
||||
{"clat network, prefix known", false, true, "64:ff9b::/96", false},
|
||||
{"v6-only no clat, prefix known", false, false, "64:ff9b::/96", true},
|
||||
{"v6-only no clat, no prefix yet", false, false, "", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
old := dns64NetworkClassFn
|
||||
dns64NetworkClassFn = func() (bool, bool, error) { return tc.hasV4, tc.hasCLAT, nil }
|
||||
t.Cleanup(func() { dns64NetworkClassFn = old })
|
||||
|
||||
p := &prog{}
|
||||
p.dns64.discovering = true // block background discovery in tests
|
||||
if tc.prefix != "" {
|
||||
p.dns64.prefix = netip.MustParsePrefix(tc.prefix)
|
||||
}
|
||||
if got := p.dns64Active(); got != tc.want {
|
||||
t.Errorf("dns64Active() = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreDiscoveredNAT64PrefixActivatesImmediately(t *testing.T) {
|
||||
p := &prog{}
|
||||
p.dns64.generation = 4
|
||||
p.dns64.checkedAt = time.Now()
|
||||
prefix := netip.MustParsePrefix("64:ff9b::/96")
|
||||
if !p.storeDiscoveredNAT64Prefix(4, prefix) {
|
||||
t.Fatal("current discovery result was rejected")
|
||||
}
|
||||
if !p.dns64.active || p.dns64.prefix != prefix || p.dns64.checkedAt.IsZero() {
|
||||
t.Fatalf("discovery did not immediately activate DNS64: active=%v prefix=%s checkedAt=%s", p.dns64.active, p.dns64.prefix, p.dns64.checkedAt)
|
||||
}
|
||||
if p.storeDiscoveredNAT64Prefix(3, netip.MustParsePrefix("2001:db8:64::/96")) {
|
||||
t.Fatal("stale discovery result was accepted")
|
||||
}
|
||||
if p.dns64.prefix != prefix {
|
||||
t.Fatalf("stale discovery replaced prefix: %s", p.dns64.prefix)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNS64NetworkChangeInvalidatesPrefix(t *testing.T) {
|
||||
p := &prog{}
|
||||
p.dns64.prefix = netip.MustParsePrefix("64:ff9b::/96")
|
||||
p.dns64.active = true
|
||||
p.dns64.checkedAt = time.Now()
|
||||
p.dns64.discovering = true
|
||||
|
||||
delta := &netmon.ChangeDelta{
|
||||
Old: &netmon.State{DefaultRouteInterface: "en0", HaveV6: true, InterfaceIPs: map[string][]netip.Prefix{"en0": {netip.MustParsePrefix("2001:db8:1::1/64")}}},
|
||||
New: &netmon.State{DefaultRouteInterface: "en0", HaveV6: true, InterfaceIPs: map[string][]netip.Prefix{"en0": {netip.MustParsePrefix("2001:db8:2::1/64")}}},
|
||||
}
|
||||
p.handleDNS64NetworkChange(delta, false)
|
||||
if p.dns64.active || p.dns64.prefix.IsValid() || !p.dns64.checkedAt.IsZero() || p.dns64.discovering {
|
||||
t.Fatalf("network change did not invalidate DNS64 state: active=%v prefix=%s checkedAt=%s discovering=%v", p.dns64.active, p.dns64.prefix, p.dns64.checkedAt, p.dns64.discovering)
|
||||
}
|
||||
if p.dns64.generation != 1 {
|
||||
t.Fatalf("generation = %d, want 1", p.dns64.generation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNS64CacheKeyPartitionsByPrefix(t *testing.T) {
|
||||
req := mkAAAAReq("legacy.example")
|
||||
normal := dnscache.NewKey(req, "upstream.0")
|
||||
wellKnown := dns64CacheKey(req, "upstream.0", netip.MustParsePrefix("64:ff9b::/96"))
|
||||
carrier := dns64CacheKey(req, "upstream.0", netip.MustParsePrefix("2001:db8:64::/96"))
|
||||
if normal == wellKnown || wellKnown == carrier {
|
||||
t.Fatalf("normal and per-prefix synthesized cache keys must be distinct: normal=%+v well-known=%+v carrier=%+v", normal, wellKnown, carrier)
|
||||
}
|
||||
cache, err := dnscache.NewLRUCache(4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
answer := new(dns.Msg)
|
||||
answer.SetReply(req)
|
||||
cache.Add(wellKnown, dnscache.NewValue(answer, time.Now().Add(time.Minute)))
|
||||
if cache.Get(wellKnown) == nil || cache.Get(normal) != nil || cache.Get(carrier) != nil {
|
||||
t.Fatal("synthesized cache entry crossed the normal or carrier-prefix partition")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeDNS64EndToEnd(t *testing.T) {
|
||||
old := dns64NetworkClassFn
|
||||
dns64NetworkClassFn = func() (bool, bool, error) { return false, false, nil }
|
||||
t.Cleanup(func() { dns64NetworkClassFn = old })
|
||||
|
||||
p := &prog{}
|
||||
p.dns64.discovering = true
|
||||
p.dns64.prefix = netip.MustParsePrefix("64:ff9b::/96")
|
||||
|
||||
req := mkAAAAReq("legacy.example.com")
|
||||
empty := new(dns.Msg)
|
||||
empty.SetReply(req)
|
||||
|
||||
resolveA := func(aReq *dns.Msg) *dns.Msg {
|
||||
if aReq.Question[0].Qtype != dns.TypeA {
|
||||
t.Fatalf("resolveA called with qtype %d", aReq.Question[0].Qtype)
|
||||
}
|
||||
r := new(dns.Msg)
|
||||
r.SetReply(aReq)
|
||||
r.Answer = []dns.RR{&dns.A{Hdr: dns.RR_Header{Name: aReq.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 30}, A: net.ParseIP("203.0.113.9")}}
|
||||
return r
|
||||
}
|
||||
out, usedPrefix := p.maybeDNS64(t.Context(), req, empty, resolveA)
|
||||
if !answerHasAAAA(out) {
|
||||
t.Fatal("expected synthesized AAAA answer")
|
||||
}
|
||||
if usedPrefix != p.dns64.prefix {
|
||||
t.Fatalf("used prefix = %s, want %s", usedPrefix, p.dns64.prefix)
|
||||
}
|
||||
|
||||
blocked := new(dns.Msg)
|
||||
blocked.SetReply(req)
|
||||
blocked.Rcode = dns.RcodeNameError
|
||||
if got, _ := p.maybeDNS64(t.Context(), req, blocked, resolveA); got != blocked {
|
||||
t.Error("NXDOMAIN answer must pass through unsynthesized")
|
||||
}
|
||||
|
||||
blockedA := func(aReq *dns.Msg) *dns.Msg {
|
||||
r := new(dns.Msg)
|
||||
r.SetReply(aReq)
|
||||
r.Answer = []dns.RR{&dns.A{Hdr: dns.RR_Header{Name: aReq.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 30}, A: net.IPv4zero}}
|
||||
return r
|
||||
}
|
||||
if got, prefix := p.maybeDNS64(t.Context(), req, empty, blockedA); got != empty || prefix != p.dns64.prefix {
|
||||
t.Error("NODATA plus 0.0.0.0 block answer must pass through unsynthesized and be cacheable for the current prefix")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeDNS64DropsStaleInFlightPrefix(t *testing.T) {
|
||||
p := &prog{}
|
||||
p.dns64.active = true
|
||||
p.dns64.checkedAt = time.Now()
|
||||
p.dns64.prefix = netip.MustParsePrefix("64:ff9b::/96")
|
||||
req := mkAAAAReq("legacy.example")
|
||||
empty := new(dns.Msg)
|
||||
empty.SetReply(req)
|
||||
|
||||
got, prefix := p.maybeDNS64(t.Context(), req, empty, func(aReq *dns.Msg) *dns.Msg {
|
||||
p.resetDNS64State()
|
||||
r := new(dns.Msg)
|
||||
r.SetReply(aReq)
|
||||
r.Answer = []dns.RR{&dns.A{Hdr: dns.RR_Header{Name: aReq.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 30}, A: net.ParseIP("203.0.113.9")}}
|
||||
return r
|
||||
})
|
||||
if got != empty || prefix.IsValid() {
|
||||
t.Fatal("in-flight synthesis used a prefix invalidated by a network change")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
//go:build windows
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestStaleCleanupRequiresPositiveEvidence pins the guard that protects a live
|
||||
// pre-session-scoped ctrld service.
|
||||
//
|
||||
// ctrldServiceLiveness cannot answer for an unreachable SCM, a caller without rights, or a
|
||||
// service mid-stop. Folding those into "stopped" would let a hand-run "ctrld run" delete
|
||||
// the sublayer of a live old-build service - the exact enforcement strip the interactive
|
||||
// guard exists to prevent - because that build's non-dynamic sublayer looks like an
|
||||
// orphan. Only a positive stopped answer may unlock the cleanup.
|
||||
func TestStaleCleanupRequiresPositiveEvidence(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
interactive bool
|
||||
liveness serviceLiveness
|
||||
want bool
|
||||
}{
|
||||
// A service start is the deadlock case: nothing of ctrld's is live yet, and this
|
||||
// is the only path that can break a host locked out by orphaned filters.
|
||||
{"service start with a running service", false, serviceLivenessRunning, true},
|
||||
{"service start with an unknown state", false, serviceLivenessUnknown, true},
|
||||
{"service start with a stopped service", false, serviceLivenessStopped, true},
|
||||
|
||||
// Interactive: only positive evidence of absence unlocks it.
|
||||
{"ctrld run alongside a live service", true, serviceLivenessRunning, false},
|
||||
{"ctrld run when the SCM cannot be queried", true, serviceLivenessUnknown, false},
|
||||
{"ctrld run with the service stopped or absent", true, serviceLivenessStopped, true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := staleCleanupAllowed(tc.interactive, tc.liveness); got != tc.want {
|
||||
t.Errorf("staleCleanupAllowed(%v, %v) = %v, want %v", tc.interactive, tc.liveness, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The zero value must be the safe one: a serviceLiveness that was never assigned - a
|
||||
// future code path that forgets to set it - must not read as "nothing is live".
|
||||
if staleCleanupAllowed(true, serviceLiveness(0)) {
|
||||
t.Error("the zero serviceLiveness must not unlock the cleanup: an unset value is not evidence of absence")
|
||||
}
|
||||
}
|
||||
|
||||
// stubStaleCleanup installs fakes for the cleanup's guard inputs and for the deletion
|
||||
// itself, and returns a pointer to the delete-attempt count.
|
||||
//
|
||||
// The delete is a WFP syscall: running it for real would remove live filters from the
|
||||
// machine running the tests, so the assertion has to be made against a substituted delete.
|
||||
func stubStaleCleanup(t *testing.T, elevated bool, elevErr error, interactive bool, liveness serviceLiveness) *int {
|
||||
t.Helper()
|
||||
oldElev, oldInter, oldLive, oldDel := staleCleanupElevatedFn, staleCleanupInteractiveFn, staleCleanupLivenessFn, deleteStaleWFPSublayerFn
|
||||
t.Cleanup(func() {
|
||||
staleCleanupElevatedFn = oldElev
|
||||
staleCleanupInteractiveFn = oldInter
|
||||
staleCleanupLivenessFn = oldLive
|
||||
deleteStaleWFPSublayerFn = oldDel
|
||||
})
|
||||
|
||||
deletes := 0
|
||||
staleCleanupElevatedFn = func() (bool, error) { return elevated, elevErr }
|
||||
staleCleanupInteractiveFn = func() bool { return interactive }
|
||||
staleCleanupLivenessFn = func() serviceLiveness { return liveness }
|
||||
deleteStaleWFPSublayerFn = func() { deletes++ }
|
||||
return &deletes
|
||||
}
|
||||
|
||||
// TestCleanupStaleStateConsultsTheGuardBeforeDeleting is the caller-level half of the
|
||||
// guard's coverage.
|
||||
//
|
||||
// staleCleanupAllowed being correct proves nothing on its own: cleanupStaleDNSInterceptState
|
||||
// could stop calling it, or call it and delete anyway, and a predicate-only test would stay
|
||||
// green while a hand-run "ctrld run" stripped a live service's enforcement. This asserts on
|
||||
// the deletion itself - whether the WFP delete is attempted at all - which is the behaviour
|
||||
// that matters.
|
||||
func TestCleanupStaleStateConsultsTheGuardBeforeDeleting(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
elevated bool
|
||||
elevErr error
|
||||
interactive bool
|
||||
liveness serviceLiveness
|
||||
wantDeletes int
|
||||
}{
|
||||
{
|
||||
// The deadlock case this function exists for: a service start, where nothing
|
||||
// of ctrld's is live and the host may be carrying orphaned block-all filters.
|
||||
name: "service start attempts the delete",
|
||||
elevated: true,
|
||||
wantDeletes: 1,
|
||||
},
|
||||
{
|
||||
// A service start does not consult the SCM at all, so a running service
|
||||
// reported here must not change the outcome.
|
||||
name: "service start is not gated on service state",
|
||||
elevated: true,
|
||||
liveness: serviceLivenessRunning,
|
||||
wantDeletes: 1,
|
||||
},
|
||||
{
|
||||
name: "interactive run with a stopped service attempts the delete",
|
||||
elevated: true,
|
||||
interactive: true,
|
||||
liveness: serviceLivenessStopped,
|
||||
wantDeletes: 1,
|
||||
},
|
||||
{
|
||||
// Deleting here would strip a live pre-session-scoped service's enforcement.
|
||||
name: "interactive run beside a live service does not delete",
|
||||
elevated: true,
|
||||
interactive: true,
|
||||
liveness: serviceLivenessRunning,
|
||||
},
|
||||
{
|
||||
// The concern this test was added for: "could not tell" is not absence.
|
||||
name: "interactive run with an unreadable SCM does not delete",
|
||||
elevated: true,
|
||||
interactive: true,
|
||||
liveness: serviceLivenessUnknown,
|
||||
},
|
||||
{
|
||||
// Elevation is the only barrier between an unprivileged local process and a
|
||||
// path that opens a WFP engine and deletes ctrld's sublayer.
|
||||
name: "an unelevated caller does not delete",
|
||||
elevated: false,
|
||||
},
|
||||
{
|
||||
name: "an elevation check that fails does not delete",
|
||||
elevErr: errors.New("cannot determine privilege"),
|
||||
},
|
||||
{
|
||||
// Elevation reported true alongside an error is not a yes.
|
||||
name: "an inconclusive elevation check does not delete",
|
||||
elevated: true,
|
||||
elevErr: errors.New("cannot determine privilege"),
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
deletes := stubStaleCleanup(t, tc.elevated, tc.elevErr, tc.interactive, tc.liveness)
|
||||
|
||||
cleanupStaleDNSInterceptState()
|
||||
|
||||
if *deletes != tc.wantDeletes {
|
||||
t.Errorf("WFP delete attempts = %d, want %d", *deletes, tc.wantDeletes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanupStaleStateDoesNotQueryTheSCMOnAServiceStart pins the ordering the deadlock
|
||||
// recovery depends on.
|
||||
//
|
||||
// The cleanup runs before the network-up wait and before API preflight, on the path a
|
||||
// locked-out host has to take. Consulting the SCM there would make the one case this
|
||||
// exists for depend on a query that can block or fail - and a failure answers Unknown,
|
||||
// which refuses the cleanup. A service start must not ask.
|
||||
func TestCleanupStaleStateDoesNotQueryTheSCMOnAServiceStart(t *testing.T) {
|
||||
deletes := stubStaleCleanup(t, true, nil, false, serviceLivenessStopped)
|
||||
queried := false
|
||||
staleCleanupLivenessFn = func() serviceLiveness {
|
||||
queried = true
|
||||
return serviceLivenessRunning
|
||||
}
|
||||
|
||||
cleanupStaleDNSInterceptState()
|
||||
|
||||
if queried {
|
||||
t.Error("a service start queried the SCM: an unreadable SCM would then refuse the cleanup the locked-out host needs")
|
||||
}
|
||||
if *deletes != 1 {
|
||||
t.Errorf("WFP delete attempts = %d, want 1", *deletes)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,830 @@
|
||||
//go:build darwin
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"tailscale.com/net/netmon"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// buildPFAnchorRules tests
|
||||
// =============================================================================
|
||||
|
||||
func TestPFBuildAnchorRules_Basic(t *testing.T) {
|
||||
p := &prog{cfg: &ctrld.Config{Listener: map[string]*ctrld.ListenerConfig{"0": {IP: "127.0.0.1", Port: 53}}}}
|
||||
rules := p.buildPFAnchorRules(nil)
|
||||
|
||||
// rdr (translation) must come before pass (filtering)
|
||||
rdrIdx := strings.Index(rules, "rdr on lo0 inet proto udp")
|
||||
passRouteIdx := strings.Index(rules, "pass out quick on ! lo0 route-to lo0 inet proto udp")
|
||||
passInIdx := strings.Index(rules, "pass in quick on lo0 reply-to lo0")
|
||||
|
||||
if rdrIdx < 0 {
|
||||
t.Fatal("missing rdr rule")
|
||||
}
|
||||
if passRouteIdx < 0 {
|
||||
t.Fatal("missing pass out route-to rule")
|
||||
}
|
||||
if passInIdx < 0 {
|
||||
t.Fatal("missing pass in on lo0 rule")
|
||||
}
|
||||
if rdrIdx >= passRouteIdx {
|
||||
t.Error("rdr rules must come before pass out route-to rules")
|
||||
}
|
||||
if passRouteIdx >= passInIdx {
|
||||
t.Error("pass out route-to must come before pass in on lo0")
|
||||
}
|
||||
|
||||
// Both UDP and TCP rdr rules
|
||||
if !strings.Contains(rules, "proto udp") || !strings.Contains(rules, "proto tcp") {
|
||||
t.Error("must have both UDP and TCP rdr rules")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPFBuildAnchorRules_WithVPNServers(t *testing.T) {
|
||||
p := &prog{cfg: &ctrld.Config{Listener: map[string]*ctrld.ListenerConfig{"0": {IP: "127.0.0.1", Port: 53}}}}
|
||||
vpnServers := []vpnDNSExemption{
|
||||
{Server: "10.8.0.1"},
|
||||
{Server: "10.8.0.2"},
|
||||
}
|
||||
rules := p.buildPFAnchorRules(vpnServers)
|
||||
|
||||
// VPN exemption rules must appear
|
||||
for _, s := range vpnServers {
|
||||
if !strings.Contains(rules, s.Server) {
|
||||
t.Errorf("missing VPN exemption for %s", s.Server)
|
||||
}
|
||||
}
|
||||
|
||||
// VPN exemptions must come before route-to
|
||||
exemptIdx := strings.Index(rules, "10.8.0.1 port 53 group")
|
||||
routeIdx := strings.Index(rules, "pass out quick on ! lo0 route-to lo0 inet proto udp")
|
||||
if exemptIdx < 0 {
|
||||
t.Fatal("missing VPN exemption rule for 10.8.0.1")
|
||||
}
|
||||
if routeIdx < 0 {
|
||||
t.Fatal("missing route-to rule")
|
||||
}
|
||||
if exemptIdx >= routeIdx {
|
||||
t.Error("VPN exemptions must come before route-to rules")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPFBuildAnchorRules_IPv4AndIPv6VPN(t *testing.T) {
|
||||
p := &prog{cfg: &ctrld.Config{Listener: map[string]*ctrld.ListenerConfig{"0": {IP: "127.0.0.1", Port: 53}}}}
|
||||
vpnServers := []vpnDNSExemption{
|
||||
{Server: "10.8.0.1"},
|
||||
{Server: "fd00::1"},
|
||||
}
|
||||
rules := p.buildPFAnchorRules(vpnServers)
|
||||
|
||||
// IPv4 server should use "inet"
|
||||
lines := strings.Split(rules, "\n")
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, "10.8.0.1") && strings.HasPrefix(line, "pass") {
|
||||
if !strings.Contains(line, "inet ") {
|
||||
t.Error("IPv4 VPN server rule should contain 'inet'")
|
||||
}
|
||||
if strings.Contains(line, "inet6") {
|
||||
t.Error("IPv4 VPN server rule should not contain 'inet6'")
|
||||
}
|
||||
}
|
||||
if strings.Contains(line, "fd00::1") && strings.HasPrefix(line, "pass") {
|
||||
if !strings.Contains(line, "inet6") {
|
||||
t.Error("IPv6 VPN server rule should contain 'inet6'")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPFBuildAnchorRules_Ordering(t *testing.T) {
|
||||
p := &prog{cfg: &ctrld.Config{Listener: map[string]*ctrld.ListenerConfig{"0": {IP: "127.0.0.1", Port: 53}}}}
|
||||
vpnServers := []vpnDNSExemption{
|
||||
{Server: "10.8.0.1"},
|
||||
}
|
||||
rules := p.buildPFAnchorRules(vpnServers)
|
||||
|
||||
// Verify ordering: rdr → exemptions → route-to → pass in on lo0
|
||||
rdrIdx := strings.Index(rules, "rdr on lo0 inet proto udp")
|
||||
exemptIdx := strings.Index(rules, "pass out quick on ! lo0 inet proto { udp, tcp } from any to 10.8.0.1 port 53 group _ctrld")
|
||||
routeIdx := strings.Index(rules, "pass out quick on ! lo0 route-to lo0 inet proto udp")
|
||||
passInIdx := strings.Index(rules, "pass in quick on lo0 reply-to lo0")
|
||||
|
||||
if rdrIdx < 0 || exemptIdx < 0 || routeIdx < 0 || passInIdx < 0 {
|
||||
t.Fatalf("missing expected rules: rdr=%d exempt=%d route=%d passIn=%d", rdrIdx, exemptIdx, routeIdx, passInIdx)
|
||||
}
|
||||
|
||||
if !(rdrIdx < exemptIdx && exemptIdx < routeIdx && routeIdx < passInIdx) {
|
||||
t.Errorf("incorrect rule ordering: rdr(%d) < exempt(%d) < route(%d) < passIn(%d)", rdrIdx, exemptIdx, routeIdx, passInIdx)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
ip string
|
||||
want string
|
||||
}{
|
||||
{"10.0.0.1", "inet"},
|
||||
{"192.168.1.1", "inet"},
|
||||
{"127.0.0.1", "inet"},
|
||||
{"::1", "inet6"},
|
||||
{"fd00::1", "inet6"},
|
||||
{"2001:db8::1", "inet6"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := pfAddressFamily(tt.ip); got != tt.want {
|
||||
t.Errorf("pfAddressFamily(%q) = %q, want %q", tt.ip, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func stubPFAnchorCheckCommand(t *testing.T, outputs map[string]string) {
|
||||
t.Helper()
|
||||
original := runPFAnchorCheckCommand
|
||||
runPFAnchorCheckCommand = func(args ...string) ([]byte, error) {
|
||||
key := strings.Join(args, " ")
|
||||
output, ok := outputs[key]
|
||||
if !ok {
|
||||
t.Fatalf("unexpected pf anchor check command: pfctl %s", key)
|
||||
}
|
||||
return []byte(output), nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
runPFAnchorCheckCommand = original
|
||||
})
|
||||
}
|
||||
|
||||
func TestEnsurePFAnchorActiveRecentRestoreWithIntactRulesDoesNotStabilize(t *testing.T) {
|
||||
stubPFAnchorCheckCommand(t, map[string]string{
|
||||
"-sn": `rdr-anchor "com.controld.ctrld"`,
|
||||
"-sr": `anchor "com.controld.ctrld"`,
|
||||
"-a com.controld.ctrld -sr": "pass in quick on lo0",
|
||||
"-a com.controld.ctrld -sn": "rdr on lo0",
|
||||
})
|
||||
|
||||
p := &prog{
|
||||
dnsInterceptState: &pfState{},
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
restoredAt := time.Now().Add(-time.Second).UnixMilli()
|
||||
p.pfLastRestoreTime.Store(restoredAt)
|
||||
|
||||
if result := p.ensurePFAnchorActive(); result != pfAnchorCheckIntact {
|
||||
t.Fatalf("intact rules result = %v, want intact", result)
|
||||
}
|
||||
if p.pfBackoffMultiplier.Load() != 0 {
|
||||
t.Fatalf("intact rules incremented backoff to %d", p.pfBackoffMultiplier.Load())
|
||||
}
|
||||
if p.pfStabilizing.Load() {
|
||||
t.Fatal("intact rules must not enter stabilization")
|
||||
}
|
||||
if got := p.pfLastRestoreTime.Load(); got != restoredAt {
|
||||
t.Fatalf("intact check changed restore timestamp: got %d, want %d", got, restoredAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsurePFAnchorActiveCheckFailureIsNotIntact(t *testing.T) {
|
||||
original := runPFAnchorCheckCommand
|
||||
runPFAnchorCheckCommand = func(...string) ([]byte, error) {
|
||||
return nil, errors.New("pfctl unavailable")
|
||||
}
|
||||
t.Cleanup(func() { runPFAnchorCheckCommand = original })
|
||||
|
||||
p := &prog{dnsInterceptState: &pfState{}}
|
||||
if result := p.ensurePFAnchorActive(); result != pfAnchorCheckFailed {
|
||||
t.Fatalf("failed PF inspection result = %v, want failed", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsurePFAnchorActiveRecentActualWipeStartsStabilization(t *testing.T) {
|
||||
stubPFAnchorCheckCommand(t, map[string]string{
|
||||
"-sn": "",
|
||||
})
|
||||
|
||||
stopCh := make(chan struct{})
|
||||
close(stopCh)
|
||||
p := &prog{
|
||||
dnsInterceptState: &pfState{},
|
||||
stopCh: stopCh,
|
||||
}
|
||||
restoredAt := time.Now().Add(-time.Second).UnixMilli()
|
||||
p.pfLastRestoreTime.Store(restoredAt)
|
||||
|
||||
if result := p.ensurePFAnchorActive(); result != pfAnchorCheckDeferred {
|
||||
t.Fatalf("recent repeated wipe result = %v, want deferred", result)
|
||||
}
|
||||
if got := p.pfBackoffMultiplier.Load(); got != 1 {
|
||||
t.Fatalf("recent repeated wipe backoff = %d, want 1", got)
|
||||
}
|
||||
if got := p.pfLastRestoreTime.Load(); got != restoredAt {
|
||||
t.Fatalf("deferred restore changed restore timestamp: got %d, want %d", got, restoredAt)
|
||||
}
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for p.pfStabilizing.Load() && time.Now().Before(deadline) {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
if p.pfStabilizing.Load() {
|
||||
t.Fatal("stabilization goroutine did not observe closed stop channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSInterceptIgnoredChangeReconcileDue(t *testing.T) {
|
||||
p := &prog{}
|
||||
start := time.Unix(1_000_000, 0)
|
||||
|
||||
if !p.dnsInterceptIgnoredChangeReconcileDue(start) {
|
||||
t.Fatal("first ignored change must reconcile immediately")
|
||||
}
|
||||
if p.dnsInterceptIgnoredChangeReconcileDue(start.Add(pfIgnoredChangeReconcileInterval - time.Millisecond)) {
|
||||
t.Fatal("ignored changes inside the interval must be coalesced")
|
||||
}
|
||||
if !p.dnsInterceptIgnoredChangeReconcileDue(start.Add(pfIgnoredChangeReconcileInterval)) {
|
||||
t.Fatal("continuous ignored changes must reconcile again at the interval boundary")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIgnoredNetworkChangeCallbackBoundsWorkWithoutBurningStabilizedSlot(t *testing.T) {
|
||||
outputs := map[string]string{
|
||||
"-sn": `rdr-anchor "com.controld.ctrld"`,
|
||||
"-sr": `anchor "com.controld.ctrld"`,
|
||||
"-a com.controld.ctrld -sr": "pass in quick on lo0",
|
||||
"-a com.controld.ctrld -sn": "rdr on lo0",
|
||||
}
|
||||
originalCheck := runPFAnchorCheckCommand
|
||||
pfChecks := 0
|
||||
runPFAnchorCheckCommand = func(args ...string) ([]byte, error) {
|
||||
key := strings.Join(args, " ")
|
||||
output, ok := outputs[key]
|
||||
if !ok {
|
||||
t.Fatalf("unexpected pf anchor check command: pfctl %s", key)
|
||||
}
|
||||
if key == "-sn" {
|
||||
pfChecks++
|
||||
}
|
||||
return []byte(output), nil
|
||||
}
|
||||
originalDiscover := discoverTunnelInterfacesForReconcile
|
||||
discoverTunnelInterfacesForReconcile = func() []string { return nil }
|
||||
t.Cleanup(func() {
|
||||
runPFAnchorCheckCommand = originalCheck
|
||||
discoverTunnelInterfacesForReconcile = originalDiscover
|
||||
})
|
||||
|
||||
refreshes := 0
|
||||
vpnDNS := newVPNDNSManager(&mainLog, nil)
|
||||
vpnDNS.discoverVPNDNS = func(context.Context) []ctrld.VPNDNSConfig {
|
||||
refreshes++
|
||||
return nil
|
||||
}
|
||||
p := &prog{dnsInterceptState: &pfState{}, vpnDNS: vpnDNS}
|
||||
t.Cleanup(func() {
|
||||
p.pfDelayedRecheckMu.Lock()
|
||||
defer p.pfDelayedRecheckMu.Unlock()
|
||||
for _, timer := range p.pfDelayedRecheckTimers {
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
delta := &netmon.ChangeDelta{
|
||||
Old: &netmon.State{Interface: map[string]netmon.Interface{}},
|
||||
New: &netmon.State{Interface: map[string]netmon.Interface{}},
|
||||
}
|
||||
start := time.Unix(1_000_000, 0)
|
||||
p.handleDNSInterceptIgnoredNetworkChange(delta, start)
|
||||
if pfChecks != 1 || refreshes != 1 {
|
||||
t.Fatalf("first ignored delta work: pf checks=%d refreshes=%d, want 1 each", pfChecks, refreshes)
|
||||
}
|
||||
|
||||
p.pfStabilizing.Store(true)
|
||||
p.handleDNSInterceptIgnoredNetworkChange(delta, start.Add(pfIgnoredChangeReconcileInterval))
|
||||
if pfChecks != 1 || refreshes != 1 {
|
||||
t.Fatalf("stabilized delta ran leading reconciliation: pf checks=%d refreshes=%d", pfChecks, refreshes)
|
||||
}
|
||||
|
||||
p.pfStabilizing.Store(false)
|
||||
resumeAt := start.Add(pfIgnoredChangeReconcileInterval + time.Millisecond)
|
||||
p.handleDNSInterceptIgnoredNetworkChange(delta, resumeAt)
|
||||
if pfChecks != 2 || refreshes != 2 {
|
||||
t.Fatalf("first post-stabilization delta did not reconcile immediately: pf checks=%d refreshes=%d", pfChecks, refreshes)
|
||||
}
|
||||
|
||||
for i := 1; i <= 8; i++ {
|
||||
p.handleDNSInterceptIgnoredNetworkChange(delta, resumeAt.Add(time.Duration(i)*100*time.Millisecond))
|
||||
}
|
||||
if pfChecks != 2 || refreshes != 2 {
|
||||
t.Fatalf("ignored delta burst was not coalesced: pf checks=%d refreshes=%d", pfChecks, refreshes)
|
||||
}
|
||||
|
||||
p.handleDNSInterceptIgnoredNetworkChange(delta, resumeAt.Add(pfIgnoredChangeReconcileInterval))
|
||||
if pfChecks != 3 || refreshes != 3 {
|
||||
t.Fatalf("interval boundary did not reconcile: pf checks=%d refreshes=%d, want 3 each", pfChecks, refreshes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestorePFAnchorFailureIsNotReportedOrTimestamped(t *testing.T) {
|
||||
originalReference := ensurePFAnchorReferenceForRestore
|
||||
originalRebuild := rebuildPFAnchorRulesForReconcile
|
||||
ensurePFAnchorReferenceForRestore = func(*prog) error { return nil }
|
||||
rebuildPFAnchorRulesForReconcile = func(*prog, []vpnDNSExemption) ([]string, error) {
|
||||
return nil, errors.New("pf load failed")
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
ensurePFAnchorReferenceForRestore = originalReference
|
||||
rebuildPFAnchorRulesForReconcile = originalRebuild
|
||||
})
|
||||
|
||||
p := &prog{dnsInterceptState: &pfState{}}
|
||||
if result := p.restorePFAnchor("test"); result != pfAnchorCheckFailed {
|
||||
t.Fatalf("failed restore result = %v, want failed", result)
|
||||
}
|
||||
if got := p.pfLastRestoreTime.Load(); got != 0 {
|
||||
t.Fatalf("failed restore changed timestamp to %d", got)
|
||||
}
|
||||
if len(p.lastTunnelIfaces) != 0 {
|
||||
t.Fatalf("failed restore committed tunnel state: %v", p.lastTunnelIfaces)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPFStabilizationTimeoutReturnsOwnershipToDelayedRecovery(t *testing.T) {
|
||||
p := &prog{dnsInterceptState: &pfState{}}
|
||||
p.pfStabilizing.Store(true)
|
||||
p.pfStabilizationLoopWithMaxWait(t.Context(), time.Hour, 25*time.Millisecond)
|
||||
|
||||
if p.pfStabilizing.Load() {
|
||||
t.Fatal("stabilization retained ownership after the maximum wait")
|
||||
}
|
||||
p.pfDelayedRecheckMu.Lock()
|
||||
timers := append([]*time.Timer(nil), p.pfDelayedRecheckTimers...)
|
||||
p.pfDelayedRecheckTimers = nil
|
||||
p.pfDelayedRecheckMu.Unlock()
|
||||
if len(timers) != 2 {
|
||||
t.Fatalf("expected bounded timeout to schedule delayed recovery, got %d timers", len(timers))
|
||||
}
|
||||
for _, timer := range timers {
|
||||
timer.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopDNSInterceptWaitsForInFlightPFMutation(t *testing.T) {
|
||||
binDir := t.TempDir()
|
||||
pfctlPath := filepath.Join(binDir, "pfctl")
|
||||
if err := os.WriteFile(pfctlPath, []byte("#!/bin/sh\nexit 0\n"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", binDir+":"+os.Getenv("PATH"))
|
||||
|
||||
anchorFile := filepath.Join(t.TempDir(), "anchor")
|
||||
if err := os.WriteFile(anchorFile, []byte("rules"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := &prog{dnsInterceptState: &pfState{anchorName: pfAnchorName, anchorFile: anchorFile}}
|
||||
p.pfEnsureRunning.Store(true)
|
||||
|
||||
revoked := make(chan struct{})
|
||||
originalRevokedHook := pfShutdownStateRevokedForTest
|
||||
pfShutdownStateRevokedForTest = func() { close(revoked) }
|
||||
t.Cleanup(func() { pfShutdownStateRevokedForTest = originalRevokedHook })
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- p.stopDNSIntercept() }()
|
||||
|
||||
select {
|
||||
case <-revoked:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("shutdown did not revoke PF lifecycle state before waiting")
|
||||
}
|
||||
select {
|
||||
case err := <-done:
|
||||
t.Fatalf("shutdown completed before in-flight PF owner released: %v", err)
|
||||
case <-time.After(25 * time.Millisecond):
|
||||
}
|
||||
|
||||
p.pfEnsureRunning.Store(false)
|
||||
if err := <-done; err != nil {
|
||||
t.Fatalf("stopDNSIntercept() error: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(anchorFile); !os.IsNotExist(err) {
|
||||
t.Fatalf("anchor file remained after serialized shutdown: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostStabilizationReconcileRetainsOwnershipAndForcesRebuild(t *testing.T) {
|
||||
stubPFAnchorCheckCommand(t, map[string]string{
|
||||
"-sn": `rdr-anchor "com.controld.ctrld"`,
|
||||
"-sr": `anchor "com.controld.ctrld"`,
|
||||
"-a com.controld.ctrld -sr": "pass in quick on lo0",
|
||||
"-a com.controld.ctrld -sn": "rdr on lo0",
|
||||
})
|
||||
originalRestore := restorePFAnchorForReconcile
|
||||
calls := 0
|
||||
restorePFAnchorForReconcile = func(*prog, string) pfAnchorCheckResult {
|
||||
calls++
|
||||
return pfAnchorCheckRestored
|
||||
}
|
||||
t.Cleanup(func() { restorePFAnchorForReconcile = originalRestore })
|
||||
|
||||
p := &prog{
|
||||
dnsInterceptState: &pfState{},
|
||||
pendingTunnelIfaces: []string{"utun9"},
|
||||
hasPendingTunnelIfaces: true,
|
||||
}
|
||||
p.pfStabilizing.Store(true)
|
||||
if result := p.reconcilePFAnchorAfterStabilization(); result != pfAnchorCheckRestored {
|
||||
t.Fatalf("post-stabilization result = %v, want restored", result)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("post-stabilization restore calls = %d, want 1", calls)
|
||||
}
|
||||
if !p.pfStabilizing.Load() {
|
||||
t.Fatal("post-stabilization reconcile released loop ownership")
|
||||
}
|
||||
if p.pfBackoffMultiplier.Load() != 0 {
|
||||
t.Fatalf("post-stabilization reconcile changed backoff to %d", p.pfBackoffMultiplier.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostStabilizationIntactWithoutPendingAvoidsRebuild(t *testing.T) {
|
||||
stubPFAnchorCheckCommand(t, map[string]string{
|
||||
"-sn": `rdr-anchor "com.controld.ctrld"`,
|
||||
"-sr": `anchor "com.controld.ctrld"`,
|
||||
"-a com.controld.ctrld -sr": "pass in quick on lo0",
|
||||
"-a com.controld.ctrld -sn": "rdr on lo0",
|
||||
})
|
||||
originalRestore := restorePFAnchorForReconcile
|
||||
calls := 0
|
||||
restorePFAnchorForReconcile = func(*prog, string) pfAnchorCheckResult {
|
||||
calls++
|
||||
return pfAnchorCheckRestored
|
||||
}
|
||||
t.Cleanup(func() { restorePFAnchorForReconcile = originalRestore })
|
||||
|
||||
p := &prog{dnsInterceptState: &pfState{}}
|
||||
p.pfStabilizing.Store(true)
|
||||
if result := p.reconcilePFAnchorAfterStabilization(); result != pfAnchorCheckIntact {
|
||||
t.Fatalf("post-stabilization result = %v, want intact", result)
|
||||
}
|
||||
if calls != 0 {
|
||||
t.Fatalf("intact post-stabilization anchor rebuilt %d times", calls)
|
||||
}
|
||||
if !p.pfStabilizing.Load() {
|
||||
t.Fatal("intact post-stabilization reconcile released loop ownership")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelRemovalFailureRetriesBeforeCommittingBaseline(t *testing.T) {
|
||||
originalDiscover := discoverTunnelInterfacesForReconcile
|
||||
originalRestore := restorePFAnchorForReconcile
|
||||
current := []string{}
|
||||
discoverTunnelInterfacesForReconcile = func() []string {
|
||||
return append([]string(nil), current...)
|
||||
}
|
||||
calls := 0
|
||||
restorePFAnchorForReconcile = func(p *prog, _ string) pfAnchorCheckResult {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return pfAnchorCheckFailed
|
||||
}
|
||||
p.commitPFReconcileState(current)
|
||||
return pfAnchorCheckRestored
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
discoverTunnelInterfacesForReconcile = originalDiscover
|
||||
restorePFAnchorForReconcile = originalRestore
|
||||
})
|
||||
|
||||
p := &prog{
|
||||
dnsInterceptState: &pfState{},
|
||||
lastTunnelIfaces: []string{"utun7"},
|
||||
}
|
||||
if !p.checkTunnelInterfaceChanges() {
|
||||
t.Fatal("first tunnel removal was not detected")
|
||||
}
|
||||
if !stringSlicesEqual(p.lastTunnelIfaces, []string{"utun7"}) {
|
||||
t.Fatalf("failed removal committed baseline: %v", p.lastTunnelIfaces)
|
||||
}
|
||||
if !p.hasPendingTunnelReconcile() {
|
||||
t.Fatal("failed removal did not retain desired tunnel state for retry")
|
||||
}
|
||||
if !p.checkTunnelInterfaceChanges() {
|
||||
t.Fatal("failed tunnel removal was not retried")
|
||||
}
|
||||
if len(p.lastTunnelIfaces) != 0 {
|
||||
t.Fatalf("successful retry did not commit empty tunnel baseline: %v", p.lastTunnelIfaces)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("restore calls = %d, want 2", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingTunnelStateRetriesAfterStabilization(t *testing.T) {
|
||||
originalDiscover := discoverTunnelInterfacesForReconcile
|
||||
originalRestore := restorePFAnchorForReconcile
|
||||
current := []string{}
|
||||
discoverTunnelInterfacesForReconcile = func() []string { return nil }
|
||||
calls := 0
|
||||
restorePFAnchorForReconcile = func(p *prog, _ string) pfAnchorCheckResult {
|
||||
calls++
|
||||
p.commitPFReconcileState(current)
|
||||
return pfAnchorCheckRestored
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
discoverTunnelInterfacesForReconcile = originalDiscover
|
||||
restorePFAnchorForReconcile = originalRestore
|
||||
})
|
||||
|
||||
p := &prog{
|
||||
dnsInterceptState: &pfState{},
|
||||
lastTunnelIfaces: []string{"utun7"},
|
||||
pendingTunnelIfaces: current,
|
||||
hasPendingTunnelIfaces: true,
|
||||
}
|
||||
if !p.checkTunnelInterfaceChanges() {
|
||||
t.Fatal("pending tunnel removal was not retried after stabilization")
|
||||
}
|
||||
if calls != 1 || len(p.lastTunnelIfaces) != 0 || p.hasPendingTunnelReconcile() {
|
||||
t.Fatalf("pending retry result: calls=%d baseline=%v pending=%v", calls, p.lastTunnelIfaces, p.hasPendingTunnelReconcile())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelReconcileHonorsPFExecBackoff(t *testing.T) {
|
||||
originalDiscover := discoverTunnelInterfacesForReconcile
|
||||
originalRestore := restorePFAnchorForReconcile
|
||||
current := []string{}
|
||||
discoverTunnelInterfacesForReconcile = func() []string { return nil }
|
||||
calls := 0
|
||||
restorePFAnchorForReconcile = func(p *prog, _ string) pfAnchorCheckResult {
|
||||
calls++
|
||||
p.commitPFReconcileState(current)
|
||||
return pfAnchorCheckRestored
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
discoverTunnelInterfacesForReconcile = originalDiscover
|
||||
restorePFAnchorForReconcile = originalRestore
|
||||
})
|
||||
|
||||
p := &prog{dnsInterceptState: &pfState{}, lastTunnelIfaces: []string{"utun7"}}
|
||||
p.pfExecBackoffUntil.Store(time.Now().Add(time.Minute).UnixMilli())
|
||||
if !p.checkTunnelInterfaceChanges() {
|
||||
t.Fatal("tunnel removal was not detected during PF exec backoff")
|
||||
}
|
||||
if calls != 0 || !stringSlicesEqual(p.lastTunnelIfaces, []string{"utun7"}) {
|
||||
t.Fatalf("PF restore ran during exec backoff: calls=%d baseline=%v", calls, p.lastTunnelIfaces)
|
||||
}
|
||||
if p.checkTunnelInterfaceChanges() {
|
||||
t.Fatal("identical deferred tunnel retry bypassed the ignored-event limiter")
|
||||
}
|
||||
p.pfExecBackoffUntil.Store(0)
|
||||
if !p.checkTunnelInterfaceChanges() || calls != 1 || len(p.lastTunnelIfaces) != 0 {
|
||||
t.Fatalf("tunnel removal did not retry after backoff: calls=%d baseline=%v", calls, p.lastTunnelIfaces)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelRapidReversalClearsUnappliedPendingState(t *testing.T) {
|
||||
originalDiscover := discoverTunnelInterfacesForReconcile
|
||||
discoverTunnelInterfacesForReconcile = func() []string { return nil }
|
||||
t.Cleanup(func() { discoverTunnelInterfacesForReconcile = originalDiscover })
|
||||
|
||||
p := &prog{
|
||||
dnsInterceptState: &pfState{},
|
||||
pendingTunnelIfaces: []string{"utun9"},
|
||||
hasPendingTunnelIfaces: true,
|
||||
}
|
||||
p.pfStabilizing.Store(true)
|
||||
if !p.checkTunnelInterfaceChanges() {
|
||||
t.Fatal("rapid tunnel reversal was not observed")
|
||||
}
|
||||
if p.hasPendingTunnelReconcile() || len(p.lastTunnelIfaces) != 0 {
|
||||
t.Fatalf("rapid reversal left unapplied tunnel state: baseline=%v pending=%v", p.lastTunnelIfaces, p.hasPendingTunnelReconcile())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelAdditionIsCoalescedUntilSuccessfulRebuild(t *testing.T) {
|
||||
originalDiscover := discoverTunnelInterfacesForReconcile
|
||||
current := []string{"utun9"}
|
||||
discoverTunnelInterfacesForReconcile = func() []string {
|
||||
return append([]string(nil), current...)
|
||||
}
|
||||
t.Cleanup(func() { discoverTunnelInterfacesForReconcile = originalDiscover })
|
||||
|
||||
p := &prog{dnsInterceptState: &pfState{}}
|
||||
p.pfStabilizing.Store(true)
|
||||
if !p.checkTunnelInterfaceChanges() {
|
||||
t.Fatal("new tunnel was not detected")
|
||||
}
|
||||
if p.checkTunnelInterfaceChanges() {
|
||||
t.Fatal("identical pending tunnel state was not coalesced")
|
||||
}
|
||||
if len(p.lastTunnelIfaces) != 0 {
|
||||
t.Fatalf("pending tunnel was committed before PF rebuild: %v", p.lastTunnelIfaces)
|
||||
}
|
||||
if !p.hasPendingTunnelReconcile() {
|
||||
t.Fatal("new tunnel was not retained as pending")
|
||||
}
|
||||
|
||||
p.commitPFReconcileState(current)
|
||||
if !stringSlicesEqual(p.lastTunnelIfaces, current) {
|
||||
t.Fatalf("successful rebuild baseline = %v, want %v", p.lastTunnelIfaces, current)
|
||||
}
|
||||
if p.hasPendingTunnelReconcile() {
|
||||
t.Fatal("successful rebuild did not clear pending tunnel state")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVPNDNSRefreshDeferredWhileStabilizing covers the ignored network-change path,
|
||||
// which can trigger a VPN DNS refresh from outside stabilization.
|
||||
//
|
||||
// A refresh rebuilds and reloads the pf anchor. Stabilization owns pf while a VPN's
|
||||
// ruleset is still settling, so refreshing then is the mutual-overwrite collision
|
||||
// stabilization exists to prevent - and these deltas arrive exactly when a VPN is
|
||||
// coming up. Deferring is safe: checkTunnelInterfaceChanges keeps the observation
|
||||
// pending, so the transition is retried afterwards.
|
||||
//
|
||||
// The watchdog tick carries the same guard for the same reason; it is not driven here
|
||||
// because that would mean running its 30s loop.
|
||||
func TestVPNDNSRefreshDeferredWhileStabilizing(t *testing.T) {
|
||||
newProg := func(t *testing.T, refreshes *int, tunnels []string) *prog {
|
||||
t.Helper()
|
||||
outputs := map[string]string{
|
||||
"-sn": `rdr-anchor "com.controld.ctrld"`,
|
||||
"-sr": `anchor "com.controld.ctrld"`,
|
||||
"-a com.controld.ctrld -sr": "pass in quick on lo0",
|
||||
"-a com.controld.ctrld -sn": "rdr on lo0",
|
||||
}
|
||||
originalCheck := runPFAnchorCheckCommand
|
||||
runPFAnchorCheckCommand = func(args ...string) ([]byte, error) {
|
||||
output, ok := outputs[strings.Join(args, " ")]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected pf anchor check command")
|
||||
}
|
||||
return []byte(output), nil
|
||||
}
|
||||
// Discovery reports no tunnels. With a seeded baseline that is a removal, which
|
||||
// checkTunnelInterfaceChanges reports as a change without touching pf while
|
||||
// stabilizing - so this fixture never reaches a real pfctl write.
|
||||
originalDiscover := discoverTunnelInterfacesForReconcile
|
||||
discoverTunnelInterfacesForReconcile = func() []string { return nil }
|
||||
t.Cleanup(func() {
|
||||
runPFAnchorCheckCommand = originalCheck
|
||||
discoverTunnelInterfacesForReconcile = originalDiscover
|
||||
})
|
||||
|
||||
vpnDNS := newVPNDNSManager(&mainLog, nil)
|
||||
vpnDNS.discoverVPNDNS = func(context.Context) []ctrld.VPNDNSConfig {
|
||||
*refreshes++
|
||||
return nil
|
||||
}
|
||||
p := &prog{dnsInterceptState: &pfState{}, vpnDNS: vpnDNS, lastTunnelIfaces: tunnels}
|
||||
t.Cleanup(func() {
|
||||
p.pfDelayedRecheckMu.Lock()
|
||||
defer p.pfDelayedRecheckMu.Unlock()
|
||||
for _, timer := range p.pfDelayedRecheckTimers {
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
}
|
||||
})
|
||||
return p
|
||||
}
|
||||
delta := func() *netmon.ChangeDelta {
|
||||
return &netmon.ChangeDelta{
|
||||
Old: &netmon.State{Interface: map[string]netmon.Interface{}},
|
||||
New: &netmon.State{Interface: map[string]netmon.Interface{}},
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("tunnel change during stabilization does not refresh", func(t *testing.T) {
|
||||
refreshes := 0
|
||||
// Seeded baseline plus empty discovery = a tunnel transition to report, so the
|
||||
// refresh is eligible on everything except the stabilization guard.
|
||||
p := newProg(t, &refreshes, []string{"utun9"})
|
||||
p.pfStabilizing.Store(true)
|
||||
|
||||
p.handleDNSInterceptIgnoredNetworkChange(delta(), time.Unix(1_000_000, 0))
|
||||
|
||||
if refreshes != 0 {
|
||||
t.Errorf("refreshed %d time(s) while stabilizing — that rebuilds the anchor under a settling VPN ruleset", refreshes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("refresh still happens outside stabilization", func(t *testing.T) {
|
||||
refreshes := 0
|
||||
p := newProg(t, &refreshes, nil)
|
||||
|
||||
p.handleDNSInterceptIgnoredNetworkChange(delta(), time.Unix(1_000_000, 0))
|
||||
|
||||
if refreshes == 0 {
|
||||
t.Error("no refresh outside stabilization — the guard must defer, not disable")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestExemptVPNDNSServersDeferredWhileStabilizing checks the mutation point itself,
|
||||
// not just the call sites: any future caller reaching it during stabilization is
|
||||
// refused before the anchor is rewritten.
|
||||
//
|
||||
// It returns before pfEnsureRunning is taken and before any pfctl work, so this drives
|
||||
// the real function without touching the host's pf state.
|
||||
func TestExemptVPNDNSServersDeferredWhileStabilizing(t *testing.T) {
|
||||
p := &prog{dnsInterceptState: &pfState{}}
|
||||
p.pfStabilizing.Store(true)
|
||||
|
||||
err := p.exemptVPNDNSServers([]vpnDNSExemption{{Server: "192.168.1.1"}})
|
||||
if err == nil {
|
||||
t.Fatal("exemption applied while stabilizing — that rewrites the anchor under a settling VPN ruleset")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "stabilization") {
|
||||
t.Errorf("error does not name the reason: %v", err)
|
||||
}
|
||||
// The refusal must happen before the reconcile latch is claimed, or a deferral
|
||||
// would lock out the reconcile that runs once stabilization ends.
|
||||
if p.pfEnsureRunning.Load() {
|
||||
t.Error("pfEnsureRunning was left held by a deferred exemption")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//go:build windows
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDNSInterceptIgnoredChangeReconcileDueWindowsPreservesImmediateBehavior(t *testing.T) {
|
||||
p := &prog{}
|
||||
now := time.Now()
|
||||
|
||||
if !p.dnsInterceptIgnoredChangeReconcileDue(now) {
|
||||
t.Fatal("first ignored Windows change must reconcile immediately")
|
||||
}
|
||||
if !p.dnsInterceptIgnoredChangeReconcileDue(now) {
|
||||
t.Fatal("Windows ignored changes must not inherit the macOS pf rate limit")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
//go:build windows
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// newInterceptTestProg returns a prog with a published intercept state, fake NRPT
|
||||
// operations already installed, and no WFP engine (engineHandle 0).
|
||||
//
|
||||
// The fake is installed here, before anything can inspect registry state, and it is the
|
||||
// safety boundary - not the empty wfpState. A zero-valued state has owner None, and
|
||||
// shutdown's None branch sweeps orphaned ctrld rules, so an unfaked stopDNSIntercept would
|
||||
// reach the production nrptCatchAllRuleExists / removeNRPTCatchAllRule / signalNRPTChange.
|
||||
// On a host that has ctrld's deterministic key - a developer box, or a CI runner where
|
||||
// ctrld is installed - that deletes live policy and forces a Group Policy refresh, a
|
||||
// Dnscache paramchange and a cache flush. A green run on a clean runner proves nothing
|
||||
// about that.
|
||||
func newInterceptTestProg(t *testing.T) (*prog, *wfpState, *fakeNRPTOps) {
|
||||
t.Helper()
|
||||
f := fakeNRPTOpsForTest(t)
|
||||
// Prove the fake is in effect before anything can inspect registry state. Asserting
|
||||
// zero side effects afterwards cannot do that: an uninstalled fake reports zero
|
||||
// whether it was consulted or bypassed.
|
||||
requireFakeNRPTOpsInstalled(t, f)
|
||||
state := &wfpState{stopCh: make(chan struct{}), listenerIP: "127.0.0.1"}
|
||||
p := &prog{}
|
||||
p.dnsInterceptState = state
|
||||
return p, state, f
|
||||
}
|
||||
|
||||
// assertNoNRPTSideEffects fails when a lifecycle path wrote NRPT policy or signalled the
|
||||
// DNS Client. Every test in this file exercises a guard that is supposed to stand down, so
|
||||
// any registry write or signal here means the guard did not hold - and, without the fake,
|
||||
// would have hit the host's real policy.
|
||||
func assertNoNRPTSideEffects(t *testing.T, f *fakeNRPTOps) {
|
||||
t.Helper()
|
||||
add, remove, signal, _ := f.counts()
|
||||
if add != 0 || remove != 0 || signal != 0 {
|
||||
t.Errorf("addRule = %d, removeRule = %d, signal = %d, want 0/0/0: this path must not write NRPT policy",
|
||||
add, remove, signal)
|
||||
}
|
||||
if flush := f.flushCount(); flush != 0 {
|
||||
t.Errorf("flush calls = %d, want 0: this path must not flush the resolver cache", flush)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStopDNSInterceptRevokesBeforeTeardown pins the ordering the shutdown/monitor race
|
||||
// depends on. Teardown deletes our WFP sublayer, and a missing sublayer is precisely what
|
||||
// the health monitor treats as "our filters were wiped, rebuild everything". Were the
|
||||
// state revoked only after teardown, a monitor tick inside that window would rebuild the
|
||||
// intercept during shutdown.
|
||||
func TestStopDNSInterceptRevokesBeforeTeardown(t *testing.T) {
|
||||
p, state, f := newInterceptTestProg(t)
|
||||
defer assertNoNRPTSideEffects(t, f)
|
||||
|
||||
if p.interceptStateRevoked(state) {
|
||||
t.Fatal("a freshly published intercept state must not read as retired")
|
||||
}
|
||||
if err := p.stopDNSIntercept(); err != nil {
|
||||
t.Fatalf("stopDNSIntercept() = %v", err)
|
||||
}
|
||||
if !p.interceptStateRevoked(state) {
|
||||
t.Error("state still reads live after shutdown: the monitor and heal flows would keep writing host DNS state")
|
||||
}
|
||||
if p.dnsInterceptState != nil {
|
||||
t.Error("dnsInterceptState survived shutdown")
|
||||
}
|
||||
if p.dnsInterceptStopRequested.Load() {
|
||||
t.Error("stop-requested flag was left set; a later start would see a phantom shutdown")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRebuildDNSInterceptRefusedAfterShutdown is the regression test for the reported
|
||||
// race: SCM stop runs resetDNS -> stopDNSIntercept while the health monitor is mid-tick,
|
||||
// and the monitor then reaches the rebuild path before the process exits. The rebuild
|
||||
// must refuse - completing it would re-add the NRPT catch-all and the WFP filters moments
|
||||
// before ctrld disappears, leaving Windows resolving through a listener that is gone.
|
||||
//
|
||||
// That refusal is also what keeps this test safe on a real Windows host: a rebuild that
|
||||
// did not refuse would run startDNSIntercept and write NRPT policy to the machine
|
||||
// running the tests.
|
||||
func TestRebuildDNSInterceptRefusedAfterShutdown(t *testing.T) {
|
||||
p, state, f := newInterceptTestProg(t)
|
||||
defer assertNoNRPTSideEffects(t, f)
|
||||
if err := p.stopDNSIntercept(); err != nil {
|
||||
t.Fatalf("stopDNSIntercept() = %v", err)
|
||||
}
|
||||
|
||||
if got := p.rebuildDNSIntercept(state, "WFP sublayer missing during health check"); got != interceptRebuildRetired {
|
||||
t.Fatalf("rebuildDNSIntercept() = %v, want interceptRebuildRetired - a post-shutdown rebuild resurrects DNS interception", got)
|
||||
}
|
||||
if p.dnsInterceptState != nil {
|
||||
t.Error("rebuild published new intercept state after shutdown")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRebuildDNSInterceptRefusedForReplacedState covers the other stale-owner case: an
|
||||
// earlier rebuild already replaced the state, so a goroutine still holding the old one
|
||||
// must not tear down its successor.
|
||||
func TestRebuildDNSInterceptRefusedForReplacedState(t *testing.T) {
|
||||
p, old, f := newInterceptTestProg(t)
|
||||
defer assertNoNRPTSideEffects(t, f)
|
||||
current := &wfpState{stopCh: make(chan struct{}), listenerIP: "127.0.0.1"}
|
||||
p.dnsInterceptState = current
|
||||
|
||||
if got := p.rebuildDNSIntercept(old, "WFP sublayer missing during health check"); got != interceptRebuildRetired {
|
||||
t.Fatalf("rebuildDNSIntercept() = %v, want interceptRebuildRetired for a superseded state", got)
|
||||
}
|
||||
if p.dnsInterceptState != any(current) {
|
||||
t.Error("a superseded state's rebuild replaced the live intercept")
|
||||
}
|
||||
if p.interceptStateRevoked(current) {
|
||||
t.Error("the live state was revoked by a superseded rebuild")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRepairMissingWFPStandsDownAfterShutdown checks the monitor's entry point. It must
|
||||
// not even query WFP for a retired state - the sublayer it looks for is what teardown
|
||||
// just deleted - and it must tell the monitor goroutine to exit.
|
||||
func TestRepairMissingWFPStandsDownAfterShutdown(t *testing.T) {
|
||||
p, state, f := newInterceptTestProg(t)
|
||||
defer assertNoNRPTSideEffects(t, f)
|
||||
if err := p.stopDNSIntercept(); err != nil {
|
||||
t.Fatalf("stopDNSIntercept() = %v", err)
|
||||
}
|
||||
// Set the handle only after teardown. A fake handle proves the revocation check
|
||||
// comes first, but must never reach the real WFP calls in cleanupWFPFilters.
|
||||
state.engineHandle = 1
|
||||
|
||||
if !p.repairMissingWFP(state) {
|
||||
t.Error("repairMissingWFP() = false after shutdown; the health monitor would keep running for a dead intercept")
|
||||
}
|
||||
if p.dnsInterceptState != nil {
|
||||
t.Error("repairMissingWFP rebuilt the intercept after shutdown")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPendingStopSignalsRevocation covers how a stop avoids waiting: while it is blocked
|
||||
// on the lifecycle lock it must already read as revoked, so an in-flight NRPT heal
|
||||
// abandons its probe backoff instead of making the service stop wait it out. A stop that
|
||||
// waits too long is killed by the Service Control Manager, which cleans up nothing.
|
||||
func TestPendingStopSignalsRevocation(t *testing.T) {
|
||||
p, state, f := newInterceptTestProg(t)
|
||||
defer assertNoNRPTSideEffects(t, f)
|
||||
|
||||
p.dnsInterceptMu.Lock()
|
||||
stopped := make(chan struct{})
|
||||
go func() {
|
||||
defer close(stopped)
|
||||
_ = p.stopDNSIntercept()
|
||||
}()
|
||||
|
||||
// Wait for the stop to announce itself while it is blocked on the lock.
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for !p.dnsInterceptStopRequested.Load() {
|
||||
if time.Now().After(deadline) {
|
||||
p.dnsInterceptMu.Unlock()
|
||||
<-stopped
|
||||
t.Fatal("stop never announced itself before waiting for the lifecycle lock")
|
||||
}
|
||||
runtime.Gosched()
|
||||
}
|
||||
if !p.interceptStateRevoked(state) {
|
||||
t.Error("a pending stop does not read as revoked; the heal flows would keep it waiting")
|
||||
}
|
||||
p.dnsInterceptMu.Unlock()
|
||||
<-stopped
|
||||
|
||||
if p.dnsInterceptState != nil {
|
||||
t.Error("the pending stop did not tear down the intercept once it acquired the lock")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInterceptWaitAbandonsPromptlyOnPendingStop is the bound on how long a stop can be
|
||||
// delayed by a recovery flow: the heal sequence's waits add up to tens of seconds, and
|
||||
// each one must end as soon as a stop is pending.
|
||||
func TestInterceptWaitAbandonsPromptlyOnPendingStop(t *testing.T) {
|
||||
p, state, f := newInterceptTestProg(t)
|
||||
defer assertNoNRPTSideEffects(t, f)
|
||||
p.dnsInterceptStopRequested.Store(true)
|
||||
|
||||
start := time.Now()
|
||||
if p.interceptWait(state, 30*time.Second) {
|
||||
t.Fatal("interceptWait() = true with a stop pending; the caller would carry on writing host DNS state")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 2*time.Second {
|
||||
t.Errorf("interceptWait took %v to notice a pending stop; shutdown would inherit that delay", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInterceptWaitRunsToCompletionWhileLive guards the other direction: the cancellable
|
||||
// wait must still actually wait, or the recovery flows lose their backoff.
|
||||
func TestInterceptWaitRunsToCompletionWhileLive(t *testing.T) {
|
||||
p, state, f := newInterceptTestProg(t)
|
||||
defer assertNoNRPTSideEffects(t, f)
|
||||
|
||||
start := time.Now()
|
||||
if !p.interceptWait(state, 250*time.Millisecond) {
|
||||
t.Fatal("interceptWait() = false for a live intercept")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed < 250*time.Millisecond {
|
||||
t.Errorf("interceptWait returned after %v, want at least 250ms", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNRPTNeedsCtrldActivation covers the recovery gap that left a machine unfiltered
|
||||
// until restart: a failed NRPT write clears ownership, and an owner-None tick used to do
|
||||
// nothing at all, so nothing ever retried the write.
|
||||
func TestNRPTNeedsCtrldActivation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
owner nrptRuleOwner
|
||||
ruleExists bool
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
// The reported hole: activation failed, ownership was cleared, and no
|
||||
// other path re-arms it. In hard mode WFP keeps blocking DNS meanwhile.
|
||||
name: "no owner retries the failed write",
|
||||
owner: nrptRuleOwnerNone,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "no owner retries even if a rule is somehow present",
|
||||
owner: nrptRuleOwnerNone,
|
||||
ruleExists: true,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "ctrld-owned rule removed externally is re-added",
|
||||
owner: nrptRuleOwnerCtrld,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "healthy ctrld-owned rule is left alone",
|
||||
owner: nrptRuleOwnerCtrld,
|
||||
ruleExists: true,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
// Writing beside external policy would be ambiguous policy, not recovery.
|
||||
name: "external policy is never overwritten",
|
||||
owner: nrptRuleOwnerGroupPolicy,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "external policy is never overwritten even with a ctrld rule present",
|
||||
owner: nrptRuleOwnerGroupPolicy,
|
||||
ruleExists: true,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := nrptNeedsCtrldActivation(tc.owner, tc.ruleExists); got != tc.want {
|
||||
t.Errorf("nrptNeedsCtrldActivation(%v, %v) = %v, want %v", tc.owner, tc.ruleExists, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestActivateCtrldNRPTFallbackRefusedAfterShutdown guards the worst leftover. A
|
||||
// catch-all re-added after shutdown points every DNS query on the machine at a listener
|
||||
// that no longer exists, so nothing resolves at all. Refusing early also keeps this test
|
||||
// from writing NRPT policy on the machine running it.
|
||||
func TestActivateCtrldNRPTFallbackRefusedAfterShutdown(t *testing.T) {
|
||||
p, state, f := newInterceptTestProg(t)
|
||||
defer assertNoNRPTSideEffects(t, f)
|
||||
if err := p.stopDNSIntercept(); err != nil {
|
||||
t.Fatalf("stopDNSIntercept() = %v", err)
|
||||
}
|
||||
|
||||
if p.activateCtrldNRPTFallback(state, "ctrld-owned rule missing during health check") {
|
||||
t.Error("activateCtrldNRPTFallback() = true after shutdown: the catch-all would outlive ctrld")
|
||||
}
|
||||
if owner, _ := state.nrptPolicyOwner(); owner != nrptRuleOwnerNone {
|
||||
t.Errorf("NRPT owner = %v after a refused fallback, want nrptRuleOwnerNone", owner)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllowHandbackAttemptRateLimits covers the throttle on testing an external
|
||||
// catch-all. Each attempt takes ctrld's rule out of the way for a probe, so a rule that
|
||||
// never routes would cost a brief DNS outage on every 30s health tick without this - in
|
||||
// hard mode a window where WFP blocks DNS and nothing redirects it.
|
||||
func TestHandbackThrottleIsPerRule(t *testing.T) {
|
||||
state := &wfpState{stopCh: make(chan struct{})}
|
||||
now := time.Now()
|
||||
|
||||
if !state.handbackAllowed(now, "{GP-RULE}", nrptHandbackRetryInterval) {
|
||||
t.Fatal("first handback attempt must be allowed")
|
||||
}
|
||||
// Checking alone must not spend the budget: a pre-probe can still abort the attempt
|
||||
// without disturbing NRPT, and that must not cost the rule its next window.
|
||||
if !state.handbackAllowed(now, "{GP-RULE}", nrptHandbackRetryInterval) {
|
||||
t.Error("handbackAllowed must not consume the budget by itself")
|
||||
}
|
||||
|
||||
state.recordHandbackAttempt(now, "{GP-RULE}", nrptHandbackRetryInterval)
|
||||
if state.handbackAllowed(now.Add(nrptHandbackRetryInterval-time.Second), "{GP-RULE}", nrptHandbackRetryInterval) {
|
||||
t.Error("re-testing the same rule inside the interval must be suppressed")
|
||||
}
|
||||
// Group Policy alternating between two names must not erase either one's memory:
|
||||
// with a single slot every swap costs another removal of the live rule.
|
||||
if !state.handbackAllowed(now.Add(time.Second), "{OTHER-RULE}", nrptHandbackRetryInterval) {
|
||||
t.Error("a different rule name means the administrator changed policy: test it now")
|
||||
}
|
||||
state.recordHandbackAttempt(now.Add(time.Second), "{OTHER-RULE}", nrptHandbackRetryInterval)
|
||||
if state.handbackAllowed(now.Add(2*time.Second), "{GP-RULE}", nrptHandbackRetryInterval) {
|
||||
t.Error("testing another rule must not clear the first rule's throttle")
|
||||
}
|
||||
|
||||
if !state.handbackAllowed(now.Add(2*nrptHandbackRetryInterval), "{GP-RULE}", nrptHandbackRetryInterval) {
|
||||
t.Error("the same rule must be testable again after the interval")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//go:build !windows && !darwin
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// startDNSIntercept is not supported on this platform.
|
||||
// DNS intercept mode is only available on Windows (via WFP) and macOS (via pf).
|
||||
func (p *prog) startDNSIntercept() error {
|
||||
return fmt.Errorf("dns intercept: not supported on this platform (only Windows and macOS)")
|
||||
}
|
||||
|
||||
// stopDNSIntercept is a no-op on unsupported platforms.
|
||||
func (p *prog) stopDNSIntercept() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// skipInitialDNSReset is Windows-only; other platforms keep the normal reset.
|
||||
func (p *prog) skipInitialDNSReset() bool { return false }
|
||||
|
||||
// exemptVPNDNSServers is a no-op on unsupported platforms.
|
||||
func (p *prog) exemptVPNDNSServers(exemptions []vpnDNSExemption) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensurePFAnchorActive is a no-op on unsupported platforms.
|
||||
func (p *prog) ensurePFAnchorActive() pfAnchorCheckResult {
|
||||
return pfAnchorCheckSkipped
|
||||
}
|
||||
|
||||
// checkTunnelInterfaceChanges is a no-op on unsupported platforms.
|
||||
func (p *prog) checkTunnelInterfaceChanges() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *prog) dnsInterceptIgnoredChangeReconcileDue(time.Time) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// scheduleDelayedRechecks is a no-op on unsupported platforms.
|
||||
func (p *prog) scheduleDelayedRechecks() {}
|
||||
|
||||
// pfInterceptMonitor is a no-op on unsupported platforms.
|
||||
func (p *prog) pfInterceptMonitor() {}
|
||||
|
||||
// reconcileForwardedSources is a no-op on unsupported platforms (macOS-only).
|
||||
func (p *prog) reconcileForwardedSources() {}
|
||||
|
||||
// cleanupStaleDNSInterceptState is a no-op on unsupported platforms — there is no
|
||||
// intercept state that can outlive the process here.
|
||||
func cleanupStaleDNSInterceptState() {}
|
||||
|
||||
// osHealthcheckSuppressed always returns false on non-Windows platforms —
|
||||
// WFP loopback protect (the trigger for suppression) is Windows-only.
|
||||
func (p *prog) osHealthcheckSuppressed() bool { return false }
|
||||
@@ -0,0 +1,43 @@
|
||||
package cli
|
||||
|
||||
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)
|
||||
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 {
|
||||
mainLog.Load().Debug().Msg("DNS intercept: post-settle VPN DNS route refresh skipped — manager unavailable")
|
||||
return 0, 0, 0
|
||||
}
|
||||
|
||||
routes, domainlessServers, exemptions = p.vpnDNS.RefreshRoutesOnly()
|
||||
mainLog.Load().Info().Msgf("DNS intercept: post-settle VPN DNS route refresh completed — %d routes, %d domainless servers, %d exemptions",
|
||||
routes, domainlessServers, exemptions)
|
||||
return routes, domainlessServers, exemptions
|
||||
}
|
||||
|
||||
func vpnDNSExemptionsEqual(a, b []vpnDNSExemption) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
seen := make(map[vpnDNSExemption]int, len(a))
|
||||
for _, ex := range a {
|
||||
seen[ex]++
|
||||
}
|
||||
for _, ex := range b {
|
||||
if seen[ex] == 0 {
|
||||
return false
|
||||
}
|
||||
seen[ex]--
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
)
|
||||
|
||||
func TestRefreshDNSAfterVPNSettleRefreshesOSResolverAndVPNRoutes(t *testing.T) {
|
||||
oldInitialize := initializeOsResolver
|
||||
defer func() { initializeOsResolver = oldInitialize }()
|
||||
|
||||
var initialized []bool
|
||||
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(&mainLog, func(exemptions []vpnDNSExemption) error {
|
||||
exemptionUpdates = append(exemptionUpdates, append([]vpnDNSExemption{}, exemptions...))
|
||||
return nil
|
||||
})
|
||||
p.vpnDNS.discoverVPNDNS = func(context.Context) []ctrld.VPNDNSConfig {
|
||||
return []ctrld.VPNDNSConfig{{
|
||||
InterfaceName: "utun4",
|
||||
Servers: []string{"10.102.26.10"},
|
||||
Domains: []string{"bmwgroup.net"},
|
||||
}}
|
||||
}
|
||||
|
||||
routes, domainlessServers, exemptions := p.refreshDNSAfterVPNSettle("test")
|
||||
|
||||
if routes != 1 || domainlessServers != 0 || exemptions != 1 {
|
||||
t.Fatalf("expected 1 route, 0 domainless servers, 1 exemption, got routes=%d domainless=%d exemptions=%d",
|
||||
routes, domainlessServers, exemptions)
|
||||
}
|
||||
if len(initialized) != 1 || !initialized[0] {
|
||||
t.Fatalf("expected forced OS resolver refresh once, got %v", initialized)
|
||||
}
|
||||
if got := p.vpnDNS.UpstreamForDomain("jira.cc.bmwgroup.net."); len(got) != 1 || got[0] != "10.102.26.10" {
|
||||
t.Fatalf("expected refreshed VPN DNS route, got %v", got)
|
||||
}
|
||||
if len(exemptionUpdates) != 1 || len(exemptionUpdates[0]) != 1 || exemptionUpdates[0][0].Server != "10.102.26.10" {
|
||||
t.Fatalf("expected one serialized pf exemption update for the late VPN DNS server, got %+v", exemptionUpdates)
|
||||
}
|
||||
|
||||
p.refreshDNSAfterVPNSettle("test-repeat")
|
||||
if len(exemptionUpdates) != 1 {
|
||||
t.Fatalf("unchanged post-settle VPN DNS state rewrote pf: %+v", exemptionUpdates)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+1403
-569
File diff suppressed because it is too large
Load Diff
+198
-1
@@ -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)
|
||||
@@ -200,6 +286,52 @@ func TestCache(t *testing.T) {
|
||||
assert.Equal(t, answer2.Rcode, got2.answer.Rcode)
|
||||
}
|
||||
|
||||
func TestDNS64CacheLookup(t *testing.T) {
|
||||
cfg := testhelper.SampleConfig(t)
|
||||
p := &prog{cfg: cfg}
|
||||
cache, err := dnscache.NewLRUCache(16)
|
||||
require.NoError(t, err)
|
||||
p.cache = cache
|
||||
|
||||
now := time.Now()
|
||||
prefix := dns64WellKnownPrefix
|
||||
req := mkAAAAReq("legacy.example")
|
||||
upstream := "upstream.0"
|
||||
empty := new(dns.Msg)
|
||||
empty.SetReply(req)
|
||||
synthesized := new(dns.Msg)
|
||||
synthesized.SetReply(req)
|
||||
synthesized.Answer = []dns.RR{&dns.AAAA{Hdr: dns.RR_Header{Name: req.Question[0].Name, Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: 60}, AAAA: net.ParseIP("64:ff9b::c000:201")}}
|
||||
|
||||
t.Run("fresh variant hit", func(t *testing.T) {
|
||||
p.cache.Purge()
|
||||
p.cache.Add(dns64CacheKey(req, upstream, prefix), dnscache.NewValue(synthesized, now.Add(time.Minute)))
|
||||
answer, stale, hit, dns64Hit, bypass := p.cachedResponse(req, upstream, prefix, true, now)
|
||||
if answer == nil || !answerHasAAAA(answer) || stale != nil || !hit || !dns64Hit || bypass {
|
||||
t.Fatalf("unexpected lookup result: answer=%v stale=%v hit=%v dns64Hit=%v bypass=%v", answer, stale, hit, dns64Hit, bypass)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fresh empty normal answer is retained as stale while bypassed", func(t *testing.T) {
|
||||
p.cache.Purge()
|
||||
p.cache.Add(dnscache.NewKey(req, upstream), dnscache.NewValue(empty, now.Add(time.Minute)))
|
||||
answer, stale, hit, dns64Hit, bypass := p.cachedResponse(req, upstream, prefix, true, now)
|
||||
if answer != nil || stale == nil || hit || dns64Hit || !bypass {
|
||||
t.Fatalf("unexpected lookup result: answer=%v stale=%v hit=%v dns64Hit=%v bypass=%v", answer, stale, hit, dns64Hit, bypass)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("expired variant is preferred as stale", func(t *testing.T) {
|
||||
p.cache.Purge()
|
||||
p.cache.Add(dns64CacheKey(req, upstream, prefix), dnscache.NewValue(synthesized, now.Add(-time.Minute)))
|
||||
p.cache.Add(dnscache.NewKey(req, upstream), dnscache.NewValue(empty, now.Add(-time.Minute)))
|
||||
answer, stale, hit, dns64Hit, bypass := p.cachedResponse(req, upstream, prefix, true, now)
|
||||
if answer != nil || stale == nil || !answerHasAAAA(stale) || hit || dns64Hit || bypass {
|
||||
t.Fatalf("unexpected lookup result: answer=%v stale=%v hit=%v dns64Hit=%v bypass=%v", answer, stale, hit, dns64Hit, bypass)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func Test_ipAndMacFromMsg(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -405,6 +537,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 +586,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 {
|
||||
@@ -464,3 +603,61 @@ func Test_isWanClient(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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_prog_queryFromSelf(t *testing.T) {
|
||||
p := newTestProg(t)
|
||||
require.NotPanics(t, func() {
|
||||
p.queryFromSelf("")
|
||||
})
|
||||
require.NotPanics(t, func() {
|
||||
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,116 @@
|
||||
package cli
|
||||
|
||||
import "net"
|
||||
|
||||
// interceptDNSRdrTarget is the loopback address used as the macOS service
|
||||
// DNS value when ctrld's listener is NOT reachable at <listener IP>:53
|
||||
// directly (non-53 port, e.g. 127.0.0.1:5354 when mDNSResponder holds *:53).
|
||||
//
|
||||
// macOS resolvers always send DNS to port 53, so a direct-hit value is
|
||||
// impossible in that case; delivery must go through the pf rdr rule
|
||||
// ("rdr on lo0 ... to ! <listenerIP> port 53 -> <listenerIP> port <port>").
|
||||
// The value therefore must be a loopback address DIFFERENT from the listener
|
||||
// IP so the rdr's "! <listenerIP>" matches. Any 127/8 address routes via lo0
|
||||
// on macOS.
|
||||
const interceptDNSRdrTarget = "127.0.0.53"
|
||||
|
||||
// interceptDNSTargetValue returns the nameserver value to set on a DNS-less
|
||||
// macOS service so the OS emits DNS queries that reach ctrld, respecting the
|
||||
// configured listener. The listener IP/port derivation mirrors
|
||||
// buildPFAnchorRulesForTunnels so the value and the pf rules always agree.
|
||||
//
|
||||
// - listener on port 53: return the effective listener IP — queries hit the
|
||||
// listener directly, no pf dependency for this leg.
|
||||
// - listener on another port: return interceptDNSRdrTarget so the lo0 rdr
|
||||
// rule fires and rewrites to the real listener address.
|
||||
func (p *prog) interceptDNSTargetValue() string {
|
||||
listenerIP := "127.0.0.1"
|
||||
listenerPort := 53
|
||||
// FirstListener panics when no listener is configured; guard like the
|
||||
// startup paths do.
|
||||
if p.cfg != nil && len(p.cfg.Listener) > 0 {
|
||||
if lc := p.cfg.FirstListener(); lc != nil {
|
||||
if lc.IP != "" && lc.IP != "0.0.0.0" && lc.IP != "::" {
|
||||
listenerIP = lc.IP
|
||||
}
|
||||
if lc.Port != 0 {
|
||||
listenerPort = lc.Port
|
||||
}
|
||||
}
|
||||
}
|
||||
if listenerPort == 53 {
|
||||
return listenerIP
|
||||
}
|
||||
if listenerIP == interceptDNSRdrTarget {
|
||||
// Pathological config: the listener itself sits on the rdr target
|
||||
// address (with a non-53 port). Pick a different loopback so the
|
||||
// rdr's "! <listenerIP>" still matches.
|
||||
return "127.0.0.54"
|
||||
}
|
||||
return interceptDNSRdrTarget
|
||||
}
|
||||
|
||||
// hasIPv4DNS reports whether any of the given nameserver strings (bare IPs or
|
||||
// host:port) is an IPv4 address. Loopback counts: an existing local resolver
|
||||
// is treated conservatively as an intentional emittable DNS target; ctrld does
|
||||
// not probe or replace another resolver's ownership.
|
||||
func hasIPv4DNS(nameservers []string) bool {
|
||||
for _, s := range nameservers {
|
||||
host := s
|
||||
if h, _, err := net.SplitHostPort(s); err == nil {
|
||||
host = h
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
continue
|
||||
}
|
||||
if ip.To4() != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// needsInterceptDNSTarget reports whether the OS is left without any usable
|
||||
// IPv4 DNS target: neither the default-route service's static DNS nor the
|
||||
// discovered (DHCP/scutil) nameservers contain an IPv4 address.
|
||||
//
|
||||
// IPv6-only DNS is not usable under DNS intercept mode on macOS: the pf
|
||||
// ruleset blocks all outbound IPv6 port-53 traffic (IPv6 interception is not
|
||||
// supported, see issues #507/#533), and with no IPv4 DNS configured
|
||||
// mDNSResponder emits no DNS packets at all — leaving pf nothing to
|
||||
// intercept despite a healthy upstream. Observed in production on IPv6-only
|
||||
// iPhone tethering with 464XLAT (issue #533).
|
||||
func needsInterceptDNSTarget(staticDNS, discovered []string) bool {
|
||||
return !hasIPv4DNS(staticDNS) && !hasIPv4DNS(discovered)
|
||||
}
|
||||
|
||||
// isInterceptDNSTargetOnly reports whether the given static DNS list is
|
||||
// exactly the entry ctrld set via ensureInterceptDNSTarget (recorded in
|
||||
// target), meaning it is safe for ctrld to remove.
|
||||
func isInterceptDNSTargetOnly(nameservers []string, target string) bool {
|
||||
return target != "" && len(nameservers) == 1 && nameservers[0] == target
|
||||
}
|
||||
|
||||
// filterOwnTarget returns nameservers with ctrld's own recorded target
|
||||
// removed. A previously-set target must never be mistaken for user/network
|
||||
// IPv4 DNS when judging whether the network still needs one — otherwise the
|
||||
// second recovery on the same DNS-less network would see "IPv4 DNS present"
|
||||
// and remove the entry, and the third would re-add it, oscillating on every
|
||||
// recovery.
|
||||
func filterOwnTarget(nameservers []string, target string) []string {
|
||||
if target == "" {
|
||||
return nameservers
|
||||
}
|
||||
out := nameservers[:0:0]
|
||||
for _, s := range nameservers {
|
||||
host := s
|
||||
if h, _, err := net.SplitHostPort(s); err == nil {
|
||||
host = h
|
||||
}
|
||||
if host != target {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
//go:build darwin
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"tailscale.com/net/netmon"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
)
|
||||
|
||||
// interceptDNSTargetStateFile persists which service/value ctrld set, so a
|
||||
// daemon restart (crash, upgrade, plain restart) does not orphan the entry:
|
||||
// without it a restarted daemon would not know the entry is ctrld's own and
|
||||
// could neither remove it on shutdown nor keep its bookkeeping consistent.
|
||||
const interceptDNSTargetStateFile = ".intercept_dns_target"
|
||||
|
||||
var (
|
||||
interceptDNSTargetStatePathFn = interceptDNSTargetStatePath
|
||||
interceptDefaultRouteInterfaceFn = netmon.DefaultRouteInterface
|
||||
interceptInterfaceByNameFn = net.InterfaceByName
|
||||
interceptPatchNetIfaceNameFn = patchNetIfaceName
|
||||
interceptCurrentStaticDNSFn = currentStaticDNS
|
||||
interceptSaveCurrentStaticDNSFn = saveCurrentStaticDNS
|
||||
interceptSetDNSFn = setDNS
|
||||
interceptSavedStaticNameserversFn = ctrld.SavedStaticNameservers
|
||||
interceptResetDNSIgnoreUnusableIfaceFn = resetDnsIgnoreUnusableInterface
|
||||
interceptDHCPNameserversForInterfaceFn = ctrld.DHCPNameserversForInterface
|
||||
)
|
||||
|
||||
func interceptDNSTargetStatePath() string {
|
||||
dir, err := userHomeDir()
|
||||
if err != nil {
|
||||
return interceptDNSTargetStateFile
|
||||
}
|
||||
return filepath.Join(dir, interceptDNSTargetStateFile)
|
||||
}
|
||||
|
||||
type interceptDNSTargetState struct {
|
||||
Service string `json:"service"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// loadInterceptDNSTargetStateLocked hydrates in-memory tracking from the
|
||||
// state file once (only when memory is empty). Callers must hold
|
||||
// interceptDNSTargetMu.
|
||||
func (p *prog) loadInterceptDNSTargetStateLocked() {
|
||||
if p.interceptDNSTargetService != "" || p.interceptDNSTargetLoaded {
|
||||
return
|
||||
}
|
||||
p.interceptDNSTargetLoaded = true
|
||||
data, err := os.ReadFile(interceptDNSTargetStatePathFn())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var st interceptDNSTargetState
|
||||
if err := json.Unmarshal(data, &st); err != nil || st.Service == "" || st.Value == "" {
|
||||
return
|
||||
}
|
||||
p.interceptDNSTargetService = st.Service
|
||||
p.interceptDNSTargetSetValue = st.Value
|
||||
mainLog.Load().Debug().Msgf("intercept DNS target: restored tracking of %s on %q from previous run", st.Value, st.Service)
|
||||
}
|
||||
|
||||
// persistInterceptDNSTargetStateLocked writes (or clears) the state file to
|
||||
// match in-memory tracking. Callers must hold interceptDNSTargetMu.
|
||||
func (p *prog) persistInterceptDNSTargetStateLocked() {
|
||||
file := interceptDNSTargetStatePathFn()
|
||||
if p.interceptDNSTargetService == "" {
|
||||
_ = os.Remove(file)
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(interceptDNSTargetState{Service: p.interceptDNSTargetService, Value: p.interceptDNSTargetSetValue})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(file, data, 0600); err != nil {
|
||||
mainLog.Load().Debug().Err(err).Msg("intercept DNS target: could not persist state file")
|
||||
}
|
||||
}
|
||||
|
||||
// ensureInterceptDNSTarget guarantees macOS always has an emittable DNS
|
||||
// target while DNS intercept mode is active.
|
||||
//
|
||||
// Intercept mode deliberately never manages interface DNS: pf redirects DNS
|
||||
// packets in flight. But pf can only redirect packets macOS actually sends,
|
||||
// and mDNSResponder emits none when the active network service has no DNS
|
||||
// configured. IPv6-only networks (e.g. iPhone tethering with 464XLAT) supply
|
||||
// no IPv4 DNS, and the pf ruleset blocks all outbound IPv6 port 53, so such
|
||||
// networks otherwise end in a total DNS outage with a healthy upstream
|
||||
// (issue #533).
|
||||
//
|
||||
// Only when the default-route service has no usable IPv4 DNS at all does
|
||||
// ctrld set a loopback DNS value on it — chosen by interceptDNSTargetValue to
|
||||
// respect the configured listener: the listener IP directly when it serves
|
||||
// port 53, else a distinct loopback address so the pf lo0 rdr rule rewrites
|
||||
// to the listener's real port. The entry is removed when the network regains
|
||||
// IPv4 DNS and on intercept shutdown. Networks that provide IPv4 DNS are
|
||||
// never modified.
|
||||
//
|
||||
// Callers pass a non-nil raw system discovery result to prove discovery ran;
|
||||
// an empty slice is a valid DNS-less result. The decision itself uses static
|
||||
// DNS plus DHCP option 6 from the default-route interface, so resolvers on a
|
||||
// second physical interface cannot suppress the target. Invoked during
|
||||
// startup, debounced network recovery, and periodic pf watchdog reconciliation.
|
||||
func (p *prog) ensureInterceptDNSTarget(systemDiscovery []string) {
|
||||
if !dnsIntercept || p.dnsInterceptState == nil {
|
||||
return
|
||||
}
|
||||
if systemDiscovery == nil {
|
||||
mainLog.Load().Debug().Msg("intercept DNS target: system DNS discovery was not performed; not changing DNS")
|
||||
return
|
||||
}
|
||||
p.interceptDNSTargetMu.Lock()
|
||||
defer p.interceptDNSTargetMu.Unlock()
|
||||
p.loadInterceptDNSTargetStateLocked()
|
||||
|
||||
drIfaceName, err := interceptDefaultRouteInterfaceFn()
|
||||
if err != nil || drIfaceName == "" {
|
||||
// Mid-transition with no default route; the next recovery decides.
|
||||
return
|
||||
}
|
||||
iface, err := interceptInterfaceByNameFn(drIfaceName)
|
||||
if err != nil || iface == nil {
|
||||
return
|
||||
}
|
||||
// Resolve the network service name (e.g. en5 -> "iPhone USB") so
|
||||
// networksetup operates on the right service.
|
||||
if _, err := interceptPatchNetIfaceNameFn(iface); err != nil {
|
||||
mainLog.Load().Debug().Err(err).Msgf("intercept DNS target: could not resolve network service for %s", drIfaceName)
|
||||
return
|
||||
}
|
||||
|
||||
staticDNS, err := interceptCurrentStaticDNSFn(iface)
|
||||
if err != nil {
|
||||
// Interfaces without a network service (utun/VPN tunnels) land here:
|
||||
// networksetup cannot address them, ctrld never writes to them, and
|
||||
// any target set on the underlying physical service stays in place —
|
||||
// still correct while ctrld runs.
|
||||
mainLog.Load().Debug().Err(err).Msgf("intercept DNS target: could not read static DNS for %q", iface.Name)
|
||||
return
|
||||
}
|
||||
// Never count ctrld's own previously-set entry as network-provided DNS,
|
||||
// or the next recovery on the same DNS-less network would remove it and
|
||||
// the one after re-add it.
|
||||
if p.interceptDNSTargetService == iface.Name {
|
||||
staticDNS = filterOwnTarget(staticDNS, p.interceptDNSTargetSetValue)
|
||||
}
|
||||
if hasIPv4DNS(staticDNS) {
|
||||
p.removeInterceptDNSTargetLocked("network has usable static IPv4 DNS")
|
||||
return
|
||||
}
|
||||
|
||||
routeDHCPDNS, err := interceptDHCPNameserversForInterfaceFn(drIfaceName)
|
||||
if err != nil {
|
||||
mainLog.Load().Debug().Err(err).Msgf("intercept DNS target: could not read DHCP DNS for default-route service %q", iface.Name)
|
||||
return
|
||||
}
|
||||
if hasIPv4DNS(routeDHCPDNS) {
|
||||
// The default-route service regained DHCP option 6. Remove a target
|
||||
// previously set on this or another service.
|
||||
p.removeInterceptDNSTargetLocked("network has usable DHCP IPv4 DNS")
|
||||
return
|
||||
}
|
||||
|
||||
target := p.interceptDNSTargetValue()
|
||||
if p.interceptDNSTargetService == iface.Name && p.interceptDNSTargetSetValue == target {
|
||||
return // already set on this service
|
||||
}
|
||||
// Default route moved to a different DNS-less service (or the listener
|
||||
// config changed): clear the stale entry first.
|
||||
p.removeInterceptDNSTargetLocked("default route service changed")
|
||||
|
||||
// Preserve any existing (IPv6-only) static entries for later restore.
|
||||
// saveCurrentStaticDNS filters loopback on write, and
|
||||
// savedStaticNameservers filters loopback on read, so ctrld's own
|
||||
// loopback target can never be recorded or restored as user DNS.
|
||||
if err := interceptSaveCurrentStaticDNSFn(iface); err != nil {
|
||||
mainLog.Load().Debug().Err(err).Msgf("intercept DNS target: could not save static DNS for %q", iface.Name)
|
||||
}
|
||||
if err := interceptSetDNSFn(iface, []string{target}); err != nil {
|
||||
mainLog.Load().Warn().Err(err).Msgf("intercept DNS target: could not set %s on %q", target, iface.Name)
|
||||
return
|
||||
}
|
||||
p.interceptDNSTargetService = iface.Name
|
||||
p.interceptDNSTargetSetValue = target
|
||||
p.persistInterceptDNSTargetStateLocked()
|
||||
mainLog.Load().Warn().Msgf("intercept DNS target: service %q provides no usable IPv4 DNS; set %s so macOS can emit DNS queries (removed automatically when the network provides IPv4 DNS)", iface.Name, target)
|
||||
}
|
||||
|
||||
// removeInterceptDNSTarget removes a previously set intercept DNS target,
|
||||
// restoring the service's saved static DNS (or empty). Safe no-op when no
|
||||
// target was set.
|
||||
func (p *prog) removeInterceptDNSTarget(reason string) {
|
||||
p.interceptDNSTargetMu.Lock()
|
||||
defer p.interceptDNSTargetMu.Unlock()
|
||||
p.loadInterceptDNSTargetStateLocked()
|
||||
p.removeInterceptDNSTargetLocked(reason)
|
||||
}
|
||||
|
||||
// removeInterceptDNSTargetLocked is removeInterceptDNSTarget without locking;
|
||||
// callers must hold interceptDNSTargetMu.
|
||||
func (p *prog) removeInterceptDNSTargetLocked(reason string) {
|
||||
svc := p.interceptDNSTargetService
|
||||
val := p.interceptDNSTargetSetValue
|
||||
if svc == "" {
|
||||
return
|
||||
}
|
||||
iface := &net.Interface{Name: svc}
|
||||
// Only remove what ctrld set. If the service's DNS changed externally,
|
||||
// leave that value alone and discard our stale ownership record.
|
||||
cur, err := interceptCurrentStaticDNSFn(iface)
|
||||
if err != nil {
|
||||
mainLog.Load().Debug().Err(err).Msgf("intercept DNS target: could not read %q DNS; retaining cleanup state (%s)", svc, reason)
|
||||
return
|
||||
}
|
||||
if !isInterceptDNSTargetOnly(cur, val) {
|
||||
mainLog.Load().Debug().Msgf("intercept DNS target: %q DNS changed externally; not removing (%s)", svc, reason)
|
||||
p.clearInterceptDNSTargetStateLocked()
|
||||
return
|
||||
}
|
||||
if saved := interceptSavedStaticNameserversFn(iface); len(saved) > 0 {
|
||||
if err := interceptSetDNSFn(iface, saved); err != nil {
|
||||
mainLog.Load().Warn().Err(err).Msgf("intercept DNS target: could not restore saved DNS on %q; retaining cleanup state", svc)
|
||||
return
|
||||
}
|
||||
} else if err := interceptResetDNSIgnoreUnusableIfaceFn(iface); err != nil {
|
||||
mainLog.Load().Warn().Err(err).Msgf("intercept DNS target: could not reset DNS on %q; retaining cleanup state", svc)
|
||||
return
|
||||
}
|
||||
p.clearInterceptDNSTargetStateLocked()
|
||||
mainLog.Load().Info().Msgf("intercept DNS target: removed %s from %q (%s)", val, svc, reason)
|
||||
}
|
||||
|
||||
func (p *prog) clearInterceptDNSTargetStateLocked() {
|
||||
p.interceptDNSTargetService = ""
|
||||
p.interceptDNSTargetSetValue = ""
|
||||
p.persistInterceptDNSTargetStateLocked()
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
//go:build darwin
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
)
|
||||
|
||||
type interceptTargetHarness struct {
|
||||
dns map[string][]string
|
||||
saved map[string][]string
|
||||
serviceByDev map[string]string
|
||||
dhcp []string
|
||||
dhcpErr error
|
||||
readErr error
|
||||
setErr error
|
||||
resetErr error
|
||||
setCalls []string
|
||||
resetCalls []string
|
||||
statePath string
|
||||
}
|
||||
|
||||
func newInterceptTargetHarness(t *testing.T) *interceptTargetHarness {
|
||||
t.Helper()
|
||||
h := &interceptTargetHarness{
|
||||
dns: make(map[string][]string),
|
||||
saved: make(map[string][]string),
|
||||
serviceByDev: map[string]string{"en1": "Wi-Fi"},
|
||||
statePath: filepath.Join(t.TempDir(), interceptDNSTargetStateFile),
|
||||
}
|
||||
|
||||
origPath := interceptDNSTargetStatePathFn
|
||||
origRoute := interceptDefaultRouteInterfaceFn
|
||||
origIface := interceptInterfaceByNameFn
|
||||
origPatch := interceptPatchNetIfaceNameFn
|
||||
origCurrent := interceptCurrentStaticDNSFn
|
||||
origSave := interceptSaveCurrentStaticDNSFn
|
||||
origSet := interceptSetDNSFn
|
||||
origSaved := interceptSavedStaticNameserversFn
|
||||
origReset := interceptResetDNSIgnoreUnusableIfaceFn
|
||||
origDHCP := interceptDHCPNameserversForInterfaceFn
|
||||
origIntercept := dnsIntercept
|
||||
t.Cleanup(func() {
|
||||
interceptDNSTargetStatePathFn = origPath
|
||||
interceptDefaultRouteInterfaceFn = origRoute
|
||||
interceptInterfaceByNameFn = origIface
|
||||
interceptPatchNetIfaceNameFn = origPatch
|
||||
interceptCurrentStaticDNSFn = origCurrent
|
||||
interceptSaveCurrentStaticDNSFn = origSave
|
||||
interceptSetDNSFn = origSet
|
||||
interceptSavedStaticNameserversFn = origSaved
|
||||
interceptResetDNSIgnoreUnusableIfaceFn = origReset
|
||||
interceptDHCPNameserversForInterfaceFn = origDHCP
|
||||
dnsIntercept = origIntercept
|
||||
})
|
||||
|
||||
dnsIntercept = true
|
||||
interceptDNSTargetStatePathFn = func() string { return h.statePath }
|
||||
interceptDefaultRouteInterfaceFn = func() (string, error) { return "en1", nil }
|
||||
interceptInterfaceByNameFn = func(name string) (*net.Interface, error) { return &net.Interface{Name: name}, nil }
|
||||
interceptPatchNetIfaceNameFn = func(iface *net.Interface) (bool, error) {
|
||||
service, ok := h.serviceByDev[iface.Name]
|
||||
if !ok {
|
||||
return false, errors.New("unknown network service")
|
||||
}
|
||||
iface.Name = service
|
||||
return true, nil
|
||||
}
|
||||
interceptCurrentStaticDNSFn = func(iface *net.Interface) ([]string, error) {
|
||||
if h.readErr != nil {
|
||||
return nil, h.readErr
|
||||
}
|
||||
return slices.Clone(h.dns[iface.Name]), nil
|
||||
}
|
||||
interceptSaveCurrentStaticDNSFn = func(iface *net.Interface) error {
|
||||
h.saved[iface.Name] = slices.Clone(h.dns[iface.Name])
|
||||
return nil
|
||||
}
|
||||
interceptSetDNSFn = func(iface *net.Interface, nameservers []string) error {
|
||||
h.setCalls = append(h.setCalls, iface.Name)
|
||||
if h.setErr != nil {
|
||||
return h.setErr
|
||||
}
|
||||
h.dns[iface.Name] = slices.Clone(nameservers)
|
||||
return nil
|
||||
}
|
||||
interceptSavedStaticNameserversFn = func(iface *net.Interface) []string {
|
||||
return slices.Clone(h.saved[iface.Name])
|
||||
}
|
||||
interceptResetDNSIgnoreUnusableIfaceFn = func(iface *net.Interface) error {
|
||||
h.resetCalls = append(h.resetCalls, iface.Name)
|
||||
if h.resetErr != nil {
|
||||
return h.resetErr
|
||||
}
|
||||
h.dns[iface.Name] = nil
|
||||
return nil
|
||||
}
|
||||
interceptDHCPNameserversForInterfaceFn = func(iface string) ([]string, error) {
|
||||
if iface != "en1" {
|
||||
return nil, errors.New("DHCP lookup used a non-default interface")
|
||||
}
|
||||
return slices.Clone(h.dhcp), h.dhcpErr
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func newInterceptTargetProg() *prog {
|
||||
return &prog{
|
||||
cfg: &ctrld.Config{Listener: map[string]*ctrld.ListenerConfig{
|
||||
"0": {IP: "127.0.0.1", Port: 5354},
|
||||
}},
|
||||
dnsInterceptState: &interceptStateStub{},
|
||||
}
|
||||
}
|
||||
|
||||
func persistInterceptTargetForTest(t *testing.T, p *prog, service, value string) {
|
||||
t.Helper()
|
||||
p.interceptDNSTargetMu.Lock()
|
||||
defer p.interceptDNSTargetMu.Unlock()
|
||||
p.interceptDNSTargetLoaded = true
|
||||
p.interceptDNSTargetService = service
|
||||
p.interceptDNSTargetSetValue = value
|
||||
p.persistInterceptDNSTargetStateLocked()
|
||||
}
|
||||
|
||||
func TestEnsureInterceptDNSTargetRequiresCompletedDiscovery(t *testing.T) {
|
||||
h := newInterceptTargetHarness(t)
|
||||
p := newInterceptTargetProg()
|
||||
p.ensureInterceptDNSTarget(nil)
|
||||
if len(h.setCalls) != 0 || len(h.resetCalls) != 0 {
|
||||
t.Fatal("nil system discovery changed service DNS")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureInterceptDNSTargetMigratesService(t *testing.T) {
|
||||
h := newInterceptTargetHarness(t)
|
||||
p := newInterceptTargetProg()
|
||||
persistInterceptTargetForTest(t, p, "iPhone USB", "127.0.0.53")
|
||||
h.dns["iPhone USB"] = []string{"127.0.0.53"}
|
||||
h.dns["Wi-Fi"] = nil
|
||||
|
||||
p.ensureInterceptDNSTarget([]string{})
|
||||
|
||||
if len(h.dns["iPhone USB"]) != 0 {
|
||||
t.Fatalf("old service DNS = %v, want empty", h.dns["iPhone USB"])
|
||||
}
|
||||
if got := h.dns["Wi-Fi"]; !slices.Equal(got, []string{"127.0.0.53"}) {
|
||||
t.Fatalf("new service DNS = %v, want [127.0.0.53]", got)
|
||||
}
|
||||
if p.interceptDNSTargetService != "Wi-Fi" || p.interceptDNSTargetSetValue != "127.0.0.53" {
|
||||
t.Fatalf("tracking = %q/%q, want Wi-Fi/127.0.0.53", p.interceptDNSTargetService, p.interceptDNSTargetSetValue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureInterceptDNSTargetUsesDefaultRouteDHCPOnly(t *testing.T) {
|
||||
t.Run("other interface IPv4 does not suppress target", func(t *testing.T) {
|
||||
h := newInterceptTargetHarness(t)
|
||||
p := newInterceptTargetProg()
|
||||
p.ensureInterceptDNSTarget([]string{"10.10.10.1"})
|
||||
if got := h.dns["Wi-Fi"]; !slices.Equal(got, []string{"127.0.0.53"}) {
|
||||
t.Fatalf("other interface DNS suppressed target: %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returned default route DHCP removes target", func(t *testing.T) {
|
||||
h := newInterceptTargetHarness(t)
|
||||
p := newInterceptTargetProg()
|
||||
persistInterceptTargetForTest(t, p, "Wi-Fi", "127.0.0.53")
|
||||
h.dns["Wi-Fi"] = []string{"127.0.0.53"}
|
||||
h.dhcp = []string{"192.168.10.1"}
|
||||
|
||||
p.ensureInterceptDNSTarget([]string{"10.10.10.1"})
|
||||
|
||||
if len(h.dns["Wi-Fi"]) != 0 || p.interceptDNSTargetService != "" {
|
||||
t.Fatalf("returned default-route DHCP DNS did not remove target: dns=%v service=%q", h.dns["Wi-Fi"], p.interceptDNSTargetService)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRemoveInterceptDNSTargetRestoresStateFileAfterRestart(t *testing.T) {
|
||||
h := newInterceptTargetHarness(t)
|
||||
h.dns["iPhone USB"] = []string{"127.0.0.53"}
|
||||
if err := os.WriteFile(h.statePath, []byte(`{"service":"iPhone USB","value":"127.0.0.53"}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := newInterceptTargetProg()
|
||||
|
||||
p.removeInterceptDNSTarget("intercept mode inactive")
|
||||
|
||||
if len(h.dns["iPhone USB"]) != 0 || p.interceptDNSTargetService != "" {
|
||||
t.Fatalf("restart cleanup failed: dns=%v service=%q", h.dns["iPhone USB"], p.interceptDNSTargetService)
|
||||
}
|
||||
if _, err := os.Stat(h.statePath); !os.IsNotExist(err) {
|
||||
t.Fatalf("state file still exists after cleanup: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveInterceptDNSTargetKeepsExternalDNS(t *testing.T) {
|
||||
h := newInterceptTargetHarness(t)
|
||||
p := newInterceptTargetProg()
|
||||
persistInterceptTargetForTest(t, p, "Wi-Fi", "127.0.0.53")
|
||||
h.dns["Wi-Fi"] = []string{"8.8.8.8"}
|
||||
|
||||
p.removeInterceptDNSTarget("test")
|
||||
|
||||
if !slices.Equal(h.dns["Wi-Fi"], []string{"8.8.8.8"}) || len(h.setCalls) != 0 || len(h.resetCalls) != 0 {
|
||||
t.Fatalf("external DNS was changed: dns=%v set=%v reset=%v", h.dns["Wi-Fi"], h.setCalls, h.resetCalls)
|
||||
}
|
||||
if p.interceptDNSTargetService != "" {
|
||||
t.Fatal("external change left stale ownership tracking")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveInterceptDNSTargetRetainsStateOnFailure(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
readErr error
|
||||
resetErr error
|
||||
}{
|
||||
{"read failure", errors.New("networksetup read failed"), nil},
|
||||
{"restore failure", nil, errors.New("networksetup reset failed")},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
h := newInterceptTargetHarness(t)
|
||||
p := newInterceptTargetProg()
|
||||
persistInterceptTargetForTest(t, p, "Wi-Fi", "127.0.0.53")
|
||||
h.dns["Wi-Fi"] = []string{"127.0.0.53"}
|
||||
h.readErr = tc.readErr
|
||||
h.resetErr = tc.resetErr
|
||||
|
||||
p.removeInterceptDNSTarget("test")
|
||||
|
||||
if p.interceptDNSTargetService != "Wi-Fi" || p.interceptDNSTargetSetValue != "127.0.0.53" {
|
||||
t.Fatal("failed cleanup discarded retry state")
|
||||
}
|
||||
if _, err := os.Stat(h.statePath); err != nil {
|
||||
t.Fatalf("failed cleanup removed persisted retry state: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package cli
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFilterOwnTarget(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in []string
|
||||
target string
|
||||
wantLen int
|
||||
}{
|
||||
// The oscillation guard (MR !997 review): the second recovery on the
|
||||
// same DNS-less network must not count ctrld's own entry as
|
||||
// network-provided IPv4 DNS.
|
||||
{"removes own entry", []string{"127.0.0.1"}, "127.0.0.1", 0},
|
||||
{"removes own entry with resolver port", []string{"127.0.0.53:53"}, "127.0.0.53", 0},
|
||||
{"keeps user entries", []string{"127.0.0.1", "1.1.1.1"}, "127.0.0.1", 1},
|
||||
{"empty target keeps all", []string{"127.0.0.1"}, "", 1},
|
||||
{"no match keeps all", []string{"1.1.1.1"}, "127.0.0.53", 1},
|
||||
{"nil input", nil, "127.0.0.1", 0},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := filterOwnTarget(tc.in, tc.target)
|
||||
if len(got) != tc.wantLen {
|
||||
t.Errorf("filterOwnTarget(%v, %q) = %v, want len %d", tc.in, tc.target, got, tc.wantLen)
|
||||
}
|
||||
for _, s := range got {
|
||||
if tc.target != "" && s == tc.target {
|
||||
t.Errorf("filterOwnTarget(%v, %q) retained the target entry", tc.in, tc.target)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilterOwnTargetStability pins the recovery-cycle contract: on a
|
||||
// DNS-less network where ctrld already set its target, needsInterceptDNSTarget
|
||||
// over the filtered list must still report true (entry kept, no oscillation),
|
||||
// while a genuine user-added IPv4 server must report false (entry removed).
|
||||
func TestFilterOwnTargetStability(t *testing.T) {
|
||||
target := "127.0.0.1"
|
||||
|
||||
// Second recovery, same tether: only our own entry present. The OS resolver
|
||||
// reports it with :53, while networksetup reports the bare address.
|
||||
static := filterOwnTarget([]string{target}, target)
|
||||
discovered := filterOwnTarget([]string{target + ":53"}, target)
|
||||
if !needsInterceptDNSTarget(static, discovered) {
|
||||
t.Error("second recovery on the same DNS-less network would remove the target (oscillation)")
|
||||
}
|
||||
|
||||
// User manually added a public server meanwhile: target no longer needed.
|
||||
static = filterOwnTarget([]string{target, "1.1.1.1"}, target)
|
||||
if needsInterceptDNSTarget(static, nil) {
|
||||
t.Error("user-added IPv4 DNS not recognized; target would be kept unnecessarily")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//go:build !darwin
|
||||
|
||||
package cli
|
||||
|
||||
// ensureInterceptDNSTarget is a no-op on non-Darwin platforms: the DNS-less
|
||||
// network problem it solves is specific to macOS pf interception blocking
|
||||
// IPv6 port 53 with no IPv4 fallback (issue #533). Windows intercept mode
|
||||
// uses NRPT, which routes queries regardless of adapter DNS configuration.
|
||||
func (p *prog) ensureInterceptDNSTarget(_ []string) {}
|
||||
|
||||
// removeInterceptDNSTarget is a no-op on non-Darwin platforms.
|
||||
//
|
||||
//lint:ignore U1000 called from Darwin-only intercept shutdown; kept for API symmetry.
|
||||
func (p *prog) removeInterceptDNSTarget(_ string) {}
|
||||
@@ -0,0 +1,113 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
)
|
||||
|
||||
func TestHasIPv4DNS(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in []string
|
||||
want bool
|
||||
}{
|
||||
{"empty", nil, false},
|
||||
{"ipv4", []string{"8.8.8.8"}, true},
|
||||
{"ipv4 with port", []string{"192.168.1.1:53"}, true},
|
||||
{"loopback counts", []string{"127.0.0.1"}, true},
|
||||
{"ipv6 only", []string{"2001:4860:4860::8888"}, false},
|
||||
{"ipv6 with port", []string{"[2001:4860:4860::8888]:53"}, false},
|
||||
{"mixed", []string{"2001:4860:4860::8888", "9.9.9.9"}, true},
|
||||
{"garbage ignored", []string{"not-an-ip", ""}, false},
|
||||
{"garbage plus v4", []string{"not-an-ip", "1.1.1.1"}, true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := hasIPv4DNS(tc.in); got != tc.want {
|
||||
t.Errorf("hasIPv4DNS(%v) = %v, want %v", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeedsInterceptDNSTarget(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
static, discovered []string
|
||||
want bool
|
||||
}{
|
||||
{"no dns at all", nil, nil, true},
|
||||
{"ipv6-only tether (464XLAT, issue #533)", nil, []string{"2605:8d80::1"}, true},
|
||||
{"static v4 present", []string{"1.1.1.1"}, nil, false},
|
||||
{"discovered v4 present", nil, []string{"192.168.1.1:53"}, false},
|
||||
{"existing ctrld target satisfies", []string{"127.0.0.1"}, nil, false},
|
||||
{"ipv6 static, v4 discovered", []string{"2001:db8::1"}, []string{"10.0.0.1"}, false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := needsInterceptDNSTarget(tc.static, tc.discovered); got != tc.want {
|
||||
t.Errorf("needsInterceptDNSTarget(%v, %v) = %v, want %v", tc.static, tc.discovered, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsInterceptDNSTargetOnly(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in []string
|
||||
target string
|
||||
want bool
|
||||
}{
|
||||
{"exactly ours (direct listener)", []string{"127.0.0.1"}, "127.0.0.1", true},
|
||||
{"exactly ours (rdr target)", []string{"127.0.0.53"}, "127.0.0.53", true},
|
||||
{"empty list", nil, "127.0.0.1", false},
|
||||
{"empty target never matches", []string{"127.0.0.1"}, "", false},
|
||||
{"ours plus user entry", []string{"127.0.0.1", "1.1.1.1"}, "127.0.0.1", false},
|
||||
{"user entry only", []string{"1.1.1.1"}, "127.0.0.1", false},
|
||||
{"different loopback than ours", []string{"127.0.0.53"}, "127.0.0.1", false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := isInterceptDNSTargetOnly(tc.in, tc.target); got != tc.want {
|
||||
t.Errorf("isInterceptDNSTargetOnly(%v, %q) = %v, want %v", tc.in, tc.target, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptDNSTargetValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ip string
|
||||
port int
|
||||
want string
|
||||
}{
|
||||
{"default direct listener :53", "127.0.0.1", 53, "127.0.0.1"},
|
||||
{"custom loopback listener :53", "127.0.0.2", 53, "127.0.0.2"},
|
||||
{"non-53 port uses rdr target", "127.0.0.1", 5354, "127.0.0.53"},
|
||||
{"listener on rdr target with non-53 port", "127.0.0.53", 5354, "127.0.0.54"},
|
||||
{"wildcard ip :53 falls back to loopback", "0.0.0.0", 53, "127.0.0.1"},
|
||||
{"wildcard ip non-53 uses rdr target", "0.0.0.0", 5354, "127.0.0.53"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p := &prog{cfg: &ctrld.Config{
|
||||
Listener: map[string]*ctrld.ListenerConfig{
|
||||
"0": {IP: tc.ip, Port: tc.port},
|
||||
},
|
||||
}}
|
||||
if got := p.interceptDNSTargetValue(); got != tc.want {
|
||||
t.Errorf("interceptDNSTargetValue() with listener %s:%d = %q, want %q", tc.ip, tc.port, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptDNSTargetValue_NoListener(t *testing.T) {
|
||||
p := &prog{cfg: &ctrld.Config{}}
|
||||
if got := p.interceptDNSTargetValue(); got != "127.0.0.1" {
|
||||
t.Errorf("interceptDNSTargetValue() with no listener = %q, want 127.0.0.1", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,800 @@
|
||||
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/controld"
|
||||
"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.firewallAllowList() != nil
|
||||
}
|
||||
|
||||
// firewallAllowList returns the allowlist this run enforces, or nil when Firewall
|
||||
// Mode is off.
|
||||
//
|
||||
// syncFirewallMode replaces the field from the reload goroutine while refreshes
|
||||
// read it, so a read that is followed by a dereference has to work from a
|
||||
// snapshot rather than from the field: otherwise a reload landing in between
|
||||
// turns the pointer to nil under the caller. Acting on a superseded allowlist is
|
||||
// harmless - reconcileDestinations re-checks the firewall generation under
|
||||
// destinationsMu and does nothing for a generation that has ended.
|
||||
func (p *prog) firewallAllowList() *firewall.AllowList {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.allowList
|
||||
}
|
||||
|
||||
// setFirewallAllowList publishes this run's allowlist. Only syncFirewallMode
|
||||
// calls it; every other goroutine reads through firewallAllowList.
|
||||
func (p *prog) setFirewallAllowList(al *firewall.AllowList) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.allowList = al
|
||||
}
|
||||
|
||||
// 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 and upgrade download server IPs
|
||||
func (p *prog) initFirewallAllowList(ctx context.Context, al *firewall.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)
|
||||
|
||||
// ControlD API and download IPs — ctrld needs to reach its own control plane.
|
||||
p.addControlDEndpointIPsToPermanent(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) {
|
||||
// This is the only writer of p.allowList, so its own reads need no lock; every
|
||||
// other goroutine reads the published pointer through firewallAllowList.
|
||||
al := p.allowList
|
||||
|
||||
if p.cfg.Service.FirewallMode != "on" {
|
||||
if al != nil || p.platformFirewallState != nil {
|
||||
p.Info().Msg("Firewall mode disabled: removing platform enforcement and clearing allowlist")
|
||||
}
|
||||
if al != nil {
|
||||
al.SetOnChange(nil)
|
||||
al.SetOnBatchChange(nil)
|
||||
p.setFirewallAllowList(nil)
|
||||
}
|
||||
// Platform enforcement is torn down below, so nothing may be mirrored any
|
||||
// more: end this generation, which retires the maintenance worker, and
|
||||
// forget what was applied so a later re-enable reinstalls it.
|
||||
p.retireFirewallDestinations()
|
||||
if p.platformFirewallState != nil {
|
||||
p.shutdownPlatformFirewall()
|
||||
p.platformFirewallState = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if al == nil {
|
||||
al = firewall.New()
|
||||
p.initFirewallAllowList(ctx, al)
|
||||
p.setFirewallAllowList(al)
|
||||
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(al)
|
||||
p.addControlDEndpointIPsToPermanent(al)
|
||||
}
|
||||
|
||||
// Open this run's firewall generation before any work is scheduled against it,
|
||||
// so the previous run's maintenance worker stops acting on enforcement this
|
||||
// run is now responsible for.
|
||||
gen := p.startFirewallGeneration()
|
||||
|
||||
// Apply the organization's allowed destinations from the resolver config this
|
||||
// run started with. A reload that turns Firewall Mode on builds a fresh
|
||||
// allowlist, so the set has to be re-applied rather than assumed present.
|
||||
p.syncAllowedDestinations()
|
||||
|
||||
// The run context is canceled on each reload. Restart the reaper/maintenance
|
||||
// workers for this run so reused allowlists keep expiring entries.
|
||||
al.StartReaper(ctx)
|
||||
go p.firewallMaintenance(ctx, al, gen)
|
||||
|
||||
// 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.
|
||||
//
|
||||
// Called whether or not enforcement is already up, because the permanent adds
|
||||
// above reach memory only: AddPermanent fires no change callback, so a reload
|
||||
// that resolves a new API address would log it as permitted while the platform
|
||||
// never hears about it. Each platform's re-entry is a refresh - Windows
|
||||
// reinstalls the permanent filters it is missing, macOS returns early - so
|
||||
// calling it when enforcement is already up costs nothing and closes that gap.
|
||||
if p.dnsInterceptState != 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// syncAllowedDestinations applies the organization's Allowed Destination IP list
|
||||
// from the resolver config currently held by prog.
|
||||
//
|
||||
// Called whenever that config could have changed: on every start and reload (via
|
||||
// syncFirewallMode) and after every API refresh, forced or scheduled (via
|
||||
// apiConfigReload). Reading the list from p.rc rather than taking it as an
|
||||
// argument keeps those callers from having to know whether Firewall Mode is on.
|
||||
func (p *prog) syncAllowedDestinations() {
|
||||
p.mu.Lock()
|
||||
rc := p.rc
|
||||
al := p.allowList
|
||||
p.mu.Unlock()
|
||||
|
||||
var entries []string
|
||||
if rc != nil {
|
||||
entries = rc.DestinationIPs
|
||||
}
|
||||
p.applyAllowedDestinations(al, entries)
|
||||
}
|
||||
|
||||
// applyAllowedDestinations records entries as the desired Firewall Mode exception
|
||||
// set and mirrors it into platform enforcement. Entries the API sent that are not
|
||||
// a valid address or CIDR are dropped individually, so one bad entry never voids
|
||||
// the rest of an organization's list.
|
||||
//
|
||||
// A no-op when Firewall Mode is off: with no allowlist there is nothing to
|
||||
// except from, and the set is re-applied from p.rc if the mode is turned on.
|
||||
//
|
||||
// The allowlist is an argument rather than a field read because a reload can
|
||||
// replace p.allowList - including with nil - between the nil check and the
|
||||
// SetExceptions call below, which would dereference nil. Callers snapshot it
|
||||
// once under mu (see firewallAllowList) and pass what they snapshotted.
|
||||
func (p *prog) applyAllowedDestinations(al *firewall.AllowList, entries []string) {
|
||||
if al == nil {
|
||||
return
|
||||
}
|
||||
|
||||
prefixes, rejected, wide := parseAllowedDestinations(entries)
|
||||
p.warnRejectedAllowedDestinations(rejected)
|
||||
p.warnWideAllowedDestinations(wide)
|
||||
|
||||
al.SetExceptions(prefixes)
|
||||
p.reconcileDestinations(al, p.firewallGen.Load())
|
||||
}
|
||||
|
||||
// reconcileAllowedDestinations brings platform enforcement in line with the
|
||||
// desired allowed-destination set, installing what is missing and removing what
|
||||
// the organization has withdrawn.
|
||||
//
|
||||
// The applied snapshot advances ONLY after the platform accepted the change. A
|
||||
// failed pfctl call or WFP filter operation therefore leaves the previous
|
||||
// snapshot recorded, so the same delta is recomputed - and retried - by the next
|
||||
// refresh and by the periodic reconcile, instead of being silently dropped while
|
||||
// the logs claim the new set is in force. Retrying the whole delta is safe
|
||||
// because both mirrors are idempotent: installing an entry that is already there
|
||||
// and removing one that is already gone are no-ops.
|
||||
//
|
||||
// Callers must not hold destinationsMu; the mirror can block on pfctl.
|
||||
func (p *prog) reconcileAllowedDestinations() {
|
||||
p.reconcileDestinations(p.firewallAllowList(), p.firewallGen.Load())
|
||||
}
|
||||
|
||||
// reconcileDestinations is reconcileAllowedDestinations for one firewall
|
||||
// generation. Background workers pass the allowlist and generation they were
|
||||
// started with, and the generation is re-checked under destinationsMu: teardown
|
||||
// bumps it while holding the same lock, so a worker from a previous run can never
|
||||
// mirror anything into enforcement that is being (or has been) removed.
|
||||
func (p *prog) reconcileDestinations(al *firewall.AllowList, gen uint64) {
|
||||
if al == nil {
|
||||
return
|
||||
}
|
||||
desired := al.Exceptions()
|
||||
|
||||
p.destinationsMu.Lock()
|
||||
defer p.destinationsMu.Unlock()
|
||||
|
||||
if p.firewallGen.Load() != gen {
|
||||
return
|
||||
}
|
||||
|
||||
// A resync owes the platform the whole set, not a delta: it means enforcement
|
||||
// started with state ctrld does not know (a persist pf table from a previous
|
||||
// run) or with none at all. Until the replace succeeds nothing about the
|
||||
// applied set can be assumed, so the flag stays set and it is retried.
|
||||
if p.destinationsNeedResync {
|
||||
if err := firewallReplaceExceptionsFn(p, desired); err != nil {
|
||||
p.Warn().Err(err).Int("total", len(desired)).
|
||||
Msg("Firewall: could not install organization allowed destinations, will retry")
|
||||
return
|
||||
}
|
||||
p.destinationsNeedResync = false
|
||||
p.appliedDestinations = desired
|
||||
p.logDestinationChange(len(desired), 0, desired, nil)
|
||||
return
|
||||
}
|
||||
|
||||
added := prefixesNotIn(desired, p.appliedDestinations)
|
||||
removed := prefixesNotIn(p.appliedDestinations, desired)
|
||||
if len(added) == 0 && len(removed) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if err := firewallMirrorExceptionsFn(p, added, removed); err != nil {
|
||||
p.Warn().Err(err).
|
||||
Int("pending_add", len(added)).
|
||||
Int("pending_remove", len(removed)).
|
||||
Msg("Firewall: could not apply all organization allowed destinations, will retry")
|
||||
return
|
||||
}
|
||||
|
||||
p.appliedDestinations = desired
|
||||
p.logDestinationChange(len(added), len(removed), added, removed)
|
||||
}
|
||||
|
||||
// logDestinationChange reports an applied change: counts at Info, addresses at
|
||||
// Debug. The list is an organization's network topology, and Info-level logs are
|
||||
// persisted and uploaded with support bundles, so the counts are all that goes
|
||||
// into the routine record.
|
||||
func (p *prog) logDestinationChange(nAdded, nRemoved int, added, removed []netip.Prefix) {
|
||||
p.Info().
|
||||
Int("added", nAdded).
|
||||
Int("removed", nRemoved).
|
||||
Int("total", len(p.appliedDestinations)).
|
||||
Msg("Firewall: applied organization allowed destination IPs")
|
||||
p.Debug().
|
||||
Strs("added", prefixStrings(added)).
|
||||
Strs("removed", prefixStrings(removed)).
|
||||
Msg("Firewall: organization allowed destination changes")
|
||||
}
|
||||
|
||||
// pendingDestinations reports how many allowed-destination changes platform
|
||||
// enforcement has not accepted yet. Non-zero means a mirror attempt failed and
|
||||
// the reconcile is still retrying, which is the difference between "the set is in
|
||||
// force" and "the set is what we want" - the stats line must not conflate them.
|
||||
func (p *prog) pendingDestinations(al *firewall.AllowList) int {
|
||||
if al == nil {
|
||||
return 0
|
||||
}
|
||||
desired := al.Exceptions()
|
||||
|
||||
p.destinationsMu.Lock()
|
||||
defer p.destinationsMu.Unlock()
|
||||
|
||||
if p.destinationsNeedResync {
|
||||
// Nothing about the applied set is known, so everything is outstanding -
|
||||
// and an empty desired set still owes the platform a flush of whatever it
|
||||
// is holding, which is one pending operation, not zero.
|
||||
return max(len(desired), 1)
|
||||
}
|
||||
return len(prefixesNotIn(desired, p.appliedDestinations)) + len(prefixesNotIn(p.appliedDestinations, desired))
|
||||
}
|
||||
|
||||
// startFirewallGeneration opens a new firewall generation and returns it,
|
||||
// retiring the workers of the previous one. Called for every run (start or
|
||||
// reload) that has Firewall Mode on; the applied snapshot is left alone because
|
||||
// platform enforcement survives a reload.
|
||||
func (p *prog) startFirewallGeneration() uint64 {
|
||||
p.destinationsMu.Lock()
|
||||
defer p.destinationsMu.Unlock()
|
||||
return p.firewallGen.Add(1)
|
||||
}
|
||||
|
||||
// markDestinationsForResync records that platform enforcement holds unknown
|
||||
// state, so the next reconcile replaces its whole allowed-destination set rather
|
||||
// than applying a delta against a snapshot that no longer describes anything.
|
||||
// Called when enforcement starts: a fresh WFP session holds nothing, and a pf
|
||||
// persist table may still hold what a previous run put there.
|
||||
func (p *prog) markDestinationsForResync() {
|
||||
p.destinationsMu.Lock()
|
||||
defer p.destinationsMu.Unlock()
|
||||
p.appliedDestinations = nil
|
||||
p.destinationsNeedResync = true
|
||||
}
|
||||
|
||||
// retireFirewallDestinations ends the current firewall generation and forgets the
|
||||
// applied set, for teardown: enforcement is about to be removed, so there is
|
||||
// nothing left to reconcile against and no resync to owe.
|
||||
//
|
||||
// Bumping the generation under destinationsMu is what makes teardown safe against
|
||||
// the maintenance worker: either the worker is mid-reconcile and this blocks
|
||||
// until it finishes, or it reaches its own reconcile afterwards, sees a
|
||||
// generation it does not own, and does nothing.
|
||||
func (p *prog) retireFirewallDestinations() {
|
||||
p.destinationsMu.Lock()
|
||||
defer p.destinationsMu.Unlock()
|
||||
p.firewallGen.Add(1)
|
||||
p.appliedDestinations = nil
|
||||
p.destinationsNeedResync = false
|
||||
}
|
||||
|
||||
// firewallMirrorExceptionsFn mirrors an allowed-destination delta into platform
|
||||
// enforcement, and firewallReplaceExceptionsFn makes enforcement hold exactly the
|
||||
// given set regardless of what it held before. Indirected so the
|
||||
// failure-and-retry paths are testable without pf or WFP.
|
||||
var (
|
||||
firewallMirrorExceptionsFn = (*prog).firewallApplyExceptionsPlatform
|
||||
firewallReplaceExceptionsFn = (*prog).firewallReplaceExceptionsPlatform
|
||||
)
|
||||
|
||||
// prefixesNotIn returns the members of a that are absent from b.
|
||||
func prefixesNotIn(a, b []netip.Prefix) []netip.Prefix {
|
||||
if len(a) == 0 {
|
||||
return nil
|
||||
}
|
||||
inB := make(map[netip.Prefix]struct{}, len(b))
|
||||
for _, prefix := range b {
|
||||
inB[prefix] = struct{}{}
|
||||
}
|
||||
var out []netip.Prefix
|
||||
for _, prefix := range a {
|
||||
if _, ok := inB[prefix]; ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, prefix)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parseAllowedDestinations converts the API's Allowed Destination IP entries into
|
||||
// prefixes, returning the usable ones and the raw entries that were rejected.
|
||||
//
|
||||
// The API reports a single host as a bare address ("1.2.3.4", "2606:1a40::1") and
|
||||
// anything wider in CIDR form, so both spellings are accepted; a bare address
|
||||
// becomes a single-host prefix. IPv4-in-IPv6 forms are unmapped to match how the
|
||||
// allowlist stores addresses, otherwise a "::ffff:1.2.3.4" entry would never
|
||||
// match the IPv4 address it denotes.
|
||||
// The third result is the accepted prefixes that are wide enough to be worth
|
||||
// reporting; see wideAllowedDestination.
|
||||
func parseAllowedDestinations(entries []string) (accepted []netip.Prefix, rejected []string, wide []netip.Prefix) {
|
||||
if len(entries) == 0 {
|
||||
return nil, nil, nil
|
||||
}
|
||||
accepted = make([]netip.Prefix, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
entry = strings.TrimSpace(entry)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
if prefix, err := netip.ParsePrefix(entry); err == nil {
|
||||
prefix = unmapPrefix(prefix)
|
||||
accepted = append(accepted, prefix)
|
||||
if wideAllowedDestination(prefix) {
|
||||
wide = append(wide, prefix)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if addr, err := netip.ParseAddr(entry); err == nil {
|
||||
addr = addr.Unmap()
|
||||
accepted = append(accepted, netip.PrefixFrom(addr, addr.BitLen()))
|
||||
continue
|
||||
}
|
||||
rejected = append(rejected, entry)
|
||||
}
|
||||
return accepted, rejected, wide
|
||||
}
|
||||
|
||||
// An accepted prefix with fewer mask bits than these is reported. The floors are
|
||||
// set below anything an organization plausibly means: /8 is the widest classical
|
||||
// IPv4 network and the size of RFC1918's 10.0.0.0/8, and /32 is a whole IPv6 RIR
|
||||
// allocation. A bare address always parses to a single-host prefix, so only a
|
||||
// CIDR entry can reach either floor.
|
||||
const (
|
||||
minSaneAllowedDestinationV4Bits = 8
|
||||
minSaneAllowedDestinationV6Bits = 32
|
||||
)
|
||||
|
||||
// wideAllowedDestination reports whether an accepted prefix covers enough of the
|
||||
// address space to deserve a line in the log.
|
||||
//
|
||||
// netip.ParsePrefix takes "1.2.3.4/0", and normalizeExceptions masks it to
|
||||
// 0.0.0.0/0; "::/0" and - through unmapPrefix - "::ffff:0:0/96" do the same for
|
||||
// IPv6. One such entry lets every destination of that family bypass Firewall
|
||||
// Mode while the mode still reports on, and logDestinationChange records only
|
||||
// counts at Info, so without this the bypass is invisible outside Debug logs.
|
||||
//
|
||||
// This is not a defence against a hostile API, which can already turn the mode
|
||||
// off through custom_config. It is a defence against a wide prefix arriving by
|
||||
// accident - a dashboard bug, or an admin who typed the wrong mask - and nobody
|
||||
// noticing.
|
||||
func wideAllowedDestination(prefix netip.Prefix) bool {
|
||||
if prefix.Addr().Is4() {
|
||||
return prefix.Bits() < minSaneAllowedDestinationV4Bits
|
||||
}
|
||||
return prefix.Bits() < minSaneAllowedDestinationV6Bits
|
||||
}
|
||||
|
||||
// unmapPrefix rewrites an IPv4-in-IPv6 prefix to its IPv4 form, adjusting the
|
||||
// mask by the 96-bit IPv4-mapped prefix length. Prefixes of other families are
|
||||
// returned unchanged.
|
||||
func unmapPrefix(prefix netip.Prefix) netip.Prefix {
|
||||
addr := prefix.Addr()
|
||||
if !addr.Is4In6() {
|
||||
return prefix
|
||||
}
|
||||
bits := prefix.Bits() - 96
|
||||
if bits < 0 {
|
||||
// A mask wider than the mapped range does not denote an IPv4 network;
|
||||
// leave it as the IPv6 prefix it literally is.
|
||||
return prefix
|
||||
}
|
||||
return netip.PrefixFrom(addr.Unmap(), bits)
|
||||
}
|
||||
|
||||
// warnRejectedAllowedDestinations reports unusable entries, but only when the set
|
||||
// of rejections changes. The list is re-parsed on every refresh (hourly by
|
||||
// default), so warning unconditionally would repeat the same lines for the life
|
||||
// of the process while still saying nothing new.
|
||||
// The rejected values themselves go to Debug, never to Warn. A rejected entry is
|
||||
// still an organization's topology - "10.0.0.0/33" names a real network - and
|
||||
// Warn logs are persisted and travel in support bundles exactly like Info ones,
|
||||
// so they follow the same rule as logDestinationChange: counts in the routine
|
||||
// record, addresses only when someone turned Debug on to look.
|
||||
func (p *prog) warnRejectedAllowedDestinations(rejected []string) {
|
||||
key := strings.Join(rejected, ",")
|
||||
|
||||
p.mu.Lock()
|
||||
unchanged := p.rejectedDestinationsKey == key
|
||||
p.rejectedDestinationsKey = key
|
||||
p.mu.Unlock()
|
||||
|
||||
if unchanged || len(rejected) == 0 {
|
||||
return
|
||||
}
|
||||
p.Warn().Int("rejected", len(rejected)).
|
||||
Msg("Firewall: ignoring organization allowed destinations that are not a valid IP address or CIDR")
|
||||
p.Debug().Strs("values", rejected).
|
||||
Msg("Firewall: rejected organization allowed destinations")
|
||||
}
|
||||
|
||||
// warnWideAllowedDestinations reports accepted entries wide enough to blanket an
|
||||
// address family, with the mask width but not the address, and only when the set
|
||||
// of them changes - the list is re-parsed on every refresh, so an unconditional
|
||||
// warning would repeat the same lines for the life of the process.
|
||||
func (p *prog) warnWideAllowedDestinations(wide []netip.Prefix) {
|
||||
key := strings.Join(prefixStrings(wide), ",")
|
||||
|
||||
p.mu.Lock()
|
||||
unchanged := p.wideDestinationsKey == key
|
||||
p.wideDestinationsKey = key
|
||||
p.mu.Unlock()
|
||||
|
||||
if unchanged || len(wide) == 0 {
|
||||
return
|
||||
}
|
||||
for _, prefix := range wide {
|
||||
family := "ipv6"
|
||||
if prefix.Addr().Is4() {
|
||||
family = "ipv4"
|
||||
}
|
||||
p.Warn().Str("family", family).Int("bits", prefix.Bits()).
|
||||
Msg("Firewall: organization allowed destination covers a very wide range; traffic to it bypasses Firewall Mode")
|
||||
}
|
||||
p.Debug().Strs("values", prefixStrings(wide)).
|
||||
Msg("Firewall: wide organization allowed destinations")
|
||||
}
|
||||
|
||||
// prefixStrings renders prefixes for logging.
|
||||
func prefixStrings(prefixes []netip.Prefix) []string {
|
||||
out := make([]string, 0, len(prefixes))
|
||||
for _, prefix := range prefixes {
|
||||
out = append(out, prefix.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// addControlDEndpointIPsToPermanent permits the ControlD endpoints ctrld dials on
|
||||
// its own behalf. Called at startup and on config reload, like the upstream IPs.
|
||||
//
|
||||
// Firewall Mode permits what ctrld's listener resolved, and each of these has a
|
||||
// hardcoded address it dials when DNS is unusable - which is exactly the state a
|
||||
// ctrld blocked by its own filters is in. Nothing teaches the allowlist about
|
||||
// those addresses, so the block-all filters deny ctrld's own sockets. See
|
||||
// controld.APIEndpointIPs for the incident this comes from.
|
||||
func (p *prog) addControlDEndpointIPsToPermanent(al *firewall.AllowList) {
|
||||
// The API. Its transport resolves with ctrld.LookupIP, which queries the OS
|
||||
// nameservers directly rather than through the listener, so neither what it
|
||||
// resolves nor what it falls back to is ever learned - both are permitted here.
|
||||
p.addPermanentIPs(al, "ControlD API", controld.APIEndpointIPs(cdDev))
|
||||
p.addPermanentResolvedIPs(al, "ControlD API", controld.APIDomain(cdDev))
|
||||
|
||||
// The upgrade download server. performUpgrade spawns a detached child process,
|
||||
// which WFP's block-all filters deny exactly like this one: they carry no
|
||||
// process condition. Its hostname lookup does go through the listener and is
|
||||
// learned, so only the direct IP it falls back to needs permitting - and that
|
||||
// fallback is the one an upgrade on a blocked host depends on.
|
||||
p.addPermanentIPs(al, "ControlD download server", []string{downloadServerIp})
|
||||
}
|
||||
|
||||
// addPermanentIPs permits literal addresses, ignoring any that do not parse.
|
||||
func (p *prog) addPermanentIPs(al *firewall.AllowList, what string, ips []string) {
|
||||
for _, ipStr := range ips {
|
||||
if ip, err := netip.ParseAddr(ipStr); err == nil {
|
||||
al.AddPermanent(ip)
|
||||
p.Debug().Msgf("Firewall: added %s IP %s to permanent allowlist", what, ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// addPermanentResolvedIPs permits whatever domain resolves to right now.
|
||||
func (p *prog) addPermanentResolvedIPs(al *firewall.AllowList, what, domain string) {
|
||||
ips, err := net.LookupHost(domain)
|
||||
if err != nil {
|
||||
// Neither fatal nor surprising during early startup, and not a Warn: the
|
||||
// direct addresses are permitted regardless, and they are what the
|
||||
// transport itself falls back to in this same situation.
|
||||
p.Debug().Err(err).Msgf("Firewall: could not resolve %s for the permanent allowlist; its direct IPs are permitted", domain)
|
||||
return
|
||||
}
|
||||
for _, ipStr := range ips {
|
||||
if ip, err := netip.ParseAddr(ipStr); err == nil {
|
||||
al.AddPermanent(ip)
|
||||
p.Debug().Msgf("Firewall: added %s IP %s (%s) to permanent allowlist", what, ip, domain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
// firewallMaintenance logs allowlist metrics immediately, then every 5 minutes
|
||||
// while firewall mode is active, and retries any allowed-destination change that
|
||||
// platform enforcement rejected.
|
||||
//
|
||||
// The retry has to be time-based, not only refresh-driven: configuration
|
||||
// refreshes are hourly by default, so a transient pfctl or WFP failure would
|
||||
// otherwise leave an approved destination blocked - or worse, a withdrawn one
|
||||
// permitted - for up to an hour.
|
||||
// It works on the allowlist and generation it was started with, not on
|
||||
// p.allowList: a reload replaces that field from another goroutine, and this
|
||||
// worker outlives the run whose context it was given by however long it takes to
|
||||
// observe cancellation. Once its generation is over - a reload, or Firewall Mode
|
||||
// being turned off - the worker retires rather than reconciling enforcement it no
|
||||
// longer owns; reconcileDestinations re-checks the generation under
|
||||
// destinationsMu, so even a worker that is already inside it cannot act late.
|
||||
func (p *prog) firewallMaintenance(ctx context.Context, al *firewall.AllowList, gen uint64) {
|
||||
if al == nil {
|
||||
return
|
||||
}
|
||||
p.logFirewallStatsOnce(al)
|
||||
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
// A tick can win the select against an already-canceled context, and
|
||||
// a generation can end without the context being canceled at all.
|
||||
if ctx.Err() != nil || p.firewallGen.Load() != gen {
|
||||
return
|
||||
}
|
||||
p.reconcileDestinations(al, gen)
|
||||
p.logFirewallStatsOnce(al)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *prog) logFirewallStatsOnce(al *firewall.AllowList) {
|
||||
if al == nil {
|
||||
return
|
||||
}
|
||||
stats := al.Stats()
|
||||
p.Info().
|
||||
Int("allowed_ips", stats.AllowedIPs).
|
||||
Int("permanent_ips", stats.PermanentIPs).
|
||||
Int("allowed_destinations", stats.ExceptionPrefixes).
|
||||
Int("allowed_destinations_pending", p.pendingDestinations(al)).
|
||||
Int("tracked_domains", stats.TrackedDomains).
|
||||
Int64("total_hits", stats.TotalHits).
|
||||
Int64("total_misses", stats.TotalMisses).
|
||||
Msg("Firewall allowlist stats")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,416 @@
|
||||
//go:build darwin
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld/internal/firewall"
|
||||
)
|
||||
|
||||
// vmnetBridgeIfconfig is ifconfig output for a vmnet.framework bridge: the RFC1918
|
||||
// gateway lives here, and the vendor-named vmenet0 is an address-less member. This is
|
||||
// the shape that name-only detection could never see.
|
||||
const vmnetBridgeIfconfig = `bridge100: flags=8a63<UP,BROADCAST,SMART,RUNNING,ALLMULTI,SIMPLEX,MULTICAST> mtu 1500
|
||||
options=3<RXCSUM,TXCSUM>
|
||||
ether 5e:cf:7f:9a:1b:64
|
||||
inet 192.168.64.1 netmask 0xffffff00 broadcast 192.168.64.255
|
||||
Configuration:
|
||||
id 0:0:0:0:0:0 priority 0 hellotime 0 fwddelay 0
|
||||
maxage 0 holdcnt 0 proto stp maxaddr 100 timeout 1200
|
||||
root id 0:0:0:0:0:0 priority 0 ifcost 0 port 0
|
||||
ipfilter disabled flags 0x0
|
||||
member: vmenet0 flags=3<LEARNING,DISCOVER>
|
||||
ifmaxaddr 0 port 22 priority 0 path cost 0
|
||||
nd6 options=201<PERFORMNUD,DAD>
|
||||
media: <unknown type>
|
||||
status: active
|
||||
`
|
||||
|
||||
// thunderboltBridgeIfconfig is ifconfig output for the Thunderbolt bridge macOS
|
||||
// creates by default. It can carry an RFC1918 address, and its members are physical
|
||||
// interfaces - trusting it would force-route unrelated same-subnet traffic.
|
||||
const thunderboltBridgeIfconfig = `bridge0: flags=8863<UP,BROADCAST,SMART,RUNNING,SIMPLEX,MULTICAST> mtu 1500
|
||||
options=63<RXCSUM,TXCSUM,TSO4,TSO6>
|
||||
ether 36:12:8a:1f:2b:00
|
||||
inet 192.168.10.5 netmask 0xffffff00 broadcast 192.168.10.255
|
||||
Configuration:
|
||||
id 0:0:0:0:0:0 priority 0 hellotime 0 fwddelay 0
|
||||
member: en1 flags=3<LEARNING,DISCOVER>
|
||||
ifmaxaddr 0 port 9 priority 0 path cost 0
|
||||
member: en2 flags=3<LEARNING,DISCOVER>
|
||||
ifmaxaddr 0 port 10 priority 0 path cost 0
|
||||
nd6 options=201<PERFORMNUD,DAD>
|
||||
media: <unknown type>
|
||||
status: inactive
|
||||
`
|
||||
|
||||
func TestParseBridgeMembers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
out string
|
||||
want []string
|
||||
}{
|
||||
{"vmnet.framework bridge", vmnetBridgeIfconfig, []string{"vmenet0"}},
|
||||
{"thunderbolt bridge", thunderboltBridgeIfconfig, []string{"en1", "en2"}},
|
||||
{"no members", "bridge2: flags=8822<BROADCAST,SMART,SIMPLEX,MULTICAST> mtu 1500\n\tether 1a:2b:3c\n", nil},
|
||||
{"empty output", "", nil},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := parseBridgeMembers(tc.out)
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("parseBridgeMembers() = %v, want %v", got, tc.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Errorf("member[%d] = %q, want %q", i, got[i], tc.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeHasVMMember(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
members []string
|
||||
want bool
|
||||
}{
|
||||
{"vmnet.framework member", []string{"vmenet0"}, true},
|
||||
{"parallels member", []string{"vnic0"}, true},
|
||||
{"mixed with vm member", []string{"en1", "vmenet2"}, true},
|
||||
{"physical members only", []string{"en1", "en2"}, false},
|
||||
{"no members", nil, false},
|
||||
// A name that merely looks bridge-ish proves nothing.
|
||||
{"bridge member", []string{"bridge1"}, false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := bridgeHasVMMember(tc.members); got != tc.want {
|
||||
t.Errorf("bridgeHasVMMember(%v) = %v, want %v", tc.members, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mustAddrs(t *testing.T, cidrs ...string) []net.Addr {
|
||||
t.Helper()
|
||||
var out []net.Addr
|
||||
for _, c := range cidrs {
|
||||
ip, ipnet, err := net.ParseCIDR(c)
|
||||
if err != nil {
|
||||
t.Fatalf("bad test CIDR %q: %v", c, err)
|
||||
}
|
||||
out = append(out, &net.IPNet{IP: ip, Mask: ipnet.Mask})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestForwardedSourcesForIface covers the trust decision, including every case that
|
||||
// must NOT be auto-trusted. This is a security boundary: anything that qualifies here
|
||||
// gets its guest traffic passed to allowed public destinations.
|
||||
func TestForwardedSourcesForIface(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
iface string
|
||||
addrs []string
|
||||
members []string
|
||||
wantCIDR []string
|
||||
}{
|
||||
{
|
||||
// The case name-only detection missed: address on the bridge, vendor
|
||||
// interface bridged into it. This is UTM/Docker/Multipass/Fusion 12.1+.
|
||||
name: "bridge with vmenet member is trusted",
|
||||
iface: "bridge100",
|
||||
addrs: []string{"192.168.64.1/24"},
|
||||
members: []string{"vmenet0"},
|
||||
wantCIDR: []string{"192.168.64.0/24"},
|
||||
},
|
||||
{
|
||||
// The reason membership is required rather than the bridge name.
|
||||
name: "thunderbolt bridge is not trusted",
|
||||
iface: "bridge0",
|
||||
addrs: []string{"192.168.10.5/24"},
|
||||
members: []string{"en1", "en2"},
|
||||
},
|
||||
{
|
||||
name: "vendor-named interface with its own address is trusted",
|
||||
iface: "vnic0",
|
||||
addrs: []string{"10.211.55.2/24"},
|
||||
wantCIDR: []string{"10.211.55.0/24"},
|
||||
},
|
||||
{
|
||||
// vmenet* under vmnet.framework: up, but no address of its own.
|
||||
name: "address-less vendor interface yields nothing",
|
||||
iface: "vmenet0",
|
||||
addrs: nil,
|
||||
},
|
||||
{
|
||||
// The RFC1918 boundary: a VM network on a public range is never trusted.
|
||||
name: "public range on a VM bridge is not trusted",
|
||||
iface: "bridge100",
|
||||
addrs: []string{"93.184.216.34/24"},
|
||||
members: []string{"vmenet0"},
|
||||
},
|
||||
{
|
||||
// Interception is IPv4-only; an IPv6-only VM bridge must not qualify.
|
||||
name: "ipv6 only is not trusted",
|
||||
iface: "bridge100",
|
||||
addrs: []string{"fd00::1/64"},
|
||||
members: []string{"vmenet0"},
|
||||
},
|
||||
{
|
||||
name: "physical uplink is not trusted",
|
||||
iface: "en0",
|
||||
addrs: []string{"192.168.1.20/24"},
|
||||
},
|
||||
{
|
||||
name: "vpn tunnel is not trusted",
|
||||
iface: "utun4",
|
||||
addrs: []string{"10.2.0.2/24"},
|
||||
},
|
||||
{
|
||||
name: "mixed addresses keep only the private ipv4 one",
|
||||
iface: "bridge101",
|
||||
addrs: []string{"fd00::1/64", "192.168.105.1/24"},
|
||||
members: []string{"vmenet1"},
|
||||
wantCIDR: []string{"192.168.105.0/24"},
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
members := func(string) []string { return tc.members }
|
||||
got, reason := forwardedSourcesForIface(tc.iface, mustAddrs(t, tc.addrs...), members)
|
||||
if len(got) != len(tc.wantCIDR) {
|
||||
t.Fatalf("got %d sources %v, want %d %v", len(got), got, len(tc.wantCIDR), tc.wantCIDR)
|
||||
}
|
||||
for i, want := range tc.wantCIDR {
|
||||
if got[i].prefix != netip.MustParsePrefix(want) {
|
||||
t.Errorf("prefix[%d] = %s, want %s", i, got[i].prefix, want)
|
||||
}
|
||||
// Rules must be scoped to the interface traffic actually arrives on.
|
||||
if got[i].iface != tc.iface {
|
||||
t.Errorf("source %s scoped to %q, want %q", got[i].prefix, got[i].iface, tc.iface)
|
||||
}
|
||||
}
|
||||
if len(got) > 0 && reason == "" {
|
||||
t.Error("a trusted source must report why it qualified")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestForwardedSourcesForIface_NoMemberLookupWithoutAddress verifies the ifconfig call
|
||||
// is skipped for a bridge that cannot qualify anyway. Detection runs on every anchor
|
||||
// build and every watchdog tick, so this keeps a typical host at zero subprocesses.
|
||||
func TestForwardedSourcesForIface_NoMemberLookupWithoutAddress(t *testing.T) {
|
||||
called := false
|
||||
members := func(string) []string {
|
||||
called = true
|
||||
return []string{"vmenet0"}
|
||||
}
|
||||
|
||||
if got, _ := forwardedSourcesForIface("bridge100", nil, members); got != nil {
|
||||
t.Errorf("address-less bridge must yield nothing, got %v", got)
|
||||
}
|
||||
if called {
|
||||
t.Error("member list must not be queried for a bridge with no RFC1918 address")
|
||||
}
|
||||
|
||||
// A public-range bridge is equally hopeless, and equally must not exec.
|
||||
called = false
|
||||
if got, _ := forwardedSourcesForIface("bridge100", mustAddrs(t, "93.184.216.34/24"), members); got != nil {
|
||||
t.Errorf("public-range bridge must yield nothing, got %v", got)
|
||||
}
|
||||
if called {
|
||||
t.Error("member list must not be queried for a bridge with no RFC1918 address")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseForwardedSourceConfig covers the parse/reject split: usable entries survive
|
||||
// alongside bad ones, and each rejection carries a reason to report.
|
||||
func TestParseForwardedSourceConfig(t *testing.T) {
|
||||
sources, rejected := parseForwardedSourceConfig([]string{
|
||||
"192.168.64.7/24", // host bits get normalized
|
||||
"not-a-cidr", // malformed
|
||||
"fd00::/64", // not IPv4
|
||||
" 10.0.0.0/8 ", // surrounding space tolerated
|
||||
})
|
||||
|
||||
wantSources := map[string]bool{"192.168.64.0/24": true, "10.0.0.0/8": true}
|
||||
if len(sources) != len(wantSources) {
|
||||
t.Fatalf("got %d usable sources %v, want %d", len(sources), sources, len(wantSources))
|
||||
}
|
||||
for _, src := range sources {
|
||||
if !wantSources[src.prefix.String()] {
|
||||
t.Errorf("unexpected usable prefix %s", src.prefix)
|
||||
}
|
||||
if src.iface != "" {
|
||||
t.Errorf("configured source %s must have no interface scope, got %q", src.prefix, src.iface)
|
||||
}
|
||||
}
|
||||
|
||||
if len(rejected) != 2 {
|
||||
t.Fatalf("got %d rejections %v, want 2", len(rejected), rejected)
|
||||
}
|
||||
for _, r := range rejected {
|
||||
if r.value != "not-a-cidr" && r.value != "fd00::/64" {
|
||||
t.Errorf("unexpected rejected value %q", r.value)
|
||||
}
|
||||
if r.reason == "" {
|
||||
t.Errorf("rejection of %q carries no reason", r.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseForwardedSourceConfig_Empty(t *testing.T) {
|
||||
sources, rejected := parseForwardedSourceConfig(nil)
|
||||
if sources != nil || rejected != nil {
|
||||
t.Errorf("empty config must yield nothing, got %v / %v", sources, rejected)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRejectedForwardedSourcesKey verifies the signature ignores order, so re-parsing
|
||||
// an unchanged config is recognised as nothing new, while a changed set is not.
|
||||
func TestRejectedForwardedSourcesKey(t *testing.T) {
|
||||
a := []rejectedForwardedSource{{value: "x", reason: "r1"}, {value: "y", reason: "r2"}}
|
||||
b := []rejectedForwardedSource{{value: "y", reason: "r2"}, {value: "x", reason: "r1"}}
|
||||
if rejectedForwardedSourcesKey(a) != rejectedForwardedSourcesKey(b) {
|
||||
t.Error("key must be order-independent")
|
||||
}
|
||||
if rejectedForwardedSourcesKey(nil) != "" {
|
||||
t.Error("no rejections must produce an empty key")
|
||||
}
|
||||
c := []rejectedForwardedSource{{value: "x", reason: "r1"}}
|
||||
if rejectedForwardedSourcesKey(a) == rejectedForwardedSourcesKey(c) {
|
||||
t.Error("different rejection sets must produce different keys")
|
||||
}
|
||||
}
|
||||
|
||||
// resetForwardedSourceWarnTracker clears the process-wide warning dedupe so each test
|
||||
// starts from "nothing reported yet".
|
||||
func resetForwardedSourceWarnTracker(t *testing.T) {
|
||||
t.Helper()
|
||||
forwardedSourceWarnTracker.mu.Lock()
|
||||
forwardedSourceWarnTracker.key = ""
|
||||
forwardedSourceWarnTracker.mu.Unlock()
|
||||
}
|
||||
|
||||
func trackedRejectionKey() string {
|
||||
forwardedSourceWarnTracker.mu.Lock()
|
||||
defer forwardedSourceWarnTracker.mu.Unlock()
|
||||
return forwardedSourceWarnTracker.key
|
||||
}
|
||||
|
||||
// TestWarnRejectedForwardedSources_OnlyOnChange verifies a standing bad entry is
|
||||
// reported once rather than on every watchdog tick, and that a newly-introduced one is
|
||||
// still reported after a config reload.
|
||||
//
|
||||
// The dedupe must not depend on pfFirewallState: no state is installed here, matching
|
||||
// the window where Firewall Mode is on but pf enforcement is still deferred until
|
||||
// intercept mode starts.
|
||||
func TestWarnRejectedForwardedSources_OnlyOnChange(t *testing.T) {
|
||||
resetForwardedSourceWarnTracker(t)
|
||||
p := progWithForwardedSources("not-a-cidr", "192.168.64.0/24")
|
||||
|
||||
// First parse reports; the signature is now recorded.
|
||||
p.firewallForwardedSources()
|
||||
first := trackedRejectionKey()
|
||||
if first == "" {
|
||||
t.Fatal("a rejected entry must be recorded as reported")
|
||||
}
|
||||
|
||||
// Re-parsing the same config (every anchor build, every 30s tick) must not change
|
||||
// what is recorded - that is what stops the repeated warning.
|
||||
for i := 0; i < 5; i++ {
|
||||
p.firewallForwardedSources()
|
||||
}
|
||||
if got := trackedRejectionKey(); got != first {
|
||||
t.Errorf("recorded rejection key changed on re-parse: %q -> %q", first, got)
|
||||
}
|
||||
|
||||
// A config reload that introduces a different bad entry must be reported.
|
||||
p.cfg.Service.FirewallForwardedSources = []string{"also-not-a-cidr"}
|
||||
p.firewallForwardedSources()
|
||||
if trackedRejectionKey() == first {
|
||||
t.Error("a newly-introduced bad entry must be reported, not suppressed")
|
||||
}
|
||||
|
||||
// Fixing the config clears the recorded set, so a later regression reports again.
|
||||
p.cfg.Service.FirewallForwardedSources = []string{"192.168.64.0/24"}
|
||||
p.firewallForwardedSources()
|
||||
if got := trackedRejectionKey(); got != "" {
|
||||
t.Errorf("a clean config must clear the recorded rejections, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCurrentForwardedSources_GatedOnFirewallMode verifies no detection or config
|
||||
// parsing happens with Firewall Mode off. Anchor rebuilds run on tunnel changes,
|
||||
// watchdog restores and VPN DNS updates regardless of firewall mode, so an ungated
|
||||
// call would enumerate interfaces, exec ifconfig and re-report bad config entries on
|
||||
// every one of them.
|
||||
func TestCurrentForwardedSources_GatedOnFirewallMode(t *testing.T) {
|
||||
resetForwardedSourceWarnTracker(t)
|
||||
p := progWithForwardedSources("not-a-cidr", "192.168.64.0/24")
|
||||
|
||||
// Firewall mode off: nothing detected, and the bad entry is not even looked at.
|
||||
if got := p.currentForwardedSources(); got != nil {
|
||||
t.Errorf("firewall mode off must yield no sources, got %v", got)
|
||||
}
|
||||
if got := trackedRejectionKey(); got != "" {
|
||||
t.Errorf("config must not be parsed with firewall mode off, but a rejection was recorded: %q", got)
|
||||
}
|
||||
|
||||
// With firewall mode on (allowList present), the configured entry is honoured and
|
||||
// the bad one reported.
|
||||
p.allowList = firewall.New()
|
||||
got := p.currentForwardedSources()
|
||||
if len(got) != 1 || got[0].prefix != netip.MustParsePrefix("192.168.64.0/24") {
|
||||
t.Errorf("firewall mode on must yield the configured source, got %v", got)
|
||||
}
|
||||
if trackedRejectionKey() == "" {
|
||||
t.Error("the unusable entry must be reported once firewall mode is on")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordAppliedForwardedSources verifies a full-anchor rebuild can baseline the
|
||||
// reconcile snapshot, so the next reconcile does not redo the same change.
|
||||
func TestRecordAppliedForwardedSources(t *testing.T) {
|
||||
p := progWithForwardedSources()
|
||||
state := &pfFirewallState{}
|
||||
p.platformFirewallState = state
|
||||
|
||||
sources := []forwardedSource{{prefix: netip.MustParsePrefix("192.168.64.0/24"), iface: "bridge100"}}
|
||||
p.recordAppliedForwardedSources(sources)
|
||||
|
||||
if state.lastForwardedKey != forwardedSourceSetKey(sources) {
|
||||
t.Errorf("snapshot key = %q, want %q", state.lastForwardedKey, forwardedSourceSetKey(sources))
|
||||
}
|
||||
if len(state.lastForwardedSources) != 1 || state.lastForwardedSources[0] != sources[0] {
|
||||
t.Errorf("snapshot sources = %v, want %v", state.lastForwardedSources, sources)
|
||||
}
|
||||
|
||||
// A reconcile against the same set must now find nothing to do: no second rebuild,
|
||||
// no killed states, no transition logged for something already in effect.
|
||||
reloads := 0
|
||||
_, _, changed, err := state.applyForwardedSourceChange(sources, func() error { reloads++; return nil })
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if changed {
|
||||
t.Error("reconcile treated an already-applied set as a change")
|
||||
}
|
||||
if reloads != 0 {
|
||||
t.Errorf("anchor was rebuilt %d times for an unchanged set, want 0", reloads)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordAppliedForwardedSources_NoState verifies recording is a no-op when
|
||||
// firewall mode is off, since the rebuild paths call it unconditionally.
|
||||
func TestRecordAppliedForwardedSources_NoState(t *testing.T) {
|
||||
p := progWithForwardedSources()
|
||||
p.recordAppliedForwardedSources([]forwardedSource{{prefix: netip.MustParsePrefix("10.0.0.0/8")}})
|
||||
}
|
||||
@@ -0,0 +1,653 @@
|
||||
//go:build darwin
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
"github.com/Control-D-Inc/ctrld/internal/firewall"
|
||||
)
|
||||
|
||||
// progWithForwardedSources builds a prog whose config declares the given
|
||||
// forwarded-workload source subnets. A logger is attached so the invalid-entry
|
||||
// warning path is safe to exercise.
|
||||
func progWithForwardedSources(sources ...string) *prog {
|
||||
p := &prog{cfg: &ctrld.Config{
|
||||
Listener: map[string]*ctrld.ListenerConfig{"0": {IP: "127.0.0.1", Port: 53}},
|
||||
}}
|
||||
p.cfg.Service.FirewallForwardedSources = sources
|
||||
p.logger.Store(mainLog.Load())
|
||||
return p
|
||||
}
|
||||
|
||||
// TestIsHypervisorVMNetIface verifies only vendor-specific VM/NAT interfaces
|
||||
// qualify for auto-detection - never generic bridges (bridge*, which macOS also
|
||||
// uses for Thunderbolt/aggregated links), physical uplinks, loopback, or VPN tunnels.
|
||||
func TestIsHypervisorVMNetIface(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
want bool
|
||||
}{
|
||||
{"vmnet8", true}, // VMware Fusion
|
||||
{"vmenet0", true}, // Apple Virtualization.framework / UTM
|
||||
{"vnic0", true}, // Parallels
|
||||
{"vboxnet0", true}, // VirtualBox
|
||||
{"bridge0", false}, // generic bridge (Thunderbolt/aggregated) - NOT auto-trusted
|
||||
{"bridge100", false}, // Multipass/Docker generic bridge - opt-in only
|
||||
{"en0", false}, // physical uplink
|
||||
{"lo0", false}, // loopback
|
||||
{"utun3", false}, // VPN tunnel
|
||||
{"awdl0", false}, // Apple Wireless Direct Link
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := isHypervisorVMNetIface(tt.name); got != tt.want {
|
||||
t.Errorf("isHypervisorVMNetIface(%q) = %v, want %v", tt.name, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildPFForwardedSourceRulesFor_Basic verifies the rules emit, for each source:
|
||||
// a route-to-lo0 redirect of the guest's plaintext DNS (udp+tcp) and a DoT block;
|
||||
// that auto-detected sources are scoped "on <iface>" while configured sources match
|
||||
// on CIDR alone; and crucially NOT an interface-wide or destination-wide permit that
|
||||
// would let the guest bypass policy.
|
||||
func TestBuildPFForwardedSourceRulesFor_Basic(t *testing.T) {
|
||||
sources := []forwardedSource{
|
||||
{prefix: netip.MustParsePrefix("192.168.105.0/24"), iface: "vmnet8"}, // auto-detected
|
||||
{prefix: netip.MustParsePrefix("10.211.55.0/24")}, // configured (no iface)
|
||||
}
|
||||
rules := buildPFForwardedSourceRulesFor(sources, "127.0.0.1")
|
||||
|
||||
wants := []string{
|
||||
// Auto-detected: scoped to its ingress interface.
|
||||
"pass in quick on vmnet8 route-to lo0 inet proto udp from 192.168.105.0/24 to ! 127.0.0.1 port 53",
|
||||
"pass in quick on vmnet8 route-to lo0 inet proto tcp from 192.168.105.0/24 to ! 127.0.0.1 port 53",
|
||||
"block return in quick on vmnet8 inet proto { tcp, udp } from 192.168.105.0/24 to any port 853",
|
||||
// Configured: CIDR-only (admin opt-in), no "on <iface>".
|
||||
"pass in quick route-to lo0 inet proto udp from 10.211.55.0/24 to ! 127.0.0.1 port 53",
|
||||
"block return in quick inet proto { tcp, udp } from 10.211.55.0/24 to any port 853",
|
||||
}
|
||||
for _, w := range wants {
|
||||
if !strings.Contains(rules, w) {
|
||||
t.Errorf("missing rule:\n %s\nin:\n%s", w, rules)
|
||||
}
|
||||
}
|
||||
|
||||
// Address family must match the source literal. An "inet6 ... from <IPv4 CIDR>"
|
||||
// rule makes pfctl reject the whole anchor, which would take DNS interception
|
||||
// down with it, so no inet6 rule may name an IPv4 source.
|
||||
for _, line := range strings.Split(rules, "\n") {
|
||||
if strings.Contains(line, "inet6") && (strings.Contains(line, "192.168.105.0/24") || strings.Contains(line, "10.211.55.0/24")) {
|
||||
t.Errorf("inet6 rule with an IPv4 source - pf address-family mismatch:\n %s", line)
|
||||
}
|
||||
}
|
||||
|
||||
// Security boundary: every "pass" for a source must be the port-53 redirect;
|
||||
// no rule may grant a source an unrestricted destination.
|
||||
for _, line := range strings.Split(rules, "\n") {
|
||||
if strings.HasPrefix(line, "pass") && !strings.Contains(line, "port 53") {
|
||||
t.Errorf("forwarded-source pass rule is not scoped to DNS - possible policy bypass:\n %s", line)
|
||||
}
|
||||
if strings.HasPrefix(line, "pass") && strings.HasSuffix(strings.TrimSpace(line), "to any") {
|
||||
t.Errorf("forwarded-source rules must not contain a blanket 'to any' permit:\n %s", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildPFForwardedSourceRulesFor_Empty verifies that with no sources the
|
||||
// builder returns "", leaving anchor behavior unchanged.
|
||||
func TestBuildPFForwardedSourceRulesFor_Empty(t *testing.T) {
|
||||
if got := buildPFForwardedSourceRulesFor(nil, "127.0.0.1"); got != "" {
|
||||
t.Errorf("expected empty output with no sources, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFirewallForwardedSources_InvalidDropped verifies a malformed CIDR and a
|
||||
// non-IPv4 CIDR are dropped (with a warning) without voiding the valid config
|
||||
// entries, that host bits are normalized to the network address, and that configured
|
||||
// sources carry no interface. Rejecting IPv6 at parse time is what keeps the emitted
|
||||
// rules single-family: a mixed-family rule makes pfctl reject the whole anchor.
|
||||
func TestFirewallForwardedSources_InvalidDropped(t *testing.T) {
|
||||
p := progWithForwardedSources("192.168.64.7/24", "not-a-cidr", "fd00::/64", "10.0.0.0/8")
|
||||
got := p.firewallForwardedSources()
|
||||
|
||||
want := map[string]bool{"192.168.64.0/24": true, "10.0.0.0/8": true}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %d sources, want %d: %v", len(got), len(want), got)
|
||||
}
|
||||
for _, src := range got {
|
||||
if !want[src.prefix.String()] {
|
||||
t.Errorf("unexpected prefix %s (invalid entries should be dropped)", src.prefix)
|
||||
}
|
||||
if src.iface != "" {
|
||||
t.Errorf("configured source %s must have no interface, got %q", src.prefix, src.iface)
|
||||
}
|
||||
if !src.prefix.Addr().Is4() {
|
||||
t.Errorf("non-IPv4 source %s must be dropped (interception is IPv4-only)", src.prefix)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildPFForwardedSourceRulesFor_SkipsNonIPv4 verifies an IPv6 source produces no
|
||||
// rules even if one reaches the builder, so a stray entry can never introduce a
|
||||
// mixed-family rule that pfctl would reject the whole anchor over.
|
||||
func TestBuildPFForwardedSourceRulesFor_SkipsNonIPv4(t *testing.T) {
|
||||
v6 := forwardedSource{prefix: netip.MustParsePrefix("fd00::/64")}
|
||||
if got := buildPFForwardedSourceRulesFor([]forwardedSource{v6}, "127.0.0.1"); got != "" {
|
||||
t.Errorf("IPv6-only source must produce no rules, got:\n%s", got)
|
||||
}
|
||||
|
||||
v4 := forwardedSource{prefix: netip.MustParsePrefix("192.168.64.0/24"), iface: "vmnet8"}
|
||||
rules := buildPFForwardedSourceRulesFor([]forwardedSource{v6, v4}, "127.0.0.1")
|
||||
if strings.Contains(rules, "fd00::") {
|
||||
t.Errorf("IPv6 source must be skipped in a mixed set:\n%s", rules)
|
||||
}
|
||||
if !strings.Contains(rules, "from 192.168.64.0/24 to ! 127.0.0.1 port 53") {
|
||||
t.Errorf("IPv4 source must still produce its rules:\n%s", rules)
|
||||
}
|
||||
for _, line := range strings.Split(rules, "\n") {
|
||||
if strings.HasPrefix(line, "pass") || strings.HasPrefix(line, "block") {
|
||||
if strings.Contains(line, "inet6") {
|
||||
t.Errorf("no inet6 rule may be emitted for IPv4-only sources:\n %s", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPFForwardedSourceRules_Syntax runs the real pf parser over the generated rules.
|
||||
// This is the check string assertions cannot make: pfctl rejects an ENTIRE ruleset
|
||||
// over one malformed or mixed-address-family rule, so a bad forwarded-source rule
|
||||
// would take DNS interception down with it rather than just failing to trust a guest.
|
||||
//
|
||||
// Two rulesets are parsed: the forwarded-source rules alone (self-contained, so this
|
||||
// arm is environment-independent) and the full anchor ctrld would load.
|
||||
func TestPFForwardedSourceRules_Syntax(t *testing.T) {
|
||||
// lo0 as the auto-detected ingress interface: any interface name parses, and lo0
|
||||
// is the one guaranteed to exist on every runner.
|
||||
sources := []forwardedSource{
|
||||
{prefix: netip.MustParsePrefix("192.168.105.0/24"), iface: "lo0"}, // auto-detected, scoped
|
||||
{prefix: netip.MustParsePrefix("10.211.55.0/24")}, // configured, CIDR-only
|
||||
}
|
||||
|
||||
t.Run("forwarded rules alone", func(t *testing.T) {
|
||||
rules := buildPFForwardedSourceRulesFor(sources, "127.0.0.1")
|
||||
if rules == "" {
|
||||
t.Fatal("no forwarded-source rules generated")
|
||||
}
|
||||
pfctlParseCheck(t, rules)
|
||||
})
|
||||
|
||||
t.Run("full anchor", func(t *testing.T) {
|
||||
p := progWithForwardedSources("192.168.64.0/24", "10.211.55.0/24")
|
||||
p.allowList = firewall.New()
|
||||
rules := p.buildPFAnchorRules(nil)
|
||||
if !strings.Contains(rules, "from 192.168.64.0/24 to ! ") {
|
||||
t.Fatalf("forwarded-source rules missing from anchor under test:\n%s", rules)
|
||||
}
|
||||
pfctlParseCheck(t, stripPFGroupRules(rules))
|
||||
})
|
||||
}
|
||||
|
||||
// pfctlParseCheck validates a ruleset with the real pf parser in ctrld's anchor
|
||||
// context, failing the test on any parse error. pfctl needs /dev/pf, so the check
|
||||
// skips (rather than fails) where the runner cannot open it.
|
||||
func pfctlParseCheck(t *testing.T, ruleset string) {
|
||||
t.Helper()
|
||||
|
||||
pfctl, err := exec.LookPath("pfctl")
|
||||
if err != nil {
|
||||
t.Skip("pfctl not available:", err)
|
||||
}
|
||||
file := filepath.Join(t.TempDir(), "ctrld-rules-test.conf")
|
||||
if err := os.WriteFile(file, []byte(ruleset), 0600); err != nil {
|
||||
t.Fatalf("write ruleset under test: %v", err)
|
||||
}
|
||||
|
||||
// -n parses and validates without loading anything.
|
||||
out, err := exec.Command(pfctl, "-a", pfAnchorName, "-n", "-f", file).CombinedOutput()
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
msg := strings.TrimSpace(string(out))
|
||||
if strings.Contains(msg, "Permission denied") || strings.Contains(msg, "Operation not permitted") ||
|
||||
strings.Contains(msg, "/dev/pf") {
|
||||
t.Skipf("pfctl cannot open /dev/pf on this runner (%v): %s", err, msg)
|
||||
}
|
||||
t.Errorf("pfctl rejected the generated ruleset (%v):\n%s\n--- ruleset ---\n%s", err, msg, ruleset)
|
||||
}
|
||||
|
||||
// stripPFGroupRules drops rules scoped to ctrld's runtime group. That group is created
|
||||
// by the installed service (dscl), so on a dev box or CI runner pfctl reports "unknown
|
||||
// group _ctrld" for them - an environment fact, not a defect in the generated rules.
|
||||
// Only those lines are removed, so pf's ordering requirement (translation rules before
|
||||
// filtering rules) still holds for what remains.
|
||||
func stripPFGroupRules(ruleset string) string {
|
||||
lines := strings.Split(ruleset, "\n")
|
||||
kept := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(trimmed, "#") && strings.Contains(trimmed, "group "+pfGroupName) {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, line)
|
||||
}
|
||||
return strings.Join(kept, "\n")
|
||||
}
|
||||
|
||||
// TestForwardedSources_UnionDedup verifies the effective set unions auto-detected
|
||||
// and configured subnets, de-duplicated by prefix. Auto-detection is
|
||||
// environment-dependent, so this asserts config entries are always included and
|
||||
// that duplicate config entries collapse to one - deterministic regardless of host.
|
||||
func TestForwardedSources_UnionDedup(t *testing.T) {
|
||||
p := progWithForwardedSources("192.168.199.0/24", "192.168.199.0/24")
|
||||
got := p.forwardedSources()
|
||||
|
||||
count := 0
|
||||
for _, src := range got {
|
||||
if src.prefix.String() == "192.168.199.0/24" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("configured subnet appears %d times, want exactly 1 (union must dedup):\n%v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestForwardedSourceDescriptions verifies the log rendering names each subnet's
|
||||
// origin, including the interface an auto-detected source is scoped to. Configured
|
||||
// entries used to be invisible in the log, which left admins unable to confirm
|
||||
// firewall_forwarded_sources took effect.
|
||||
func TestForwardedSourceDescriptions(t *testing.T) {
|
||||
got := forwardedSourceDescriptions([]forwardedSource{
|
||||
{prefix: netip.MustParsePrefix("192.168.105.0/24"), iface: "vmenet0"},
|
||||
{prefix: netip.MustParsePrefix("192.168.252.0/24")},
|
||||
})
|
||||
want := []string{
|
||||
"192.168.105.0/24 (auto-detected on vmenet0)",
|
||||
"192.168.252.0/24 (configured)",
|
||||
}
|
||||
if !equalStringSets(got, want) {
|
||||
t.Errorf("descriptions = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
if got := forwardedSourceDescriptions(nil); len(got) != 0 {
|
||||
t.Errorf("empty set must render no descriptions, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestForwardedSourceSetKey verifies the signature is order-independent, distinguishes
|
||||
// interface scope, and changes when the set changes - the basis for detecting VM
|
||||
// start/stop at runtime.
|
||||
func TestForwardedSourceSetKey(t *testing.T) {
|
||||
a := netip.MustParsePrefix("192.168.64.0/24")
|
||||
b := netip.MustParsePrefix("10.211.55.0/24")
|
||||
|
||||
// Order-independent.
|
||||
k1 := forwardedSourceSetKey([]forwardedSource{{prefix: a, iface: "vmnet8"}, {prefix: b}})
|
||||
k2 := forwardedSourceSetKey([]forwardedSource{{prefix: b}, {prefix: a, iface: "vmnet8"}})
|
||||
if k1 != k2 {
|
||||
t.Errorf("key must be order-independent: %q vs %q", k1, k2)
|
||||
}
|
||||
|
||||
// A guest appearing changes the key (empty -> one source).
|
||||
if forwardedSourceSetKey(nil) == k1 {
|
||||
t.Error("adding a source must change the key")
|
||||
}
|
||||
|
||||
// A guest stopping changes the key (two sources -> one).
|
||||
k3 := forwardedSourceSetKey([]forwardedSource{{prefix: b}})
|
||||
if k3 == k1 {
|
||||
t.Error("removing a source must change the key")
|
||||
}
|
||||
|
||||
// Same prefix on a different interface is a distinct trust and must differ.
|
||||
kIface := forwardedSourceSetKey([]forwardedSource{{prefix: a, iface: "vmnet8"}})
|
||||
kNoIface := forwardedSourceSetKey([]forwardedSource{{prefix: a}})
|
||||
if kIface == kNoIface {
|
||||
t.Error("interface scope must affect the key")
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyForwardedSourceChange_Lifecycle walks the guest start/stop lifecycle
|
||||
// deterministically (no pf, no hypervisor): initial build, guest start, no-change
|
||||
// re-check, second guest start, guest stop, scope change, and back to none. Each
|
||||
// anchor reload succeeds here. It asserts both halves of the contract - whether the
|
||||
// anchor needs rebuilding, and which subnets' pf states must be dropped because
|
||||
// their trust changed.
|
||||
func TestApplyForwardedSourceChange_Lifecycle(t *testing.T) {
|
||||
vmA := forwardedSource{prefix: netip.MustParsePrefix("192.168.105.0/24"), iface: "vmnet8"}
|
||||
vmB := forwardedSource{prefix: netip.MustParsePrefix("10.211.55.0/24"), iface: "vnic0"}
|
||||
cfgB := forwardedSource{prefix: vmB.prefix} // same subnet, configured (no iface scope)
|
||||
|
||||
steps := []struct {
|
||||
name string
|
||||
cur []forwardedSource
|
||||
wantChanged bool
|
||||
wantGained []string
|
||||
wantLost []string
|
||||
}{
|
||||
{name: "initial state, no guests", cur: nil, wantChanged: false},
|
||||
{
|
||||
name: "first guest starts", cur: []forwardedSource{vmA},
|
||||
wantChanged: true, wantGained: []string{"192.168.105.0/24"},
|
||||
},
|
||||
{name: "network change, nothing moved", cur: []forwardedSource{vmA}, wantChanged: false},
|
||||
{
|
||||
// Reordered plus a new guest: order must not register as a change.
|
||||
name: "second guest starts", cur: []forwardedSource{vmB, vmA},
|
||||
wantChanged: true, wantGained: []string{"10.211.55.0/24"},
|
||||
},
|
||||
{
|
||||
name: "first guest stops", cur: []forwardedSource{vmB},
|
||||
wantChanged: true, wantLost: []string{"192.168.105.0/24"},
|
||||
},
|
||||
{
|
||||
// Same subnet, different scope: a distinct trust, so its states must be
|
||||
// dropped even though the subnet itself neither appeared nor vanished.
|
||||
name: "guest subnet loses its interface scope", cur: []forwardedSource{cfgB},
|
||||
wantChanged: true,
|
||||
wantGained: []string{"10.211.55.0/24"},
|
||||
wantLost: []string{"10.211.55.0/24"},
|
||||
},
|
||||
{
|
||||
name: "last guest stops", cur: nil,
|
||||
wantChanged: true, wantLost: []string{"10.211.55.0/24"},
|
||||
},
|
||||
{name: "still no guests", cur: nil, wantChanged: false},
|
||||
}
|
||||
|
||||
state := &pfFirewallState{}
|
||||
reloads := 0
|
||||
okReload := func() error { reloads++; return nil }
|
||||
wantReloads := 0
|
||||
for _, step := range steps {
|
||||
gained, lost, changed, err := state.applyForwardedSourceChange(step.cur, okReload)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: unexpected reload error: %v", step.name, err)
|
||||
}
|
||||
if changed != step.wantChanged {
|
||||
t.Errorf("%s: changed = %v, want %v", step.name, changed, step.wantChanged)
|
||||
}
|
||||
if got := prefixStrings(gained); !equalStringSets(got, step.wantGained) {
|
||||
t.Errorf("%s: gained trust = %v, want %v", step.name, got, step.wantGained)
|
||||
}
|
||||
if got := prefixStrings(lost); !equalStringSets(got, step.wantLost) {
|
||||
t.Errorf("%s: lost trust = %v, want %v", step.name, got, step.wantLost)
|
||||
}
|
||||
// The anchor must be rebuilt exactly on the transitions, never on a re-check.
|
||||
if step.wantChanged {
|
||||
wantReloads++
|
||||
}
|
||||
if reloads != wantReloads {
|
||||
t.Errorf("%s: anchor reloads = %d, want %d", step.name, reloads, wantReloads)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyForwardedSourceChange_FailureThenRetry is the convergence guarantee: a
|
||||
// failed anchor write/load must NOT advance the applied snapshot, so the very next
|
||||
// reconcile (at the latest the next watchdog tick) retries the same transition
|
||||
// instead of seeing the new key and going quiet with the old anchor still installed.
|
||||
func TestApplyForwardedSourceChange_FailureThenRetry(t *testing.T) {
|
||||
guest := netip.MustParsePrefix("192.168.105.0/24")
|
||||
cur := []forwardedSource{{prefix: guest, iface: "vmnet8"}}
|
||||
|
||||
state := &pfFirewallState{}
|
||||
loadErr := errors.New("pfctl: syntax error")
|
||||
attempts := 0
|
||||
failing := func() error { attempts++; return loadErr }
|
||||
succeeding := func() error { attempts++; return nil }
|
||||
|
||||
// Attempt 1: guest starts, reload fails. The caller is told what changed (so it
|
||||
// can log it) but must not treat it as applied.
|
||||
gained, lost, changed, err := state.applyForwardedSourceChange(cur, failing)
|
||||
if !changed || !errors.Is(err, loadErr) {
|
||||
t.Fatalf("failed reload: changed = %v, err = %v, want true / the load error", changed, err)
|
||||
}
|
||||
if got := prefixStrings(gained); !equalStringSets(got, []string{guest.String()}) {
|
||||
t.Errorf("failed reload: gained trust = %v, want %v", got, []string{guest.String()})
|
||||
}
|
||||
if len(lost) != 0 {
|
||||
t.Errorf("failed reload: lost trust = %v, want none", prefixStrings(lost))
|
||||
}
|
||||
if state.lastForwardedKey != "" || state.lastForwardedSources != nil {
|
||||
t.Fatalf("failed reload must not advance the applied snapshot, got key %q sources %v",
|
||||
state.lastForwardedKey, state.lastForwardedSources)
|
||||
}
|
||||
|
||||
// Attempt 2: nothing else moved, but the change is still pending - it must be
|
||||
// retried and reported identically, not swallowed.
|
||||
gained, _, changed, err = state.applyForwardedSourceChange(cur, failing)
|
||||
if !changed || err == nil {
|
||||
t.Fatalf("retry after failure: changed = %v, err = %v, want true / an error", changed, err)
|
||||
}
|
||||
if got := prefixStrings(gained); !equalStringSets(got, []string{guest.String()}) {
|
||||
t.Errorf("retry after failure: gained trust = %v, want %v", got, []string{guest.String()})
|
||||
}
|
||||
|
||||
// Attempt 3: pf accepts the anchor - now the snapshot advances and the affected
|
||||
// subnet's states are reported for killing.
|
||||
gained, _, changed, err = state.applyForwardedSourceChange(cur, succeeding)
|
||||
if !changed || err != nil {
|
||||
t.Fatalf("successful reload: changed = %v, err = %v, want true / nil", changed, err)
|
||||
}
|
||||
if got := prefixStrings(gained); !equalStringSets(got, []string{guest.String()}) {
|
||||
t.Errorf("successful reload: gained trust = %v, want %v", got, []string{guest.String()})
|
||||
}
|
||||
if state.lastForwardedKey == "" {
|
||||
t.Fatal("successful reload must record the applied source set")
|
||||
}
|
||||
|
||||
// Attempt 4: converged - no further rebuild, and no reload call at all.
|
||||
before := attempts
|
||||
if _, _, changed, err := state.applyForwardedSourceChange(cur, succeeding); changed || err != nil {
|
||||
t.Errorf("after convergence: changed = %v, err = %v, want false / nil", changed, err)
|
||||
}
|
||||
if attempts != before {
|
||||
t.Errorf("after convergence: reload was called %d extra time(s), want 0", attempts-before)
|
||||
}
|
||||
|
||||
// A failure while *removing* trust must likewise not be latched: the subnet stays
|
||||
// recorded as applied until pf accepts the anchor without it.
|
||||
if _, lost, changed, err := state.applyForwardedSourceChange(nil, failing); !changed || err == nil {
|
||||
t.Errorf("guest stop with failing reload: changed = %v, err = %v, want true / an error", changed, err)
|
||||
} else if got := prefixStrings(lost); !equalStringSets(got, []string{guest.String()}) {
|
||||
t.Errorf("guest stop with failing reload: lost trust = %v, want %v", got, []string{guest.String()})
|
||||
}
|
||||
if state.lastForwardedKey == "" {
|
||||
t.Error("failed removal must keep the previously applied set recorded")
|
||||
}
|
||||
if _, lost, _, err := state.applyForwardedSourceChange(nil, succeeding); err != nil {
|
||||
t.Errorf("guest stop retry: unexpected error %v", err)
|
||||
} else if got := prefixStrings(lost); !equalStringSets(got, []string{guest.String()}) {
|
||||
t.Errorf("guest stop retry: lost trust = %v, want %v", got, []string{guest.String()})
|
||||
}
|
||||
if state.lastForwardedKey != "" || state.lastForwardedSources != nil {
|
||||
t.Errorf("after successful removal the applied set must be empty, got key %q sources %v",
|
||||
state.lastForwardedKey, state.lastForwardedSources)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconcileForwardedSources_GatedOff verifies the reconcile entry point is inert
|
||||
// when firewall mode is off or pf state was never initialized, so the network-change
|
||||
// and watchdog call sites never touch pf outside firewall mode.
|
||||
func TestReconcileForwardedSources_GatedOff(t *testing.T) {
|
||||
// Firewall mode off (no allowList) - must return before touching pf state.
|
||||
off := progWithForwardedSources("192.168.64.0/24")
|
||||
off.platformFirewallState = &pfFirewallState{}
|
||||
off.dnsInterceptState = &pfState{anchorFile: pfAnchorFile, anchorName: pfAnchorName}
|
||||
off.reconcileForwardedSources()
|
||||
if state := off.platformFirewallState.(*pfFirewallState); state.lastForwardedKey != "" {
|
||||
t.Errorf("reconcile must not record a source set when firewall mode is off, got %q", state.lastForwardedKey)
|
||||
}
|
||||
|
||||
// Firewall mode on but pf firewall state not initialized - must not panic.
|
||||
noState := progWithForwardedSources("192.168.64.0/24")
|
||||
noState.allowList = firewall.New()
|
||||
noState.dnsInterceptState = &pfState{anchorFile: pfAnchorFile, anchorName: pfAnchorName}
|
||||
noState.reconcileForwardedSources()
|
||||
|
||||
// Firewall mode on but intercept inactive - no anchor to rebuild.
|
||||
noIntercept := progWithForwardedSources("192.168.64.0/24")
|
||||
noIntercept.allowList = firewall.New()
|
||||
noIntercept.platformFirewallState = &pfFirewallState{}
|
||||
noIntercept.reconcileForwardedSources()
|
||||
if state := noIntercept.platformFirewallState.(*pfFirewallState); state.lastForwardedKey != "" {
|
||||
t.Errorf("reconcile must not record a source set without intercept, got %q", state.lastForwardedKey)
|
||||
}
|
||||
}
|
||||
|
||||
// equalStringSets compares two string slices ignoring order and nil-vs-empty.
|
||||
func equalStringSets(got, want []string) bool {
|
||||
if len(got) != len(want) {
|
||||
return false
|
||||
}
|
||||
seen := make(map[string]int, len(got))
|
||||
for _, s := range got {
|
||||
seen[s]++
|
||||
}
|
||||
for _, s := range want {
|
||||
seen[s]--
|
||||
if seen[s] < 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// TestDetectForwardedSources_OnlyPrivate verifies detection returns only private
|
||||
// IPv4 vendor-VM subnets, each tagged with a vendor interface. Environment-dependent,
|
||||
// so it asserts a property rather than an exact set.
|
||||
func TestDetectForwardedSources_OnlyPrivate(t *testing.T) {
|
||||
p := &prog{cfg: &ctrld.Config{}}
|
||||
p.logger.Store(mainLog.Load())
|
||||
for _, src := range p.detectForwardedSources() {
|
||||
if !src.prefix.Addr().Is4() {
|
||||
t.Errorf("detected non-IPv4 forwarded source: %s", src.prefix)
|
||||
}
|
||||
if !src.prefix.Addr().IsPrivate() {
|
||||
t.Errorf("detected non-private forwarded source (must never auto-trust public): %s", src.prefix)
|
||||
}
|
||||
if !isHypervisorVMNetIface(src.iface) {
|
||||
t.Errorf("detected source on non-vendor interface %q", src.iface)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPFBuildAnchorRules_ForwardedSourcesGating verifies the forwarded-source rules
|
||||
// appear in the full anchor only when firewall mode is active, and when a configured
|
||||
// source is present it appears before the blanket allowlist block so the redirect
|
||||
// wins. A configured source makes the "on" case deterministic regardless of host.
|
||||
func TestPFBuildAnchorRules_ForwardedSourcesGating(t *testing.T) {
|
||||
// Firewall OFF (no allowList): a configured source must NOT appear.
|
||||
off := progWithForwardedSources("192.168.64.0/24")
|
||||
if rules := off.buildPFAnchorRules(nil); strings.Contains(rules, "192.168.64.0/24 to ! ") {
|
||||
t.Errorf("forwarded-source rules must not be emitted when firewall mode is off:\n%s", rules)
|
||||
}
|
||||
|
||||
// Firewall ON: allowList present → rules appear, before the blanket block.
|
||||
on := progWithForwardedSources("192.168.64.0/24")
|
||||
on.allowList = firewall.New()
|
||||
rules := on.buildPFAnchorRules(nil)
|
||||
|
||||
fwdIdx := strings.Index(rules, "from 192.168.64.0/24 to ! 127.0.0.1 port 53")
|
||||
blockIdx := strings.Index(rules, "block return out quick inet proto { tcp, udp } from any to any")
|
||||
if fwdIdx < 0 {
|
||||
t.Fatalf("configured forwarded-source redirect missing when firewall mode is on:\n%s", rules)
|
||||
}
|
||||
if blockIdx < 0 {
|
||||
t.Fatalf("blanket firewall block missing:\n%s", rules)
|
||||
}
|
||||
if fwdIdx >= blockIdx {
|
||||
t.Errorf("forwarded-source redirect (%d) must come before the blanket block (%d)", fwdIdx, blockIdx)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildPFFirewallRulesDeclaresExceptionTable pins the pf side of the
|
||||
// organization's Allowed Destination IP list.
|
||||
//
|
||||
// Every cmd/cli test of the allowed-destination paths stubs the platform mirror,
|
||||
// so nothing else reaches this generator: the table the mirror populates could
|
||||
// stop being declared, or lose its pass rules, and the mirror would keep
|
||||
// reporting success while every approved destination stayed blocked. Both
|
||||
// families are asserted - a list is not usable if only one of them passes.
|
||||
func TestBuildPFFirewallRulesDeclaresExceptionTable(t *testing.T) {
|
||||
rules := buildPFFirewallRules()
|
||||
|
||||
wants := []string{
|
||||
// Declared persist, like the dynamic table: pfctl -T add/delete/replace
|
||||
// against an undeclared table fails, and persist is what keeps the table
|
||||
// alive while it holds no addresses.
|
||||
"table <" + pfFirewallExceptionTable + "> persist",
|
||||
"pass out quick inet proto { tcp, udp } from any to <" + pfFirewallExceptionTable + ">",
|
||||
"pass out quick inet6 proto { tcp, udp } from any to <" + pfFirewallExceptionTable + ">",
|
||||
}
|
||||
for _, want := range wants {
|
||||
if !strings.Contains(rules, want) {
|
||||
t.Errorf("missing rule:\n %s\nin:\n%s", want, rules)
|
||||
}
|
||||
}
|
||||
|
||||
// The exception table is separate from the dynamic one on purpose: the flushes
|
||||
// that discard DNS-resolved IPs must leave administratively allowed
|
||||
// destinations in place.
|
||||
if pfFirewallExceptionTable == pfFirewallTable {
|
||||
t.Fatal("the exception table and the dynamic table are the same table; a flush would drop the organization's list")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPFExceptionTableChunks covers the argv-length split. The organization's
|
||||
// list is API-supplied and unbounded, and every entry becomes an argv element, so
|
||||
// a long enough list would blow past ARG_MAX and fail as a whole.
|
||||
func TestPFExceptionTableChunks(t *testing.T) {
|
||||
entries := make([]string, pfExceptionTableOpChunk*2+1)
|
||||
for i := range entries {
|
||||
entries[i] = "203.0.113.10/32"
|
||||
}
|
||||
|
||||
if got := pfExceptionTableChunks("replace", nil); len(got) != 0 {
|
||||
t.Errorf("chunks for an empty list = %d, want 0", len(got))
|
||||
}
|
||||
|
||||
short := pfExceptionTableChunks("replace", entries[:2])
|
||||
if len(short) != 1 || short[0].op != "replace" || len(short[0].entries) != 2 {
|
||||
t.Fatalf("a list that fits was split: %+v", short)
|
||||
}
|
||||
|
||||
// A split replace must replace once and add the rest. Splitting it into three
|
||||
// replaces would leave pf holding only the final chunk, with the organization's
|
||||
// other destinations silently dropped while the mirror reported success.
|
||||
split := pfExceptionTableChunks("replace", entries)
|
||||
if len(split) != 3 {
|
||||
t.Fatalf("chunks = %d, want 3 for %d entries at %d per call", len(split), len(entries), pfExceptionTableOpChunk)
|
||||
}
|
||||
if split[0].op != "replace" {
|
||||
t.Errorf("first chunk op = %q, want replace", split[0].op)
|
||||
}
|
||||
for _, chunk := range split[1:] {
|
||||
if chunk.op != "add" {
|
||||
t.Errorf("chunk after the first has op %q, want add: a second replace discards the first", chunk.op)
|
||||
}
|
||||
}
|
||||
var total int
|
||||
for _, chunk := range split {
|
||||
total += len(chunk.entries)
|
||||
}
|
||||
if total != len(entries) {
|
||||
t.Errorf("chunked entries = %d, want %d: the split dropped entries", total, len(entries))
|
||||
}
|
||||
|
||||
// delete is per-entry, so every chunk keeps the operation.
|
||||
for _, chunk := range pfExceptionTableChunks("delete", entries) {
|
||||
if chunk.op != "delete" {
|
||||
t.Errorf("delete chunk op = %q, want delete", chunk.op)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//go:build !windows && !darwin
|
||||
|
||||
package cli
|
||||
|
||||
import "net/netip"
|
||||
|
||||
// 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() {}
|
||||
|
||||
// firewallApplyExceptionsPlatform succeeds trivially on unsupported platforms.
|
||||
// Nothing enforces the allowlist here, so the organization's allowed destinations
|
||||
// need no platform rules and there is nothing that can fail; the in-memory set is
|
||||
// still maintained for stats and for the Contains() path used by embedders.
|
||||
func (p *prog) firewallApplyExceptionsPlatform(added, removed []netip.Prefix) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// firewallReplaceExceptionsPlatform succeeds trivially on unsupported platforms,
|
||||
// for the same reason: there is no platform state to replace.
|
||||
func (p *prog) firewallReplaceExceptionsPlatform(desired []netip.Prefix) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,668 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
"github.com/Control-D-Inc/ctrld/internal/controld"
|
||||
"github.com/Control-D-Inc/ctrld/internal/firewall"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAllowedDestinations(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
entries []string
|
||||
want []string
|
||||
wantRejected []string
|
||||
wantWide []string
|
||||
}{
|
||||
{
|
||||
name: "bare IPv4 becomes a host prefix",
|
||||
entries: []string{"203.0.113.10"},
|
||||
want: []string{"203.0.113.10/32"},
|
||||
},
|
||||
{
|
||||
name: "bare IPv6 becomes a host prefix",
|
||||
entries: []string{"2606:1a40::1"},
|
||||
want: []string{"2606:1a40::1/128"},
|
||||
},
|
||||
{
|
||||
name: "CIDRs of both families",
|
||||
entries: []string{"198.51.100.0/24", "2001:db8::/48"},
|
||||
want: []string{"198.51.100.0/24", "2001:db8::/48"},
|
||||
},
|
||||
{
|
||||
name: "IPv4-in-IPv6 is unmapped to its IPv4 form",
|
||||
entries: []string{"::ffff:203.0.113.10", "::ffff:198.51.100.0/120"},
|
||||
want: []string{"203.0.113.10/32", "198.51.100.0/24"},
|
||||
},
|
||||
{
|
||||
name: "surrounding whitespace is tolerated",
|
||||
entries: []string{" 203.0.113.10 ", "\t198.51.100.0/24"},
|
||||
want: []string{"203.0.113.10/32", "198.51.100.0/24"},
|
||||
},
|
||||
{
|
||||
name: "empty entries are skipped without being reported",
|
||||
entries: []string{"", " ", "203.0.113.10"},
|
||||
want: []string{"203.0.113.10/32"},
|
||||
},
|
||||
{
|
||||
name: "one bad entry does not void the rest",
|
||||
entries: []string{"203.0.113.10", "not-an-ip", "198.51.100.0/33", "example.com"},
|
||||
want: []string{"203.0.113.10/32"},
|
||||
wantRejected: []string{"not-an-ip", "198.51.100.0/33", "example.com"},
|
||||
},
|
||||
{
|
||||
name: "no entries",
|
||||
entries: nil,
|
||||
},
|
||||
{
|
||||
// A full-range prefix is accepted - the organization is entitled to one -
|
||||
// but it lets every destination of that family bypass Firewall Mode, so it
|
||||
// has to be reported rather than disappearing into a count.
|
||||
name: "full-range prefixes are reported as wide",
|
||||
// Masking happens in normalizeExceptions, so the parsed form is still
|
||||
// the entry as sent; the mask is what makes it a full range.
|
||||
entries: []string{"1.2.3.4/0", "::/0", "203.0.113.10"},
|
||||
want: []string{"1.2.3.4/0", "::/0", "203.0.113.10/32"},
|
||||
wantWide: []string{"1.2.3.4/0", "::/0"},
|
||||
},
|
||||
{
|
||||
// unmapPrefix turns this into 0.0.0.0/0, which the raw entry does not look
|
||||
// like at all.
|
||||
name: "an IPv4-mapped full range is reported after unmapping",
|
||||
entries: []string{"::ffff:0:0/96"},
|
||||
want: []string{"0.0.0.0/0"},
|
||||
wantWide: []string{"0.0.0.0/0"},
|
||||
},
|
||||
{
|
||||
// /8 and /32 are the floors themselves: a whole classical IPv4 network
|
||||
// and a whole IPv6 RIR allocation are wide, but both are things an
|
||||
// organization can legitimately mean, so neither is reported.
|
||||
name: "prefixes at the floor are not reported as wide",
|
||||
entries: []string{"198.51.100.0/24", "10.0.0.0/8", "2001:db8::/48", "2001:db8::/32"},
|
||||
want: []string{"198.51.100.0/24", "10.0.0.0/8", "2001:db8::/48", "2001:db8::/32"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
prefixes, rejected, wide := parseAllowedDestinations(tt.entries)
|
||||
if got := strings.Join(prefixStrings(prefixes), ","); got != strings.Join(tt.want, ",") {
|
||||
t.Errorf("prefixes = %q, want %q", got, strings.Join(tt.want, ","))
|
||||
}
|
||||
if got := strings.Join(rejected, ","); got != strings.Join(tt.wantRejected, ",") {
|
||||
t.Errorf("rejected = %q, want %q", got, strings.Join(tt.wantRejected, ","))
|
||||
}
|
||||
if got := strings.Join(prefixStrings(wide), ","); got != strings.Join(tt.wantWide, ",") {
|
||||
t.Errorf("wide = %q, want %q", got, strings.Join(tt.wantWide, ","))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// progWithAllowList builds a prog with Firewall Mode's allowlist in place and a
|
||||
// logger attached, so the allowed-destination paths can be exercised without any
|
||||
// platform enforcement.
|
||||
func progWithAllowList() *prog {
|
||||
p := &prog{allowList: firewall.New()}
|
||||
p.logger.Store(discardLogger())
|
||||
return p
|
||||
}
|
||||
|
||||
// discardLogger returns a logger that writes nowhere.
|
||||
//
|
||||
// Firewall Mode's paths start background workers that log as soon as they run,
|
||||
// concurrently with the test goroutine. The package-wide test logger writes into
|
||||
// a shared strings.Builder (see TestMain) that is neither safe for concurrent
|
||||
// writes nor for a write racing another test's read of it, so tests that spawn
|
||||
// those workers must not share it.
|
||||
func discardLogger() *ctrld.Logger {
|
||||
return &ctrld.Logger{Logger: zap.NewNop()}
|
||||
}
|
||||
|
||||
// mirrorCall records one attempt to change platform enforcement: a delta, or a
|
||||
// full replace (replace is true, and added carries the whole desired set).
|
||||
type mirrorCall struct {
|
||||
added []string
|
||||
removed []string
|
||||
replace bool
|
||||
}
|
||||
|
||||
// stubMirror replaces both platform mirrors for the duration of a test,
|
||||
// recording every change they are handed and failing while *failing is true. The
|
||||
// recorded calls are what proves a rejected change is retried rather than
|
||||
// forgotten.
|
||||
func stubMirror(t *testing.T, calls *[]mirrorCall, failing *bool) {
|
||||
t.Helper()
|
||||
origMirror, origReplace := firewallMirrorExceptionsFn, firewallReplaceExceptionsFn
|
||||
t.Cleanup(func() {
|
||||
firewallMirrorExceptionsFn, firewallReplaceExceptionsFn = origMirror, origReplace
|
||||
})
|
||||
firewallMirrorExceptionsFn = func(_ *prog, added, removed []netip.Prefix) error {
|
||||
*calls = append(*calls, mirrorCall{added: prefixStrings(added), removed: prefixStrings(removed)})
|
||||
if *failing {
|
||||
return errors.New("platform enforcement rejected the change")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
firewallReplaceExceptionsFn = func(_ *prog, desired []netip.Prefix) error {
|
||||
*calls = append(*calls, mirrorCall{added: prefixStrings(desired), replace: true})
|
||||
if *failing {
|
||||
return errors.New("platform enforcement rejected the replacement")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c mirrorCall) String() string {
|
||||
kind := "delta"
|
||||
if c.replace {
|
||||
kind = "replace"
|
||||
}
|
||||
return kind + " added=" + strings.Join(c.added, ",") + " removed=" + strings.Join(c.removed, ",")
|
||||
}
|
||||
|
||||
// TestAllowedDestinationsRetriedAfterMirrorFailure is the regression guard for
|
||||
// committing a change in memory that platform enforcement refused: an addition
|
||||
// that pf/WFP rejected must be retried by the next refresh, even though that
|
||||
// refresh carries an identical list from the API and so produces no new delta.
|
||||
// Without a separate applied snapshot, the destination would stay blocked with
|
||||
// the logs claiming it was applied.
|
||||
func TestAllowedDestinationsRetriedAfterMirrorFailure(t *testing.T) {
|
||||
p := progWithAllowList()
|
||||
var calls []mirrorCall
|
||||
failing := true
|
||||
stubMirror(t, &calls, &failing)
|
||||
|
||||
p.rc = &controld.ResolverConfig{DestinationIPs: []string{"203.0.113.10"}}
|
||||
p.syncAllowedDestinations()
|
||||
if len(calls) != 1 || strings.Join(calls[0].added, ",") != "203.0.113.10/32" {
|
||||
t.Fatalf("first refresh: calls = %v", calls)
|
||||
}
|
||||
|
||||
// An identical refresh must retry the rejected addition.
|
||||
p.syncAllowedDestinations()
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("identical refresh after a failure did not retry: calls = %v", calls)
|
||||
}
|
||||
if strings.Join(calls[1].added, ",") != "203.0.113.10/32" {
|
||||
t.Fatalf("retry carried the wrong delta: %v", calls[1])
|
||||
}
|
||||
if got := p.pendingDestinations(p.allowList); got != 1 {
|
||||
t.Fatalf("pendingDestinations = %d, want 1 while the mirror is failing", got)
|
||||
}
|
||||
|
||||
// Once the platform accepts it, the change stops being retried.
|
||||
failing = false
|
||||
p.syncAllowedDestinations()
|
||||
if len(calls) != 3 {
|
||||
t.Fatalf("recovery refresh did not reach the mirror: calls = %v", calls)
|
||||
}
|
||||
p.syncAllowedDestinations()
|
||||
if len(calls) != 3 {
|
||||
t.Fatalf("an applied set was mirrored again: calls = %v", calls)
|
||||
}
|
||||
if got := p.pendingDestinations(p.allowList); got != 0 {
|
||||
t.Fatalf("pendingDestinations = %d, want 0 after a successful mirror", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllowedDestinationRemovalRetriedAfterMirrorFailure is the same guarantee
|
||||
// for the direction that matters more: a withdrawn destination whose removal the
|
||||
// platform rejected must keep being retried, or it stays permitted for good.
|
||||
func TestAllowedDestinationRemovalRetriedAfterMirrorFailure(t *testing.T) {
|
||||
p := progWithAllowList()
|
||||
var calls []mirrorCall
|
||||
failing := false
|
||||
stubMirror(t, &calls, &failing)
|
||||
|
||||
p.rc = &controld.ResolverConfig{DestinationIPs: []string{"203.0.113.10", "198.51.100.0/24"}}
|
||||
p.syncAllowedDestinations()
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("initial apply: calls = %v", calls)
|
||||
}
|
||||
|
||||
// The organization withdraws one entry and the removal is rejected.
|
||||
failing = true
|
||||
p.rc = &controld.ResolverConfig{DestinationIPs: []string{"203.0.113.10"}}
|
||||
p.syncAllowedDestinations()
|
||||
if len(calls) != 2 || strings.Join(calls[1].removed, ",") != "198.51.100.0/24" {
|
||||
t.Fatalf("withdrawal: calls = %v", calls)
|
||||
}
|
||||
|
||||
// The next refresh sends the same (already reduced) list; the removal must
|
||||
// still be retried rather than treated as done.
|
||||
p.syncAllowedDestinations()
|
||||
if len(calls) != 3 || strings.Join(calls[2].removed, ",") != "198.51.100.0/24" {
|
||||
t.Fatalf("identical refresh after a failed removal: calls = %v", calls)
|
||||
}
|
||||
|
||||
failing = false
|
||||
p.syncAllowedDestinations()
|
||||
p.syncAllowedDestinations()
|
||||
if len(calls) != 4 {
|
||||
t.Fatalf("removal kept being retried after it succeeded: calls = %v", calls)
|
||||
}
|
||||
if got := p.pendingDestinations(p.allowList); got != 0 {
|
||||
t.Fatalf("pendingDestinations = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResyncRetriedUntilPlatformAcceptsIt covers enforcement starting over state
|
||||
// ctrld cannot describe - most importantly a macOS persist pf table that outlived
|
||||
// the previous run. The whole set is replaced rather than added, and a replace
|
||||
// the platform rejected must be retried: otherwise a destination the organization
|
||||
// withdrew while ctrld was stopped stays in that table forever, with nothing
|
||||
// pending to reveal it.
|
||||
func TestResyncRetriedUntilPlatformAcceptsIt(t *testing.T) {
|
||||
p := progWithAllowList()
|
||||
var calls []mirrorCall
|
||||
failing := true
|
||||
stubMirror(t, &calls, &failing)
|
||||
|
||||
p.rc = &controld.ResolverConfig{DestinationIPs: []string{"203.0.113.10"}}
|
||||
p.markDestinationsForResync()
|
||||
p.syncAllowedDestinations()
|
||||
|
||||
if len(calls) != 1 || !calls[0].replace {
|
||||
t.Fatalf("a resync must replace the whole set, not apply a delta: calls = %v", calls)
|
||||
}
|
||||
if got := p.pendingDestinations(p.allowList); got == 0 {
|
||||
t.Fatal("a rejected replace was reported as nothing pending")
|
||||
}
|
||||
|
||||
// Retried, still as a replace: until it succeeds nothing about what the
|
||||
// platform holds is known, so a delta would leave stale entries behind.
|
||||
p.reconcileAllowedDestinations()
|
||||
if len(calls) != 2 || !calls[1].replace {
|
||||
t.Fatalf("rejected replace was not retried: calls = %v", calls)
|
||||
}
|
||||
|
||||
failing = false
|
||||
p.reconcileAllowedDestinations()
|
||||
if len(calls) != 3 || !calls[2].replace {
|
||||
t.Fatalf("recovery did not replace the set: calls = %v", calls)
|
||||
}
|
||||
if got := p.pendingDestinations(p.allowList); got != 0 {
|
||||
t.Fatalf("pendingDestinations = %d after a successful replace, want 0", got)
|
||||
}
|
||||
|
||||
// Once the platform is known-good, later changes go back to deltas.
|
||||
p.rc = &controld.ResolverConfig{DestinationIPs: []string{"203.0.113.10", "198.51.100.0/24"}}
|
||||
p.syncAllowedDestinations()
|
||||
if len(calls) != 4 || calls[3].replace {
|
||||
t.Fatalf("a later change should be a delta: calls = %v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResyncWithEmptyListRetriedUntilAccepted is the same guarantee for an
|
||||
// organization with no entries at all: the platform still owes ctrld a flush of
|
||||
// whatever it inherited, so an empty desired set is not "nothing to do".
|
||||
func TestResyncWithEmptyListRetriedUntilAccepted(t *testing.T) {
|
||||
p := progWithAllowList()
|
||||
var calls []mirrorCall
|
||||
failing := true
|
||||
stubMirror(t, &calls, &failing)
|
||||
|
||||
p.rc = &controld.ResolverConfig{}
|
||||
p.markDestinationsForResync()
|
||||
p.syncAllowedDestinations()
|
||||
|
||||
if len(calls) != 1 || !calls[0].replace || len(calls[0].added) != 0 {
|
||||
t.Fatalf("empty list did not ask the platform to empty itself: calls = %v", calls)
|
||||
}
|
||||
if got := p.pendingDestinations(p.allowList); got == 0 {
|
||||
t.Fatal("a rejected flush of an empty set was reported as nothing pending")
|
||||
}
|
||||
|
||||
p.reconcileAllowedDestinations()
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("rejected flush was not retried: calls = %v", calls)
|
||||
}
|
||||
|
||||
failing = false
|
||||
p.reconcileAllowedDestinations()
|
||||
if got := p.pendingDestinations(p.allowList); got != 0 {
|
||||
t.Fatalf("pendingDestinations = %d after the flush succeeded, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetiredGenerationDoesNotTouchEnforcement pins the teardown boundary: a
|
||||
// maintenance worker holds the allowlist and generation of the run that started
|
||||
// it, and once that generation is over - Firewall Mode turned off, or a reload -
|
||||
// it must not mirror anything, or it would reinstall permits into enforcement
|
||||
// that is being removed or now belongs to another run.
|
||||
func TestRetiredGenerationDoesNotTouchEnforcement(t *testing.T) {
|
||||
p := progWithAllowList()
|
||||
var calls []mirrorCall
|
||||
failing := false
|
||||
stubMirror(t, &calls, &failing)
|
||||
|
||||
gen := p.startFirewallGeneration()
|
||||
p.rc = &controld.ResolverConfig{DestinationIPs: []string{"203.0.113.10"}}
|
||||
p.syncAllowedDestinations()
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("initial apply: calls = %v", calls)
|
||||
}
|
||||
|
||||
// Firewall Mode goes off: the generation ends and the applied set is dropped,
|
||||
// which is exactly the state that used to make a stale worker reinstall.
|
||||
stale := p.allowList
|
||||
p.retireFirewallDestinations()
|
||||
p.allowList = nil
|
||||
|
||||
p.reconcileDestinations(stale, gen)
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("a retired generation reached platform enforcement: calls = %v", calls)
|
||||
}
|
||||
|
||||
// A new generation may act again.
|
||||
p.allowList = stale
|
||||
newGen := p.startFirewallGeneration()
|
||||
p.reconcileDestinations(stale, newGen)
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("the current generation was blocked: calls = %v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSyncAllowedDestinationsFollowsResolverConfig covers what a configuration
|
||||
// refresh has to deliver: a destination added to the organization's list becomes
|
||||
// reachable without ctrld having resolved it, and one removed from the list stops
|
||||
// being reachable on the next refresh.
|
||||
func TestSyncAllowedDestinationsFollowsResolverConfig(t *testing.T) {
|
||||
p := progWithAllowList()
|
||||
direct := netip.MustParseAddr("203.0.113.10")
|
||||
inRange := netip.MustParseAddr("198.51.100.7")
|
||||
|
||||
// Refresh before the organization has any entries.
|
||||
p.rc = &controld.ResolverConfig{}
|
||||
p.syncAllowedDestinations()
|
||||
if p.allowList.Contains(direct) {
|
||||
t.Fatalf("%s allowed with an empty organization list", direct)
|
||||
}
|
||||
|
||||
// The organization adds an address and a CIDR.
|
||||
p.rc = &controld.ResolverConfig{DestinationIPs: []string{"203.0.113.10", "198.51.100.0/24"}}
|
||||
p.syncAllowedDestinations()
|
||||
if !p.allowList.Contains(direct) || !p.allowList.Contains(inRange) {
|
||||
t.Fatalf("allowed destinations not reachable: %s=%v %s=%v",
|
||||
direct, p.allowList.Contains(direct), inRange, p.allowList.Contains(inRange))
|
||||
}
|
||||
|
||||
// The organization removes the CIDR; the remaining entry is untouched.
|
||||
p.rc = &controld.ResolverConfig{DestinationIPs: []string{"203.0.113.10"}}
|
||||
p.syncAllowedDestinations()
|
||||
if p.allowList.Contains(inRange) {
|
||||
t.Fatalf("%s still allowed after its entry was removed", inRange)
|
||||
}
|
||||
if !p.allowList.Contains(direct) {
|
||||
t.Fatalf("%s should still be allowed", direct)
|
||||
}
|
||||
|
||||
// The organization clears the list entirely.
|
||||
p.rc = &controld.ResolverConfig{}
|
||||
p.syncAllowedDestinations()
|
||||
if p.allowList.Contains(direct) {
|
||||
t.Fatalf("%s still allowed after the list was cleared", direct)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyAllowedDestinationsFirewallModeOff pins that devices with Firewall
|
||||
// Mode disabled are unaffected: there is no allowlist to apply the list to, and
|
||||
// the refresh path must not panic on the nil one.
|
||||
func TestApplyAllowedDestinationsFirewallModeOff(t *testing.T) {
|
||||
p := &prog{rc: &controld.ResolverConfig{DestinationIPs: []string{"203.0.113.10"}}}
|
||||
p.logger.Store(discardLogger())
|
||||
p.syncAllowedDestinations()
|
||||
if p.allowList != nil {
|
||||
t.Fatal("applying allowed destinations created an allowlist while firewall mode is off")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSyncFirewallModeAppliesAllowedDestinations covers the startup and reload
|
||||
// path: turning Firewall Mode on builds a fresh allowlist, which must be seeded
|
||||
// with the organization's list from the resolver config the run started with -
|
||||
// otherwise the destinations stay blocked until the next hourly refresh.
|
||||
func TestSyncFirewallModeAppliesAllowedDestinations(t *testing.T) {
|
||||
p := &prog{cfg: &ctrld.Config{}}
|
||||
p.cfg.Service.FirewallMode = "on"
|
||||
p.logger.Store(discardLogger())
|
||||
p.rc = &controld.ResolverConfig{DestinationIPs: []string{"203.0.113.10"}}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
p.syncFirewallMode(ctx)
|
||||
if p.allowList == nil {
|
||||
t.Fatal("firewall mode on did not create an allowlist")
|
||||
}
|
||||
if !p.allowList.Contains(netip.MustParseAddr("203.0.113.10")) {
|
||||
t.Fatal("allowed destination not applied when firewall mode came up")
|
||||
}
|
||||
|
||||
// Turning the mode off drops the set with the allowlist, and forgets what
|
||||
// enforcement was holding so a later re-enable reinstalls everything.
|
||||
p.cfg.Service.FirewallMode = "off"
|
||||
p.syncFirewallMode(ctx)
|
||||
if p.allowList != nil {
|
||||
t.Fatal("firewall mode off did not clear the allowlist")
|
||||
}
|
||||
if got := p.pendingDestinations(p.allowList); got != 0 {
|
||||
t.Fatalf("pendingDestinations = %d with firewall mode off, want 0", got)
|
||||
}
|
||||
|
||||
p.cfg.Service.FirewallMode = "on"
|
||||
p.syncFirewallMode(ctx)
|
||||
if !p.allowList.Contains(netip.MustParseAddr("203.0.113.10")) {
|
||||
t.Fatal("allowed destination not re-applied after firewall mode was turned back on")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllowedDestinationsSurviveConcurrentReload drives the interleaving the race
|
||||
// detector caught: apiConfigReload applies the organization's destinations on the
|
||||
// refresh goroutine while a config reload replaces - or clears - the allowlist on
|
||||
// another.
|
||||
//
|
||||
// The apply path used to read p.allowList twice, once to check it for nil and
|
||||
// again to call SetExceptions on it, with a parse in between. A reload that
|
||||
// turned Firewall Mode off inside that gap left the second read nil, and ctrld
|
||||
// panicked on a refresh that had nothing wrong with it.
|
||||
func TestAllowedDestinationsSurviveConcurrentReload(t *testing.T) {
|
||||
origMirror, origReplace := firewallMirrorExceptionsFn, firewallReplaceExceptionsFn
|
||||
t.Cleanup(func() {
|
||||
firewallMirrorExceptionsFn, firewallReplaceExceptionsFn = origMirror, origReplace
|
||||
})
|
||||
firewallMirrorExceptionsFn = func(*prog, []netip.Prefix, []netip.Prefix) error { return nil }
|
||||
firewallReplaceExceptionsFn = func(*prog, []netip.Prefix) error { return nil }
|
||||
|
||||
p := &prog{cfg: &ctrld.Config{}}
|
||||
p.logger.Store(discardLogger())
|
||||
p.rc = &controld.ResolverConfig{DestinationIPs: []string{"203.0.113.10", "198.51.100.0/24"}}
|
||||
p.cfg.Service.FirewallMode = "on"
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
p.syncFirewallMode(ctx)
|
||||
|
||||
stop := make(chan struct{})
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
p.syncAllowedDestinations()
|
||||
}
|
||||
}()
|
||||
|
||||
// Firewall Mode off then on is what clears p.allowList and installs a fresh
|
||||
// one, which is the whole of the reload path this refresh can collide with.
|
||||
for range 20 {
|
||||
p.cfg.Service.FirewallMode = "off"
|
||||
p.syncFirewallMode(ctx)
|
||||
p.cfg.Service.FirewallMode = "on"
|
||||
p.syncFirewallMode(ctx)
|
||||
}
|
||||
close(stop)
|
||||
<-done
|
||||
|
||||
// The churn must not have cost the set: the run that is live at the end owes
|
||||
// the platform exactly what the API last sent.
|
||||
p.syncAllowedDestinations()
|
||||
p.destinationsMu.Lock()
|
||||
applied := prefixStrings(p.appliedDestinations)
|
||||
p.destinationsMu.Unlock()
|
||||
if got := strings.Join(applied, ","); got != "198.51.100.0/24,203.0.113.10/32" {
|
||||
t.Errorf("applied destinations = %q, want the full set after the reload churn", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllowedDestinationLogsKeepAddressesOutOfWarnings holds the whole
|
||||
// allowed-destination path to one policy: Warn and Info carry counts, addresses
|
||||
// appear only at Debug.
|
||||
//
|
||||
// Warn logs are persisted and travel in support bundles exactly like Info ones,
|
||||
// and both a rejected entry and an accepted one are an organization's network
|
||||
// topology - "10.0.0.0/33" names a real network as surely as the entry next to
|
||||
// it. logDestinationChange already followed this rule; the rejection and
|
||||
// wide-prefix warnings are the paths that can leak around it.
|
||||
func TestAllowedDestinationLogsKeepAddressesOutOfWarnings(t *testing.T) {
|
||||
var calls []mirrorCall
|
||||
failing := false
|
||||
stubMirror(t, &calls, &failing)
|
||||
|
||||
core, logs := observer.New(zapcore.DebugLevel)
|
||||
p := progWithAllowList()
|
||||
p.logger.Store(&ctrld.Logger{Logger: zap.New(core)})
|
||||
|
||||
const (
|
||||
bad = "10.0.0.0/33" // near-miss topology: a real network, a bad mask
|
||||
wide = "0.0.0.0/0" // accepted, and blankets the whole family
|
||||
ordinal = "203.0.113.10/32" // an ordinary accepted entry
|
||||
)
|
||||
p.rc = &controld.ResolverConfig{DestinationIPs: []string{bad, wide, "203.0.113.10"}}
|
||||
p.syncAllowedDestinations()
|
||||
|
||||
var warned, wideWarned bool
|
||||
for _, entry := range logs.FilterLevelExact(zapcore.WarnLevel).All() {
|
||||
line := entry.Message + fmt.Sprint(entry.ContextMap())
|
||||
for _, addr := range []string{bad, wide, ordinal, "203.0.113.10"} {
|
||||
if strings.Contains(line, addr) {
|
||||
t.Errorf("a Warn line carries the address %q, which support bundles then carry too:\n %s", addr, line)
|
||||
}
|
||||
}
|
||||
if strings.Contains(entry.Message, "not a valid IP address or CIDR") {
|
||||
warned = true
|
||||
if got := entry.ContextMap()["rejected"]; got != int64(1) {
|
||||
t.Errorf("rejected count = %v, want 1", got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(entry.Message, "very wide range") {
|
||||
wideWarned = true
|
||||
if got := entry.ContextMap()["bits"]; got != int64(0) {
|
||||
t.Errorf("wide prefix bits = %v, want 0 for a full range", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !warned {
|
||||
t.Error("an unusable entry was dropped without any warning")
|
||||
}
|
||||
if !wideWarned {
|
||||
t.Error("a full-range destination was accepted without any warning; the bypass would be invisible")
|
||||
}
|
||||
|
||||
// The values are still recoverable by whoever turns Debug on to look.
|
||||
var debugged string
|
||||
for _, entry := range logs.FilterLevelExact(zapcore.DebugLevel).All() {
|
||||
debugged += entry.Message + fmt.Sprint(entry.ContextMap())
|
||||
}
|
||||
if !strings.Contains(debugged, bad) {
|
||||
t.Errorf("the rejected entry %q appears in no Debug line, so nothing can diagnose it", bad)
|
||||
}
|
||||
if !strings.Contains(debugged, wide) {
|
||||
t.Errorf("the wide entry %q appears in no Debug line", wide)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFirewallPermanentAllowListPermitsControlDEndpoints is the regression guard
|
||||
// for the Windows lockout described on controld.APIEndpointIPs.
|
||||
//
|
||||
// Firewall Mode learns destinations from queries ctrld's own listener answered.
|
||||
// The addresses asserted here are the ones each endpoint falls back to when DNS
|
||||
// does not work at all - which is the state a ctrld blocked by its own filters is
|
||||
// in - so they are exactly the ones no lookup can ever teach it.
|
||||
func TestFirewallPermanentAllowListPermitsControlDEndpoints(t *testing.T) {
|
||||
for _, dev := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "prod", true: "dev"}[dev], func(t *testing.T) {
|
||||
origDev := cdDev
|
||||
cdDev = dev
|
||||
t.Cleanup(func() { cdDev = origDev })
|
||||
|
||||
al := firewall.New()
|
||||
p := &prog{cfg: &ctrld.Config{}}
|
||||
p.logger.Store(mainLog.Load())
|
||||
p.initFirewallAllowList(context.Background(), al)
|
||||
|
||||
apiIPs := controld.APIEndpointIPs(dev)
|
||||
if len(apiIPs) == 0 {
|
||||
t.Fatal("no ControlD API addresses to permit")
|
||||
}
|
||||
|
||||
endpoints := map[string][]string{
|
||||
// The API transport's direct addresses.
|
||||
"API": apiIPs,
|
||||
// The upgrade download server's fallback. performUpgrade runs the
|
||||
// download in a detached child process, and WFP's block-all filters
|
||||
// carry no process condition, so the service blocks its own upgrade.
|
||||
"download server": {downloadServerIp},
|
||||
}
|
||||
for what, ips := range endpoints {
|
||||
for _, ipStr := range ips {
|
||||
ip, err := netip.ParseAddr(ipStr)
|
||||
if err != nil {
|
||||
t.Fatalf("the %s address %q does not parse: %v", what, ipStr, err)
|
||||
}
|
||||
if !al.Contains(ip) {
|
||||
t.Errorf("the ControlD %s address %s is not permitted; Firewall Mode would block ctrld's own socket to it", what, ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,765 @@
|
||||
//go:build windows
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"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
|
||||
|
||||
// exceptionFilterMap tracks permit filters for the organization's Allowed
|
||||
// Destination IP list, keyed by prefix string. Kept apart from filterMap so
|
||||
// the flushes that discard DNS-resolved IPs leave them installed, and apart
|
||||
// from permanentFilterMap because entries are removed when the organization
|
||||
// removes them. Guarded by mu.
|
||||
//
|
||||
// Known gap, shared with filterMap and permanentFilterMap: rebuildDNSIntercept
|
||||
// recreates the WFP engine without re-initializing this state, so after a
|
||||
// health-monitor repair every ID here refers to a filter in a session that is
|
||||
// gone. Mirroring then fails for good and allowed_destinations_pending stays
|
||||
// non-zero until the service restarts. Predates the allowed-destination work
|
||||
// and wants its own fix - re-initializing platform firewall state as part of
|
||||
// the rebuild - rather than a patch here.
|
||||
exceptionFilterMap 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 {
|
||||
// A reload re-enters here with enforcement already up, to install permits
|
||||
// for permanent entries added since - a newly resolved API address, say -
|
||||
// which AddPermanent records in memory without any callback that would
|
||||
// reach WFP. Both populate calls skip what they already hold, so this is a
|
||||
// refresh rather than a reinstall.
|
||||
fwState.populatePermanentFilters(p)
|
||||
// The filter IDs this state holds describe whatever engine session
|
||||
// installed them, which after a rebuildDNSIntercept is not the live one -
|
||||
// so ask for a full replace instead of a delta against a snapshot that may
|
||||
// describe filters that no longer exist. markDestinationsForResync is
|
||||
// idempotent and cheap, and an unnecessary replace is a no-op the mirrors
|
||||
// already tolerate.
|
||||
p.markDestinationsForResync()
|
||||
p.reconcileAllowedDestinations()
|
||||
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),
|
||||
exceptionFilterMap: 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)
|
||||
|
||||
// The organization's allowed destinations are applied as soon as the allowlist
|
||||
// exists, which is before WFP enforcement comes up. This session holds no
|
||||
// filters of its own yet, so mark the set for a full install and let the
|
||||
// reconcile put it in - and retry it if WFP refuses.
|
||||
p.markDestinationsForResync()
|
||||
p.reconcileAllowedDestinations()
|
||||
|
||||
// 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)
|
||||
|
||||
exceptionFilters := make(map[string]uint64, len(s.exceptionFilterMap))
|
||||
for key, filterID := range s.exceptionFilterMap {
|
||||
exceptionFilters[key] = filterID
|
||||
}
|
||||
s.exceptionFilterMap = 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 key, filterID := range exceptionFilters {
|
||||
if r1, _, _ := procFwpmFilterDeleteById0.Call(s.engineHandle, uintptr(filterID)); r1 != 0 {
|
||||
p.Debug().Msgf("Firewall: failed to remove allowed destination 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)
|
||||
}
|
||||
}
|
||||
|
||||
// fwpErrFilterNotFound is FWP_E_FILTER_NOT_FOUND: the filter is already gone, so
|
||||
// a delete that reports it has achieved what it was asked to do.
|
||||
const fwpErrFilterNotFound = 0x80320003
|
||||
|
||||
// firewallApplyExceptionsPlatform mirrors a change to the organization's Allowed
|
||||
// Destination IP list into WFP permit filters, reporting whether WFP took it.
|
||||
//
|
||||
// An error - including "WFP enforcement is not up yet", because there is no
|
||||
// engine handle to install filters through - leaves the caller's applied snapshot
|
||||
// unadvanced, so the same delta is retried later.
|
||||
func (p *prog) firewallApplyExceptionsPlatform(added, removed []netip.Prefix) error {
|
||||
fwState, ok := p.platformFirewallState.(*wfpFirewallState)
|
||||
if !ok || fwState == nil {
|
||||
return errors.New("WFP firewall enforcement is not initialized")
|
||||
}
|
||||
return fwState.syncExceptionFilters(p, added, removed)
|
||||
}
|
||||
|
||||
// firewallReplaceExceptionsPlatform makes WFP hold permit filters for exactly
|
||||
// desired, whatever it held before, and reports whether it took.
|
||||
//
|
||||
// This is what runs when WFP enforcement starts. A fresh session holds nothing,
|
||||
// but the session can also be re-initialized over state this process installed
|
||||
// earlier, so anything not in desired is removed rather than assumed absent.
|
||||
func (p *prog) firewallReplaceExceptionsPlatform(desired []netip.Prefix) error {
|
||||
fwState, ok := p.platformFirewallState.(*wfpFirewallState)
|
||||
if !ok || fwState == nil {
|
||||
return errors.New("WFP firewall enforcement is not initialized")
|
||||
}
|
||||
return fwState.syncExceptionFilters(p, desired, fwState.exceptionsNotIn(desired))
|
||||
}
|
||||
|
||||
// exceptionsNotIn returns the prefixes WFP currently permits that are absent from
|
||||
// keep, i.e. the filters a full resync has to remove.
|
||||
func (s *wfpFirewallState) exceptionsNotIn(keep []netip.Prefix) []netip.Prefix {
|
||||
kept := make(map[string]struct{}, len(keep))
|
||||
for _, prefix := range keep {
|
||||
kept[prefix.String()] = struct{}{}
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
var out []netip.Prefix
|
||||
for key := range s.exceptionFilterMap {
|
||||
if _, ok := kept[key]; ok {
|
||||
continue
|
||||
}
|
||||
prefix, err := netip.ParsePrefix(key)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, prefix)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// syncExceptionFilters installs permit filters for added prefixes and removes the
|
||||
// filters of removed ones. Permits use the same weight as dynamically allowed
|
||||
// IPs, so they override the base block-all filter while still losing to the
|
||||
// higher-weighted DNS permits.
|
||||
//
|
||||
// A filter whose deletion failed keeps its ID in the map: dropping it would leak
|
||||
// a permit that no longer belongs to any allowed destination and that nothing
|
||||
// could ever remove, while the returned error keeps the withdrawal pending so the
|
||||
// next reconcile retries the same deletion.
|
||||
func (s *wfpFirewallState) syncExceptionFilters(p *prog, added, removed []netip.Prefix) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
var errs []error
|
||||
|
||||
for _, prefix := range removed {
|
||||
key := prefix.String()
|
||||
filterID, ok := s.exceptionFilterMap[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if r1, _, _ := procFwpmFilterDeleteById0.Call(s.engineHandle, uintptr(filterID)); r1 != 0 && r1 != fwpErrFilterNotFound {
|
||||
errs = append(errs, fmt.Errorf("delete WFP permit filter for allowed destination %s: HRESULT 0x%x", key, r1))
|
||||
continue
|
||||
}
|
||||
delete(s.exceptionFilterMap, key)
|
||||
p.Debug().Msgf("Firewall: removed WFP permit filter for allowed destination %s", key)
|
||||
}
|
||||
|
||||
for _, prefix := range added {
|
||||
key := prefix.String()
|
||||
if _, exists := s.exceptionFilterMap[key]; exists {
|
||||
continue
|
||||
}
|
||||
filterID, err := p.addWFPFirewallPermitPrefix(s, prefix)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("add WFP permit filter for allowed destination %s: %w", key, err))
|
||||
continue
|
||||
}
|
||||
s.exceptionFilterMap[key] = filterID
|
||||
p.Debug().Msgf("Firewall: added WFP permit filter for allowed destination %s (ID: %d)", key, filterID)
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
+73
-1
@@ -1,14 +1,86 @@
|
||||
package cli
|
||||
|
||||
import "regexp"
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// isHostnameChar reports whether r is part of validHostname's accepted
|
||||
// charset (letters, digits, hyphen, dot). It does not check position, so a
|
||||
// hostname can fail validHostname on structure (length, leading/trailing
|
||||
// hyphen) while every one of its characters passes here.
|
||||
func isHostnameChar(r rune) bool {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
|
||||
return true
|
||||
case r == '-' || r == '.':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// offendingHostnameChars returns the distinct characters in hostname that
|
||||
// validHostname's charset does not accept, in first-seen order. Empty when
|
||||
// every character is accepted - a rejection can still come from structure
|
||||
// alone (too short, too long, a leading or trailing hyphen).
|
||||
func offendingHostnameChars(hostname string) string {
|
||||
seen := make(map[rune]bool)
|
||||
var bad []rune
|
||||
for _, r := range hostname {
|
||||
if isHostnameChar(r) || seen[r] {
|
||||
continue
|
||||
}
|
||||
seen[r] = true
|
||||
bad = append(bad, r)
|
||||
}
|
||||
return string(bad)
|
||||
}
|
||||
|
||||
// serverFoldedHostnameChars are the characters ControlD's
|
||||
// DevicesTableModel.formatDeviceName folds to '-' (or strips) when it
|
||||
// registers a device name server-side. Only '.' can actually reach
|
||||
// hostnameMayBeFoldedByServer through validateCustomHostnameFlag's guarded
|
||||
// path: validHostname runs first and already rejects any hostname
|
||||
// containing a space or a '+' as CUSTOM_HOSTNAME_INVALID, so those two never
|
||||
// get here from an explicit --custom-hostname value. They stay in this set
|
||||
// for completeness: a mobile caller can set CustomHostname to an
|
||||
// OS-derived default directly, without going through
|
||||
// validateCustomHostnameFlag at all, so a space or '+' can still reach the
|
||||
// API unvalidated by this client.
|
||||
const serverFoldedHostnameChars = ". +"
|
||||
|
||||
// hostnameMayBeFoldedByServer reports whether hostname contains a character
|
||||
// ControlD may fold or strip when it registers the device, so the name ctrld
|
||||
// accepted may not be the name the dashboard ends up showing.
|
||||
func hostnameMayBeFoldedByServer(hostname string) bool {
|
||||
return strings.ContainsAny(hostname, serverFoldedHostnameChars)
|
||||
}
|
||||
|
||||
// customHostnameFailureMessage names the field, the offending character(s)
|
||||
// when there are any, and the allowed format for CUSTOM_HOSTNAME_INVALID.
|
||||
// ctrld's accept/reject rule (validHostname) is unchanged - this only
|
||||
// explains a rejection that used to be a bare fatal exit.
|
||||
func customHostnameFailureMessage(hostname string) string {
|
||||
const allowedFormat = "3-64 characters of letters, digits, hyphens, and dots (RFC1123 hostname format)"
|
||||
reason := "is not a valid hostname"
|
||||
if bad := offendingHostnameChars(hostname); bad != "" {
|
||||
reason = fmt.Sprintf("contains characters a hostname cannot use: %q", bad)
|
||||
}
|
||||
return fmt.Sprintf("--custom-hostname (CustomHostname) %q %s; allowed format: %s", hostname, reason, allowedFormat)
|
||||
}
|
||||
|
||||
@@ -33,3 +33,65 @@ func Test_validHostname(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestOffendingHostnameChars pins the characters surfaced in the
|
||||
// CUSTOM_HOSTNAME_INVALID message, so the failure names what is actually
|
||||
// wrong instead of a bare "invalid hostname".
|
||||
func TestOffendingHostnameChars(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hostname string
|
||||
want string
|
||||
}{
|
||||
{"single offender", "foo@bar", "@"},
|
||||
{"space", "foo bar", " "},
|
||||
{"dot is allowed", "foo.bar", ""},
|
||||
{"hyphen is allowed", "foo-bar", ""},
|
||||
{"distinct offenders in order", "a!b!c#d", "!#"},
|
||||
{"structurally invalid but no bad char", strings.Repeat("a", 65), ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := offendingHostnameChars(tc.hostname); got != tc.want {
|
||||
t.Errorf("offendingHostnameChars(%q) = %q, want %q", tc.hostname, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHostnameMayBeFoldedByServer pins the characters ControlD's
|
||||
// DevicesTableModel.formatDeviceName folds or strips when it registers a
|
||||
// device, so a ctrld-accepted name using one gets a heads-up notice instead
|
||||
// of silently registering under a different name.
|
||||
func TestHostnameMayBeFoldedByServer(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hostname string
|
||||
want bool
|
||||
}{
|
||||
{"dot", "foo.bar", true},
|
||||
{"space", "foo bar", true},
|
||||
{"plus", "foo+bar", true},
|
||||
{"plain", "foobar", false},
|
||||
{"hyphen only", "foo-bar", false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := hostnameMayBeFoldedByServer(tc.hostname); got != tc.want {
|
||||
t.Errorf("hostnameMayBeFoldedByServer(%q) = %v, want %v", tc.hostname, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCustomHostnameFailureMessage pins the message shape the T6 contract
|
||||
// requires: the flag/field name, the offending character(s), and the
|
||||
// allowed format.
|
||||
func TestCustomHostnameFailureMessage(t *testing.T) {
|
||||
msg := customHostnameFailureMessage("foo@bar")
|
||||
for _, want := range []string{"--custom-hostname", "CustomHostname", "@", "allowed format"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("customHostnameFailureMessage() = %q, want it to contain %q", msg, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
)
|
||||
|
||||
func TestUpdateConfigInterceptMode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
current string
|
||||
mode string
|
||||
want string
|
||||
wantUpdated bool
|
||||
}{
|
||||
{name: "empty flag preserves config", current: "dns", mode: "", want: "dns"},
|
||||
{name: "dns is persisted", mode: "dns", want: "dns", wantUpdated: true},
|
||||
{name: "hard is persisted", current: "dns", mode: "hard", want: "hard", wantUpdated: true},
|
||||
{name: "off clears persisted mode", current: "dns", mode: "off", want: "", wantUpdated: true},
|
||||
{name: "off is idempotent", mode: "off", want: ""},
|
||||
{name: "invalid flag preserves config", current: "hard", mode: "invalid", want: "hard"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := &ctrld.Config{}
|
||||
cfg.Service.InterceptMode = tc.current
|
||||
updated := updateConfigInterceptMode(cfg, tc.mode)
|
||||
if updated != tc.wantUpdated {
|
||||
t.Fatalf("updateConfigInterceptMode() updated = %v, want %v", updated, tc.wantUpdated)
|
||||
}
|
||||
if cfg.Service.InterceptMode != tc.want {
|
||||
t.Fatalf("service.intercept_mode = %q, want %q", cfg.Service.InterceptMode, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredInterceptMode(t *testing.T) {
|
||||
oldInterceptMode := interceptMode
|
||||
t.Cleanup(func() { interceptMode = oldInterceptMode })
|
||||
|
||||
p := &prog{cfg: &ctrld.Config{}}
|
||||
p.cfg.Service.InterceptMode = "dns"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
flag string
|
||||
want string
|
||||
}{
|
||||
{name: "empty flag falls back to config", flag: "", want: "dns"},
|
||||
{name: "explicit off is final", flag: "off", want: "off"},
|
||||
{name: "explicit hard wins over config", flag: "hard", want: "hard"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
interceptMode = tc.flag
|
||||
if got := p.configuredInterceptMode(); got != tc.want {
|
||||
t.Fatalf("configuredInterceptMode() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package cli
|
||||
|
||||
// Interception probe registry.
|
||||
//
|
||||
// A probe sends a DNS query for a unique synthetic domain through the OS resolver and
|
||||
// waits for ctrld's own handler to receive it. That is the only way to tell "the rules are
|
||||
// present" from "the rules are actually redirecting packets", and both the macOS pf path
|
||||
// and the Windows NRPT path use it.
|
||||
//
|
||||
// Each attempt registers its own domain, so overlapping probes cannot cancel each other,
|
||||
// and deregistration only removes the entry it owns.
|
||||
|
||||
// registerInterceptProbe registers domain and returns the channel it will be signalled on
|
||||
// plus the function that removes the registration.
|
||||
//
|
||||
//lint:ignore U1000 used on darwin (pf probes) and windows (NRPT probes)
|
||||
func (p *prog) registerInterceptProbe(domain string) (<-chan struct{}, func()) {
|
||||
ch := make(chan struct{}, 1)
|
||||
|
||||
p.interceptProbeMu.Lock()
|
||||
current, _ := p.interceptProbes.Load().(map[string]chan struct{})
|
||||
next := make(map[string]chan struct{}, len(current)+1)
|
||||
for k, v := range current {
|
||||
next[k] = v
|
||||
}
|
||||
next[domain] = ch
|
||||
p.interceptProbes.Store(next)
|
||||
p.interceptProbeMu.Unlock()
|
||||
|
||||
return ch, func() {
|
||||
p.interceptProbeMu.Lock()
|
||||
defer p.interceptProbeMu.Unlock()
|
||||
current, _ := p.interceptProbes.Load().(map[string]chan struct{})
|
||||
// Only drop the entry while it is still this attempt's channel. A later probe
|
||||
// that reused the domain owns the slot now, and clearing it would make that one
|
||||
// wait out its timeout for a query it already received.
|
||||
if existing, ok := current[domain]; !ok || existing != ch {
|
||||
return
|
||||
}
|
||||
next := make(map[string]chan struct{}, len(current))
|
||||
for k, v := range current {
|
||||
if k != domain {
|
||||
next[k] = v
|
||||
}
|
||||
}
|
||||
p.interceptProbes.Store(next)
|
||||
}
|
||||
}
|
||||
|
||||
// signalInterceptProbe reports whether domain is a pending probe, signalling its waiter
|
||||
// when it is. Called from the DNS handler for every query, so the common case is a nil or
|
||||
// empty map and no allocation.
|
||||
func (p *prog) signalInterceptProbe(domain string) bool {
|
||||
probes, _ := p.interceptProbes.Load().(map[string]chan struct{})
|
||||
if len(probes) == 0 {
|
||||
return false
|
||||
}
|
||||
ch, ok := probes[domain]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case ch <- struct{}{}:
|
||||
default:
|
||||
// Buffered channel already holds a signal: the waiter has what it needs.
|
||||
}
|
||||
return true
|
||||
}
|
||||
+38
-11
@@ -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,9 +62,15 @@ 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) {
|
||||
return doWithRetryClient(httpClientWithFallback(defaultHTTPTimeout), req, maxRetries, ip)
|
||||
}
|
||||
|
||||
// doWithRetryClient is doWithRetry with an injectable client, so the retry and
|
||||
// error-composition behaviour can be tested without real network access.
|
||||
func doWithRetryClient(client *http.Client, req *http.Request, maxRetries int, ip string) (*http.Response, error) {
|
||||
var lastErr error
|
||||
client := httpClientWithFallback(defaultHTTPTimeout)
|
||||
var ipReq *http.Request
|
||||
if ip != "" {
|
||||
ipReq = req.Clone(req.Context())
|
||||
@@ -60,32 +79,40 @@ 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)
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
// Keep the hostname attempt's error: it carries the diagnosis (on Windows,
|
||||
// a local firewall denying the socket shows up here as WSAEACCES), while the
|
||||
// direct-ip fallback often fails for an unrelated reason such as an
|
||||
// unreachable IPv6 route.
|
||||
attemptErr := err
|
||||
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)
|
||||
resp, err = client.Do(ipReq)
|
||||
if err == 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)
|
||||
resp, fallbackErr := client.Do(ipReq)
|
||||
if fallbackErr == nil {
|
||||
return resp, nil
|
||||
}
|
||||
attemptErr = fmt.Errorf("%w; fallback to direct ip %s failed: %w", attemptErr, ip, fallbackErr)
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
mainLog.Load().Debug().Err(err).
|
||||
lastErr = attemptErr
|
||||
mainLog.Load().Debug().Err(attemptErr).
|
||||
Str("method", req.Method).
|
||||
Str("url", req.URL.String()).
|
||||
Msgf("HTTP request attempt %d/%d failed", attempt+1, maxRetries)
|
||||
}
|
||||
return nil, fmt.Errorf("failed after %d attempts to %s %s: %v", maxRetries, req.Method, req.URL, lastErr)
|
||||
return nil, fmt.Errorf("failed after %d attempts to %s %s: %w", maxRetries, req.Method, req.URL, lastErr)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld/internal/controld"
|
||||
)
|
||||
|
||||
// wsaEACCES is WSAEACCES (10013): "An attempt was made to access a socket in a way
|
||||
// forbidden by its access permissions." This is what Windows reports when a WFP
|
||||
// filter denies the connect. Used as a plain errno so the test runs everywhere.
|
||||
const wsaEACCES = syscall.Errno(10013)
|
||||
|
||||
// denyingRoundTripper denies the hostname attempt with firstErr and the direct-ip
|
||||
// attempt with fbErr, the shape seen during the Firewall Mode incident: the
|
||||
// hostname attempt was denied by ctrld's own stale block-all filters, while the
|
||||
// direct-ip fallback failed on an unreachable IPv6 route.
|
||||
type denyingRoundTripper struct {
|
||||
hostname string
|
||||
firstErr error
|
||||
fbErr error
|
||||
}
|
||||
|
||||
func (rt *denyingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.Host == rt.hostname {
|
||||
return nil, &net.OpError{Op: "dial", Net: "tcp4", Err: rt.firstErr}
|
||||
}
|
||||
return nil, &net.OpError{Op: "dial", Net: "tcp6", Err: rt.fbErr}
|
||||
}
|
||||
|
||||
func TestDoWithRetryPreservesHostnameError(t *testing.T) {
|
||||
const hostname = "dl.controld.dev"
|
||||
req, err := http.NewRequest(http.MethodGet, "https://"+hostname+"/windows-amd64/ctrld-client.exe", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rt := &denyingRoundTripper{
|
||||
hostname: hostname,
|
||||
firstErr: wsaEACCES,
|
||||
fbErr: syscall.EHOSTUNREACH,
|
||||
}
|
||||
|
||||
_, err = doWithRetryClient(&http.Client{Transport: rt}, req, 1, "23.171.240.151")
|
||||
if err == nil {
|
||||
t.Fatal("expected doWithRetry to fail when both attempts are denied")
|
||||
}
|
||||
if !errors.Is(err, wsaEACCES) {
|
||||
t.Errorf("hostname-attempt error (WSAEACCES) was lost, got: %v", err)
|
||||
}
|
||||
if !errors.Is(err, syscall.EHOSTUNREACH) {
|
||||
t.Errorf("fallback error was lost, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// composedAttemptErrors builds the error shape the two-attempt paths return: each
|
||||
// attempt's *url.Error (as produced by http.Client.Do) wrapped by a single fmt.Errorf
|
||||
// with two %w verbs, hostname attempt first. Mirrors doWithFallback in
|
||||
// internal/controld and doWithRetryClient above.
|
||||
func composedAttemptErrors(first, fallback error) error {
|
||||
attempt := func(network string, cause error) error {
|
||||
return &url.Error{
|
||||
Op: "Post",
|
||||
URL: "https://api.controld.com/utility",
|
||||
Err: &net.OpError{Op: "dial", Net: network, Err: cause},
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("request failed: %w; fallback to direct ip %s failed: %w",
|
||||
attempt("tcp4", first), "147.185.34.1", attempt("tcp6", fallback))
|
||||
}
|
||||
|
||||
// TestComposedFallbackErrorRetryClassification pins which attempt decides whether
|
||||
// preflight keeps retrying.
|
||||
//
|
||||
// Reporting both attempt errors is not purely diagnostic: processCDFlags decides
|
||||
// retryability with errUrlNetworkError, which uses errors.As, and errors.As is
|
||||
// order-sensitive - it returns the *first* matching error in the tree. Composing the
|
||||
// hostname attempt first therefore hands the retry predicate the hostname failure,
|
||||
// where previously only the fallback's error survived to be classified.
|
||||
//
|
||||
// The consequence is deliberate: a locally denied socket (WSAEACCES, a firewall
|
||||
// blocking ctrld) is no longer treated as a transient network error, so preflight fails
|
||||
// fast and reports instead of backing off - the incident logged 256 retry cycles
|
||||
// against filters that were never going to clear on their own. The boot case that
|
||||
// justifies the indefinite retry, a network unreachable on both attempts, is preserved.
|
||||
//
|
||||
// If the wrap order is ever reversed, this test fails rather than silently restoring
|
||||
// indefinite retries against a host that is actively refusing.
|
||||
func TestComposedFallbackErrorRetryClassification(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hostname error
|
||||
fallback error
|
||||
wantRetryable bool
|
||||
}{
|
||||
{
|
||||
// The incident's pair: denied locally, IPv6 route unusable.
|
||||
name: "denied socket then unreachable fallback fails fast",
|
||||
hostname: wsaEACCES,
|
||||
fallback: syscall.EHOSTUNREACH,
|
||||
wantRetryable: false,
|
||||
},
|
||||
{
|
||||
// Boot with no network yet: must still retry indefinitely.
|
||||
name: "network unreachable on both attempts still retries",
|
||||
hostname: syscall.ENETUNREACH,
|
||||
fallback: syscall.ENETUNREACH,
|
||||
wantRetryable: true,
|
||||
},
|
||||
{
|
||||
name: "connection refused still retries",
|
||||
hostname: syscall.ECONNREFUSED,
|
||||
fallback: syscall.EHOSTUNREACH,
|
||||
wantRetryable: true,
|
||||
},
|
||||
{
|
||||
name: "permission denied on both attempts fails fast",
|
||||
hostname: syscall.EACCES,
|
||||
fallback: syscall.EACCES,
|
||||
wantRetryable: false,
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := composedAttemptErrors(tc.hostname, tc.fallback)
|
||||
if got := errUrlNetworkError(err); got != tc.wantRetryable {
|
||||
t.Errorf("errUrlNetworkError() = %v, want %v", got, tc.wantRetryable)
|
||||
}
|
||||
// Both attempts remain reportable regardless of classification.
|
||||
if !errors.Is(err, tc.hostname) {
|
||||
t.Error("hostname attempt error was lost")
|
||||
}
|
||||
if !errors.Is(err, tc.fallback) {
|
||||
t.Error("fallback attempt error was lost")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnresolvedHostnameDefersToFallbackAttempt covers the asymmetric pair.
|
||||
//
|
||||
// Only the hostname attempt resolves DNS, and Go marks a *net.DNSError as temporary only
|
||||
// for socket failures that reached the server - so a SERVFAIL or "no such host" answer is
|
||||
// not temporary. At boot behind a captive portal, or before a router's forwarder is up,
|
||||
// that is exactly how the hostname attempt fails while the network is merely not ready.
|
||||
// Before the composed error existed only the fallback decided, so this pair retried;
|
||||
// classifying the hostname attempt alone would fail it fast and reach Fatal.
|
||||
//
|
||||
// A name-resolution failure therefore carries no verdict: the fallback attempt decides.
|
||||
// The locally-denied case above still fails fast, because a denied socket is definitive.
|
||||
func TestUnresolvedHostnameDefersToFallbackAttempt(t *testing.T) {
|
||||
dnsFailure := &url.Error{
|
||||
Op: "Post",
|
||||
URL: "https://api.controld.com/utility",
|
||||
Err: &net.DNSError{Err: "server misbehaving", Name: "api.controld.com", IsTemporary: false},
|
||||
}
|
||||
attempt := func(cause error) error {
|
||||
return &url.Error{
|
||||
Op: "Post",
|
||||
URL: "https://api.controld.com/utility",
|
||||
Err: &net.OpError{Op: "dial", Net: "tcp6", Err: cause},
|
||||
}
|
||||
}
|
||||
|
||||
retryable := fmt.Errorf("request failed: %w; fallback to direct ip %s failed: %w",
|
||||
dnsFailure, "147.185.34.1", attempt(syscall.ECONNREFUSED))
|
||||
if !errUrlNetworkError(retryable) {
|
||||
t.Error("an unresolved hostname with a retryable fallback must keep retrying: at boot the network is simply not up yet")
|
||||
}
|
||||
|
||||
denied := fmt.Errorf("request failed: %w; fallback to direct ip %s failed: %w",
|
||||
dnsFailure, "147.185.34.1", attempt(wsaEACCES))
|
||||
if errUrlNetworkError(denied) {
|
||||
t.Error("an unresolved hostname with a denied fallback must fail fast: nothing here clears on its own")
|
||||
}
|
||||
|
||||
// A resolution failure alone still says nothing, so it must not be read as retryable.
|
||||
if errUrlNetworkError(dnsFailure) {
|
||||
t.Error("a bare name-resolution failure must not be classified as retryable")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoWithFallbackClassificationEndToEnd drives the real composition in
|
||||
// internal/controld through the real predicate, instead of asserting a hand-written copy
|
||||
// of its error shape against another hand-written copy. A change to either side's format
|
||||
// string or wrap order is caught here.
|
||||
func TestDoWithFallbackClassificationEndToEnd(t *testing.T) {
|
||||
const hostname = "api.controld.com"
|
||||
req, err := http.NewRequest(http.MethodPost, "https://"+hostname+"/utility", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rt := &denyingRoundTripper{
|
||||
hostname: hostname,
|
||||
firstErr: wsaEACCES,
|
||||
fbErr: syscall.EHOSTUNREACH,
|
||||
}
|
||||
|
||||
_, gotErr := controld.DoWithFallbackForTest(context.Background(), &http.Client{Transport: rt}, req, "147.185.34.1")
|
||||
if gotErr == nil {
|
||||
t.Fatal("expected both attempts to fail")
|
||||
}
|
||||
if errUrlNetworkError(gotErr) {
|
||||
t.Errorf("the real composed error was classified as retryable: %v", gotErr)
|
||||
}
|
||||
if !errors.Is(gotErr, wsaEACCES) || !errors.Is(gotErr, syscall.EHOSTUNREACH) {
|
||||
t.Errorf("the real composed error lost an attempt: %v", gotErr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoWithRetryComposesHostnameAttemptFirst anchors the ordering assumption above to
|
||||
// the real composition, so a reordering of the wrap in doWithRetryClient is caught here
|
||||
// and not only in the hand-built shape.
|
||||
func TestDoWithRetryComposesHostnameAttemptFirst(t *testing.T) {
|
||||
const hostname = "dl.controld.dev"
|
||||
req, err := http.NewRequest(http.MethodGet, "https://"+hostname+"/windows-amd64/ctrld-client.exe", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rt := &denyingRoundTripper{hostname: hostname, firstErr: wsaEACCES, fbErr: syscall.EHOSTUNREACH}
|
||||
|
||||
_, gotErr := doWithRetryClient(&http.Client{Transport: rt}, req, 1, "23.171.240.151")
|
||||
if gotErr == nil {
|
||||
t.Fatal("expected both attempts to fail")
|
||||
}
|
||||
|
||||
// errors.As must reach the hostname attempt first: that is what the retry
|
||||
// predicate classifies.
|
||||
var opErr *net.OpError
|
||||
if !errors.As(gotErr, &opErr) {
|
||||
t.Fatalf("no net.OpError in the chain: %v", gotErr)
|
||||
}
|
||||
if !errors.Is(opErr.Err, wsaEACCES) {
|
||||
t.Errorf("first OpError in the chain is %v, want the hostname attempt (%v)", opErr.Err, wsaEACCES)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
)
|
||||
|
||||
func TestListenerInterceptModeExplicitOff(t *testing.T) {
|
||||
oldIntercept := interceptMode
|
||||
t.Cleanup(func() { interceptMode = oldIntercept })
|
||||
|
||||
cfg := &ctrld.Config{}
|
||||
cfg.Service.InterceptMode = "dns"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
flag string
|
||||
want string
|
||||
}{
|
||||
{name: "explicit off is final", flag: "off", want: "off"},
|
||||
{name: "empty flag falls back to config", flag: "", want: "dns"},
|
||||
{name: "explicit dns wins over config", flag: "dns", want: "dns"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
interceptMode = tc.flag
|
||||
if got := listenerInterceptMode(cfg); got != tc.want {
|
||||
t.Fatalf("listenerInterceptMode() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// logWriter.tailLastLines tests
|
||||
// =============================================================================
|
||||
|
||||
func Test_logWriter_tailLastLines_Empty(t *testing.T) {
|
||||
lw := newLogWriterWithSize(4096)
|
||||
if got := lw.tailLastLines(10); got != nil {
|
||||
t.Fatalf("expected nil for empty buffer, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_logWriter_tailLastLines_ZeroLines(t *testing.T) {
|
||||
lw := newLogWriterWithSize(4096)
|
||||
lw.Write([]byte("line1\nline2\n"))
|
||||
if got := lw.tailLastLines(0); got != nil {
|
||||
t.Fatalf("expected nil for n=0, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_logWriter_tailLastLines_NegativeLines(t *testing.T) {
|
||||
lw := newLogWriterWithSize(4096)
|
||||
lw.Write([]byte("line1\nline2\n"))
|
||||
if got := lw.tailLastLines(-1); got != nil {
|
||||
t.Fatalf("expected nil for n=-1, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_logWriter_tailLastLines_FewerThanN(t *testing.T) {
|
||||
lw := newLogWriterWithSize(4096)
|
||||
lw.Write([]byte("line1\nline2\n"))
|
||||
got := string(lw.tailLastLines(10))
|
||||
want := "line1\nline2\n"
|
||||
if got != want {
|
||||
t.Fatalf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_logWriter_tailLastLines_ExactN(t *testing.T) {
|
||||
lw := newLogWriterWithSize(4096)
|
||||
lw.Write([]byte("line1\nline2\nline3\n"))
|
||||
got := string(lw.tailLastLines(3))
|
||||
want := "line1\nline2\nline3\n"
|
||||
if got != want {
|
||||
t.Fatalf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_logWriter_tailLastLines_MoreThanN(t *testing.T) {
|
||||
lw := newLogWriterWithSize(4096)
|
||||
lw.Write([]byte("line1\nline2\nline3\nline4\nline5\n"))
|
||||
got := string(lw.tailLastLines(2))
|
||||
want := "line4\nline5\n"
|
||||
if got != want {
|
||||
t.Fatalf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_logWriter_tailLastLines_NoTrailingNewline(t *testing.T) {
|
||||
lw := newLogWriterWithSize(4096)
|
||||
lw.Write([]byte("line1\nline2\nline3"))
|
||||
// Without trailing newline, "line3" is a partial line.
|
||||
// Asking for 1 line returns the last newline-terminated line plus the partial.
|
||||
got := string(lw.tailLastLines(1))
|
||||
want := "line2\nline3"
|
||||
if got != want {
|
||||
t.Fatalf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_logWriter_tailLastLines_SingleLineNoNewline(t *testing.T) {
|
||||
lw := newLogWriterWithSize(4096)
|
||||
lw.Write([]byte("only line"))
|
||||
got := string(lw.tailLastLines(5))
|
||||
want := "only line"
|
||||
if got != want {
|
||||
t.Fatalf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_logWriter_tailLastLines_SingleLineWithNewline(t *testing.T) {
|
||||
lw := newLogWriterWithSize(4096)
|
||||
lw.Write([]byte("only line\n"))
|
||||
got := string(lw.tailLastLines(1))
|
||||
want := "only line\n"
|
||||
if got != want {
|
||||
t.Fatalf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// logWriter.Subscribe tests
|
||||
// =============================================================================
|
||||
|
||||
func Test_logWriter_Subscribe_Basic(t *testing.T) {
|
||||
lw := newLogWriterWithSize(4096)
|
||||
ch, unsub := lw.Subscribe()
|
||||
defer unsub()
|
||||
|
||||
msg := []byte("hello world\n")
|
||||
lw.Write(msg)
|
||||
|
||||
select {
|
||||
case got := <-ch:
|
||||
if string(got) != string(msg) {
|
||||
t.Fatalf("got %q, want %q", got, msg)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for subscriber data")
|
||||
}
|
||||
}
|
||||
|
||||
func Test_logWriter_Subscribe_MultipleSubscribers(t *testing.T) {
|
||||
lw := newLogWriterWithSize(4096)
|
||||
ch1, unsub1 := lw.Subscribe()
|
||||
defer unsub1()
|
||||
ch2, unsub2 := lw.Subscribe()
|
||||
defer unsub2()
|
||||
|
||||
msg := []byte("broadcast\n")
|
||||
lw.Write(msg)
|
||||
|
||||
for i, ch := range []<-chan []byte{ch1, ch2} {
|
||||
select {
|
||||
case got := <-ch:
|
||||
if string(got) != string(msg) {
|
||||
t.Fatalf("subscriber %d: got %q, want %q", i, got, msg)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("subscriber %d: timed out", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func Test_logWriter_Subscribe_Unsubscribe(t *testing.T) {
|
||||
lw := newLogWriterWithSize(4096)
|
||||
ch, unsub := lw.Subscribe()
|
||||
|
||||
// Verify subscribed.
|
||||
lw.Write([]byte("before unsub\n"))
|
||||
select {
|
||||
case <-ch:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out before unsub")
|
||||
}
|
||||
|
||||
unsub()
|
||||
|
||||
// Channel should be closed after unsub.
|
||||
if _, ok := <-ch; ok {
|
||||
t.Fatal("channel should be closed after unsubscribe")
|
||||
}
|
||||
|
||||
// Verify subscriber list is empty.
|
||||
lw.mu.Lock()
|
||||
count := len(lw.subscribers)
|
||||
lw.mu.Unlock()
|
||||
if count != 0 {
|
||||
t.Fatalf("expected 0 subscribers after unsub, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_logWriter_Subscribe_UnsubscribeIdempotent(t *testing.T) {
|
||||
lw := newLogWriterWithSize(4096)
|
||||
_, unsub := lw.Subscribe()
|
||||
unsub()
|
||||
// Second unsub should not panic.
|
||||
unsub()
|
||||
}
|
||||
|
||||
func Test_logWriter_Subscribe_SlowSubscriberDropped(t *testing.T) {
|
||||
lw := newLogWriterWithSize(4096)
|
||||
ch, unsub := lw.Subscribe()
|
||||
defer unsub()
|
||||
|
||||
// Fill the subscriber channel (buffer size is 256).
|
||||
for i := 0; i < 300; i++ {
|
||||
lw.Write([]byte("msg\n"))
|
||||
}
|
||||
|
||||
// Should have 256 buffered messages, rest dropped.
|
||||
count := 0
|
||||
for {
|
||||
select {
|
||||
case <-ch:
|
||||
count++
|
||||
default:
|
||||
goto done
|
||||
}
|
||||
}
|
||||
done:
|
||||
if count != 256 {
|
||||
t.Fatalf("expected 256 buffered messages, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_logWriter_Subscribe_ConcurrentWriteAndRead(t *testing.T) {
|
||||
lw := newLogWriterWithSize(64 * 1024)
|
||||
ch, unsub := lw.Subscribe()
|
||||
defer unsub()
|
||||
|
||||
const numWrites = 100
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < numWrites; i++ {
|
||||
lw.Write([]byte("concurrent write\n"))
|
||||
}
|
||||
}()
|
||||
|
||||
received := 0
|
||||
timeout := time.After(5 * time.Second)
|
||||
for received < numWrites {
|
||||
select {
|
||||
case <-ch:
|
||||
received++
|
||||
case <-timeout:
|
||||
t.Fatalf("timed out after receiving %d/%d messages", received, numWrites)
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// tailFileLastLines tests
|
||||
// =============================================================================
|
||||
|
||||
func writeTempFile(t *testing.T, content string) *os.File {
|
||||
t.Helper()
|
||||
f, err := os.CreateTemp(t.TempDir(), "tail-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.WriteString(content); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func Test_tailFileLastLines_Empty(t *testing.T) {
|
||||
f := writeTempFile(t, "")
|
||||
defer f.Close()
|
||||
if got := tailFileLastLines(f, 10); got != nil {
|
||||
t.Fatalf("expected nil for empty file, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_tailFileLastLines_FewerThanN(t *testing.T) {
|
||||
f := writeTempFile(t, "line1\nline2\n")
|
||||
defer f.Close()
|
||||
got := string(tailFileLastLines(f, 10))
|
||||
want := "line1\nline2\n"
|
||||
if got != want {
|
||||
t.Fatalf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_tailFileLastLines_ExactN(t *testing.T) {
|
||||
f := writeTempFile(t, "a\nb\nc\n")
|
||||
defer f.Close()
|
||||
got := string(tailFileLastLines(f, 3))
|
||||
want := "a\nb\nc\n"
|
||||
if got != want {
|
||||
t.Fatalf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_tailFileLastLines_MoreThanN(t *testing.T) {
|
||||
f := writeTempFile(t, "line1\nline2\nline3\nline4\nline5\n")
|
||||
defer f.Close()
|
||||
got := string(tailFileLastLines(f, 2))
|
||||
want := "line4\nline5\n"
|
||||
if got != want {
|
||||
t.Fatalf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_tailFileLastLines_NoTrailingNewline(t *testing.T) {
|
||||
f := writeTempFile(t, "line1\nline2\nline3")
|
||||
defer f.Close()
|
||||
// Without trailing newline, partial last line comes with the previous line.
|
||||
got := string(tailFileLastLines(f, 1))
|
||||
want := "line2\nline3"
|
||||
if got != want {
|
||||
t.Fatalf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_tailFileLastLines_LargerThanChunk(t *testing.T) {
|
||||
// Build content larger than the 4096 chunk size to exercise multi-chunk reads.
|
||||
var sb strings.Builder
|
||||
for i := 0; i < 200; i++ {
|
||||
sb.WriteString(strings.Repeat("x", 50))
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
f := writeTempFile(t, sb.String())
|
||||
defer f.Close()
|
||||
got := string(tailFileLastLines(f, 3))
|
||||
lines := strings.Split(strings.TrimRight(got, "\n"), "\n")
|
||||
if len(lines) != 3 {
|
||||
t.Fatalf("expected 3 lines, got %d: %q", len(lines), got)
|
||||
}
|
||||
expectedLine := strings.Repeat("x", 50)
|
||||
for _, line := range lines {
|
||||
if line != expectedLine {
|
||||
t.Fatalf("unexpected line content: %q", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func Test_tailFileLastLines_SeeksToEnd(t *testing.T) {
|
||||
f := writeTempFile(t, "line1\nline2\nline3\n")
|
||||
defer f.Close()
|
||||
tailFileLastLines(f, 1)
|
||||
|
||||
// After tailFileLastLines, file position should be at the end.
|
||||
pos, err := f.Seek(0, io.SeekCurrent)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stat, err := f.Stat()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pos != stat.Size() {
|
||||
t.Fatalf("expected file position at end (%d), got %d", stat.Size(), pos)
|
||||
}
|
||||
}
|
||||
+503
-39
@@ -6,70 +6,303 @@ import (
|
||||
"fmt"
|
||||
"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
|
||||
}
|
||||
|
||||
// logSubscriber represents a subscriber to live log output.
|
||||
type logSubscriber struct {
|
||||
ch chan []byte
|
||||
}
|
||||
|
||||
// logWriter is an internal buffer to keep track of runtime log when no logging is enabled.
|
||||
// When a file path is configured via setLogFile, writes are also persisted to
|
||||
// a rotated file on disk (max logFileMaxSize, 1 backup) so logs survive restarts.
|
||||
type logWriter struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
size int
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
size int
|
||||
subscribers []*logSubscriber
|
||||
|
||||
// File persistence fields.
|
||||
logFile *os.File
|
||||
logFilePath string
|
||||
logFileSize int64
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// setLogFile configures file-backed persistence for the log writer.
|
||||
// The directory is created if it does not exist. An existing file is
|
||||
// opened in append mode and its current size is tracked for rotation.
|
||||
func (lw *logWriter) setLogFile(path string) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0750); err != nil {
|
||||
return fmt.Errorf("creating log directory: %w", err)
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR|os.O_APPEND, 0600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening log file: %w", err)
|
||||
}
|
||||
st, err := f.Stat()
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return fmt.Errorf("stat log file: %w", err)
|
||||
}
|
||||
lw.mu.Lock()
|
||||
defer lw.mu.Unlock()
|
||||
lw.logFile = f
|
||||
lw.logFilePath = path
|
||||
lw.logFileSize = st.Size()
|
||||
return nil
|
||||
}
|
||||
|
||||
// rotateLogFile rotates the current log file to a .1 backup.
|
||||
// It returns true if lw.logFile is usable after the call, false otherwise.
|
||||
// Must be called with lw.mu held.
|
||||
func (lw *logWriter) rotateLogFile() bool {
|
||||
if lw.logFile == nil {
|
||||
return false
|
||||
}
|
||||
lw.logFile.Close()
|
||||
backupPath := lw.logFilePath + ".1"
|
||||
// Best effort: rename current to backup (overwrites old backup).
|
||||
os.Rename(lw.logFilePath, backupPath)
|
||||
f, err := os.OpenFile(lw.logFilePath, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
// If we can't reopen, disable file logging.
|
||||
lw.logFile = nil
|
||||
lw.logFileSize = 0
|
||||
return false
|
||||
}
|
||||
lw.logFile = f
|
||||
lw.logFileSize = 0
|
||||
return true
|
||||
}
|
||||
|
||||
// closeLogFile closes the backing file if open.
|
||||
func (lw *logWriter) closeLogFile() {
|
||||
lw.mu.Lock()
|
||||
defer lw.mu.Unlock()
|
||||
if lw.logFile != nil {
|
||||
lw.logFile.Close()
|
||||
lw.logFile = nil
|
||||
}
|
||||
}
|
||||
|
||||
// logFilePaths returns the paths to the current log file and its backup
|
||||
// (if they exist) for inclusion in log send payloads.
|
||||
func (lw *logWriter) logFilePaths() (current, backup string) {
|
||||
lw.mu.Lock()
|
||||
defer lw.mu.Unlock()
|
||||
if lw.logFilePath == "" {
|
||||
return "", ""
|
||||
}
|
||||
current = lw.logFilePath
|
||||
bp := lw.logFilePath + ".1"
|
||||
if _, err := os.Stat(bp); err == nil {
|
||||
backup = bp
|
||||
}
|
||||
return current, backup
|
||||
}
|
||||
|
||||
// Subscribe returns a channel that receives new log data as it's written,
|
||||
// and an unsubscribe function to clean up when done.
|
||||
func (lw *logWriter) Subscribe() (<-chan []byte, func()) {
|
||||
lw.mu.Lock()
|
||||
defer lw.mu.Unlock()
|
||||
sub := &logSubscriber{ch: make(chan []byte, 256)}
|
||||
lw.subscribers = append(lw.subscribers, sub)
|
||||
unsub := func() {
|
||||
lw.mu.Lock()
|
||||
defer lw.mu.Unlock()
|
||||
for i, s := range lw.subscribers {
|
||||
if s == sub {
|
||||
lw.subscribers = append(lw.subscribers[:i], lw.subscribers[i+1:]...)
|
||||
close(sub.ch)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return sub.ch, unsub
|
||||
}
|
||||
|
||||
// tailLastLines returns the last n lines from the current buffer.
|
||||
func (lw *logWriter) tailLastLines(n int) []byte {
|
||||
lw.mu.Lock()
|
||||
defer lw.mu.Unlock()
|
||||
data := lw.buf.Bytes()
|
||||
if n <= 0 || len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
// Find the last n newlines from the end.
|
||||
count := 0
|
||||
pos := len(data)
|
||||
for pos > 0 {
|
||||
pos--
|
||||
if data[pos] == '\n' {
|
||||
count++
|
||||
if count == n+1 {
|
||||
pos++ // move past this newline
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
result := make([]byte, len(data)-pos)
|
||||
copy(result, data[pos:])
|
||||
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()
|
||||
|
||||
// Fan-out to subscribers (non-blocking).
|
||||
if len(lw.subscribers) > 0 {
|
||||
cp := make([]byte, len(p))
|
||||
copy(cp, p)
|
||||
for _, sub := range lw.subscribers {
|
||||
select {
|
||||
case sub.ch <- cp:
|
||||
default:
|
||||
// Drop if subscriber is slow to avoid blocking the logger.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write to backing file if configured.
|
||||
if lw.logFile != nil {
|
||||
needsRotation := lw.logFileSize+int64(len(p)) > logFileMaxSize
|
||||
if !needsRotation || lw.rotateLogFile() {
|
||||
if n, err := lw.logFile.Write(p); err == nil {
|
||||
lw.logFileSize += int64(n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -95,50 +328,62 @@ 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 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()
|
||||
// Persist internal logs to disk so they survive restarts.
|
||||
if path := internalLogFilePath(); path != "" {
|
||||
if err := p.internalLogWriter.setLogFile(path); err != nil {
|
||||
mainLog.Load().Warn().Err(err).Msg("could not enable persistent internal logging")
|
||||
} else {
|
||||
mainLog.Load().Notice().Msgf("internal log file: %s", path)
|
||||
}
|
||||
}
|
||||
})
|
||||
p.mu.Lock()
|
||||
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.
|
||||
@@ -154,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
|
||||
@@ -166,14 +473,22 @@ func (p *prog) logReader() (*logReader, error) {
|
||||
if wlw == nil {
|
||||
return nil, errors.New("nil internal warn log writer")
|
||||
}
|
||||
// Normal log content.
|
||||
|
||||
// If we have a persisted log file, read from disk (includes data
|
||||
// from previous runs that the in-memory buffer wouldn't have).
|
||||
current, backup := lw.logFilePaths()
|
||||
if current != "" {
|
||||
return p.logReaderFromFiles(current, backup, wlw)
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -202,3 +517,152 @@ 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) {
|
||||
var rcs []io.ReadCloser
|
||||
var totalSize int64
|
||||
|
||||
closeAll := func() {
|
||||
for _, rc := range rcs {
|
||||
rc.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Read backup file first (older entries).
|
||||
if backup != "" {
|
||||
if bf, err := os.Open(backup); err == nil {
|
||||
if st, err := bf.Stat(); err == nil {
|
||||
totalSize += st.Size()
|
||||
}
|
||||
rcs = append(rcs, bf)
|
||||
}
|
||||
}
|
||||
|
||||
// Read current file.
|
||||
cf, err := os.Open(current)
|
||||
if err != nil {
|
||||
closeAll()
|
||||
return nil, fmt.Errorf("opening current log file: %w", err)
|
||||
}
|
||||
if st, err := cf.Stat(); err == nil {
|
||||
totalSize += st.Size()
|
||||
}
|
||||
rcs = append(rcs, cf)
|
||||
|
||||
// Append warn log content from memory.
|
||||
wlw.mu.Lock()
|
||||
warnData := make([]byte, wlw.buf.Len())
|
||||
copy(warnData, wlw.buf.Bytes())
|
||||
wlw.mu.Unlock()
|
||||
|
||||
if len(warnData) > 0 {
|
||||
rcs = append(rcs, io.NopCloser(bytes.NewReader([]byte(logWriterLogEndMarker))))
|
||||
rcs = append(rcs, io.NopCloser(bytes.NewReader(warnData)))
|
||||
totalSize += int64(len(logWriterLogEndMarker) + len(warnData))
|
||||
}
|
||||
|
||||
if totalSize == 0 {
|
||||
closeAll()
|
||||
return nil, errors.New("internal log is empty")
|
||||
}
|
||||
|
||||
readers := make([]io.Reader, len(rcs))
|
||||
closers := make([]io.Closer, len(rcs))
|
||||
for i, rc := range rcs {
|
||||
readers[i] = rc
|
||||
closers[i] = rc
|
||||
}
|
||||
combined := io.MultiReader(readers...)
|
||||
lr := &logReader{
|
||||
r: &multiCloser{Reader: combined, closers: closers},
|
||||
size: totalSize,
|
||||
}
|
||||
return lr, nil
|
||||
}
|
||||
|
||||
// multiCloser wraps an io.Reader and closes multiple underlying closers.
|
||||
type multiCloser struct {
|
||||
io.Reader
|
||||
closers []io.Closer
|
||||
}
|
||||
|
||||
func (mc *multiCloser) Close() error {
|
||||
var firstErr error
|
||||
for _, c := range mc.closers {
|
||||
if err := c.Close(); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
@@ -1,9 +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) {
|
||||
@@ -83,3 +92,451 @@ func Test_logWriter_MarkerInitEnd(t *testing.T) {
|
||||
t.Fatalf("unexpected log content: %s", lw.buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
lw := newLogWriterWithSize(logWriterSize)
|
||||
if err := lw.setLogFile(path); err != nil {
|
||||
t.Fatalf("setLogFile: %v", err)
|
||||
}
|
||||
defer lw.closeLogFile()
|
||||
|
||||
msg := "hello file\n"
|
||||
lw.Write([]byte(msg))
|
||||
|
||||
// Verify data in memory buffer.
|
||||
if lw.buf.String() != msg {
|
||||
t.Fatalf("buffer: got %q, want %q", lw.buf.String(), msg)
|
||||
}
|
||||
// Verify data on disk.
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
if string(data) != msg {
|
||||
t.Fatalf("file: got %q, want %q", data, msg)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_logWriter_FileRotation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.log")
|
||||
// Use a tiny max size to trigger rotation quickly.
|
||||
lw := newLogWriterWithSize(logWriterSize)
|
||||
if err := lw.setLogFile(path); err != nil {
|
||||
t.Fatalf("setLogFile: %v", err)
|
||||
}
|
||||
defer lw.closeLogFile()
|
||||
|
||||
// Write enough to exceed logFileMaxSize.
|
||||
chunk := strings.Repeat("X", 1024) + "\n"
|
||||
written := 0
|
||||
for written < logFileMaxSize+1024 {
|
||||
lw.Write([]byte(chunk))
|
||||
written += len(chunk)
|
||||
}
|
||||
|
||||
// Backup file should exist.
|
||||
backupPath := path + ".1"
|
||||
if _, err := os.Stat(backupPath); os.IsNotExist(err) {
|
||||
t.Fatal("expected backup file to exist after rotation")
|
||||
}
|
||||
|
||||
// Current file should be smaller than max (it was rotated).
|
||||
st, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat current: %v", err)
|
||||
}
|
||||
if st.Size() > logFileMaxSize {
|
||||
t.Fatalf("current file too large after rotation: %d", st.Size())
|
||||
}
|
||||
}
|
||||
|
||||
func Test_logWriter_FilePaths(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.log")
|
||||
lw := newLogWriterWithSize(logWriterSize)
|
||||
|
||||
// No file configured.
|
||||
c, b := lw.logFilePaths()
|
||||
if c != "" || b != "" {
|
||||
t.Fatalf("expected empty paths, got %q %q", c, b)
|
||||
}
|
||||
|
||||
if err := lw.setLogFile(path); err != nil {
|
||||
t.Fatalf("setLogFile: %v", err)
|
||||
}
|
||||
defer lw.closeLogFile()
|
||||
|
||||
// Current exists, no backup yet.
|
||||
c, b = lw.logFilePaths()
|
||||
if c != path {
|
||||
t.Fatalf("current: got %q, want %q", c, path)
|
||||
}
|
||||
if b != "" {
|
||||
t.Fatalf("backup should be empty, got %q", b)
|
||||
}
|
||||
|
||||
// Create a backup file manually.
|
||||
os.WriteFile(path+".1", []byte("old"), 0600)
|
||||
_, b = lw.logFilePaths()
|
||||
if b != path+".1" {
|
||||
t.Fatalf("backup: got %q, want %q", b, path+".1")
|
||||
}
|
||||
}
|
||||
|
||||
func Test_logWriter_FileAppendOnRestart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.log")
|
||||
|
||||
// Simulate first run.
|
||||
lw1 := newLogWriterWithSize(logWriterSize)
|
||||
if err := lw1.setLogFile(path); err != nil {
|
||||
t.Fatalf("setLogFile: %v", err)
|
||||
}
|
||||
lw1.Write([]byte("run1\n"))
|
||||
lw1.closeLogFile()
|
||||
|
||||
// Simulate second run (restart) — file should be appended.
|
||||
lw2 := newLogWriterWithSize(logWriterSize)
|
||||
if err := lw2.setLogFile(path); err != nil {
|
||||
t.Fatalf("setLogFile: %v", err)
|
||||
}
|
||||
lw2.Write([]byte("run2\n"))
|
||||
lw2.closeLogFile()
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
want := "run1\nrun2\n"
|
||||
if string(data) != want {
|
||||
t.Fatalf("file: got %q, want %q", data, want)
|
||||
}
|
||||
}
|
||||
|
||||
+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)
|
||||
|
||||
+146
-71
@@ -1,72 +1,100 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"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
|
||||
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 // "", "off", "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.
|
||||
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
|
||||
@@ -82,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.
|
||||
@@ -124,68 +148,119 @@ 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.
|
||||
func pfProbeSend(host, hexPacket string) {
|
||||
packet, err := hex.DecodeString(hexPacket)
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
conn, err := net.DialTimeout("udp", net.JoinHostPort(host, "53"), time.Second)
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
defer conn.Close()
|
||||
conn.SetDeadline(time.Now().Add(time.Second))
|
||||
_, _ = conn.Write(packet)
|
||||
buf := make([]byte, 512)
|
||||
_, _ = conn.Read(buf)
|
||||
}
|
||||
|
||||
+78
-4
@@ -1,17 +1,91 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
|
||||
"github.com/Control-D-Inc/ctrld"
|
||||
)
|
||||
|
||||
var logOutput strings.Builder
|
||||
// logOutput is the log sink for the whole test binary. Tests share it with any
|
||||
// background goroutine the code under test starts (watchdogs, timers), so it
|
||||
// must tolerate concurrent writes.
|
||||
var logOutput syncBuffer
|
||||
|
||||
// syncBuffer is a strings.Builder guarded by a mutex.
|
||||
type syncBuffer struct {
|
||||
mu sync.Mutex
|
||||
sb strings.Builder
|
||||
}
|
||||
|
||||
func (b *syncBuffer) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.sb.Write(p)
|
||||
}
|
||||
|
||||
func (b *syncBuffer) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.sb.String()
|
||||
}
|
||||
|
||||
// envFakeVersionOutput makes this test binary impersonate a ctrld executable: when
|
||||
// set, the process writes the value to stdout and exits without running any test, so
|
||||
// binaryVersion() can be exercised on every platform without building or shipping a
|
||||
// fixture binary. The value envFakeVersionSilent produces no output at all, which
|
||||
// reproduces the unusable ctrld.exe_previous seen in the Firewall Mode incident.
|
||||
//
|
||||
// This must be handled before m.Run(), which is what parses the test flags: the child
|
||||
// is invoked as "<binary> --version" and would otherwise die on an unknown flag.
|
||||
const (
|
||||
envFakeVersionOutput = "CTRLD_TEST_FAKE_VERSION_OUTPUT"
|
||||
envFakeVersionSilent = "<silent>"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
l := zerolog.New(&logOutput)
|
||||
mainLog.Store(&l)
|
||||
if out := os.Getenv(envFakeVersionOutput); out != "" {
|
||||
if out != envFakeVersionSilent {
|
||||
fmt.Println(out)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// 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
|
||||
// — with positional args ("upgrade", ...). `go test` stops flag parsing at
|
||||
// the first positional arg and ignores the rest, so the child just re-runs
|
||||
// the entire suite, hits the upgrade tests again, and spawns more children:
|
||||
// a fork bomb of detached processes that stalls the host and (on Windows)
|
||||
// holds the test binary's image locked, breaking CI artifact cleanup.
|
||||
// Point it at the test binary with a no-match -test.run so any test that
|
||||
// reaches performUpgrade still exercises the cmd.Start() success path while
|
||||
// the child exits immediately without recursing.
|
||||
newUpgradeCmd = func(exe string) *exec.Cmd {
|
||||
return exec.Command(exe, "-test.run=^$")
|
||||
}
|
||||
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
+27
-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
|
||||
@@ -113,9 +120,25 @@ func (p *prog) runMetricsServer(ctx context.Context, reloadCh chan struct{}) {
|
||||
}
|
||||
|
||||
addr := p.cfg.Service.MetricsListener
|
||||
if addr != "" {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
mainLog.Load().Warn().Err(err).Msgf("Invalid metrics listener address (%s); expected host:port", addr)
|
||||
} else {
|
||||
if host == "" {
|
||||
host = "127.0.0.1"
|
||||
addr = net.JoinHostPort(host, port)
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if (ip != nil && !ip.IsLoopback()) || (ip == nil && host != "localhost") {
|
||||
mainLog.Load().Warn().Msgf("Metrics server is bound to a non-loopback address (%s). This exposes sensitive data without authentication.", addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 +153,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 +167,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()
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/vishvananda/netlink"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func (p *prog) watchLinkState(ctx context.Context) {
|
||||
ch := make(chan netlink.LinkUpdate)
|
||||
done := make(chan struct{})
|
||||
defer close(done)
|
||||
if err := netlink.LinkSubscribe(ch, done); err != nil {
|
||||
mainLog.Load().Warn().Err(err).Msg("could not subscribe link")
|
||||
return
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case lu := <-ch:
|
||||
if lu.Change == 0xFFFFFFFF {
|
||||
continue
|
||||
}
|
||||
if lu.Change&unix.IFF_UP != 0 {
|
||||
mainLog.Load().Debug().Msgf("link state changed, re-bootstrapping")
|
||||
for _, uc := range p.cfg.Upstream {
|
||||
uc.ReBootstrap()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
//go:build !linux
|
||||
|
||||
package cli
|
||||
|
||||
import "context"
|
||||
|
||||
func (p *prog) watchLinkState(ctx context.Context) {}
|
||||
@@ -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,63 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/netip"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const nrptRuleName = `CtrldCatchAll`
|
||||
|
||||
// errGPNRPTVerified marks an intercept startup failure that happened while an externally
|
||||
// managed (Group Policy) NRPT catch-all was proved - by probe, not by registry shape
|
||||
// alone - to be routing DNS to this listener. It is the difference between "intercept
|
||||
// failed but DNS still reaches ctrld" and "intercept failed and nothing is filtering",
|
||||
// which is what decides whether the interface-DNS fallback must run.
|
||||
//
|
||||
// Only the Windows path produces it, but setDNS is shared, so the sentinel and its
|
||||
// predicate live here with the other platform-neutral NRPT helpers.
|
||||
var errGPNRPTVerified = errors.New("GP-managed NRPT verified routing to ctrld")
|
||||
|
||||
// errGPNRPTIneffective marks a startup that ends with externally managed NRPT owning the
|
||||
// namespace while no probe has proved it routes to ctrld. DNS is not reaching ctrld, but
|
||||
// adapter DNS was deliberately preserved and no ctrld rule may be written beside an
|
||||
// administrator's catch-all - so this is a failed start that must not take the
|
||||
// interface-DNS fallback either.
|
||||
var errGPNRPTIneffective = errors.New("GP-managed NRPT owns the namespace but no probe reached ctrld")
|
||||
|
||||
// interceptFailedWithVerifiedExternalDNS reports whether an intercept startup failure
|
||||
// happened while externally managed DNS policy was verified to be routing to ctrld.
|
||||
func interceptFailedWithVerifiedExternalDNS(err error) bool {
|
||||
return errors.Is(err, errGPNRPTVerified)
|
||||
}
|
||||
|
||||
// interceptFailedUnderExternalDNSPolicy reports whether an intercept startup failure
|
||||
// happened while externally managed DNS policy owned the namespace, whether or not it was
|
||||
// proved to route. Either way the interface-DNS fallback must not run: adapter DNS was
|
||||
// preserved on purpose, and rewriting it would violate the policy ctrld just deferred to.
|
||||
// Only the verified case is a successful start.
|
||||
func interceptFailedUnderExternalDNSPolicy(err error) bool {
|
||||
return errors.Is(err, errGPNRPTVerified) || errors.Is(err, errGPNRPTIneffective)
|
||||
}
|
||||
|
||||
// isExternalGPCatchAll recognizes only a single catch-all namespace that is not
|
||||
// ctrld's deterministic GP key. Registry access stays in the Windows file; this
|
||||
// pure classifier is shared with host-runnable tests.
|
||||
func isExternalGPCatchAll(ruleName string, namespaces []string) bool {
|
||||
return ruleName != "" && !strings.EqualFold(ruleName, nrptRuleName) && len(namespaces) == 1 && strings.TrimSpace(namespaces[0]) == "."
|
||||
}
|
||||
|
||||
func isMatchingGPNRPTRule(ruleName string, namespaces []string, dnsServers, listenerIP string) bool {
|
||||
if !isExternalGPCatchAll(ruleName, namespaces) {
|
||||
return false
|
||||
}
|
||||
server, err := netip.ParseAddr(strings.TrimSpace(dnsServers))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
listener, err := netip.ParseAddr(strings.TrimSpace(listenerIP))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return server.Unmap() == listener.Unmap()
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsMatchingGPNRPTRule(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ruleName string
|
||||
namespaces []string
|
||||
servers string
|
||||
listener string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "exact IPv4 catch-all",
|
||||
ruleName: "{A1B2C3D4}",
|
||||
namespaces: []string{"."},
|
||||
servers: "127.0.0.1",
|
||||
listener: "127.0.0.1",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "normalized IPv4-mapped listener",
|
||||
ruleName: "{A1B2C3D4}",
|
||||
namespaces: []string{"."},
|
||||
servers: "::ffff:127.0.0.1",
|
||||
listener: "127.0.0.1",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "ctrld GP key is not external",
|
||||
ruleName: "ctrldcatchall",
|
||||
namespaces: []string{"."},
|
||||
servers: "127.0.0.1",
|
||||
listener: "127.0.0.1",
|
||||
},
|
||||
{
|
||||
name: "partial namespace",
|
||||
ruleName: "{A1B2C3D4}",
|
||||
namespaces: []string{"corp.example"},
|
||||
servers: "127.0.0.1",
|
||||
listener: "127.0.0.1",
|
||||
},
|
||||
{
|
||||
name: "multiple namespaces",
|
||||
ruleName: "{A1B2C3D4}",
|
||||
namespaces: []string{".", "corp.example"},
|
||||
servers: "127.0.0.1",
|
||||
listener: "127.0.0.1",
|
||||
},
|
||||
{
|
||||
name: "wrong listener",
|
||||
ruleName: "{A1B2C3D4}",
|
||||
namespaces: []string{"."},
|
||||
servers: "127.0.0.2",
|
||||
listener: "127.0.0.1",
|
||||
},
|
||||
{
|
||||
name: "multiple nameservers",
|
||||
ruleName: "{A1B2C3D4}",
|
||||
namespaces: []string{"."},
|
||||
servers: "127.0.0.1;127.0.0.2",
|
||||
listener: "127.0.0.1",
|
||||
},
|
||||
{
|
||||
name: "malformed nameserver",
|
||||
ruleName: "{A1B2C3D4}",
|
||||
namespaces: []string{"."},
|
||||
servers: "localhost",
|
||||
listener: "127.0.0.1",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isMatchingGPNRPTRule(tt.ruleName, tt.namespaces, tt.servers, tt.listener); got != tt.want {
|
||||
t.Fatalf("isMatchingGPNRPTRule() = %t, want %t", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsExternalGPCatchAll(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ruleName string
|
||||
namespaces []string
|
||||
want bool
|
||||
}{
|
||||
{name: "external catch-all", ruleName: "{GP-RULE}", namespaces: []string{"."}, want: true},
|
||||
{name: "ctrld key", ruleName: nrptRuleName, namespaces: []string{"."}},
|
||||
{name: "partial namespace", ruleName: "{GP-RULE}", namespaces: []string{"corp.example"}},
|
||||
{name: "multiple namespaces", ruleName: "{GP-RULE}", namespaces: []string{".", "corp.example"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isExternalGPCatchAll(tt.ruleName, tt.namespaces); got != tt.want {
|
||||
t.Fatalf("isExternalGPCatchAll() = %t, want %t", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestInterceptFailedWithVerifiedExternalDNS covers the distinction the interface-DNS
|
||||
// fallback turns on. "A GP rule exists" is not enough: if it is not actually routing and
|
||||
// intercept failed too, skipping the fallback leaves the machine with no NRPT, no WFP and
|
||||
// no adapter DNS - that is, unfiltered. Only a probe-verified route earns the skip.
|
||||
func TestInterceptFailedWithVerifiedExternalDNS(t *testing.T) {
|
||||
wfpErr := errors.New("FwpmEngineOpen0 failed: HRESULT 0x5")
|
||||
|
||||
verified := fmt.Errorf("dns intercept: WFP setup failed: %w: %w", wfpErr, errGPNRPTVerified)
|
||||
if !interceptFailedWithVerifiedExternalDNS(verified) {
|
||||
t.Error("a failure carrying errGPNRPTVerified must skip the interface-DNS fallback")
|
||||
}
|
||||
if !errors.Is(verified, wfpErr) {
|
||||
t.Error("the underlying cause must stay inspectable for logs and callers")
|
||||
}
|
||||
|
||||
if interceptFailedWithVerifiedExternalDNS(fmt.Errorf("dns intercept: WFP setup failed: %w", wfpErr)) {
|
||||
t.Error("an unverified failure must take the interface-DNS fallback rather than leave the machine unfiltered")
|
||||
}
|
||||
if interceptFailedWithVerifiedExternalDNS(nil) {
|
||||
t.Error("no error must not read as a verified external route")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//go:build windows
|
||||
|
||||
package cli
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestWFPStateNRPTPolicyOwner(t *testing.T) {
|
||||
state := &wfpState{}
|
||||
state.setNRPTPolicyOwner(nrptRuleOwnerGroupPolicy, "{GP-RULE}")
|
||||
owner, ruleName := state.nrptPolicyOwner()
|
||||
if owner != nrptRuleOwnerGroupPolicy || ruleName != "{GP-RULE}" {
|
||||
t.Fatalf("owner = %v, rule = %q", owner, ruleName)
|
||||
}
|
||||
|
||||
state.setNRPTPolicyOwner(nrptRuleOwnerCtrld, "")
|
||||
owner, ruleName = state.nrptPolicyOwner()
|
||||
if owner != nrptRuleOwnerCtrld || ruleName != "" {
|
||||
t.Fatalf("owner = %v, rule = %q", owner, ruleName)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user