mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-07-16 13:17:19 +02:00
Compare commits
153 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c43739e42d | |||
| 7f3d332b64 | |||
| 9cf8bc3b5b | |||
| 265573c744 | |||
| 5ef0a59081 | |||
| 3226c2d0e2 | |||
| eb8756bbe5 | |||
| ff14fc8ac9 | |||
| 8948fa402b | |||
| 8330049b66 | |||
| 9eb7067fbe | |||
| c1d3686f9a | |||
| 9399f4590b | |||
| a4bc23d17e | |||
| 7a8450cc40 | |||
| 5ccbf63e58 | |||
| 6c5489873b | |||
| f281466118 | |||
| 3c740b9693 | |||
| 0d183feddb | |||
| e15ca5c466 | |||
| 98ca63325f | |||
| 7b360288ed | |||
| 65d3d468f7 | |||
| 01490434a6 | |||
| a61677b6e4 | |||
| 8e2ef7ca65 | |||
| 1735d3d55b | |||
| 81aa6b237b | |||
| 8abeeea4c3 | |||
| b3c670b17e | |||
| 70b45710e7 | |||
| 2742669bc1 | |||
| a767ebdaa5 | |||
| a92d20cef8 | |||
| a8821e6d00 | |||
| a3880beec2 | |||
| d7124995d2 | |||
| 86dafc432d | |||
| ca8d07d3f5 | |||
| 2aaa78ef48 | |||
| 0f2a930cf8 | |||
| 5a6163142c | |||
| 402771bed6 | |||
| a99dcca288 | |||
| 395335162f | |||
| c56d4771de | |||
| ea48186d73 | |||
| bc71622deb | |||
| 846aaac27a | |||
| f1e49a7ee6 | |||
| 878b3d7920 | |||
| f1b93c81bc | |||
| 60dd366cc4 | |||
| e45e56c021 | |||
| 6f331f19c8 | |||
| e4ca728ef0 | |||
| 8117084d39 | |||
| e23451df37 | |||
| 43d4e1957c | |||
| ba3dd3a4b0 | |||
| 8b92dc97a3 | |||
| 9158cd7835 | |||
| 2d9603609f | |||
| e4e655414c | |||
| aacba92698 | |||
| c3c9e1a4d7 | |||
| 9a3840954b | |||
| 673308a1fe | |||
| 2cb0456265 | |||
| 9dd4183981 | |||
| aacbcad133 | |||
| 1489245f50 | |||
| 6aedc2b2d3 | |||
| 9b1f102315 | |||
| c365051732 | |||
| 6294ba4028 | |||
| 261f9483a2 | |||
| e17a538312 | |||
| 650e47a504 | |||
| f24059885f | |||
| 52cfb4c302 | |||
| 6cf754883d | |||
| 00c1e0fd76 | |||
| 4be262156f | |||
| 37ddbd90f1 | |||
| d3b01dc7e8 | |||
| 166b7f38fc | |||
| 88a297ad43 | |||
| 7040c2024a | |||
| 082f5a0fac | |||
| 7778c96f38 | |||
| 64393b7b6c | |||
| c8477fe442 | |||
| 36afb16e57 | |||
| 134561c85a | |||
| 54be78f092 | |||
| 5bc8da6470 | |||
| b187ec98a3 | |||
| 4d8e10ca0d | |||
| ed147a3362 | |||
| 38f0b84d44 | |||
| 0b4dc51c24 | |||
| f3a5fffc6f | |||
| 8959319382 | |||
| bfe6060df1 | |||
| a61cb1f5bf | |||
| 33dd720d80 | |||
| 76e602afc3 | |||
| dd930a30a1 | |||
| d81042089b | |||
| 8cc5b71c69 | |||
| d5281d5df4 | |||
| 7677c2fbbe | |||
| b510fe1af5 | |||
| 35cc8adecb | |||
| aa8af67365 | |||
| d81eef9585 | |||
| efee5b67c1 | |||
| 2d6fea19a6 | |||
| 1cd034d526 | |||
| f0d7cfaaa1 | |||
| d0830a7ba2 | |||
| ddb81b6f83 | |||
| f73a17f25d | |||
| f933664e7d | |||
| d41334c66f | |||
| 016c566307 | |||
| 48d0558103 | |||
| 6e1e9426da | |||
| 02032c8f9f | |||
| 7552f1ca7c | |||
| f573de851a | |||
| a63fa31969 | |||
| 975b465e3e | |||
| ba9057e466 | |||
| af1a6e9f3a | |||
| 38ae916068 | |||
| 719d76f641 | |||
| 2030025130 | |||
| eaa6ccc356 | |||
| 627eb23ed6 | |||
| 7ec4353d90 | |||
| abad9ef8d4 | |||
| 29b8b4277c | |||
| 95699fa4a1 | |||
| aaf31b6471 | |||
| 0e66697247 | |||
| 47c04bf0f6 | |||
| 6286a71f2a | |||
| 5ce92abf1f | |||
| 37c3331559 | |||
| f334993f79 |
@@ -9,18 +9,18 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
os: ["windows-latest", "ubuntu-latest", "macOS-latest"]
|
os: ["windows-latest", "ubuntu-latest", "macOS-latest"]
|
||||||
go: ["1.24.x"]
|
go: ["1.25.x"]
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
- uses: WillAbides/setup-go-faster@v1.8.0
|
- uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: ${{ matrix.go }}
|
go-version: ${{ matrix.go }}
|
||||||
- run: "go test -race ./..."
|
- run: "go test -race ./..."
|
||||||
- uses: dominikh/staticcheck-action@v1.3.1
|
- uses: dominikh/staticcheck-action@v1.4.0
|
||||||
with:
|
with:
|
||||||
version: "2025.1"
|
version: "2025.1.1"
|
||||||
install-go: false
|
install-go: false
|
||||||
cache-key: ${{ matrix.go }}
|
cache-key: ${{ matrix.go }}
|
||||||
|
|||||||
@@ -12,3 +12,5 @@ ctrld-*
|
|||||||
|
|
||||||
# generated file
|
# generated file
|
||||||
cmd/cli/rsrc_*.syso
|
cmd/cli/rsrc_*.syso
|
||||||
|
ctrld
|
||||||
|
ctrld.exe
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ A highly configurable DNS forwarding proxy with support for:
|
|||||||
- Multiple upstreams with fallbacks
|
- Multiple upstreams with fallbacks
|
||||||
- Multiple network policy driven DNS query steering (via network cidr, MAC address or FQDN)
|
- Multiple network policy driven DNS query steering (via network cidr, MAC address or FQDN)
|
||||||
- Policy driven domain based "split horizon" DNS with wildcard support
|
- 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
|
- LAN client discovery via DHCP, mDNS, ARP, NDP, hosts file parsing
|
||||||
- Prometheus metrics exporter
|
- Prometheus metrics exporter
|
||||||
|
|
||||||
@@ -25,50 +24,32 @@ All DNS protocols are supported, including:
|
|||||||
- `DNS-over-HTTP/3` (DOH3)
|
- `DNS-over-HTTP/3` (DOH3)
|
||||||
- `DNS-over-QUIC`
|
- `DNS-over-QUIC`
|
||||||
|
|
||||||
# Use Cases
|
## Use Cases
|
||||||
1. Use secure DNS protocols on networks and devices that don't natively support them (legacy routers, legacy OSes, TVs, smart toasters).
|
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.
|
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.
|
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.
|
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
|
## OS Support
|
||||||
- Windows (386, amd64, arm)
|
- Windows Desktop (386, amd64, arm64)
|
||||||
- Windows Server (386, amd64)
|
|
||||||
- MacOS (amd64, arm64)
|
- MacOS (amd64, arm64)
|
||||||
- Linux (386, amd64, arm, mips)
|
- Linux (386, amd64, arm, arm64, mips, mipsle, mips64)
|
||||||
- FreeBSD (386, amd64, arm)
|
- FreeBSD (386, amd64, arm, arm64)
|
||||||
- Common routers (See below)
|
|
||||||
|
|
||||||
|
## Install
|
||||||
### 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
|
|
||||||
There are several ways to download and install `ctrld`.
|
There are several ways to download and install `ctrld`.
|
||||||
|
|
||||||
## Quick Install
|
## Quick Install
|
||||||
The simplest way to download and install `ctrld` is to use the following installer command on any UNIX-like platform:
|
The simplest way to download and install `ctrld` is to use the following installer command on any UNIX-like platform:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
sh -c 'sh -c "$(curl -sL https://api.controld.com/dl)"'
|
sh -c 'sh -c "$(curl -sL https://api.controld.com/dl?version=2)"'
|
||||||
```
|
```
|
||||||
|
|
||||||
Windows user and prefer Powershell (who doesn't)? No problem, execute this command instead in administrative PowerShell:
|
Windows user and prefer Powershell (who doesn't)? No problem, execute this command instead in administrative PowerShell:
|
||||||
```shell
|
```shell
|
||||||
(Invoke-WebRequest -Uri 'https://api.controld.com/dl/ps1' -UseBasicParsing).Content | Set-Content "$env:TEMPctrld_install.ps1"; Invoke-Expression "& '$env:TEMPctrld_install.ps1'"
|
(Invoke-WebRequest -Uri 'https://api.controld.com/dl/ps1?version=2' -UseBasicParsing).Content | Set-Content "$env:TEMP\ctrld_install.ps1"; Invoke-Expression "& '$env:TEMP\ctrld_install.ps1'"
|
||||||
```
|
```
|
||||||
|
|
||||||
Or you can pull and run a Docker container from [Docker Hub](https://hub.docker.com/r/controldns/ctrld)
|
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.
|
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
|
## Build
|
||||||
Lastly, you can build `ctrld` from source which requires `go1.21+`:
|
Lastly, you can build `ctrld` from source which requires `go1.24+`:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
go build ./cmd/ctrld
|
go build ./cmd/ctrld
|
||||||
@@ -99,8 +80,8 @@ docker build -t controldns/ctrld . -f docker/Dockerfile
|
|||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
# Usage
|
## Usage
|
||||||
The cli is self documenting, so free free to run `--help` on any sub-command to get specific usages.
|
The cli is self documenting, so feel free to run `--help` on any sub-command to get specific usages.
|
||||||
|
|
||||||
## Arguments
|
## Arguments
|
||||||
```
|
```
|
||||||
@@ -130,7 +111,7 @@ Available Commands:
|
|||||||
Flags:
|
Flags:
|
||||||
-h, --help help for ctrld
|
-h, --help help for ctrld
|
||||||
-s, --silent do not write any log output
|
-s, --silent do not write any log output
|
||||||
-v, --verbose count verbose log output, "-v" basic logging, "-vv" debug level logging
|
-v, --verbose count verbose log output, "-v" basic logging, "-vv" debug logging
|
||||||
--version version for ctrld
|
--version version for ctrld
|
||||||
|
|
||||||
Use "ctrld [command] --help" for more information about a command.
|
Use "ctrld [command] --help" for more information about a command.
|
||||||
@@ -161,9 +142,7 @@ You can then run a test query using a DNS client, for example, `dig`:
|
|||||||
If `verify.controld.com` resolves, you're successfully using the default Control D upstream. From here, you can start editing the config file that was generated. To enforce a new config, restart the server.
|
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
|
## 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.
|
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.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
### Command
|
### Command
|
||||||
|
|
||||||
@@ -196,11 +175,11 @@ Linux or Macos
|
|||||||
sudo ctrld service start
|
sudo ctrld service start
|
||||||
```
|
```
|
||||||
|
|
||||||
# Configuration
|
## Configuration
|
||||||
`ctrld` can be configured in variety of different ways, which include: API, local config file or via cli launch args.
|
`ctrld` can be configured in variety of different ways, which include: API, local config file or via cli launch args.
|
||||||
|
|
||||||
## API Based Auto Configuration
|
## 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.
|
The following command will use your own personal Control D Device resolver, and start the application in service mode. Your resolver ID is displayed on the "Show Resolvers" screen for the relevant Control D Endpoint.
|
||||||
|
|
||||||
@@ -217,7 +196,7 @@ sudo ctrld start --cd abcd1234
|
|||||||
Once you run the above command, the following things will happen:
|
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
|
- 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
|
- 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
|
- All DNS queries will be sent to the listener
|
||||||
|
|
||||||
## Manual Configuration
|
## Manual Configuration
|
||||||
@@ -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`
|
- 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`
|
- 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.exe start --intercept-mode dns --cd RESOLVER_ID_HERE
|
||||||
|
```
|
||||||
|
|
||||||
|
macOS
|
||||||
|
```shell
|
||||||
|
sudo ctrld 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.exe start --intercept-mode off
|
||||||
|
```
|
||||||
|
|
||||||
|
macOS
|
||||||
|
```shell
|
||||||
|
sudo ctrld 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
|
## Contributing
|
||||||
See [Contribution Guideline](./docs/contributing.md)
|
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.
|
// addExtraSplitDnsRule adds split DNS rule if present.
|
||||||
func addExtraSplitDnsRule(_ *ctrld.Config) bool { return false }
|
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
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"strings"
|
"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"
|
||||||
|
"github.com/Control-D-Inc/ctrld/internal/system"
|
||||||
)
|
)
|
||||||
|
|
||||||
// addExtraSplitDnsRule adds split DNS rule for domain if it's part of active directory.
|
// addExtraSplitDnsRule adds split DNS rule for domain if it's part of active directory.
|
||||||
func addExtraSplitDnsRule(cfg *ctrld.Config) bool {
|
func addExtraSplitDnsRule(cfg *ctrld.Config) bool {
|
||||||
domain, err := getActiveDirectoryDomain()
|
domain, err := system.GetActiveDirectoryDomain()
|
||||||
if err != nil {
|
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
|
return false
|
||||||
}
|
}
|
||||||
if domain == "" {
|
if domain == "" {
|
||||||
mainLog.Load().Debug().Msg("no active directory domain found")
|
mainLog.Load().Debug().Msg("No active directory domain found")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// Network rules are lowercase during toml config marshaling,
|
// 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 {
|
for _, rule := range lc.Policy.Rules {
|
||||||
if _, ok := rule[domain]; ok {
|
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
|
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{}})
|
lc.Policy.Rules = append(lc.Policy.Rules, ctrld.Rule{domain: []string{}})
|
||||||
}
|
}
|
||||||
return true
|
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"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Control-D-Inc/ctrld"
|
|
||||||
"github.com/Control-D-Inc/ctrld/testhelper"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"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) {
|
func Test_getActiveDirectoryDomain(t *testing.T) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
domain, err := getActiveDirectoryDomain()
|
domain, err := system.GetActiveDirectoryDomain()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
+453
-297
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
-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,87 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InterfacesCommand handles interfaces-related operations
|
||||||
|
type InterfacesCommand struct{}
|
||||||
|
|
||||||
|
// NewInterfacesCommand creates a new interfaces command handler
|
||||||
|
func NewInterfacesCommand() (*InterfacesCommand, error) {
|
||||||
|
return &InterfacesCommand{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListInterfaces lists all network interfaces
|
||||||
|
func (ic *InterfacesCommand) ListInterfaces(cmd *cobra.Command, args []string) error {
|
||||||
|
withEachPhysicalInterfaces("", "Interface list", func(i *net.Interface) error {
|
||||||
|
fmt.Printf("Index : %d\n", i.Index)
|
||||||
|
fmt.Printf("Name : %s\n", i.Name)
|
||||||
|
var status string
|
||||||
|
if i.Flags&net.FlagUp != 0 {
|
||||||
|
status = "Up"
|
||||||
|
} else {
|
||||||
|
status = "Down"
|
||||||
|
}
|
||||||
|
fmt.Printf("Status: %s\n", status)
|
||||||
|
addrs, _ := i.Addrs()
|
||||||
|
for i, ipaddr := range addrs {
|
||||||
|
if i == 0 {
|
||||||
|
fmt.Printf("Addrs : %v\n", ipaddr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Printf(" %v\n", ipaddr)
|
||||||
|
}
|
||||||
|
nss, err := currentStaticDNS(i)
|
||||||
|
if err != nil {
|
||||||
|
mainLog.Load().Warn().Err(err).Msg("Failed to get DNS")
|
||||||
|
}
|
||||||
|
if len(nss) == 0 {
|
||||||
|
nss = currentDNS(i)
|
||||||
|
}
|
||||||
|
for i, dns := range nss {
|
||||||
|
if i == 0 {
|
||||||
|
fmt.Printf("DNS : %s\n", dns)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Printf(" : %s\n", dns)
|
||||||
|
}
|
||||||
|
println()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitInterfacesCmd creates the interfaces command with proper logic
|
||||||
|
func InitInterfacesCmd(_ *cobra.Command) *cobra.Command {
|
||||||
|
listInterfacesCmd := &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List network interfaces",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
PreRun: func(cmd *cobra.Command, args []string) {
|
||||||
|
checkHasElevatedPrivilege()
|
||||||
|
},
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
ic, err := NewInterfacesCommand()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ic.ListInterfaces(cmd, args)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
interfacesCmd := &cobra.Command{
|
||||||
|
Use: "interfaces",
|
||||||
|
Short: "Manage network interfaces",
|
||||||
|
Args: cobra.OnlyValidArgs,
|
||||||
|
ValidArgs: []string{
|
||||||
|
listInterfacesCmd.Use,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
interfacesCmd.AddCommand(listInterfacesCmd)
|
||||||
|
|
||||||
|
return interfacesCmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/docker/go-units"
|
||||||
|
"github.com/kardianos/service"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LogCommand handles log-related operations
|
||||||
|
type LogCommand struct {
|
||||||
|
controlClient *controlClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLogCommand creates a new log command handler
|
||||||
|
func NewLogCommand() (*LogCommand, error) {
|
||||||
|
dir, err := socketDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to find ctrld home dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cc := newControlClient(filepath.Join(dir, ctrldControlUnixSock))
|
||||||
|
return &LogCommand{
|
||||||
|
controlClient: cc,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// warnRuntimeLoggingNotEnabled logs a warning about runtime logging not being enabled
|
||||||
|
func (lc *LogCommand) warnRuntimeLoggingNotEnabled() {
|
||||||
|
mainLog.Load().Warn().Msg("Runtime debug logging is not enabled")
|
||||||
|
mainLog.Load().Warn().Msg(`ctrld may be running without "--cd" flag or logging is already enabled`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendLogs sends runtime debug logs to ControlD
|
||||||
|
func (lc *LogCommand) SendLogs(cmd *cobra.Command, args []string) error {
|
||||||
|
sc := NewServiceCommand()
|
||||||
|
s, _, err := sc.initializeServiceManager()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err := s.Status()
|
||||||
|
if errors.Is(err, service.ErrNotInstalled) {
|
||||||
|
mainLog.Load().Warn().Msg("Service not installed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if status == service.StatusStopped {
|
||||||
|
mainLog.Load().Warn().Msg("Service is not running")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := lc.controlClient.post(sendLogsPath, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to send logs: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
switch resp.StatusCode {
|
||||||
|
case http.StatusServiceUnavailable:
|
||||||
|
mainLog.Load().Warn().Msg("Runtime logs could only be sent once per minute")
|
||||||
|
return nil
|
||||||
|
case http.StatusMovedPermanently:
|
||||||
|
lc.warnRuntimeLoggingNotEnabled()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var logs logSentResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&logs); err != nil {
|
||||||
|
return fmt.Errorf("failed to decode sent logs result: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if logs.Error != "" {
|
||||||
|
return fmt.Errorf("failed to send logs: %s", logs.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
mainLog.Load().Notice().Msgf("Sent %s of runtime logs", units.BytesSize(float64(logs.Size)))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ViewLogs views current runtime debug logs
|
||||||
|
func (lc *LogCommand) ViewLogs(cmd *cobra.Command, args []string) error {
|
||||||
|
sc := NewServiceCommand()
|
||||||
|
s, _, err := sc.initializeServiceManager()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err := s.Status()
|
||||||
|
if errors.Is(err, service.ErrNotInstalled) {
|
||||||
|
mainLog.Load().Warn().Msg("Service not installed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if status == service.StatusStopped {
|
||||||
|
mainLog.Load().Warn().Msg("Service is not running")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := lc.controlClient.post(viewLogsPath, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get logs: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
switch resp.StatusCode {
|
||||||
|
case http.StatusMovedPermanently:
|
||||||
|
lc.warnRuntimeLoggingNotEnabled()
|
||||||
|
return nil
|
||||||
|
case http.StatusBadRequest:
|
||||||
|
mainLog.Load().Warn().Msg("Runtime debug logs are not available")
|
||||||
|
buf, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
mainLog.Load().Fatal().Err(err).Msg("Failed to read response body")
|
||||||
|
}
|
||||||
|
mainLog.Load().Warn().Msgf("ctrld process response:\n\n%s\n", string(buf))
|
||||||
|
return nil
|
||||||
|
case http.StatusOK:
|
||||||
|
}
|
||||||
|
|
||||||
|
var logs logViewResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&logs); err != nil {
|
||||||
|
return fmt.Errorf("failed to decode view logs result: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Print(logs.Data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TailLogs streams live runtime debug logs to the terminal
|
||||||
|
func (lc *LogCommand) TailLogs(cmd *cobra.Command, args []string) error {
|
||||||
|
sc := NewServiceCommand()
|
||||||
|
s, _, err := sc.initializeServiceManager()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err := s.Status()
|
||||||
|
if errors.Is(err, service.ErrNotInstalled) {
|
||||||
|
mainLog.Load().Warn().Msg("Service not installed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if status == service.StatusStopped {
|
||||||
|
mainLog.Load().Warn().Msg("Service is not running")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tailLines, _ := cmd.Flags().GetInt("lines")
|
||||||
|
tailPath := fmt.Sprintf("%s?lines=%d", tailLogsPath, tailLines)
|
||||||
|
resp, err := lc.controlClient.postStream(tailPath, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect for log tailing: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
switch resp.StatusCode {
|
||||||
|
case http.StatusMovedPermanently:
|
||||||
|
lc.warnRuntimeLoggingNotEnabled()
|
||||||
|
return nil
|
||||||
|
case http.StatusOK:
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unexpected response status: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up signal handling for clean shutdown.
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
// Stream output to stdout.
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
for {
|
||||||
|
n, readErr := resp.Body.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
os.Stdout.Write(buf[:n])
|
||||||
|
}
|
||||||
|
if readErr != nil {
|
||||||
|
if readErr != io.EOF {
|
||||||
|
mainLog.Load().Error().Err(readErr).Msg("Error reading log stream")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
if errors.Is(ctx.Err(), context.Canceled) {
|
||||||
|
msg := fmt.Sprintf("\nexiting: %s\n", context.Cause(ctx).Error())
|
||||||
|
os.Stdout.WriteString(msg)
|
||||||
|
}
|
||||||
|
case <-done:
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitLogCmd creates the log command with proper logic
|
||||||
|
func InitLogCmd(rootCmd *cobra.Command) *cobra.Command {
|
||||||
|
lc, err := NewLogCommand()
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("failed to create log command: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
logSendCmd := &cobra.Command{
|
||||||
|
Use: "send",
|
||||||
|
Short: "Send runtime debug logs to ControlD",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
PreRun: func(cmd *cobra.Command, args []string) {
|
||||||
|
checkHasElevatedPrivilege()
|
||||||
|
},
|
||||||
|
RunE: lc.SendLogs,
|
||||||
|
}
|
||||||
|
|
||||||
|
logViewCmd := &cobra.Command{
|
||||||
|
Use: "view",
|
||||||
|
Short: "View current runtime debug logs",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
PreRun: func(cmd *cobra.Command, args []string) {
|
||||||
|
checkHasElevatedPrivilege()
|
||||||
|
},
|
||||||
|
RunE: lc.ViewLogs,
|
||||||
|
}
|
||||||
|
|
||||||
|
logTailCmd := &cobra.Command{
|
||||||
|
Use: "tail",
|
||||||
|
Short: "Tail live runtime debug logs",
|
||||||
|
Long: "Stream live runtime debug logs to the terminal, similar to tail -f. Press Ctrl+C to stop.",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
PreRun: func(cmd *cobra.Command, args []string) {
|
||||||
|
checkHasElevatedPrivilege()
|
||||||
|
},
|
||||||
|
RunE: lc.TailLogs,
|
||||||
|
}
|
||||||
|
logTailCmd.Flags().IntP("lines", "n", 10, "Number of historical lines to show on connect")
|
||||||
|
|
||||||
|
logCmd := &cobra.Command{
|
||||||
|
Use: "log",
|
||||||
|
Short: "Manage runtime debug logs",
|
||||||
|
Args: cobra.OnlyValidArgs,
|
||||||
|
ValidArgs: []string{
|
||||||
|
logSendCmd.Use,
|
||||||
|
logViewCmd.Use,
|
||||||
|
logTailCmd.Use,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
logCmd.AddCommand(logSendCmd)
|
||||||
|
logCmd.AddCommand(logViewCmd)
|
||||||
|
logCmd.AddCommand(logTailCmd)
|
||||||
|
rootCmd.AddCommand(logCmd)
|
||||||
|
|
||||||
|
return logCmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/Control-D-Inc/ctrld"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RunCommand handles run-related operations
|
||||||
|
type RunCommand struct {
|
||||||
|
// Add any dependencies here if needed in the future
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRunCommand creates a new run command handler
|
||||||
|
func NewRunCommand() *RunCommand {
|
||||||
|
return &RunCommand{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run implements the logic for the run command
|
||||||
|
func (rc *RunCommand) Run(cmd *cobra.Command, args []string) {
|
||||||
|
RunCobraCommand(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitRunCmd creates the run command with proper logic
|
||||||
|
func InitRunCmd(rootCmd *cobra.Command) *cobra.Command {
|
||||||
|
rc := NewRunCommand()
|
||||||
|
|
||||||
|
runCmd := &cobra.Command{
|
||||||
|
Use: "run",
|
||||||
|
Short: "Run the DNS proxy server",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
Run: rc.Run,
|
||||||
|
}
|
||||||
|
runCmd.Flags().BoolVarP(&daemon, "daemon", "d", false, "Run as daemon")
|
||||||
|
runCmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
|
||||||
|
runCmd.Flags().StringVarP(&configBase64, "base64_config", "", "", "Base64 encoded config")
|
||||||
|
runCmd.Flags().StringVarP(&listenAddress, "listen", "", "", "Listener address and port, in format: address:port")
|
||||||
|
runCmd.Flags().StringVarP(&primaryUpstream, "primary_upstream", "", "", "Primary upstream endpoint")
|
||||||
|
runCmd.Flags().StringVarP(&secondaryUpstream, "secondary_upstream", "", "", "Secondary upstream endpoint")
|
||||||
|
runCmd.Flags().StringSliceVarP(&domains, "domains", "", nil, "List of domain to apply in a split DNS policy")
|
||||||
|
runCmd.Flags().StringVarP(&logPath, "log", "", "", "Path to log file")
|
||||||
|
runCmd.Flags().IntVarP(&cacheSize, "cache_size", "", 0, "Enable cache with size items")
|
||||||
|
runCmd.Flags().StringVarP(&cdUID, cdUidFlagName, "", "", "Control D resolver uid")
|
||||||
|
runCmd.Flags().StringVarP(&cdOrg, cdOrgFlagName, "", "", "Control D provision token")
|
||||||
|
runCmd.Flags().StringVarP(&customHostname, customHostnameFlagName, "", "", "Custom hostname passed to ControlD API")
|
||||||
|
runCmd.Flags().BoolVarP(&cdDev, "dev", "", false, "Use Control D dev resolver/domain")
|
||||||
|
_ = runCmd.Flags().MarkHidden("dev")
|
||||||
|
runCmd.Flags().StringVarP(&homedir, "homedir", "", "", "")
|
||||||
|
_ = runCmd.Flags().MarkHidden("homedir")
|
||||||
|
runCmd.Flags().StringVarP(&iface, "iface", "", "", `Update DNS setting for iface, "auto" means the default interface gateway`)
|
||||||
|
_ = runCmd.Flags().MarkHidden("iface")
|
||||||
|
runCmd.Flags().StringVarP(&cdUpstreamProto, "proto", "", ctrld.ResolverTypeDOH, `Control D upstream type, either "doh" or "doh3"`)
|
||||||
|
runCmd.Flags().BoolVarP(&rfc1918, "rfc1918", "", false, "Listen on RFC1918 addresses when 127.0.0.1 is the only listener")
|
||||||
|
runCmd.Flags().StringVarP(&interceptMode, "intercept-mode", "", "", "OS-level DNS interception mode: 'dns' (with VPN split routing) or 'hard' (all DNS through ctrld, no VPN split routing)")
|
||||||
|
runCmd.Flags().StringVarP(&firewallMode, "firewall-mode", "", "off", "DNS-resolved IP allowlist: 'on' blocks connections to IPs not resolved by ctrld, 'off' allows all")
|
||||||
|
|
||||||
|
runCmd.FParseErrWhitelist = cobra.FParseErrWhitelist{UnknownFlags: true}
|
||||||
|
rootCmd.AddCommand(runCmd)
|
||||||
|
|
||||||
|
return runCmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/kardianos/service"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// filterEmptyStrings removes empty strings from a slice
|
||||||
|
// This is used to clean up command line arguments and configuration values
|
||||||
|
func filterEmptyStrings(slice []string) []string {
|
||||||
|
var result []string
|
||||||
|
for _, s := range slice {
|
||||||
|
if s != "" {
|
||||||
|
result = append(result, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceCommand handles service-related operations
|
||||||
|
// This encapsulates all service management functionality for the CLI
|
||||||
|
type ServiceCommand struct {
|
||||||
|
serviceManager *ServiceManager
|
||||||
|
}
|
||||||
|
|
||||||
|
// initializeServiceManager creates a service manager with default configuration
|
||||||
|
// This sets up the basic service infrastructure needed for all service operations
|
||||||
|
func (sc *ServiceCommand) initializeServiceManager() (service.Service, *prog, error) {
|
||||||
|
svcConfig := sc.createServiceConfig()
|
||||||
|
return sc.initializeServiceManagerWithServiceConfig(svcConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// initializeServiceManagerWithServiceConfig creates a service manager with the given configuration
|
||||||
|
// This allows for custom service configuration while maintaining the same initialization pattern
|
||||||
|
func (sc *ServiceCommand) initializeServiceManagerWithServiceConfig(svcConfig *service.Config) (service.Service, *prog, error) {
|
||||||
|
p := &prog{}
|
||||||
|
|
||||||
|
s, err := sc.newService(p, svcConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to create service: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sc.serviceManager = &ServiceManager{prog: p, svc: s}
|
||||||
|
return s, p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newService creates a new service instance using the provided program and configuration.
|
||||||
|
// This abstracts the service creation process for different operating systems
|
||||||
|
func (sc *ServiceCommand) newService(p *prog, svcConfig *service.Config) (service.Service, error) {
|
||||||
|
s, err := newService(p, svcConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create service: %w", err)
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServiceCommand creates a new service command handler
|
||||||
|
// This provides a clean factory method for creating service command instances
|
||||||
|
func NewServiceCommand() *ServiceCommand {
|
||||||
|
return &ServiceCommand{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createServiceConfig creates a properly initialized service configuration
|
||||||
|
// This ensures consistent service naming and description across all platforms
|
||||||
|
func (sc *ServiceCommand) createServiceConfig() *service.Config {
|
||||||
|
return &service.Config{
|
||||||
|
Name: ctrldServiceName,
|
||||||
|
DisplayName: "Control-D Helper Service",
|
||||||
|
Description: "A highly configurable, multi-protocol DNS forwarding proxy",
|
||||||
|
Option: service.KeyValue{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitServiceCmd creates the service command with proper logic and aliases
|
||||||
|
// This sets up all service-related subcommands with appropriate permissions and flags
|
||||||
|
func InitServiceCmd(rootCmd *cobra.Command) *cobra.Command {
|
||||||
|
// Create service command handlers
|
||||||
|
sc := NewServiceCommand()
|
||||||
|
|
||||||
|
startCmd, startCmdAlias := createStartCommands(sc)
|
||||||
|
rootCmd.AddCommand(startCmdAlias)
|
||||||
|
|
||||||
|
// Stop command
|
||||||
|
stopCmd := &cobra.Command{
|
||||||
|
Use: "stop",
|
||||||
|
Short: "Stop the ctrld service",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
PreRun: func(cmd *cobra.Command, args []string) {
|
||||||
|
checkHasElevatedPrivilege()
|
||||||
|
},
|
||||||
|
RunE: sc.Stop,
|
||||||
|
}
|
||||||
|
stopCmd.Flags().StringVarP(&iface, "iface", "", "", `Reset DNS setting for iface, "auto" means the default interface gateway`)
|
||||||
|
stopCmd.Flags().Int64VarP(&deactivationPin, "pin", "", defaultDeactivationPin, `Pin code for stopping ctrld`)
|
||||||
|
_ = stopCmd.Flags().MarkHidden("pin")
|
||||||
|
|
||||||
|
// Restart command
|
||||||
|
restartCmd := &cobra.Command{
|
||||||
|
Use: "restart",
|
||||||
|
Short: "Restart the ctrld service",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
PreRun: func(cmd *cobra.Command, args []string) {
|
||||||
|
checkHasElevatedPrivilege()
|
||||||
|
},
|
||||||
|
RunE: sc.Restart,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status command
|
||||||
|
statusCmd := &cobra.Command{
|
||||||
|
Use: "status",
|
||||||
|
Short: "Show status of the ctrld service",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: sc.Status,
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "darwin" {
|
||||||
|
// On darwin, running status command without privileges may return wrong information.
|
||||||
|
statusCmd.PreRun = func(cmd *cobra.Command, args []string) {
|
||||||
|
checkHasElevatedPrivilege()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload command
|
||||||
|
reloadCmd := &cobra.Command{
|
||||||
|
Use: "reload",
|
||||||
|
Short: "Reload the ctrld service",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
PreRun: func(cmd *cobra.Command, args []string) {
|
||||||
|
checkHasElevatedPrivilege()
|
||||||
|
},
|
||||||
|
RunE: sc.Reload,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uninstall command
|
||||||
|
uninstallCmd := &cobra.Command{
|
||||||
|
Use: "uninstall",
|
||||||
|
Short: "Stop and uninstall the ctrld service",
|
||||||
|
Long: `Stop and uninstall the ctrld service.
|
||||||
|
|
||||||
|
NOTE: Uninstalling will set DNS to values provided by DHCP.`,
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
PreRun: func(cmd *cobra.Command, args []string) {
|
||||||
|
checkHasElevatedPrivilege()
|
||||||
|
},
|
||||||
|
RunE: sc.Uninstall,
|
||||||
|
}
|
||||||
|
uninstallCmd.Flags().StringVarP(&iface, "iface", "", "", `Reset DNS setting for iface, "auto" means the default interface gateway`)
|
||||||
|
uninstallCmd.Flags().Int64VarP(&deactivationPin, "pin", "", defaultDeactivationPin, `Pin code for stopping ctrld`)
|
||||||
|
_ = uninstallCmd.Flags().MarkHidden("pin")
|
||||||
|
uninstallCmd.Flags().BoolVarP(&cleanup, "cleanup", "", false, `Removing ctrld binary and config files`)
|
||||||
|
|
||||||
|
// Interfaces command - use the existing InitInterfacesCmd function
|
||||||
|
interfacesCmd := InitInterfacesCmd(rootCmd)
|
||||||
|
|
||||||
|
stopCmdAlias := &cobra.Command{
|
||||||
|
PreRun: func(cmd *cobra.Command, args []string) {
|
||||||
|
checkHasElevatedPrivilege()
|
||||||
|
},
|
||||||
|
Use: "stop",
|
||||||
|
Short: "Quick stop service and remove DNS from interface",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if !cmd.Flags().Changed("iface") {
|
||||||
|
os.Args = append(os.Args, "--iface="+ifaceStartStop)
|
||||||
|
}
|
||||||
|
iface = ifaceStartStop
|
||||||
|
return stopCmd.RunE(cmd, args)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
stopCmdAlias.Flags().StringVarP(&ifaceStartStop, "iface", "", "auto", `Reset DNS setting for iface, "auto" means the default interface gateway`)
|
||||||
|
stopCmdAlias.Flags().AddFlagSet(stopCmd.Flags())
|
||||||
|
rootCmd.AddCommand(stopCmdAlias)
|
||||||
|
|
||||||
|
// Create aliases for other service commands
|
||||||
|
restartCmdAlias := &cobra.Command{
|
||||||
|
PreRun: func(cmd *cobra.Command, args []string) {
|
||||||
|
checkHasElevatedPrivilege()
|
||||||
|
},
|
||||||
|
Use: "restart",
|
||||||
|
Short: "Restart the ctrld service",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return restartCmd.RunE(cmd, args)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
rootCmd.AddCommand(restartCmdAlias)
|
||||||
|
|
||||||
|
reloadCmdAlias := &cobra.Command{
|
||||||
|
PreRun: func(cmd *cobra.Command, args []string) {
|
||||||
|
checkHasElevatedPrivilege()
|
||||||
|
},
|
||||||
|
Use: "reload",
|
||||||
|
Short: "Reload the ctrld service",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return reloadCmd.RunE(cmd, args)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
rootCmd.AddCommand(reloadCmdAlias)
|
||||||
|
|
||||||
|
statusCmdAlias := &cobra.Command{
|
||||||
|
Use: "status",
|
||||||
|
Short: "Show status of the ctrld service",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: statusCmd.RunE,
|
||||||
|
}
|
||||||
|
rootCmd.AddCommand(statusCmdAlias)
|
||||||
|
|
||||||
|
uninstallCmdAlias := &cobra.Command{
|
||||||
|
PreRun: func(cmd *cobra.Command, args []string) {
|
||||||
|
checkHasElevatedPrivilege()
|
||||||
|
},
|
||||||
|
Use: "uninstall",
|
||||||
|
Short: "Stop and uninstall the ctrld service",
|
||||||
|
Long: `Stop and uninstall the ctrld service.
|
||||||
|
|
||||||
|
NOTE: Uninstalling will set DNS to values provided by DHCP.`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if !cmd.Flags().Changed("iface") {
|
||||||
|
os.Args = append(os.Args, "--iface="+ifaceStartStop)
|
||||||
|
}
|
||||||
|
iface = ifaceStartStop
|
||||||
|
return uninstallCmd.RunE(cmd, args)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
uninstallCmdAlias.Flags().StringVarP(&ifaceStartStop, "iface", "", "auto", `Reset DNS setting for iface, "auto" means the default interface gateway`)
|
||||||
|
uninstallCmdAlias.Flags().AddFlagSet(uninstallCmd.Flags())
|
||||||
|
rootCmd.AddCommand(uninstallCmdAlias)
|
||||||
|
|
||||||
|
// Create service command
|
||||||
|
serviceCmd := &cobra.Command{
|
||||||
|
Use: "service",
|
||||||
|
Short: "Manage ctrld service",
|
||||||
|
Args: cobra.OnlyValidArgs,
|
||||||
|
}
|
||||||
|
serviceCmd.ValidArgs = make([]string, 7)
|
||||||
|
serviceCmd.ValidArgs[0] = startCmd.Use
|
||||||
|
serviceCmd.ValidArgs[1] = stopCmd.Use
|
||||||
|
serviceCmd.ValidArgs[2] = restartCmd.Use
|
||||||
|
serviceCmd.ValidArgs[3] = reloadCmd.Use
|
||||||
|
serviceCmd.ValidArgs[4] = statusCmd.Use
|
||||||
|
serviceCmd.ValidArgs[5] = uninstallCmd.Use
|
||||||
|
serviceCmd.ValidArgs[6] = interfacesCmd.Use
|
||||||
|
|
||||||
|
serviceCmd.AddCommand(startCmd)
|
||||||
|
serviceCmd.AddCommand(stopCmd)
|
||||||
|
serviceCmd.AddCommand(restartCmd)
|
||||||
|
serviceCmd.AddCommand(reloadCmd)
|
||||||
|
serviceCmd.AddCommand(statusCmd)
|
||||||
|
serviceCmd.AddCommand(uninstallCmd)
|
||||||
|
serviceCmd.AddCommand(interfacesCmd)
|
||||||
|
|
||||||
|
rootCmd.AddCommand(serviceCmd)
|
||||||
|
|
||||||
|
return serviceCmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// validInterceptMode reports whether the given value is a recognized --intercept-mode.
|
||||||
|
// This is the single source of truth for mode validation — used by the early start
|
||||||
|
// command check, the runtime validation in prog.go, and onlyInterceptFlags below.
|
||||||
|
// Add new modes here to have them recognized everywhere.
|
||||||
|
func validInterceptMode(mode string) bool {
|
||||||
|
switch mode {
|
||||||
|
case "off", "dns", "hard":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// validFirewallMode reports whether the given value is a recognized --firewall-mode.
|
||||||
|
func validFirewallMode(mode string) bool {
|
||||||
|
switch mode {
|
||||||
|
case "off", "on":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// onlyInterceptFlags reports whether args contain only intercept mode
|
||||||
|
// flags (--intercept-mode <value>) and flags that are auto-added by the
|
||||||
|
// start command alias (--iface). This is used to detect "ctrld start --intercept-mode dns"
|
||||||
|
// (or "off" to disable) on an existing installation, where the intent is to modify the
|
||||||
|
// intercept flag on the existing service without replacing other arguments.
|
||||||
|
//
|
||||||
|
// Note: the startCmdAlias appends "--iface=auto" to os.Args when --iface isn't
|
||||||
|
// explicitly provided, so we must allow it here.
|
||||||
|
func onlyInterceptFlags(args []string) bool {
|
||||||
|
hasIntercept := false
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
arg := args[i]
|
||||||
|
switch {
|
||||||
|
case arg == "--intercept-mode":
|
||||||
|
// Next arg must be a valid mode value.
|
||||||
|
if i+1 < len(args) && validInterceptMode(args[i+1]) {
|
||||||
|
hasIntercept = true
|
||||||
|
i++ // skip the value
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(arg, "--intercept-mode="):
|
||||||
|
val := strings.TrimPrefix(arg, "--intercept-mode=")
|
||||||
|
if validInterceptMode(val) {
|
||||||
|
hasIntercept = true
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
case arg == "--iface="+autoIface || arg == "--iface" || arg == autoIface:
|
||||||
|
// Auto-added by startCmdAlias or its value; safe to ignore.
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hasIntercept
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kardianos/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dialSocketControlServerTimeout is the default timeout to wait when ping control server.
|
||||||
|
const dialSocketControlServerTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
// ServiceManager handles service operations
|
||||||
|
type ServiceManager struct {
|
||||||
|
prog *prog
|
||||||
|
svc service.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServiceManager creates a new service manager
|
||||||
|
func NewServiceManager() (*ServiceManager, error) {
|
||||||
|
p := &prog{}
|
||||||
|
|
||||||
|
// Create a proper service configuration
|
||||||
|
svcConfig := &service.Config{
|
||||||
|
Name: ctrldServiceName,
|
||||||
|
DisplayName: "Control-D Helper Service",
|
||||||
|
Description: "A highly configurable, multi-protocol DNS forwarding proxy",
|
||||||
|
Option: service.KeyValue{},
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := newService(p, svcConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create service: %w", err)
|
||||||
|
}
|
||||||
|
return &ServiceManager{prog: p, svc: s}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status returns the current service status
|
||||||
|
func (sm *ServiceManager) Status() (service.Status, error) {
|
||||||
|
return sm.svc.Status()
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/kardianos/service"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reload implements the logic from cmdReload.Run
|
||||||
|
func (sc *ServiceCommand) Reload(cmd *cobra.Command, args []string) error {
|
||||||
|
logger := mainLog.Load()
|
||||||
|
logger.Debug().Msg("Service reload command started")
|
||||||
|
|
||||||
|
s, _, err := sc.initializeServiceManager()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error().Err(err).Msg("Failed to initialize service manager")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err := s.Status()
|
||||||
|
if errors.Is(err, service.ErrNotInstalled) {
|
||||||
|
logger.Warn().Msg("Service not installed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if status == service.StatusStopped {
|
||||||
|
logger.Warn().Msg("Service is not running")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
dir, err := socketDir()
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatal().Err(err).Msg("Failed to find ctrld home dir")
|
||||||
|
}
|
||||||
|
|
||||||
|
cc := newControlClient(filepath.Join(dir, ctrldControlUnixSock))
|
||||||
|
resp, err := cc.post(reloadPath, nil)
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatal().Err(err).Msg("Failed to send reload signal to ctrld")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
switch resp.StatusCode {
|
||||||
|
case http.StatusOK:
|
||||||
|
logger.Notice().Msg("Service reloaded")
|
||||||
|
case http.StatusCreated:
|
||||||
|
logger.Warn().Msg("Service was reloaded, but new config requires service restart.")
|
||||||
|
logger.Warn().Msg("Restarting service")
|
||||||
|
if _, err := s.Status(); errors.Is(err, service.ErrNotInstalled) {
|
||||||
|
logger.Warn().Msg("Service not installed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return sc.Restart(cmd, args)
|
||||||
|
default:
|
||||||
|
buf, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatal().Err(err).Msg("Could not read response from control server")
|
||||||
|
}
|
||||||
|
logger.Error().Err(err).Msgf("Failed to reload ctrld: %s", string(buf))
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debug().Msg("Service reload command completed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kardianos/service"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Restart implements the logic from cmdRestart.Run
|
||||||
|
func (sc *ServiceCommand) Restart(cmd *cobra.Command, args []string) error {
|
||||||
|
logger := mainLog.Load()
|
||||||
|
logger.Debug().Msg("Service restart command started")
|
||||||
|
|
||||||
|
readConfig(false)
|
||||||
|
v.Unmarshal(&cfg)
|
||||||
|
cdUID = curCdUID()
|
||||||
|
cdMode := cdUID != ""
|
||||||
|
|
||||||
|
s, p, err := sc.initializeServiceManager()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error().Err(err).Msg("Failed to initialize service manager")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.Status(); errors.Is(err, service.ErrNotInstalled) {
|
||||||
|
logger.Warn().Msg("Service not installed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
p.cfg = &cfg
|
||||||
|
if iface == "" {
|
||||||
|
iface = autoIface
|
||||||
|
}
|
||||||
|
p.preRun()
|
||||||
|
if ir := runningIface(s); ir != nil {
|
||||||
|
p.runningIface = ir.Name
|
||||||
|
p.requiredMultiNICsConfig = ir.All
|
||||||
|
}
|
||||||
|
|
||||||
|
initInteractiveLogging()
|
||||||
|
|
||||||
|
var validateConfigErr error
|
||||||
|
if cdMode {
|
||||||
|
logger.Debug().Msg("Validating ControlD remote config")
|
||||||
|
validateConfigErr = doValidateCdRemoteConfig(cdUID, false)
|
||||||
|
if validateConfigErr != nil {
|
||||||
|
logger.Warn().Err(validateConfigErr).Msg("ControlD remote config validation failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ir := runningIface(s); ir != nil {
|
||||||
|
iface = ir.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
doRestart := func() bool {
|
||||||
|
logger.Debug().Msg("Starting service restart sequence")
|
||||||
|
|
||||||
|
tasks := []task{
|
||||||
|
{s.Stop, true, "Stop"},
|
||||||
|
{func() error {
|
||||||
|
// restore static DNS settings or DHCP
|
||||||
|
p.resetDNS(false, true)
|
||||||
|
return nil
|
||||||
|
}, false, "Cleanup"},
|
||||||
|
{func() error {
|
||||||
|
time.Sleep(time.Second * 1)
|
||||||
|
return nil
|
||||||
|
}, false, "Waiting for service to stop"},
|
||||||
|
}
|
||||||
|
if !doTasks(tasks) {
|
||||||
|
logger.Error().Msg("Service stop tasks failed")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
tasks = []task{
|
||||||
|
{s.Start, true, "Start"},
|
||||||
|
}
|
||||||
|
success := doTasks(tasks)
|
||||||
|
if success {
|
||||||
|
logger.Debug().Msg("Service restart sequence completed successfully")
|
||||||
|
} else {
|
||||||
|
logger.Error().Msg("Service restart sequence failed")
|
||||||
|
}
|
||||||
|
return success
|
||||||
|
}
|
||||||
|
|
||||||
|
if doRestart() {
|
||||||
|
if dir, err := socketDir(); err == nil {
|
||||||
|
timeout := dialSocketControlServerTimeout
|
||||||
|
if validateConfigErr != nil {
|
||||||
|
timeout = 5 * time.Second
|
||||||
|
}
|
||||||
|
if cc := newSocketControlClientWithTimeout(context.TODO(), s, dir, timeout); cc != nil {
|
||||||
|
_, _ = cc.post(ifacePath, nil)
|
||||||
|
logger.Debug().Msg("Control server ping successful")
|
||||||
|
} else {
|
||||||
|
logger.Warn().Err(err).Msg("Service was restarted, but ctrld process may not be ready yet")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.Warn().Err(err).Msg("Service was restarted, but could not ping the control server")
|
||||||
|
}
|
||||||
|
logger.Notice().Msg("Service restarted")
|
||||||
|
} else {
|
||||||
|
logger.Error().Msg("Service restart failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debug().Msg("Service restart command completed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kardianos/service"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/Control-D-Inc/ctrld"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Start implements the logic from cmdStart.Run
|
||||||
|
func (sc *ServiceCommand) Start(cmd *cobra.Command, args []string) error {
|
||||||
|
logger := mainLog.Load()
|
||||||
|
logger.Debug().Msg("Service start command started")
|
||||||
|
|
||||||
|
firewallModeFlagChanged = cmd.Flags().Changed("firewall-mode")
|
||||||
|
checkStrFlagEmpty(cmd, cdUidFlagName)
|
||||||
|
checkStrFlagEmpty(cmd, cdOrgFlagName)
|
||||||
|
validateCdAndNextDNSFlags()
|
||||||
|
|
||||||
|
svcConfig := sc.createServiceConfig()
|
||||||
|
osArgs := os.Args[2:]
|
||||||
|
osArgs = filterEmptyStrings(osArgs)
|
||||||
|
if os.Args[1] == "service" {
|
||||||
|
osArgs = os.Args[3:]
|
||||||
|
}
|
||||||
|
setDependencies(svcConfig)
|
||||||
|
svcConfig.Arguments = append([]string{"run"}, osArgs...)
|
||||||
|
|
||||||
|
// Validate --intercept-mode early, before installing the service.
|
||||||
|
// Without this, a typo like "--intercept-mode fds" would install the service,
|
||||||
|
// the child process would Fatal() on the invalid value, and the parent would
|
||||||
|
// then uninstall — confusing and destructive.
|
||||||
|
if interceptMode != "" && !validInterceptMode(interceptMode) {
|
||||||
|
logger.Fatal().Msgf("invalid --intercept-mode value %q: must be 'off', 'dns', or 'hard'", interceptMode)
|
||||||
|
}
|
||||||
|
if firewallModeFlagChanged && !validFirewallMode(firewallMode) {
|
||||||
|
logger.Fatal().Msgf("invalid --firewall-mode value %q: must be 'off' or 'on'", firewallMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize service manager with proper configuration
|
||||||
|
s, p, err := sc.initializeServiceManagerWithServiceConfig(svcConfig)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error().Err(err).Msg("Failed to initialize service manager")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
p.cfg = &cfg
|
||||||
|
p.preRun()
|
||||||
|
|
||||||
|
status, err := s.Status()
|
||||||
|
isCtrldRunning := status == service.StatusRunning
|
||||||
|
isCtrldInstalled := !errors.Is(err, service.ErrNotInstalled)
|
||||||
|
|
||||||
|
// Get current running iface, if any.
|
||||||
|
var currentIface *ifaceResponse
|
||||||
|
|
||||||
|
// Handle "ctrld start --intercept-mode dns|hard" on an existing
|
||||||
|
// service BEFORE the pin check. Adding intercept mode is an enhancement, not
|
||||||
|
// deactivation, so it doesn't require the deactivation pin. We modify the
|
||||||
|
// plist/registry directly and restart the service via the OS service manager.
|
||||||
|
osArgsEarly := os.Args[2:]
|
||||||
|
if os.Args[1] == "service" {
|
||||||
|
osArgsEarly = os.Args[3:]
|
||||||
|
}
|
||||||
|
osArgsEarly = filterEmptyStrings(osArgsEarly)
|
||||||
|
interceptOnly := onlyInterceptFlags(osArgsEarly)
|
||||||
|
svcExists := serviceConfigFileExists()
|
||||||
|
logger.Debug().Msgf("intercept upgrade check: args=%v interceptOnly=%v svcConfigExists=%v interceptMode=%q", osArgsEarly, interceptOnly, svcExists, interceptMode)
|
||||||
|
if interceptOnly && svcExists {
|
||||||
|
// Remove any existing intercept flags before applying the new value.
|
||||||
|
_ = removeServiceFlag("--intercept-mode")
|
||||||
|
|
||||||
|
if interceptMode == "off" {
|
||||||
|
// "off" = remove intercept mode entirely (just the removal above).
|
||||||
|
logger.Notice().Msg("Existing service detected — removing --intercept-mode from service arguments")
|
||||||
|
} else {
|
||||||
|
// Add the new mode value.
|
||||||
|
logger.Notice().Msgf("Existing service detected — appending --intercept-mode %s to service arguments", interceptMode)
|
||||||
|
if err := appendServiceFlag("--intercept-mode"); err != nil {
|
||||||
|
logger.Fatal().Err(err).Msg("failed to append intercept flag to service arguments")
|
||||||
|
}
|
||||||
|
if err := appendServiceFlag(interceptMode); err != nil {
|
||||||
|
logger.Fatal().Err(err).Msg("failed to append intercept mode value to service arguments")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop the service if running (bypasses ctrld pin — this is an
|
||||||
|
// enhancement, not deactivation). Then fall through to the normal
|
||||||
|
// startOnly path which handles start, self-check, and reporting.
|
||||||
|
if isCtrldRunning {
|
||||||
|
logger.Notice().Msg("Stopping service for intercept mode upgrade")
|
||||||
|
_ = s.Stop()
|
||||||
|
isCtrldRunning = false
|
||||||
|
}
|
||||||
|
startOnly = true
|
||||||
|
isCtrldInstalled = true
|
||||||
|
// Fall through to startOnly path below.
|
||||||
|
}
|
||||||
|
|
||||||
|
// If pin code was set, do not allow running start command.
|
||||||
|
if isCtrldRunning {
|
||||||
|
if err := checkDeactivationPin(s, nil); isCheckDeactivationPinErr(err) {
|
||||||
|
logger.Error().Msg("Deactivation pin check failed")
|
||||||
|
os.Exit(deactivationPinInvalidExitCode)
|
||||||
|
}
|
||||||
|
currentIface = runningIface(s)
|
||||||
|
logger.Debug().Msgf("Current interface on start: %v", currentIface)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
reportSetDnsOk := func(sockDir string) {
|
||||||
|
if cc := newSocketControlClient(ctx, s, sockDir); cc != nil {
|
||||||
|
if resp, _ := cc.post(ifacePath, nil); resp != nil && resp.StatusCode == http.StatusOK {
|
||||||
|
if iface == autoIface {
|
||||||
|
iface = defaultIfaceName()
|
||||||
|
}
|
||||||
|
res := &ifaceResponse{}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(res); err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("Failed to get iface info")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if res.OK {
|
||||||
|
// In intercept mode, show intercept-specific status instead of
|
||||||
|
// per-interface DNS messages (which are irrelevant).
|
||||||
|
if res.InterceptMode != "" {
|
||||||
|
switch res.InterceptMode {
|
||||||
|
case "hard":
|
||||||
|
logger.Notice().Msg("DNS hard intercept mode active — all DNS traffic intercepted, no VPN split routing")
|
||||||
|
default:
|
||||||
|
logger.Notice().Msg("DNS intercept mode active — all DNS traffic intercepted via OS packet filter")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
name := res.Name
|
||||||
|
if iff, err := net.InterfaceByName(name); err == nil {
|
||||||
|
_, _ = patchNetIfaceName(iff)
|
||||||
|
name = iff.Name
|
||||||
|
}
|
||||||
|
ifaceLogger := logger.With().Str("iface", name)
|
||||||
|
ifaceLogger.Debug().Msg("Setting DNS successfully")
|
||||||
|
if res.All {
|
||||||
|
// Log that DNS is set for other interfaces.
|
||||||
|
withEachPhysicalInterfaces(
|
||||||
|
name,
|
||||||
|
"set DNS",
|
||||||
|
func(i *net.Interface) error { return nil },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No config path, generating config in HOME directory.
|
||||||
|
noConfigStart := isNoConfigStart(cmd)
|
||||||
|
writeDefaultConfig := !noConfigStart && configBase64 == ""
|
||||||
|
|
||||||
|
logServerStarted := make(chan struct{})
|
||||||
|
stopLogCh := make(chan struct{})
|
||||||
|
ud, err := userHomeDir()
|
||||||
|
sockDir := ud
|
||||||
|
var logServerSocketPath string
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("Failed to get user home directory")
|
||||||
|
logger.Warn().Msg("Log server did not start")
|
||||||
|
close(logServerStarted)
|
||||||
|
} else {
|
||||||
|
setWorkingDirectory(svcConfig, ud)
|
||||||
|
if configPath == "" && writeDefaultConfig {
|
||||||
|
defaultConfigFile = filepath.Join(ud, defaultConfigFile)
|
||||||
|
}
|
||||||
|
svcConfig.Arguments = append(svcConfig.Arguments, "--homedir="+ud)
|
||||||
|
if d, err := socketDir(); err == nil {
|
||||||
|
sockDir = d
|
||||||
|
}
|
||||||
|
logServerSocketPath = filepath.Join(sockDir, ctrldLogUnixSock)
|
||||||
|
_ = os.Remove(logServerSocketPath)
|
||||||
|
go func() {
|
||||||
|
defer os.Remove(logServerSocketPath)
|
||||||
|
|
||||||
|
close(logServerStarted)
|
||||||
|
|
||||||
|
// Start HTTP log server
|
||||||
|
if err := httpLogServer(logServerSocketPath, stopLogCh); err != nil && err != http.ErrServerClosed {
|
||||||
|
logger.Warn().Err(err).Msg("Failed to serve HTTP log server")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
<-logServerStarted
|
||||||
|
|
||||||
|
if !startOnly {
|
||||||
|
startOnly = len(osArgs) == 0
|
||||||
|
}
|
||||||
|
// If user run "ctrld start" and ctrld is already installed, starting existing service.
|
||||||
|
if startOnly && isCtrldInstalled {
|
||||||
|
tryReadingConfigWithNotice(false, true)
|
||||||
|
if err := v.Unmarshal(&cfg); err != nil {
|
||||||
|
logger.Fatal().Msgf("Failed to unmarshal config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// if already running, dont restart
|
||||||
|
if isCtrldRunning {
|
||||||
|
logger.Notice().Msg("Service is already running")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
initInteractiveLogging()
|
||||||
|
tasks := []task{
|
||||||
|
{func() error {
|
||||||
|
// Save current DNS so we can restore later.
|
||||||
|
withEachPhysicalInterfaces("", "saveCurrentStaticDNS", func(i *net.Interface) error {
|
||||||
|
if err := saveCurrentStaticDNS(i); !errors.Is(err, errSaveCurrentStaticDNSNotSupported) && err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}, false, "Save current DNS"},
|
||||||
|
{func() error {
|
||||||
|
return ConfigureWindowsServiceFailureActions(ctrldServiceName)
|
||||||
|
}, false, "Configure service failure actions"},
|
||||||
|
{s.Start, true, "Start"},
|
||||||
|
{noticeWritingControlDConfig, false, "Notice writing ControlD config"},
|
||||||
|
}
|
||||||
|
logger.Notice().Msg("Starting existing ctrld service")
|
||||||
|
if doTasks(tasks) {
|
||||||
|
logger.Notice().Msg("Service started")
|
||||||
|
sockDir, err := socketDir()
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("Failed to get socket directory")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
reportSetDnsOk(sockDir)
|
||||||
|
// Verify service registration after successful start.
|
||||||
|
if err := verifyServiceRegistration(); err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("Service registry verification failed")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.Error().Err(err).Msg("Failed to start existing ctrld service")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if cdUID != "" {
|
||||||
|
_ = doValidateCdRemoteConfig(cdUID, true)
|
||||||
|
} else if uid := cdUIDFromProvToken(); uid != "" {
|
||||||
|
cdUID = uid
|
||||||
|
logger.Debug().Msg("Using uid from provision token")
|
||||||
|
removeOrgFlagsFromArgs(svcConfig)
|
||||||
|
// Pass --cd flag to "ctrld run" command, so the provision token takes no effect.
|
||||||
|
svcConfig.Arguments = append(svcConfig.Arguments, "--cd="+cdUID)
|
||||||
|
}
|
||||||
|
if cdUID != "" {
|
||||||
|
validateCdUpstreamProtocol()
|
||||||
|
}
|
||||||
|
|
||||||
|
if configPath != "" {
|
||||||
|
v.SetConfigFile(configPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
tryReadingConfigWithNotice(writeDefaultConfig, true)
|
||||||
|
|
||||||
|
if err := v.Unmarshal(&cfg); err != nil {
|
||||||
|
logger.Fatal().Msgf("Failed to unmarshal config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
initInteractiveLogging()
|
||||||
|
|
||||||
|
if nextdns != "" {
|
||||||
|
removeNextDNSFromArgs(svcConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicitly passing config, so on system where home directory could not be obtained,
|
||||||
|
// or sub-process env is different with the parent, we still behave correctly and use
|
||||||
|
// the expected config file.
|
||||||
|
if configPath == "" {
|
||||||
|
svcConfig.Arguments = append(svcConfig.Arguments, "--config="+defaultConfigFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks := []task{
|
||||||
|
{s.Stop, false, "Stop"},
|
||||||
|
{func() error { return doGenerateNextDNSConfig(nextdns) }, true, "Checking config"},
|
||||||
|
{func() error { return ensureUninstall(s) }, false, "Ensure uninstall"},
|
||||||
|
//resetDnsTask(p, s, isCtrldInstalled, currentIface),
|
||||||
|
{func() error {
|
||||||
|
// Save current DNS so we can restore later.
|
||||||
|
withEachPhysicalInterfaces("", "saveCurrentStaticDNS", func(i *net.Interface) error {
|
||||||
|
if err := saveCurrentStaticDNS(i); !errors.Is(err, errSaveCurrentStaticDNSNotSupported) && err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}, false, "Save current DNS"},
|
||||||
|
{s.Install, false, "Install"},
|
||||||
|
{func() error {
|
||||||
|
return ConfigureWindowsServiceFailureActions(ctrldServiceName)
|
||||||
|
}, false, "Configure Windows service failure actions"},
|
||||||
|
{s.Start, true, "Start"},
|
||||||
|
// Note that startCmd do not actually write ControlD config, but the config file was
|
||||||
|
// generated after s.Start, so we notice users here for consistent with nextdns mode.
|
||||||
|
{noticeWritingControlDConfig, false, "Notice writing ControlD config"},
|
||||||
|
}
|
||||||
|
logger.Notice().Msg("Starting service")
|
||||||
|
if doTasks(tasks) {
|
||||||
|
// add a small delay to ensure the service is started and did not crash
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
|
||||||
|
ok, status, err := selfCheckStatus(ctx, s, sockDir)
|
||||||
|
switch {
|
||||||
|
case ok && status == service.StatusRunning:
|
||||||
|
logger.Notice().Msg("Service started")
|
||||||
|
default:
|
||||||
|
marker := append(bytes.Repeat([]byte("="), 32), '\n')
|
||||||
|
// If ctrld service is not running, emitting log obtained from ctrld process.
|
||||||
|
if status != service.StatusRunning || ctx.Err() != nil {
|
||||||
|
logger.Error().Msg("Ctrld service may not have started due to an error or misconfiguration, service log:")
|
||||||
|
_, _ = logger.Write(marker)
|
||||||
|
|
||||||
|
// Wait for log collection to complete
|
||||||
|
<-stopLogCh
|
||||||
|
|
||||||
|
// Retrieve logs from HTTP server if available
|
||||||
|
if logServerSocketPath != "" {
|
||||||
|
hlc := newHTTPLogClient(logServerSocketPath)
|
||||||
|
logs, err := hlc.GetLogs()
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("Failed to get logs from HTTP log server")
|
||||||
|
}
|
||||||
|
if len(logs) == 0 {
|
||||||
|
logger.Write([]byte("<no log output is obtained from ctrld process>\n"))
|
||||||
|
} else {
|
||||||
|
logger.Write(logs)
|
||||||
|
logger.Write([]byte("\n"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.Write([]byte("<no log output from HTTP log server>\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Report any error if occurred.
|
||||||
|
if err != nil {
|
||||||
|
_, _ = logger.Write(marker)
|
||||||
|
msg := fmt.Sprintf("An error occurred while performing test query: %s\n", err)
|
||||||
|
logger.Write([]byte(msg))
|
||||||
|
}
|
||||||
|
// If ctrld service is running but selfCheckStatus failed, it could be related
|
||||||
|
// to user's system firewall configuration, notice users about it.
|
||||||
|
if status == service.StatusRunning && err == nil {
|
||||||
|
_, _ = logger.Write(marker)
|
||||||
|
logger.Write([]byte("ctrld service was running, but a DNS query could not be sent to its listener\n"))
|
||||||
|
logger.Write([]byte("Please check your system firewall if it is configured to block/intercept/redirect DNS queries\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = logger.Write(marker)
|
||||||
|
uninstall(p, s)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
reportSetDnsOk(sockDir)
|
||||||
|
// Verify service registration after successful start.
|
||||||
|
if err := verifyServiceRegistration(); err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("Service registry verification failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debug().Msg("Service start command completed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createStartCommands creates the start command and its alias
|
||||||
|
func createStartCommands(sc *ServiceCommand) (*cobra.Command, *cobra.Command) {
|
||||||
|
// Start command
|
||||||
|
startCmd := &cobra.Command{
|
||||||
|
Use: "start",
|
||||||
|
Short: "Install and start the ctrld service",
|
||||||
|
Long: `Install and start the ctrld service
|
||||||
|
|
||||||
|
NOTE: running "ctrld start" without any arguments will start already installed ctrld service.`,
|
||||||
|
Args: func(cmd *cobra.Command, args []string) error {
|
||||||
|
args = filterEmptyStrings(args)
|
||||||
|
if len(args) > 0 {
|
||||||
|
return fmt.Errorf("'ctrld start' doesn't accept positional arguments\n" +
|
||||||
|
"Use flags instead (e.g. --cd, --iface) or see 'ctrld start --help' for all options")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
PreRun: func(cmd *cobra.Command, args []string) {
|
||||||
|
checkHasElevatedPrivilege()
|
||||||
|
},
|
||||||
|
RunE: sc.Start,
|
||||||
|
}
|
||||||
|
// Keep these flags in sync with runCmd above, except for "-d"/"--nextdns".
|
||||||
|
startCmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
|
||||||
|
startCmd.Flags().StringVarP(&configBase64, "base64_config", "", "", "Base64 encoded config")
|
||||||
|
startCmd.Flags().StringVarP(&listenAddress, "listen", "", "", "Listener address and port, in format: address:port")
|
||||||
|
startCmd.Flags().StringVarP(&primaryUpstream, "primary_upstream", "", "", "Primary upstream endpoint")
|
||||||
|
startCmd.Flags().StringVarP(&secondaryUpstream, "secondary_upstream", "", "", "Secondary upstream endpoint")
|
||||||
|
startCmd.Flags().StringSliceVarP(&domains, "domains", "", nil, "List of domain to apply in a split DNS policy")
|
||||||
|
startCmd.Flags().StringVarP(&logPath, "log", "", "", "Path to log file")
|
||||||
|
startCmd.Flags().IntVarP(&cacheSize, "cache_size", "", 0, "Enable cache with size items")
|
||||||
|
startCmd.Flags().StringVarP(&cdUID, cdUidFlagName, "", "", "Control D resolver uid")
|
||||||
|
startCmd.Flags().StringVarP(&cdOrg, cdOrgFlagName, "", "", "Control D provision token")
|
||||||
|
startCmd.Flags().StringVarP(&customHostname, customHostnameFlagName, "", "", "Custom hostname passed to ControlD API")
|
||||||
|
startCmd.Flags().BoolVarP(&cdDev, "dev", "", false, "Use Control D dev resolver/domain")
|
||||||
|
_ = startCmd.Flags().MarkHidden("dev")
|
||||||
|
startCmd.Flags().StringVarP(&iface, "iface", "", "", `Update DNS setting for iface, "auto" means the default interface gateway`)
|
||||||
|
startCmd.Flags().StringVarP(&nextdns, nextdnsFlagName, "", "", "NextDNS resolver id")
|
||||||
|
startCmd.Flags().StringVarP(&cdUpstreamProto, "proto", "", ctrld.ResolverTypeDOH, `Control D upstream type, either "doh" or "doh3"`)
|
||||||
|
startCmd.Flags().BoolVarP(&skipSelfChecks, "skip_self_checks", "", false, `Skip self checks after installing ctrld service`)
|
||||||
|
startCmd.Flags().BoolVarP(&startOnly, "start_only", "", false, "Do not install new service")
|
||||||
|
_ = startCmd.Flags().MarkHidden("start_only")
|
||||||
|
startCmd.Flags().BoolVarP(&rfc1918, "rfc1918", "", false, "Listen on RFC1918 addresses when 127.0.0.1 is the only listener")
|
||||||
|
startCmd.Flags().StringVarP(&interceptMode, "intercept-mode", "", "", "OS-level DNS interception mode: 'dns' (with VPN split routing) or 'hard' (all DNS through ctrld, no VPN split routing)")
|
||||||
|
startCmd.Flags().StringVarP(&firewallMode, "firewall-mode", "", "off", "DNS-resolved IP allowlist: 'on' blocks connections to IPs not resolved by ctrld, 'off' allows all")
|
||||||
|
|
||||||
|
// Start command alias
|
||||||
|
startCmdAlias := &cobra.Command{
|
||||||
|
PreRun: func(cmd *cobra.Command, args []string) {
|
||||||
|
checkHasElevatedPrivilege()
|
||||||
|
},
|
||||||
|
Use: "start",
|
||||||
|
Short: "Quick start service and configure DNS on interface",
|
||||||
|
Long: `Quick start service and configure DNS on interface
|
||||||
|
|
||||||
|
NOTE: running "ctrld start" without any arguments will start already installed ctrld service.`,
|
||||||
|
Args: func(cmd *cobra.Command, args []string) error {
|
||||||
|
args = filterEmptyStrings(args)
|
||||||
|
if len(args) > 0 {
|
||||||
|
return fmt.Errorf("'ctrld start' doesn't accept positional arguments\n" +
|
||||||
|
"Use flags instead (e.g. --cd, --iface) or see 'ctrld start --help' for all options")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if len(os.Args) == 2 {
|
||||||
|
startOnly = true
|
||||||
|
}
|
||||||
|
if !cmd.Flags().Changed("iface") {
|
||||||
|
os.Args = append(os.Args, "--iface="+ifaceStartStop)
|
||||||
|
}
|
||||||
|
iface = ifaceStartStop
|
||||||
|
return startCmd.RunE(cmd, args)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
startCmdAlias.Flags().StringVarP(&ifaceStartStop, "iface", "", autoIface, `Update DNS setting for iface, "auto" means the default interface gateway`)
|
||||||
|
startCmdAlias.Flags().AddFlagSet(startCmd.Flags())
|
||||||
|
|
||||||
|
return startCmd, startCmdAlias
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/kardianos/service"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Status implements the logic from cmdStatus.Run
|
||||||
|
func (sc *ServiceCommand) Status(cmd *cobra.Command, args []string) error {
|
||||||
|
logger := mainLog.Load()
|
||||||
|
logger.Debug().Msg("Service status command started")
|
||||||
|
|
||||||
|
s, _, err := sc.initializeServiceManager()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error().Err(err).Msg("Failed to initialize service manager")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err := s.Status()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error().Msg(err.Error())
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch status {
|
||||||
|
case service.StatusUnknown:
|
||||||
|
logger.Notice().Msg("Unknown status")
|
||||||
|
os.Exit(2)
|
||||||
|
case service.StatusRunning:
|
||||||
|
logger.Notice().Msg("Service is running")
|
||||||
|
os.Exit(0)
|
||||||
|
case service.StatusStopped:
|
||||||
|
logger.Notice().Msg("Service is stopped")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debug().Msg("Service status command completed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/kardianos/service"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Stop implements the logic from cmdStop.Run
|
||||||
|
func (sc *ServiceCommand) Stop(cmd *cobra.Command, args []string) error {
|
||||||
|
logger := mainLog.Load()
|
||||||
|
logger.Debug().Msg("Service stop command started")
|
||||||
|
|
||||||
|
readConfig(false)
|
||||||
|
v.Unmarshal(&cfg)
|
||||||
|
|
||||||
|
s, p, err := sc.initializeServiceManager()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error().Err(err).Msg("Failed to initialize service manager")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
p.cfg = &cfg
|
||||||
|
if iface == "" {
|
||||||
|
iface = autoIface
|
||||||
|
}
|
||||||
|
p.preRun()
|
||||||
|
if ir := runningIface(s); ir != nil {
|
||||||
|
p.runningIface = ir.Name
|
||||||
|
p.requiredMultiNICsConfig = ir.All
|
||||||
|
}
|
||||||
|
|
||||||
|
initInteractiveLogging()
|
||||||
|
|
||||||
|
status, err := s.Status()
|
||||||
|
if errors.Is(err, service.ErrNotInstalled) {
|
||||||
|
logger.Warn().Msg("Service not installed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if status == service.StatusStopped {
|
||||||
|
logger.Warn().Msg("Service is already stopped")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := checkDeactivationPin(s, nil); isCheckDeactivationPinErr(err) {
|
||||||
|
logger.Error().Msg("Deactivation pin check failed")
|
||||||
|
os.Exit(deactivationPinInvalidExitCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debug().Msg("Stopping service")
|
||||||
|
if doTasks([]task{{s.Stop, true, "Stop"}}) {
|
||||||
|
logger.Notice().Msg("Service stopped")
|
||||||
|
} else {
|
||||||
|
logger.Error().Msg("Service stop failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debug().Msg("Service stop command completed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/Control-D-Inc/ctrld"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Uninstall implements the logic from cmdUninstall.Run
|
||||||
|
func (sc *ServiceCommand) Uninstall(cmd *cobra.Command, args []string) error {
|
||||||
|
logger := mainLog.Load()
|
||||||
|
logger.Debug().Msg("Service uninstall command started")
|
||||||
|
|
||||||
|
readConfig(false)
|
||||||
|
v.Unmarshal(&cfg)
|
||||||
|
|
||||||
|
s, p, err := sc.initializeServiceManager()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error().Err(err).Msg("Failed to initialize service manager")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
p.cfg = &cfg
|
||||||
|
if iface == "" {
|
||||||
|
iface = autoIface
|
||||||
|
}
|
||||||
|
p.preRun()
|
||||||
|
if ir := runningIface(s); ir != nil {
|
||||||
|
p.runningIface = ir.Name
|
||||||
|
p.requiredMultiNICsConfig = ir.All
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := checkDeactivationPin(s, nil); isCheckDeactivationPinErr(err) {
|
||||||
|
logger.Error().Msg("Deactivation pin check failed")
|
||||||
|
os.Exit(deactivationPinInvalidExitCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debug().Msg("Starting service uninstall")
|
||||||
|
uninstall(p, s)
|
||||||
|
|
||||||
|
if cleanup {
|
||||||
|
logger.Debug().Msg("Performing cleanup operations")
|
||||||
|
var files []string
|
||||||
|
// Config file.
|
||||||
|
files = append(files, v.ConfigFileUsed())
|
||||||
|
// Log file and backup log file.
|
||||||
|
// For safety, only process if log file path is absolute.
|
||||||
|
if logFile := normalizeLogFilePath(cfg.Service.LogPath); filepath.IsAbs(logFile) {
|
||||||
|
files = append(files, logFile)
|
||||||
|
oldLogFile := logFile + oldLogSuffix
|
||||||
|
if _, err := os.Stat(oldLogFile); err == nil {
|
||||||
|
files = append(files, oldLogFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Socket files.
|
||||||
|
if dir, _ := socketDir(); dir != "" {
|
||||||
|
files = append(files, filepath.Join(dir, ctrldControlUnixSock))
|
||||||
|
files = append(files, filepath.Join(dir, ctrldLogUnixSock))
|
||||||
|
}
|
||||||
|
// Static DNS settings files.
|
||||||
|
withEachPhysicalInterfaces("", "", func(i *net.Interface) error {
|
||||||
|
file := ctrld.SavedStaticDnsSettingsFilePath(i)
|
||||||
|
files = append(files, file)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
bin, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("Failed to get executable path")
|
||||||
|
}
|
||||||
|
if bin != "" && supportedSelfDelete {
|
||||||
|
files = append(files, bin)
|
||||||
|
}
|
||||||
|
// Backup file after upgrading.
|
||||||
|
oldBin := bin + oldBinSuffix
|
||||||
|
if _, err := os.Stat(oldBin); err == nil {
|
||||||
|
files = append(files, oldBin)
|
||||||
|
}
|
||||||
|
for _, file := range files {
|
||||||
|
if file == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := os.Remove(file); err == nil {
|
||||||
|
logger.Notice().Str("file", file).Msg("File removed during cleanup")
|
||||||
|
} else {
|
||||||
|
logger.Debug().Err(err).Str("file", file).Msg("Failed to remove file during cleanup")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Self-delete the ctrld binary if supported
|
||||||
|
if err := selfDeleteExe(); err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("Failed to delete ctrld binary")
|
||||||
|
} else {
|
||||||
|
if !supportedSelfDelete {
|
||||||
|
logger.Debug().Msgf("File removed: %s", bin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debug().Msg("Cleanup operations completed")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debug().Msg("Service uninstall command completed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestBasicCommandStructure tests the actual root command structure
|
||||||
|
func TestBasicCommandStructure(t *testing.T) {
|
||||||
|
// Test the actual root command that's returned from initCLI()
|
||||||
|
rootCmd := initCLI()
|
||||||
|
|
||||||
|
// Test that root command has basic properties
|
||||||
|
assert.Equal(t, "ctrld", rootCmd.Use)
|
||||||
|
assert.NotEmpty(t, rootCmd.Short, "Root command should have a short description")
|
||||||
|
|
||||||
|
// Test that root command has subcommands
|
||||||
|
commands := rootCmd.Commands()
|
||||||
|
assert.NotNil(t, commands, "Root command should have subcommands")
|
||||||
|
assert.Greater(t, len(commands), 0, "Root command should have at least one subcommand")
|
||||||
|
|
||||||
|
// Test that expected commands exist
|
||||||
|
expectedCommands := []string{"run", "service", "clients", "upgrade", "log"}
|
||||||
|
for _, cmdName := range expectedCommands {
|
||||||
|
found := false
|
||||||
|
for _, cmd := range commands {
|
||||||
|
if cmd.Name() == cmdName {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, found, "Expected command %s not found in root command", cmdName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestServiceCommandCreation tests service command creation
|
||||||
|
func TestServiceCommandCreation(t *testing.T) {
|
||||||
|
sc := NewServiceCommand()
|
||||||
|
require.NotNil(t, sc, "ServiceCommand should be created")
|
||||||
|
|
||||||
|
// Test service config creation
|
||||||
|
config := sc.createServiceConfig()
|
||||||
|
require.NotNil(t, config, "Service config should be created")
|
||||||
|
assert.Equal(t, ctrldServiceName, config.Name)
|
||||||
|
assert.Equal(t, "Control-D Helper Service", config.DisplayName)
|
||||||
|
assert.Equal(t, "A highly configurable, multi-protocol DNS forwarding proxy", config.Description)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestServiceCommandSubCommands tests service command sub commands
|
||||||
|
func TestServiceCommandSubCommands(t *testing.T) {
|
||||||
|
rootCmd := &cobra.Command{
|
||||||
|
Use: "ctrld",
|
||||||
|
Short: "DNS forwarding proxy",
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceCmd := InitServiceCmd(rootCmd)
|
||||||
|
require.NotNil(t, serviceCmd, "Service command should be created")
|
||||||
|
|
||||||
|
// Test that service command has subcommands
|
||||||
|
subcommands := serviceCmd.Commands()
|
||||||
|
assert.Greater(t, len(subcommands), 0, "Service command should have subcommands")
|
||||||
|
|
||||||
|
// Test specific subcommands exist
|
||||||
|
expectedCommands := []string{"start", "stop", "restart", "reload", "status", "uninstall", "interfaces"}
|
||||||
|
|
||||||
|
for _, cmdName := range expectedCommands {
|
||||||
|
found := false
|
||||||
|
for _, cmd := range subcommands {
|
||||||
|
if cmd.Name() == cmdName {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, found, "Expected service subcommand %s not found", cmdName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCommandHelp tests basic help functionality
|
||||||
|
func TestCommandHelp(t *testing.T) {
|
||||||
|
// Initialize the CLI to set up the root command
|
||||||
|
rootCmd := initCLI()
|
||||||
|
|
||||||
|
// Test help command execution
|
||||||
|
var buf bytes.Buffer
|
||||||
|
rootCmd.SetOut(&buf)
|
||||||
|
rootCmd.SetErr(&buf)
|
||||||
|
|
||||||
|
rootCmd.SetArgs([]string{"--help"})
|
||||||
|
err := rootCmd.Execute()
|
||||||
|
assert.NoError(t, err, "Help command should execute without error")
|
||||||
|
assert.Contains(t, buf.String(), "dns forwarding proxy", "Help output should contain description")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCommandVersion tests version command
|
||||||
|
func TestCommandVersion(t *testing.T) {
|
||||||
|
// Initialize the CLI to set up the root command
|
||||||
|
rootCmd := initCLI()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
rootCmd.SetOut(&buf)
|
||||||
|
rootCmd.SetErr(&buf)
|
||||||
|
|
||||||
|
// Test version command
|
||||||
|
rootCmd.SetArgs([]string{"--version"})
|
||||||
|
err := rootCmd.Execute()
|
||||||
|
assert.NoError(t, err, "Version command should execute without error")
|
||||||
|
assert.Contains(t, buf.String(), "version", "Version output should contain version information")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCommandErrorHandling tests error handling
|
||||||
|
func TestCommandErrorHandling(t *testing.T) {
|
||||||
|
// Initialize the CLI to set up the root command
|
||||||
|
rootCmd := initCLI()
|
||||||
|
|
||||||
|
// Test invalid flag instead of invalid command
|
||||||
|
rootCmd.SetArgs([]string{"--invalid-flag"})
|
||||||
|
err := rootCmd.Execute()
|
||||||
|
assert.Error(t, err, "Invalid flag should return error")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCommandFlags tests flag functionality
|
||||||
|
func TestCommandFlags(t *testing.T) {
|
||||||
|
// Initialize the CLI to set up the root command
|
||||||
|
rootCmd := initCLI()
|
||||||
|
|
||||||
|
// Test that root command has expected flags
|
||||||
|
verboseFlag := rootCmd.PersistentFlags().Lookup("verbose")
|
||||||
|
assert.NotNil(t, verboseFlag, "Verbose flag should exist")
|
||||||
|
assert.Equal(t, "v", verboseFlag.Shorthand)
|
||||||
|
|
||||||
|
silentFlag := rootCmd.PersistentFlags().Lookup("silent")
|
||||||
|
assert.NotNil(t, silentFlag, "Silent flag should exist")
|
||||||
|
assert.Equal(t, "s", silentFlag.Shorthand)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCommandExecution tests basic command execution
|
||||||
|
func TestCommandExecution(t *testing.T) {
|
||||||
|
// Initialize the CLI to set up the root command
|
||||||
|
rootCmd := initCLI()
|
||||||
|
|
||||||
|
// Test that root command can be executed (help command)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
rootCmd.SetOut(&buf)
|
||||||
|
rootCmd.SetErr(&buf)
|
||||||
|
|
||||||
|
rootCmd.SetArgs([]string{"--help"})
|
||||||
|
err := rootCmd.Execute()
|
||||||
|
assert.NoError(t, err, "Root command should execute without error")
|
||||||
|
assert.Contains(t, buf.String(), "dns forwarding proxy", "Help output should contain description")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCommandArgs tests argument handling
|
||||||
|
func TestCommandArgs(t *testing.T) {
|
||||||
|
// Initialize the CLI to set up the root command
|
||||||
|
rootCmd := initCLI()
|
||||||
|
|
||||||
|
// Test that root command can handle arguments properly
|
||||||
|
// Test with no args (should succeed)
|
||||||
|
err := rootCmd.Execute()
|
||||||
|
assert.NoError(t, err, "Root command with no args should execute")
|
||||||
|
|
||||||
|
// Test with help flag (should succeed)
|
||||||
|
rootCmd.SetArgs([]string{"--help"})
|
||||||
|
err = rootCmd.Execute()
|
||||||
|
assert.NoError(t, err, "Root command with help flag should execute")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCommandSubcommands tests subcommand functionality
|
||||||
|
func TestCommandSubcommands(t *testing.T) {
|
||||||
|
// Initialize the CLI to set up the root command
|
||||||
|
rootCmd := initCLI()
|
||||||
|
|
||||||
|
// Test that root command has subcommands
|
||||||
|
commands := rootCmd.Commands()
|
||||||
|
assert.Greater(t, len(commands), 0, "Root command should have subcommands")
|
||||||
|
|
||||||
|
// Test that specific subcommands exist and can be executed
|
||||||
|
expectedSubcommands := []string{"run", "service", "clients", "upgrade", "log"}
|
||||||
|
for _, subCmdName := range expectedSubcommands {
|
||||||
|
// Find the subcommand
|
||||||
|
var subCmd *cobra.Command
|
||||||
|
for _, cmd := range commands {
|
||||||
|
if cmd.Name() == subCmdName {
|
||||||
|
subCmd = cmd
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.NotNil(t, subCmd, "Subcommand %s should exist", subCmdName)
|
||||||
|
|
||||||
|
// Test that subcommand has help
|
||||||
|
assert.NotEmpty(t, subCmd.Short, "Subcommand %s should have a short description", subCmdName)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kardianos/service"
|
||||||
|
"github.com/minio/selfupdate"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
upgradeChannelDev = "dev"
|
||||||
|
upgradeChannelProd = "prod"
|
||||||
|
upgradeChannelDefault = "default"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UpgradeCommand handles upgrade-related operations
|
||||||
|
type UpgradeCommand struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUpgradeCommand creates a new upgrade command handler
|
||||||
|
func NewUpgradeCommand() (*UpgradeCommand, error) {
|
||||||
|
return &UpgradeCommand{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upgrade performs the upgrade operation
|
||||||
|
func (uc *UpgradeCommand) Upgrade(cmd *cobra.Command, args []string) error {
|
||||||
|
upgradeChannel := map[string]string{
|
||||||
|
upgradeChannelDefault: "https://dl.controld.dev",
|
||||||
|
upgradeChannelDev: "https://dl.controld.dev",
|
||||||
|
upgradeChannelProd: "https://dl.controld.com",
|
||||||
|
}
|
||||||
|
if isStableVersion(curVersion()) {
|
||||||
|
upgradeChannel[upgradeChannelDefault] = upgradeChannel[upgradeChannelProd]
|
||||||
|
}
|
||||||
|
|
||||||
|
bin, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
mainLog.Load().Fatal().Err(err).Msg("Failed to get current ctrld binary path")
|
||||||
|
}
|
||||||
|
|
||||||
|
readConfig(false)
|
||||||
|
v.Unmarshal(&cfg)
|
||||||
|
svcCmd := NewServiceCommand()
|
||||||
|
s, p, err := svcCmd.initializeServiceManager()
|
||||||
|
if err != nil {
|
||||||
|
mainLog.Load().Error().Msg(err.Error())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if iface == "" {
|
||||||
|
iface = autoIface
|
||||||
|
}
|
||||||
|
p.preRun()
|
||||||
|
if ir := runningIface(s); ir != nil {
|
||||||
|
p.runningIface = ir.Name
|
||||||
|
p.requiredMultiNICsConfig = ir.All
|
||||||
|
}
|
||||||
|
|
||||||
|
svcInstalled := true
|
||||||
|
if _, err := s.Status(); errors.Is(err, service.ErrNotInstalled) {
|
||||||
|
svcInstalled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
oldBin := bin + oldBinSuffix
|
||||||
|
baseUrl := upgradeChannel[upgradeChannelDefault]
|
||||||
|
if len(args) > 0 {
|
||||||
|
channel := args[0]
|
||||||
|
switch channel {
|
||||||
|
case upgradeChannelProd, upgradeChannelDev: // ok
|
||||||
|
default:
|
||||||
|
mainLog.Load().Fatal().Msgf("Upgrade argument must be either %q or %q", upgradeChannelProd, upgradeChannelDev)
|
||||||
|
}
|
||||||
|
baseUrl = upgradeChannel[channel]
|
||||||
|
}
|
||||||
|
|
||||||
|
dlUrl := upgradeUrl(baseUrl)
|
||||||
|
mainLog.Load().Debug().Msgf("Downloading binary: %s", dlUrl)
|
||||||
|
|
||||||
|
resp, err := getWithRetry(dlUrl, downloadServerIp)
|
||||||
|
if err != nil {
|
||||||
|
mainLog.Load().Fatal().Err(err).Msg("Failed to download binary")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
mainLog.Load().Fatal().Msgf("Could not download binary: %s", http.StatusText(resp.StatusCode))
|
||||||
|
}
|
||||||
|
|
||||||
|
mainLog.Load().Debug().Msg("Updating current binary")
|
||||||
|
if err := selfupdate.Apply(resp.Body, selfupdate.Options{OldSavePath: oldBin}); err != nil {
|
||||||
|
if rerr := selfupdate.RollbackError(err); rerr != nil {
|
||||||
|
mainLog.Load().Error().Err(rerr).Msg("Could not rollback old binary")
|
||||||
|
}
|
||||||
|
mainLog.Load().Fatal().Err(err).Msg("Failed to update current binary")
|
||||||
|
}
|
||||||
|
|
||||||
|
doRestart := func() bool {
|
||||||
|
if !svcInstalled {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
tasks := []task{
|
||||||
|
{s.Stop, true, "Stop"},
|
||||||
|
{func() error {
|
||||||
|
// restore static DNS settings or DHCP
|
||||||
|
p.resetDNS(false, true)
|
||||||
|
return nil
|
||||||
|
}, false, "Cleanup"},
|
||||||
|
{func() error {
|
||||||
|
time.Sleep(time.Second * 1)
|
||||||
|
return nil
|
||||||
|
}, false, "Waiting for service to stop"},
|
||||||
|
}
|
||||||
|
doTasks(tasks)
|
||||||
|
|
||||||
|
tasks = []task{
|
||||||
|
{s.Start, true, "Start"},
|
||||||
|
}
|
||||||
|
if doTasks(tasks) {
|
||||||
|
if dir, err := socketDir(); err == nil {
|
||||||
|
if cc := newSocketControlClient(context.TODO(), s, dir); cc != nil {
|
||||||
|
_, _ = cc.post(ifacePath, nil)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if svcInstalled {
|
||||||
|
mainLog.Load().Debug().Msg("Restarting ctrld service using new binary")
|
||||||
|
}
|
||||||
|
|
||||||
|
if doRestart() {
|
||||||
|
_ = os.Remove(oldBin)
|
||||||
|
_ = os.Chmod(bin, 0755)
|
||||||
|
ver := "unknown version"
|
||||||
|
out, err := exec.Command(bin, "--version").CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
mainLog.Load().Warn().Err(err).Msg("Failed to get new binary version")
|
||||||
|
}
|
||||||
|
if after, found := strings.CutPrefix(string(out), "ctrld version "); found {
|
||||||
|
ver = after
|
||||||
|
}
|
||||||
|
mainLog.Load().Notice().Msgf("Upgrade successful - %s", ver)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
mainLog.Load().Warn().Msgf("Upgrade failed, restoring previous binary: %s", oldBin)
|
||||||
|
if err := os.Remove(bin); err != nil {
|
||||||
|
mainLog.Load().Fatal().Err(err).Msg("Failed to remove new binary")
|
||||||
|
}
|
||||||
|
if err := os.Rename(oldBin, bin); err != nil {
|
||||||
|
mainLog.Load().Fatal().Err(err).Msg("Failed to restore old binary")
|
||||||
|
}
|
||||||
|
if doRestart() {
|
||||||
|
mainLog.Load().Notice().Msg("Restored previous binary successfully")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitUpgradeCmd creates the upgrade command with proper logic
|
||||||
|
func InitUpgradeCmd(rootCmd *cobra.Command) *cobra.Command {
|
||||||
|
upgradeCmd := &cobra.Command{
|
||||||
|
Use: "upgrade",
|
||||||
|
Short: "Upgrading ctrld to latest version",
|
||||||
|
ValidArgs: []string{upgradeChannelDev, upgradeChannelProd},
|
||||||
|
Args: cobra.MaximumNArgs(1),
|
||||||
|
PreRun: func(cmd *cobra.Command, args []string) {
|
||||||
|
checkHasElevatedPrivilege()
|
||||||
|
},
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uc, err := NewUpgradeCommand()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return uc.Upgrade(cmd, args)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
rootCmd.AddCommand(upgradeCmd)
|
||||||
|
|
||||||
|
return upgradeCmd
|
||||||
|
}
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
package cli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// logConn wraps a net.Conn, override the Write behavior.
|
|
||||||
// runCmd uses this wrapper, so as long as startCmd finished,
|
|
||||||
// ctrld log won't be flushed with un-necessary write errors.
|
|
||||||
type logConn struct {
|
|
||||||
conn net.Conn
|
|
||||||
}
|
|
||||||
|
|
||||||
func (lc *logConn) Read(b []byte) (n int, err error) {
|
|
||||||
return lc.conn.Read(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (lc *logConn) Close() error {
|
|
||||||
return lc.conn.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (lc *logConn) LocalAddr() net.Addr {
|
|
||||||
return lc.conn.LocalAddr()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (lc *logConn) RemoteAddr() net.Addr {
|
|
||||||
return lc.conn.RemoteAddr()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (lc *logConn) SetDeadline(t time.Time) error {
|
|
||||||
return lc.conn.SetDeadline(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (lc *logConn) SetReadDeadline(t time.Time) error {
|
|
||||||
return lc.conn.SetReadDeadline(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (lc *logConn) SetWriteDeadline(t time.Time) error {
|
|
||||||
return lc.conn.SetWriteDeadline(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (lc *logConn) Write(b []byte) (int, error) {
|
|
||||||
// Write performs writes with underlying net.Conn, ignore any errors happen.
|
|
||||||
// "ctrld run" command use this wrapper to report errors to "ctrld start".
|
|
||||||
// If no error occurred, "ctrld start" may finish before "ctrld run" attempt
|
|
||||||
// to close the connection, so ignore errors conservatively here, prevent
|
|
||||||
// un-necessary error "write to closed connection" flushed to ctrld log.
|
|
||||||
_, _ = lc.conn.Write(b)
|
|
||||||
return len(b), nil
|
|
||||||
}
|
|
||||||
@@ -8,10 +8,12 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// controlClient represents an HTTP client for communicating with the control server
|
||||||
type controlClient struct {
|
type controlClient struct {
|
||||||
c *http.Client
|
c *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newControlClient creates a new control client with Unix socket transport
|
||||||
func newControlClient(addr string) *controlClient {
|
func newControlClient(addr string) *controlClient {
|
||||||
return &controlClient{c: &http.Client{
|
return &controlClient{c: &http.Client{
|
||||||
Transport: &http.Transport{
|
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)
|
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.
|
// deactivationRequest represents request for validating deactivation pin.
|
||||||
type deactivationRequest struct {
|
type deactivationRequest struct {
|
||||||
Pin int64 `json:"pin"`
|
Pin int64 `json:"pin"`
|
||||||
|
|||||||
+233
-33
@@ -10,6 +10,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"reflect"
|
"reflect"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kardianos/service"
|
"github.com/kardianos/service"
|
||||||
@@ -29,20 +30,24 @@ const (
|
|||||||
ifacePath = "/iface"
|
ifacePath = "/iface"
|
||||||
viewLogsPath = "/log/view"
|
viewLogsPath = "/log/view"
|
||||||
sendLogsPath = "/log/send"
|
sendLogsPath = "/log/send"
|
||||||
|
tailLogsPath = "/log/tail"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ifaceResponse struct {
|
type ifaceResponse struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
All bool `json:"all"`
|
All bool `json:"all"`
|
||||||
OK bool `json:"ok"`
|
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 {
|
type controlServer struct {
|
||||||
server *http.Server
|
server *http.Server
|
||||||
mux *http.ServeMux
|
mux *http.ServeMux
|
||||||
addr string
|
addr string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newControlServer creates a new control server instance
|
||||||
func newControlServer(addr string) (*controlServer, error) {
|
func newControlServer(addr string) (*controlServer, error) {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
s := &controlServer{
|
s := &controlServer{
|
||||||
@@ -56,12 +61,18 @@ func newControlServer(addr string) (*controlServer, error) {
|
|||||||
func (s *controlServer) start() error {
|
func (s *controlServer) start() error {
|
||||||
_ = os.Remove(s.addr)
|
_ = os.Remove(s.addr)
|
||||||
unixListener, err := net.Listen("unix", s.addr)
|
unixListener, err := net.Listen("unix", s.addr)
|
||||||
if l, ok := unixListener.(*net.UnixListener); ok {
|
|
||||||
l.SetUnlinkOnClose(true)
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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)
|
go s.server.Serve(unixListener)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -79,34 +90,34 @@ func (s *controlServer) register(pattern string, handler http.Handler) {
|
|||||||
|
|
||||||
func (p *prog) registerControlServerHandler() {
|
func (p *prog) registerControlServerHandler() {
|
||||||
p.cs.register(listClientsPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
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()
|
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 {
|
sort.Slice(clients, func(i, j int) bool {
|
||||||
return clients[i].IP.Less(clients[j].IP)
|
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() {
|
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 {
|
for idx, client := range clients {
|
||||||
mainLog.Load().Debug().
|
p.Debug().
|
||||||
Int("index", idx).
|
Int("index", idx).
|
||||||
Str("ip", client.IP.String()).
|
Str("ip", client.IP.String()).
|
||||||
Str("mac", client.Mac).
|
Str("mac", client.Mac).
|
||||||
Str("hostname", client.Hostname).
|
Str("hostname", client.Hostname).
|
||||||
Msg("processing client metrics")
|
Msg("Processing client metrics")
|
||||||
|
|
||||||
client.IncludeQueryCount = true
|
client.IncludeQueryCount = true
|
||||||
dm := &dto.Metric{}
|
dm := &dto.Metric{}
|
||||||
|
|
||||||
if statsClientQueriesCount.MetricVec == nil {
|
if statsClientQueriesCount.MetricVec == nil {
|
||||||
mainLog.Load().Debug().
|
p.Debug().
|
||||||
Str("client_ip", client.IP.String()).
|
Str("client_ip", client.IP.String()).
|
||||||
Msg("skipping metrics collection: MetricVec is nil")
|
Msg("Skipping metrics collection: MetricVec is nil")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,44 +127,44 @@ func (p *prog) registerControlServerHandler() {
|
|||||||
client.Hostname,
|
client.Hostname,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
mainLog.Load().Debug().
|
p.Debug().
|
||||||
Err(err).
|
Err(err).
|
||||||
Str("client_ip", client.IP.String()).
|
Str("client_ip", client.IP.String()).
|
||||||
Str("mac", client.Mac).
|
Str("mac", client.Mac).
|
||||||
Str("hostname", client.Hostname).
|
Str("hostname", client.Hostname).
|
||||||
Msg("failed to get metrics for client")
|
Msg("Failed to get metrics for client")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.Write(dm); err == nil && dm.Counter != nil {
|
if err := m.Write(dm); err == nil && dm.Counter != nil {
|
||||||
client.QueryCount = int64(dm.Counter.GetValue())
|
client.QueryCount = int64(dm.Counter.GetValue())
|
||||||
mainLog.Load().Debug().
|
p.Debug().
|
||||||
Str("client_ip", client.IP.String()).
|
Str("client_ip", client.IP.String()).
|
||||||
Int64("query_count", client.QueryCount).
|
Int64("query_count", client.QueryCount).
|
||||||
Msg("successfully collected query count")
|
Msg("Successfully collected query count")
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
mainLog.Load().Debug().
|
p.Debug().
|
||||||
Err(err).
|
Err(err).
|
||||||
Str("client_ip", client.IP.String()).
|
Str("client_ip", client.IP.String()).
|
||||||
Msg("failed to write metric")
|
Msg("Failed to write metric")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} 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 {
|
if err := json.NewEncoder(w).Encode(&clients); err != nil {
|
||||||
mainLog.Load().Error().
|
p.Error().
|
||||||
Err(err).
|
Err(err).
|
||||||
Int("client_count", len(clients)).
|
Int("client_count", len(clients)).
|
||||||
Msg("failed to encode clients response")
|
Msg("Failed to encode clients response")
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
mainLog.Load().Debug().
|
p.Debug().
|
||||||
Int("client_count", len(clients)).
|
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) {
|
p.cs.register(startedPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||||
select {
|
select {
|
||||||
@@ -175,14 +186,14 @@ func (p *prog) registerControlServerHandler() {
|
|||||||
oldSvc := p.cfg.Service
|
oldSvc := p.cfg.Service
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
if err := p.sendReloadSignal(); err != nil {
|
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)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
case <-p.reloadDoneCh:
|
case <-p.reloadDoneCh:
|
||||||
case <-time.After(5 * time.Second):
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,15 +227,28 @@ func (p *prog) registerControlServerHandler() {
|
|||||||
return
|
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.
|
// 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 {
|
if rc.DeactivationPin != nil {
|
||||||
cdDeactivationPin.Store(*rc.DeactivationPin)
|
cdDeactivationPin.Store(*rc.DeactivationPin)
|
||||||
} else {
|
} else {
|
||||||
cdDeactivationPin.Store(defaultDeactivationPin)
|
cdDeactivationPin.Store(defaultDeactivationPin)
|
||||||
}
|
}
|
||||||
} else {
|
} 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.
|
// If pin code not set, allowing deactivation.
|
||||||
@@ -236,7 +260,7 @@ func (p *prog) registerControlServerHandler() {
|
|||||||
var req deactivationRequest
|
var req deactivationRequest
|
||||||
if err := json.NewDecoder(request.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(request.Body).Decode(&req); err != nil {
|
||||||
w.WriteHeader(http.StatusPreconditionFailed)
|
w.WriteHeader(http.StatusPreconditionFailed)
|
||||||
mainLog.Load().Err(err).Msg("invalid deactivation request")
|
p.Error().Err(err).Msg("Invalid deactivation request")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,6 +268,7 @@ func (p *prog) registerControlServerHandler() {
|
|||||||
switch req.Pin {
|
switch req.Pin {
|
||||||
case cdDeactivationPin.Load():
|
case cdDeactivationPin.Load():
|
||||||
code = http.StatusOK
|
code = http.StatusOK
|
||||||
|
deactivationFailedAttempts.Store(0)
|
||||||
select {
|
select {
|
||||||
case p.pinCodeValidCh <- struct{}{}:
|
case p.pinCodeValidCh <- struct{}{}:
|
||||||
default:
|
default:
|
||||||
@@ -251,6 +276,11 @@ func (p *prog) registerControlServerHandler() {
|
|||||||
case defaultDeactivationPin:
|
case defaultDeactivationPin:
|
||||||
// If the pin code was set, but users do not provide --pin, return proper code to client.
|
// If the pin code was set, but users do not provide --pin, return proper code to client.
|
||||||
code = http.StatusBadRequest
|
code = http.StatusBadRequest
|
||||||
|
default:
|
||||||
|
if deactivationFailedAttempts.Add(1) >= deactivationMaxFailedAttempts {
|
||||||
|
deactivationLockedUntil.Store(time.Now().Unix() + deactivationLockoutSeconds)
|
||||||
|
deactivationFailedAttempts.Store(0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
w.WriteHeader(code)
|
w.WriteHeader(code)
|
||||||
}))
|
}))
|
||||||
@@ -271,6 +301,10 @@ func (p *prog) registerControlServerHandler() {
|
|||||||
res.Name = p.runningIface
|
res.Name = p.runningIface
|
||||||
res.All = p.requiredMultiNICsConfig
|
res.All = p.requiredMultiNICsConfig
|
||||||
res.OK = true
|
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 {
|
if err := json.NewEncoder(w).Encode(res); err != nil {
|
||||||
@@ -280,7 +314,7 @@ func (p *prog) registerControlServerHandler() {
|
|||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
p.cs.register(viewLogsPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
p.cs.register(viewLogsPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||||
lr, err := p.logReader()
|
lr, err := p.logReaderRaw()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@@ -306,7 +340,7 @@ func (p *prog) registerControlServerHandler() {
|
|||||||
w.WriteHeader(http.StatusServiceUnavailable)
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
r, err := p.logReader()
|
r, err := p.logReaderNoColor()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@@ -319,14 +353,15 @@ func (p *prog) registerControlServerHandler() {
|
|||||||
UID: cdUID,
|
UID: cdUID,
|
||||||
Data: r.r,
|
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}
|
resp := logSentResponse{Size: r.size}
|
||||||
if err := controld.SendLogs(req, cdDev); err != nil {
|
loggerCtx := ctrld.LoggerCtx(context.Background(), p.logger.Load())
|
||||||
mainLog.Load().Error().Msgf("could not send log file to ControlD server: %v", err)
|
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()
|
resp.Error = err.Error()
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
} else {
|
} else {
|
||||||
mainLog.Load().Debug().Msg("sending log file successfully")
|
p.Debug().Msg("Sending log file successfully")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}
|
}
|
||||||
if err := json.NewEncoder(w).Encode(&resp); err != nil {
|
if err := json.NewEncoder(w).Encode(&resp); err != nil {
|
||||||
@@ -334,8 +369,173 @@ func (p *prog) registerControlServerHandler() {
|
|||||||
}
|
}
|
||||||
p.internalLogSent = time.Now()
|
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 {
|
func jsonResponse(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
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
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,217 @@
|
|||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
//go:build !windows && !darwin
|
||||||
|
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkTunnelInterfaceChanges is a no-op on unsupported platforms.
|
||||||
|
func (p *prog) checkTunnelInterfaceChanges() 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() {}
|
||||||
|
|
||||||
|
// 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,55 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeExemptions := p.vpnDNS.CurrentExemptions()
|
||||||
|
routes, domainlessServers, exemptions = p.vpnDNS.RefreshRoutesOnly()
|
||||||
|
afterExemptions := p.vpnDNS.CurrentExemptions()
|
||||||
|
|
||||||
|
if vpnDNSExemptionsEqual(beforeExemptions, afterExemptions) {
|
||||||
|
mainLog.Load().Info().Msgf("DNS intercept: post-settle VPN DNS route refresh completed — %d routes, %d domainless servers, %d exemptions (pf unchanged)",
|
||||||
|
routes, domainlessServers, exemptions)
|
||||||
|
return routes, domainlessServers, exemptions
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.exemptVPNDNSServers(afterExemptions); err != nil {
|
||||||
|
mainLog.Load().Warn().Err(err).Msg("DNS intercept: post-settle VPN DNS exemption update failed")
|
||||||
|
} else {
|
||||||
|
mainLog.Load().Info().Msgf("DNS intercept: post-settle VPN DNS exemptions changed — updated pf/WFP with %d exemptions", len(afterExemptions))
|
||||||
|
}
|
||||||
|
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,49 @@
|
|||||||
|
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) != 0 {
|
||||||
|
t.Fatalf("expected route-only refresh to avoid pf exemption updates, got %+v", exemptionUpdates)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+1276
-491
File diff suppressed because it is too large
Load Diff
+385
-1
@@ -77,7 +77,8 @@ func Test_prog_upstreamFor(t *testing.T) {
|
|||||||
cfg := testhelper.SampleConfig(t)
|
cfg := testhelper.SampleConfig(t)
|
||||||
cfg.Service.LeakOnUpstreamFailure = func(v bool) *bool { return &v }(false)
|
cfg.Service.LeakOnUpstreamFailure = func(v bool) *bool { return &v }(false)
|
||||||
p := &prog{cfg: cfg}
|
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.lanLoopGuard = newLoopGuard()
|
||||||
p.ptrLoopGuard = newLoopGuard()
|
p.ptrLoopGuard = newLoopGuard()
|
||||||
for _, nc := range p.cfg.Network {
|
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) {
|
func TestCache(t *testing.T) {
|
||||||
cfg := testhelper.SampleConfig(t)
|
cfg := testhelper.SampleConfig(t)
|
||||||
prog := &prog{cfg: cfg}
|
prog := &prog{cfg: cfg}
|
||||||
|
prog.logger.Store(mainLog.Load())
|
||||||
for _, nc := range prog.cfg.Network {
|
for _, nc := range prog.cfg.Network {
|
||||||
for _, cidr := range nc.Cidrs {
|
for _, cidr := range nc.Cidrs {
|
||||||
_, ipNet, err := net.ParseCIDR(cidr)
|
_, ipNet, err := net.ParseCIDR(cidr)
|
||||||
@@ -405,6 +491,8 @@ func Test_isPrivatePtrLookup(t *testing.T) {
|
|||||||
{"CGNAT", newDnsMsgPtr("100.66.27.28", t), true},
|
{"CGNAT", newDnsMsgPtr("100.66.27.28", t), true},
|
||||||
{"Loopback", newDnsMsgPtr("127.0.0.1", t), true},
|
{"Loopback", newDnsMsgPtr("127.0.0.1", t), true},
|
||||||
{"Link Local Unicast", newDnsMsgPtr("fe80::69f6:e16e:8bdb:433f", 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},
|
{"Public IP", newDnsMsgPtr("8.8.8.8", t), false},
|
||||||
}
|
}
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
@@ -452,6 +540,11 @@ func Test_isWanClient(t *testing.T) {
|
|||||||
{"CGNAT", &net.UDPAddr{IP: net.ParseIP("100.66.27.28")}, false},
|
{"CGNAT", &net.UDPAddr{IP: net.ParseIP("100.66.27.28")}, false},
|
||||||
{"Loopback", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")}, 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},
|
{"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},
|
{"Public", &net.UDPAddr{IP: net.ParseIP("8.8.8.8")}, true},
|
||||||
}
|
}
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
@@ -464,3 +557,294 @@ func Test_isWanClient(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Test_shouldStartRecovery(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
reason RecoveryReason
|
||||||
|
hasExistingRecovery bool
|
||||||
|
expectedResult bool
|
||||||
|
description string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "network change with existing recovery",
|
||||||
|
reason: RecoveryReasonNetworkChange,
|
||||||
|
hasExistingRecovery: true,
|
||||||
|
expectedResult: true,
|
||||||
|
description: "should cancel existing recovery and start new one for network change",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "network change without existing recovery",
|
||||||
|
reason: RecoveryReasonNetworkChange,
|
||||||
|
hasExistingRecovery: false,
|
||||||
|
expectedResult: true,
|
||||||
|
description: "should start new recovery for network change",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "regular failure with existing recovery",
|
||||||
|
reason: RecoveryReasonRegularFailure,
|
||||||
|
hasExistingRecovery: true,
|
||||||
|
expectedResult: false,
|
||||||
|
description: "should skip duplicate recovery for regular failure",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "regular failure without existing recovery",
|
||||||
|
reason: RecoveryReasonRegularFailure,
|
||||||
|
hasExistingRecovery: false,
|
||||||
|
expectedResult: true,
|
||||||
|
description: "should start new recovery for regular failure",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "OS failure with existing recovery",
|
||||||
|
reason: RecoveryReasonOSFailure,
|
||||||
|
hasExistingRecovery: true,
|
||||||
|
expectedResult: false,
|
||||||
|
description: "should skip duplicate recovery for OS failure",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "OS failure without existing recovery",
|
||||||
|
reason: RecoveryReasonOSFailure,
|
||||||
|
hasExistingRecovery: false,
|
||||||
|
expectedResult: true,
|
||||||
|
description: "should start new recovery for OS failure",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
p := newTestProg(t)
|
||||||
|
|
||||||
|
// Setup existing recovery if needed
|
||||||
|
if tc.hasExistingRecovery {
|
||||||
|
p.recoveryCancelMu.Lock()
|
||||||
|
p.recoveryCancel = func() {} // Mock cancel function
|
||||||
|
p.recoveryCancelMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
result := p.shouldStartRecovery(tc.reason)
|
||||||
|
assert.Equal(t, tc.expectedResult, result, tc.description)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_createRecoveryContext(t *testing.T) {
|
||||||
|
p := newTestProg(t)
|
||||||
|
|
||||||
|
ctx, cleanup := p.createRecoveryContext()
|
||||||
|
|
||||||
|
// Verify context is created
|
||||||
|
assert.NotNil(t, ctx)
|
||||||
|
assert.NotNil(t, cleanup)
|
||||||
|
|
||||||
|
// Verify recoveryCancel is set
|
||||||
|
p.recoveryCancelMu.Lock()
|
||||||
|
assert.NotNil(t, p.recoveryCancel)
|
||||||
|
p.recoveryCancelMu.Unlock()
|
||||||
|
|
||||||
|
// Test cleanup function
|
||||||
|
cleanup()
|
||||||
|
|
||||||
|
// Verify recoveryCancel is cleared
|
||||||
|
p.recoveryCancelMu.Lock()
|
||||||
|
assert.Nil(t, p.recoveryCancel)
|
||||||
|
p.recoveryCancelMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_prepareForRecovery(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
reason RecoveryReason
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "regular failure",
|
||||||
|
reason: RecoveryReasonRegularFailure,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "network change",
|
||||||
|
reason: RecoveryReasonNetworkChange,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "OS failure",
|
||||||
|
reason: RecoveryReasonOSFailure,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
p := newTestProg(t)
|
||||||
|
|
||||||
|
err := p.prepareForRecovery(tc.reason)
|
||||||
|
|
||||||
|
if tc.wantErr {
|
||||||
|
assert.Error(t, err)
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify recoveryRunning is set to true
|
||||||
|
assert.True(t, p.recoveryRunning.Load())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_completeRecovery(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
reason RecoveryReason
|
||||||
|
recovered string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "regular failure recovery",
|
||||||
|
reason: RecoveryReasonRegularFailure,
|
||||||
|
recovered: "upstream1",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "network change recovery",
|
||||||
|
reason: RecoveryReasonNetworkChange,
|
||||||
|
recovered: "upstream2",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "OS failure recovery",
|
||||||
|
reason: RecoveryReasonOSFailure,
|
||||||
|
recovered: "upstream3",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
p := newTestProg(t)
|
||||||
|
|
||||||
|
err := p.completeRecovery(tc.reason, tc.recovered)
|
||||||
|
|
||||||
|
if tc.wantErr {
|
||||||
|
assert.Error(t, err)
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify recoveryRunning is set to false
|
||||||
|
assert.False(t, p.recoveryRunning.Load())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_reinitializeOSResolver(t *testing.T) {
|
||||||
|
p := newTestProg(t)
|
||||||
|
|
||||||
|
err := p.reinitializeOSResolver("Test message")
|
||||||
|
|
||||||
|
// This function should not return an error under normal circumstances
|
||||||
|
// The actual behavior depends on the OS resolver implementation
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_handleRecovery_Integration(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
reason RecoveryReason
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "network change recovery",
|
||||||
|
reason: RecoveryReasonNetworkChange,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "regular failure recovery",
|
||||||
|
reason: RecoveryReasonRegularFailure,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "OS failure recovery",
|
||||||
|
reason: RecoveryReasonOSFailure,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
p := newTestProg(t)
|
||||||
|
|
||||||
|
// This is an integration test that exercises the full recovery flow
|
||||||
|
// In a real test environment, you would mock the dependencies
|
||||||
|
// For now, we're just testing that the method doesn't panic
|
||||||
|
// and that the recovery logic flows correctly
|
||||||
|
assert.NotPanics(t, func() {
|
||||||
|
// Test only the preparation phase to avoid actual upstream checking
|
||||||
|
if !p.shouldStartRecovery(tc.reason) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, cleanup := p.createRecoveryContext()
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if err := p.prepareForRecovery(tc.reason); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip the actual upstream recovery check for this test
|
||||||
|
// as it requires properly configured upstreams
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_prog_queryFromSelf(t *testing.T) {
|
||||||
|
p := 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,311 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kardianos/service"
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
|
||||||
|
"github.com/Control-D-Inc/ctrld/internal/firewall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// firewallModeEnabled reports whether firewall mode is active for this prog instance.
|
||||||
|
func (p *prog) firewallModeEnabled() bool {
|
||||||
|
return p.allowList != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// initFirewallAllowList populates the permanent allowlist entries and starts the
|
||||||
|
// background reaper. Called once during prog.run() when firewall_mode is "on".
|
||||||
|
//
|
||||||
|
// Permanent entries include:
|
||||||
|
// - Loopback (127.0.0.0/8, ::1)
|
||||||
|
// - RFC1918 private ranges (configurable — enabled by default)
|
||||||
|
// - Link-local (169.254.0.0/16, fe80::/10)
|
||||||
|
// - CGNAT range (100.64.0.0/10) — used by Tailscale, carrier NAT
|
||||||
|
// - ctrld listener IPs
|
||||||
|
// - DoH/DoT/DoQ upstream resolver IPs
|
||||||
|
// - ControlD API endpoint IPs
|
||||||
|
func (p *prog) initFirewallAllowList(ctx context.Context) {
|
||||||
|
al := p.allowList
|
||||||
|
|
||||||
|
// Loopback.
|
||||||
|
al.AddPermanentPrefix(netip.MustParsePrefix("127.0.0.0/8"))
|
||||||
|
al.AddPermanent(netip.MustParseAddr("::1"))
|
||||||
|
|
||||||
|
// RFC1918 private ranges — needed for LAN access, printers, NAS, etc.
|
||||||
|
al.AddPermanentPrefix(netip.MustParsePrefix("10.0.0.0/8"))
|
||||||
|
al.AddPermanentPrefix(netip.MustParsePrefix("172.16.0.0/12"))
|
||||||
|
al.AddPermanentPrefix(netip.MustParsePrefix("192.168.0.0/16"))
|
||||||
|
|
||||||
|
// Link-local.
|
||||||
|
al.AddPermanentPrefix(netip.MustParsePrefix("169.254.0.0/16"))
|
||||||
|
al.AddPermanentPrefix(netip.MustParsePrefix("fe80::/10"))
|
||||||
|
|
||||||
|
// CGNAT range — used by Tailscale (100.x.x.x), carrier-grade NAT, etc.
|
||||||
|
al.AddPermanentPrefix(netip.MustParsePrefix("100.64.0.0/10"))
|
||||||
|
|
||||||
|
// Multicast.
|
||||||
|
al.AddPermanentPrefix(netip.MustParsePrefix("224.0.0.0/4"))
|
||||||
|
al.AddPermanentPrefix(netip.MustParsePrefix("ff00::/8"))
|
||||||
|
|
||||||
|
// ctrld listener IPs — traffic to ourselves must always be allowed.
|
||||||
|
for _, lc := range p.cfg.Listener {
|
||||||
|
if ip, err := netip.ParseAddr(lc.IP); err == nil {
|
||||||
|
al.AddPermanent(ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upstream resolver IPs — ctrld needs to reach its upstreams.
|
||||||
|
p.addUpstreamIPsToPermanent(al)
|
||||||
|
|
||||||
|
// Platform-specific enforcement (pf on macOS, WFP on Windows) is initialized
|
||||||
|
// from postRun() after startDNSIntercept() has prepared dnsInterceptState.
|
||||||
|
|
||||||
|
p.Info().Msgf("Firewall allowlist initialized with %d permanent entries",
|
||||||
|
al.Stats().PermanentIPs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncFirewallMode applies the current firewall_mode setting for this run.
|
||||||
|
// Reloads create a new run-scoped context, so background firewall workers must
|
||||||
|
// be restarted each run even when the allowlist object is reused.
|
||||||
|
func (p *prog) syncFirewallMode(ctx context.Context) {
|
||||||
|
if p.cfg.Service.FirewallMode != "on" {
|
||||||
|
if p.allowList != nil || p.platformFirewallState != nil {
|
||||||
|
p.Info().Msg("Firewall mode disabled: removing platform enforcement and clearing allowlist")
|
||||||
|
}
|
||||||
|
if p.allowList != nil {
|
||||||
|
p.allowList.SetOnChange(nil)
|
||||||
|
p.allowList.SetOnBatchChange(nil)
|
||||||
|
p.allowList = nil
|
||||||
|
}
|
||||||
|
if p.platformFirewallState != nil {
|
||||||
|
p.shutdownPlatformFirewall()
|
||||||
|
p.platformFirewallState = nil
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.allowList == nil {
|
||||||
|
p.allowList = firewall.New()
|
||||||
|
p.initFirewallAllowList(ctx)
|
||||||
|
if service.Interactive() {
|
||||||
|
p.Warn().Msg("Firewall mode has no effect in interactive mode; run ctrld as a service for enforcement")
|
||||||
|
} else {
|
||||||
|
p.Info().Msg("Firewall mode enabled: only DNS-resolved IPs will be allowed")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
p.addUpstreamIPsToPermanent(p.allowList)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The run context is canceled on each reload. Restart the reaper/stats
|
||||||
|
// workers for this run so reused allowlists keep expiring entries.
|
||||||
|
p.allowList.StartReaper(ctx)
|
||||||
|
go p.logFirewallStats(ctx)
|
||||||
|
|
||||||
|
// On reload, postRun() is not called, so initialize platform enforcement here
|
||||||
|
// if intercept state already exists. Initial startup still defers to postRun()
|
||||||
|
// because DNS intercept state is prepared there.
|
||||||
|
if p.dnsInterceptState != nil && p.platformFirewallState == nil {
|
||||||
|
p.initPlatformFirewall()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// addUpstreamIPsToPermanent resolves upstream endpoint hostnames and adds their
|
||||||
|
// IPs to the permanent allowlist. Called at startup and on config reload.
|
||||||
|
func (p *prog) addUpstreamIPsToPermanent(al *firewall.AllowList) {
|
||||||
|
for _, uc := range p.cfg.Upstream {
|
||||||
|
if uc == nil || uc.Endpoint == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Extract host from the endpoint URL.
|
||||||
|
host := extractHostFromEndpoint(uc.Endpoint)
|
||||||
|
if host == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's already an IP, add directly.
|
||||||
|
if ip, err := netip.ParseAddr(host); err == nil {
|
||||||
|
al.AddPermanent(ip)
|
||||||
|
p.Debug().Msgf("Firewall: added upstream IP %s to permanent allowlist", ip)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve hostname to IPs.
|
||||||
|
ips, err := net.LookupHost(host)
|
||||||
|
if err != nil {
|
||||||
|
p.Warn().Err(err).Msgf("Firewall: could not resolve upstream host %s", host)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, ipStr := range ips {
|
||||||
|
if ip, err := netip.ParseAddr(ipStr); err == nil {
|
||||||
|
al.AddPermanent(ip)
|
||||||
|
p.Debug().Msgf("Firewall: added upstream IP %s (%s) to permanent allowlist", ip, host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractHostFromEndpoint extracts the hostname or IP from a DoH/DoT/DoQ endpoint URL.
|
||||||
|
// Handles formats like:
|
||||||
|
// - "https://dns.controld.com/abcdef"
|
||||||
|
// - "tls://dns.controld.com"
|
||||||
|
// - "quic://dns.controld.com:784"
|
||||||
|
// - "1.2.3.4:53"
|
||||||
|
// - "sdns://..." (DNS stamps — host is encoded inside, skip)
|
||||||
|
func extractHostFromEndpoint(endpoint string) string {
|
||||||
|
// DNS stamps encode the server info in base64 — we can't extract the host
|
||||||
|
// without decoding. The upstream IPs will be resolved by the sdns upstream
|
||||||
|
// initialization path at runtime.
|
||||||
|
if strings.HasPrefix(endpoint, "sdns://") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try parsing as URL first (covers https://, tls://, quic://).
|
||||||
|
if host := extractHostFromURL(endpoint); host != "" {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try as host:port.
|
||||||
|
host, _, err := net.SplitHostPort(endpoint)
|
||||||
|
if err == nil {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try as bare IP.
|
||||||
|
if _, err := netip.ParseAddr(endpoint); err == nil {
|
||||||
|
return endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractHostFromURL extracts the host from a URL string.
|
||||||
|
func extractHostFromURL(s string) string {
|
||||||
|
u, err := url.Parse(s)
|
||||||
|
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return u.Hostname()
|
||||||
|
}
|
||||||
|
|
||||||
|
// firewallRecordResolvedIPs extracts A and AAAA records from a DNS response
|
||||||
|
// and adds them to the firewall allowlist. Called from postProcessStandardQuery()
|
||||||
|
// after a successful DNS resolution.
|
||||||
|
//
|
||||||
|
// This is the primary feed for the allowlist — every IP that ctrld resolves
|
||||||
|
// gets added here, making it allowed for outbound connections.
|
||||||
|
func (p *prog) firewallRecordResolvedIPs(answer *dns.Msg, domain string) {
|
||||||
|
if p.allowList == nil || answer == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only record IPs from successful responses.
|
||||||
|
if answer.Rcode != dns.RcodeSuccess {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, rr := range answer.Answer {
|
||||||
|
switch r := rr.(type) {
|
||||||
|
case *dns.A:
|
||||||
|
if ip, ok := netip.AddrFromSlice(r.A); ok {
|
||||||
|
ttl := time.Duration(r.Hdr.Ttl) * time.Second
|
||||||
|
if ttl < 30*time.Second {
|
||||||
|
// Enforce minimum TTL to prevent constant churn for very short TTLs.
|
||||||
|
ttl = 30 * time.Second
|
||||||
|
}
|
||||||
|
p.allowList.Add(ip, domain, ttl)
|
||||||
|
}
|
||||||
|
case *dns.AAAA:
|
||||||
|
if ip, ok := netip.AddrFromSlice(r.AAAA); ok {
|
||||||
|
ttl := time.Duration(r.Hdr.Ttl) * time.Second
|
||||||
|
if ttl < 30*time.Second {
|
||||||
|
ttl = 30 * time.Second
|
||||||
|
}
|
||||||
|
p.allowList.Add(ip, domain, ttl)
|
||||||
|
}
|
||||||
|
case *dns.CNAME:
|
||||||
|
// For CNAME chains: the final A/AAAA records will be caught above.
|
||||||
|
// We don't need to do anything special for the CNAME itself, but we
|
||||||
|
// log it for debugging CNAME chain issues.
|
||||||
|
p.Debug().Msgf("Firewall: CNAME %s → %s (IPs from target will be allowlisted)", domain, r.Target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// firewallOnConfigReload is called when apiConfigReload() detects a config change.
|
||||||
|
// It flushes the entire allowlist so that DNS queries against the new policy
|
||||||
|
// repopulate it with the correct set of allowed IPs.
|
||||||
|
//
|
||||||
|
// This is the simple approach (vs. selective re-resolution per domain).
|
||||||
|
// The tradeoff is a brief window where connections may fail until DNS cache
|
||||||
|
// repopulates. Apps that reconnect directly to a previously resolved IP without
|
||||||
|
// making a fresh DNS query can remain blocked longer; this is an explicit v1
|
||||||
|
// limitation to call out in release notes and app-compatibility testing.
|
||||||
|
func (p *prog) firewallOnConfigReload() {
|
||||||
|
if p.allowList == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stats := p.allowList.Stats()
|
||||||
|
p.Info().Msgf("Firewall: config reload detected, flushing allowlist (%d IPs, %d domains)",
|
||||||
|
stats.AllowedIPs, stats.TrackedDomains)
|
||||||
|
// Flush platform-specific state first (pf table / WFP filters),
|
||||||
|
// then flush the allowlist. The AllowList's batch callbacks will
|
||||||
|
// also fire, but the platform flush handles the bulk operation more
|
||||||
|
// efficiently than removing IPs one-by-one.
|
||||||
|
p.firewallFlushPlatform()
|
||||||
|
p.allowList.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// firewallOnNetworkChange is called when monitorNetworkChanges() detects a major
|
||||||
|
// network transition (WiFi↔cellular, interface IP changes). Stale IPs from the
|
||||||
|
// old network may no longer be valid, so we flush and let DNS repopulate.
|
||||||
|
func (p *prog) firewallOnNetworkChange() {
|
||||||
|
if p.allowList == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stats := p.allowList.Stats()
|
||||||
|
p.Info().Msgf("Firewall: network change detected, flushing allowlist (%d IPs, %d domains)",
|
||||||
|
stats.AllowedIPs, stats.TrackedDomains)
|
||||||
|
p.firewallFlushPlatform()
|
||||||
|
p.allowList.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// logFirewallStats logs allowlist metrics immediately, then every 5 minutes
|
||||||
|
// while firewall mode is active.
|
||||||
|
func (p *prog) logFirewallStats(ctx context.Context) {
|
||||||
|
if p.allowList == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.logFirewallStatsOnce()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(5 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
p.logFirewallStatsOnce()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *prog) logFirewallStatsOnce() {
|
||||||
|
if p.allowList == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stats := p.allowList.Stats()
|
||||||
|
p.Info().
|
||||||
|
Int("allowed_ips", stats.AllowedIPs).
|
||||||
|
Int("permanent_ips", stats.PermanentIPs).
|
||||||
|
Int("tracked_domains", stats.TrackedDomains).
|
||||||
|
Int64("total_hits", stats.TotalHits).
|
||||||
|
Int64("total_misses", stats.TotalMisses).
|
||||||
|
Msg("Firewall allowlist stats")
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pfFirewallState holds the state for pf-based firewall mode enforcement on macOS.
|
||||||
|
// When firewall mode is active, we maintain a pf table of allowed IPs and add
|
||||||
|
// block/pass rules to the ctrld anchor that enforce the allowlist.
|
||||||
|
type pfFirewallState struct {
|
||||||
|
// mu protects batch accumulation.
|
||||||
|
mu sync.Mutex
|
||||||
|
|
||||||
|
// pendingAdds and pendingRemoves accumulate changes for batched pf updates.
|
||||||
|
pendingAdds []netip.Addr
|
||||||
|
pendingRemoves []netip.Addr
|
||||||
|
|
||||||
|
// batchTimer fires after the accumulation window to flush pending changes.
|
||||||
|
batchTimer *time.Timer
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// pfFirewallTable is the pf table name for dynamically-allowed IPs.
|
||||||
|
pfFirewallTable = "ctrld_allowed"
|
||||||
|
|
||||||
|
// pfFirewallBatchInterval is the accumulation window for batching pf table updates.
|
||||||
|
// Short enough for responsiveness, long enough to avoid per-DNS-response pfctl calls.
|
||||||
|
pfFirewallBatchInterval = 200 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
// firewallFlushPlatform flushes the pf table on macOS.
|
||||||
|
func (p *prog) firewallFlushPlatform() {
|
||||||
|
p.pfFirewallFlushTable()
|
||||||
|
}
|
||||||
|
|
||||||
|
// shutdownPlatformFirewall removes macOS firewall-mode dynamic state. The pf
|
||||||
|
// anchor itself is rebuilt by DNS intercept without firewall rules once
|
||||||
|
// p.allowList is nil.
|
||||||
|
func (p *prog) shutdownPlatformFirewall() {
|
||||||
|
p.pfFirewallFlushTable()
|
||||||
|
|
||||||
|
if p.dnsInterceptState == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var vpnExemptions []vpnDNSExemption
|
||||||
|
if p.vpnDNS != nil {
|
||||||
|
vpnExemptions = p.vpnDNS.CurrentExemptions()
|
||||||
|
}
|
||||||
|
rulesStr := p.buildPFAnchorRules(vpnExemptions)
|
||||||
|
if err := os.WriteFile(pfAnchorFile, []byte(rulesStr), 0644); err != nil {
|
||||||
|
p.Warn().Err(err).Msg("Firewall: failed to write pf anchor during shutdown")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if out, err := exec.Command("pfctl", "-a", pfAnchorName, "-f", pfAnchorFile).CombinedOutput(); err != nil {
|
||||||
|
p.Warn().Err(err).Str("output", strings.TrimSpace(string(out))).Msg("Firewall: failed to reload pf anchor during shutdown")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// initPlatformFirewall initializes macOS-specific firewall enforcement (pf tables).
|
||||||
|
func (p *prog) initPlatformFirewall() {
|
||||||
|
if _, ok := p.platformFirewallState.(*pfFirewallState); ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// pf enforcement is only meaningful when intercept mode is active —
|
||||||
|
// without it, we have no pf anchor to add rules to.
|
||||||
|
if dnsIntercept && p.dnsInterceptState != nil {
|
||||||
|
p.initPFFirewall()
|
||||||
|
} else {
|
||||||
|
p.Info().Msg("Firewall: pf enforcement deferred until intercept mode starts")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// initPFFirewall sets up pf-based firewall mode enforcement. Called from
|
||||||
|
// initFirewallAllowList() when running on macOS with intercept mode active.
|
||||||
|
//
|
||||||
|
// Architecture:
|
||||||
|
// - Creates a pf table <ctrld_allowed> for dynamic IP allowlisting
|
||||||
|
// - Registers batch change callbacks on the AllowList
|
||||||
|
// - The actual pf anchor rules are injected via buildPFFirewallRules() which
|
||||||
|
// is called from buildPFAnchorRules() when firewall mode is active
|
||||||
|
//
|
||||||
|
// The table approach (vs. per-IP pass rules) is critical for performance:
|
||||||
|
// pfctl table operations are O(log n) and don't require a full anchor reload.
|
||||||
|
func (p *prog) initPFFirewall() {
|
||||||
|
state := &pfFirewallState{}
|
||||||
|
p.platformFirewallState = state
|
||||||
|
|
||||||
|
// Register batch callback — AllowList reaper and FlushDomain use this.
|
||||||
|
p.allowList.SetOnBatchChange(func(added []netip.Addr, removed []netip.Addr) {
|
||||||
|
state.mu.Lock()
|
||||||
|
defer state.mu.Unlock()
|
||||||
|
|
||||||
|
if len(added) > 0 {
|
||||||
|
state.pendingAdds = append(state.pendingAdds, added...)
|
||||||
|
}
|
||||||
|
if len(removed) > 0 {
|
||||||
|
state.pendingRemoves = append(state.pendingRemoves, removed...)
|
||||||
|
}
|
||||||
|
state.scheduleBatchFlush(p)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Register individual change callback — Add() and Remove() use this.
|
||||||
|
p.allowList.SetOnChange(func(ip netip.Addr, added bool) {
|
||||||
|
state.mu.Lock()
|
||||||
|
defer state.mu.Unlock()
|
||||||
|
|
||||||
|
if added {
|
||||||
|
state.pendingAdds = append(state.pendingAdds, ip)
|
||||||
|
} else {
|
||||||
|
state.pendingRemoves = append(state.pendingRemoves, ip)
|
||||||
|
}
|
||||||
|
state.scheduleBatchFlush(p)
|
||||||
|
})
|
||||||
|
|
||||||
|
// DNS responses may have populated the allowlist before platform callbacks
|
||||||
|
// were registered. Bulk-load that snapshot so pf starts with the same view
|
||||||
|
// as the in-memory allowlist.
|
||||||
|
p.pfFirewallPopulateTable()
|
||||||
|
|
||||||
|
p.Info().Msg("Firewall: pf table enforcement initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
// scheduleBatchFlush starts or resets the batch timer. Must be called with state.mu held.
|
||||||
|
func (s *pfFirewallState) scheduleBatchFlush(p *prog) {
|
||||||
|
if s.batchTimer != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.batchTimer = time.AfterFunc(pfFirewallBatchInterval, func() {
|
||||||
|
s.flushBatch(p)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// flushBatch applies accumulated pf table changes in a single pfctl call per direction.
|
||||||
|
func (s *pfFirewallState) flushBatch(p *prog) {
|
||||||
|
s.mu.Lock()
|
||||||
|
adds := s.pendingAdds
|
||||||
|
removes := s.pendingRemoves
|
||||||
|
s.pendingAdds = nil
|
||||||
|
s.pendingRemoves = nil
|
||||||
|
s.batchTimer = nil
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if len(adds) == 0 && len(removes) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapse add/remove deltas into the current primary allowlist state. This
|
||||||
|
// avoids leaving pf opposite the allowlist when an Add and Remove for the
|
||||||
|
// same IP land in one batch window.
|
||||||
|
ipsToSync := make(map[netip.Addr]struct{}, len(adds)+len(removes))
|
||||||
|
for _, ip := range adds {
|
||||||
|
ipsToSync[ip] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, ip := range removes {
|
||||||
|
ipsToSync[ip] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var tableAdds, tableRemoves []string
|
||||||
|
for ip := range ipsToSync {
|
||||||
|
if p.allowList != nil && p.allowList.Contains(ip) {
|
||||||
|
tableAdds = append(tableAdds, ip.String())
|
||||||
|
} else {
|
||||||
|
tableRemoves = append(tableRemoves, ip.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply additions.
|
||||||
|
if len(tableAdds) > 0 {
|
||||||
|
// pfctl -t <table> -T add accepts multiple IPs space-separated.
|
||||||
|
args := append([]string{"-a", pfAnchorName, "-t", pfFirewallTable, "-T", "add"}, tableAdds...)
|
||||||
|
if out, err := exec.Command("pfctl", args...).CombinedOutput(); err != nil {
|
||||||
|
p.Warn().Err(err).Str("output", string(out)).
|
||||||
|
Msgf("Firewall: failed to add %d IPs to pf table", len(tableAdds))
|
||||||
|
} else {
|
||||||
|
p.Debug().Msgf("Firewall: added %d IPs to pf table %s", len(tableAdds), pfFirewallTable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply removals.
|
||||||
|
if len(tableRemoves) > 0 {
|
||||||
|
args := append([]string{"-a", pfAnchorName, "-t", pfFirewallTable, "-T", "delete"}, tableRemoves...)
|
||||||
|
if out, err := exec.Command("pfctl", args...).CombinedOutput(); err != nil {
|
||||||
|
// Not a hard error — the IP may have already been removed (e.g., by a Flush).
|
||||||
|
p.Debug().Err(err).Str("output", string(out)).
|
||||||
|
Msgf("Firewall: failed to remove %d IPs from pf table (may already be gone)", len(tableRemoves))
|
||||||
|
} else {
|
||||||
|
p.Debug().Msgf("Firewall: removed %d IPs from pf table %s", len(tableRemoves), pfFirewallTable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pfFirewallFlushTable removes all entries from the pf firewall table.
|
||||||
|
// Called on network changes and config reloads before the AllowList is flushed.
|
||||||
|
func (p *prog) pfFirewallFlushTable() {
|
||||||
|
if state, ok := p.platformFirewallState.(*pfFirewallState); ok && state != nil {
|
||||||
|
state.mu.Lock()
|
||||||
|
if state.batchTimer != nil {
|
||||||
|
state.batchTimer.Stop()
|
||||||
|
state.batchTimer = nil
|
||||||
|
}
|
||||||
|
state.pendingAdds = nil
|
||||||
|
state.pendingRemoves = nil
|
||||||
|
state.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := exec.Command("pfctl", "-a", pfAnchorName, "-t", pfFirewallTable, "-T", "flush").CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
p.Debug().Err(err).Str("output", string(out)).Msg("Firewall: failed to flush pf table (may not exist yet)")
|
||||||
|
} else {
|
||||||
|
p.Info().Msg("Firewall: flushed pf table " + pfFirewallTable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pfFirewallPopulateTable bulk-loads all currently allowed IPs into the pf table.
|
||||||
|
// Called after anchor rule installation to ensure the table is populated.
|
||||||
|
func (p *prog) pfFirewallPopulateTable() {
|
||||||
|
if p.allowList == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ips := p.allowList.AllowedIPs()
|
||||||
|
if len(ips) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ipStrs := make([]string, 0, len(ips))
|
||||||
|
for _, ip := range ips {
|
||||||
|
ipStrs = append(ipStrs, ip.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
args := append([]string{"-a", pfAnchorName, "-t", pfFirewallTable, "-T", "add"}, ipStrs...)
|
||||||
|
if out, err := exec.Command("pfctl", args...).CombinedOutput(); err != nil {
|
||||||
|
p.Warn().Err(err).Str("output", string(out)).
|
||||||
|
Msgf("Firewall: failed to populate pf table with %d IPs", len(ips))
|
||||||
|
} else {
|
||||||
|
p.Info().Msgf("Firewall: populated pf table with %d allowed IPs", len(ips))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildPFFirewallRules generates the pf rules for firewall mode enforcement.
|
||||||
|
// These rules are appended to the anchor by buildPFAnchorRules() when firewall
|
||||||
|
// mode is active.
|
||||||
|
//
|
||||||
|
// The strategy is:
|
||||||
|
// - Define table <ctrld_allowed> (dynamically populated via pfctl -T add/delete)
|
||||||
|
// - Block all outbound traffic by default (after DNS intercept rules)
|
||||||
|
// - Pass outbound to IPs in <ctrld_allowed>
|
||||||
|
// - Pass outbound from ctrld's group (already handled by blanket exemption)
|
||||||
|
// - Pass loopback, link-local, multicast (already handled by permanent allowlist,
|
||||||
|
// but explicit pf rules prevent kernel-level blocking before our check)
|
||||||
|
//
|
||||||
|
// IMPORTANT: These rules must come AFTER the DNS intercept rules in the anchor
|
||||||
|
// so that DNS itself still works (DNS is how IPs get into the allowlist).
|
||||||
|
func buildPFFirewallRules() string {
|
||||||
|
var rules strings.Builder
|
||||||
|
|
||||||
|
rules.WriteString("\n# --- Firewall Mode: DNS-resolved IP allowlist enforcement ---\n")
|
||||||
|
rules.WriteString("# Only IPs resolved by ctrld are allowed for outbound connections.\n")
|
||||||
|
rules.WriteString("# Table is dynamically populated from DNS responses.\n\n")
|
||||||
|
|
||||||
|
// Declare the table. pfctl -T add/delete operates on this table dynamically.
|
||||||
|
fmt.Fprintf(&rules, "table <%s> persist\n\n", pfFirewallTable)
|
||||||
|
|
||||||
|
// Pass traffic to allowed IPs (both IPv4 and IPv6).
|
||||||
|
rules.WriteString("# Allow outbound to DNS-resolved IPs.\n")
|
||||||
|
fmt.Fprintf(&rules, "pass out quick inet proto { tcp, udp } from any to <%s>\n", pfFirewallTable)
|
||||||
|
fmt.Fprintf(&rules, "pass out quick inet6 proto { tcp, udp } from any to <%s>\n\n", pfFirewallTable)
|
||||||
|
|
||||||
|
// Allow ICMP/ICMPv6 — needed for path MTU discovery, ping, etc.
|
||||||
|
rules.WriteString("# Allow ICMP (path MTU discovery, ping, etc.)\n")
|
||||||
|
rules.WriteString("pass out quick inet proto icmp\n")
|
||||||
|
rules.WriteString("pass out quick inet6 proto icmp6\n\n")
|
||||||
|
|
||||||
|
// Allow all loopback traffic (safety net — permanent allowlist covers this too).
|
||||||
|
rules.WriteString("# Allow all loopback traffic.\n")
|
||||||
|
rules.WriteString("pass out quick on lo0\n")
|
||||||
|
rules.WriteString("pass in quick on lo0\n\n")
|
||||||
|
|
||||||
|
// Allow RFC1918 and link-local — these are in the permanent allowlist but
|
||||||
|
// explicit pf rules prevent the block rule below from catching them.
|
||||||
|
rules.WriteString("# Allow private/link-local ranges (LAN, printers, NAS, mDNS, DHCP).\n")
|
||||||
|
rules.WriteString("pass out quick inet proto { tcp, udp } from any to 10.0.0.0/8\n")
|
||||||
|
rules.WriteString("pass out quick inet proto { tcp, udp } from any to 172.16.0.0/12\n")
|
||||||
|
rules.WriteString("pass out quick inet proto { tcp, udp } from any to 192.168.0.0/16\n")
|
||||||
|
rules.WriteString("pass out quick inet proto { tcp, udp } from any to 169.254.0.0/16\n")
|
||||||
|
rules.WriteString("pass out quick inet proto { tcp, udp } from any to 100.64.0.0/10\n")
|
||||||
|
rules.WriteString("pass out quick inet6 proto { tcp, udp } from any to fe80::/10\n\n")
|
||||||
|
|
||||||
|
// Allow multicast (mDNS, SSDP, etc.).
|
||||||
|
rules.WriteString("# Allow multicast (mDNS, SSDP, etc.).\n")
|
||||||
|
rules.WriteString("pass out quick inet proto { tcp, udp } from any to 224.0.0.0/4\n")
|
||||||
|
rules.WriteString("pass out quick inet6 proto { tcp, udp } from any to ff00::/8\n\n")
|
||||||
|
|
||||||
|
// Allow DHCP (UDP 67/68) — needed for network configuration.
|
||||||
|
rules.WriteString("# Allow DHCP.\n")
|
||||||
|
rules.WriteString("pass out quick inet proto udp from any port 68 to any port 67\n\n")
|
||||||
|
|
||||||
|
// Block everything else. This is the enforcement rule.
|
||||||
|
// "block return" sends TCP RST / ICMP unreachable so apps fail fast instead of timing out.
|
||||||
|
rules.WriteString("# Block all other outbound traffic (IPs not resolved by ctrld).\n")
|
||||||
|
rules.WriteString("block return out quick inet proto { tcp, udp } from any to any\n")
|
||||||
|
rules.WriteString("block return out quick inet6 proto { tcp, udp } from any to any\n")
|
||||||
|
|
||||||
|
return rules.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
//go:build !windows && !darwin
|
||||||
|
|
||||||
|
package cli
|
||||||
|
|
||||||
|
// initPlatformFirewall is a no-op on unsupported platforms (Linux, etc.).
|
||||||
|
// Firewall mode on Linux would require iptables/nftables or eBPF — future work.
|
||||||
|
func (p *prog) initPlatformFirewall() {
|
||||||
|
p.Warn().Msg("Firewall: platform enforcement not available on this OS; firewall_mode fails open and only records allowlist stats")
|
||||||
|
}
|
||||||
|
|
||||||
|
// firewallFlushPlatform is a no-op on unsupported platforms.
|
||||||
|
func (p *prog) firewallFlushPlatform() {}
|
||||||
|
|
||||||
|
// shutdownPlatformFirewall is a no-op on unsupported platforms.
|
||||||
|
func (p *prog) shutdownPlatformFirewall() {}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestExtractHostFromEndpoint(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
endpoint string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "https URL", endpoint: "https://dns.controld.com/abcdef", want: "dns.controld.com"},
|
||||||
|
{name: "URL with userinfo", endpoint: "https://user:pass@dns.controld.com/abcdef", want: "dns.controld.com"},
|
||||||
|
{name: "URL with IPv6 literal", endpoint: "https://[2606:4700:4700::1111]:443/dns-query", want: "2606:4700:4700::1111"},
|
||||||
|
{name: "host port", endpoint: "1.2.3.4:53", want: "1.2.3.4"},
|
||||||
|
{name: "bare IP", endpoint: "1.2.3.4", want: "1.2.3.4"},
|
||||||
|
{name: "DNS stamp", endpoint: "sdns://AgcAAAAAAAAAAA", want: ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := extractHostFromEndpoint(tt.endpoint); got != tt.want {
|
||||||
|
t.Fatalf("extractHostFromEndpoint(%q) = %q, want %q", tt.endpoint, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,614 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
// wfpFirewallState holds the state for WFP-based firewall mode enforcement on Windows.
|
||||||
|
// When firewall mode is active, we add dynamic WFP permit filters for each allowed IP
|
||||||
|
// on top of a block-all base filter.
|
||||||
|
type wfpFirewallState struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
|
||||||
|
// pendingAdds and pendingRemoves accumulate changes for batched WFP updates.
|
||||||
|
pendingAdds []netip.Addr
|
||||||
|
pendingRemoves []netip.Addr
|
||||||
|
|
||||||
|
// batchTimer fires after the accumulation window to flush pending changes.
|
||||||
|
batchTimer *time.Timer
|
||||||
|
|
||||||
|
// filterMap tracks WFP filter IDs for each dynamically allowed IP so we can remove them.
|
||||||
|
// Maps IP string → WFP filter ID.
|
||||||
|
filterMap map[string]uint64
|
||||||
|
|
||||||
|
// permanentFilterMap tracks permit filters for permanent allowlist entries
|
||||||
|
// (loopback/private/link-local/listener/upstream IPs). These stay installed
|
||||||
|
// across dynamic allowlist flushes.
|
||||||
|
permanentFilterMap map[string]uint64
|
||||||
|
|
||||||
|
// blockFilterIDv4 and blockFilterIDv6 are the base block-all filters.
|
||||||
|
blockFilterIDv4 uint64
|
||||||
|
blockFilterIDv6 uint64
|
||||||
|
|
||||||
|
// engineHandle is the WFP engine handle from the intercept state.
|
||||||
|
engineHandle uintptr
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// wfpFirewallBatchInterval is the accumulation window for batching WFP filter updates.
|
||||||
|
wfpFirewallBatchInterval = 200 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
// firewallFlushPlatform removes all dynamic WFP permit filters on Windows.
|
||||||
|
// Called on network changes and config reloads before the in-memory allowlist is flushed.
|
||||||
|
func (p *prog) firewallFlushPlatform() {
|
||||||
|
fwState, ok := p.platformFirewallState.(*wfpFirewallState)
|
||||||
|
if !ok || fwState == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fwState.flushAll(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// shutdownPlatformFirewall removes Windows firewall-mode WFP filters, including
|
||||||
|
// dynamic permits, permanent permits, and the base block-all filters.
|
||||||
|
func (p *prog) shutdownPlatformFirewall() {
|
||||||
|
fwState, ok := p.platformFirewallState.(*wfpFirewallState)
|
||||||
|
if !ok || fwState == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fwState.shutdown(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// initPlatformFirewall initializes Windows-specific firewall enforcement (WFP filters).
|
||||||
|
func (p *prog) initPlatformFirewall() {
|
||||||
|
if fwState, ok := p.platformFirewallState.(*wfpFirewallState); ok && fwState != nil {
|
||||||
|
fwState.populatePermanentFilters(p)
|
||||||
|
fwState.populateFilters(p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hardIntercept || p.dnsInterceptState == nil {
|
||||||
|
p.Info().Msg("Firewall: WFP enforcement requires hard intercept mode")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state, ok := p.dnsInterceptState.(*wfpState)
|
||||||
|
if !ok || state == nil {
|
||||||
|
p.Warn().Msg("Firewall: could not access WFP state for firewall enforcement")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fwState := &wfpFirewallState{
|
||||||
|
filterMap: make(map[string]uint64),
|
||||||
|
permanentFilterMap: make(map[string]uint64),
|
||||||
|
engineHandle: state.engineHandle,
|
||||||
|
}
|
||||||
|
p.platformFirewallState = fwState
|
||||||
|
|
||||||
|
// Install base block-all outbound filters. We add our firewall filters to the
|
||||||
|
// SAME sublayer as DNS intercept with carefully chosen weights:
|
||||||
|
// - Existing DNS permits (localhost): weight 10 (highest, evaluated first)
|
||||||
|
// - Firewall IP permits: weight 5 (middle)
|
||||||
|
// - Existing DNS block: weight 1 (blocks non-localhost DNS)
|
||||||
|
// - Firewall block-all: weight 1 (catch-all for non-DNS)
|
||||||
|
//
|
||||||
|
// WFP evaluates higher weights first within a sublayer. DNS permits at 10
|
||||||
|
// always win, ensuring DNS resolution works. Firewall IP permits at 5
|
||||||
|
// override the block-all for resolved IPs. The existing DNS block at 1
|
||||||
|
// and our block-all at 1 are both catch-alls (DNS block has port 53
|
||||||
|
// conditions so it only catches DNS; our block-all has no conditions
|
||||||
|
// so it catches everything else).
|
||||||
|
if err := p.addWFPFirewallBlockFilters(fwState); err != nil {
|
||||||
|
p.Error().Err(err).Msg("Firewall: failed to install WFP block-all filters")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install permits for the permanent allowlist before enabling dynamic updates.
|
||||||
|
// Without these, the block-all filters would also block ctrld upstreams,
|
||||||
|
// listener/loopback traffic, LAN ranges, and other permanent exceptions.
|
||||||
|
fwState.populatePermanentFilters(p)
|
||||||
|
|
||||||
|
// Register batch callback.
|
||||||
|
p.allowList.SetOnBatchChange(func(added []netip.Addr, removed []netip.Addr) {
|
||||||
|
fwState.mu.Lock()
|
||||||
|
defer fwState.mu.Unlock()
|
||||||
|
|
||||||
|
if len(added) > 0 {
|
||||||
|
fwState.pendingAdds = append(fwState.pendingAdds, added...)
|
||||||
|
}
|
||||||
|
if len(removed) > 0 {
|
||||||
|
fwState.pendingRemoves = append(fwState.pendingRemoves, removed...)
|
||||||
|
}
|
||||||
|
fwState.scheduleBatchFlush(p)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Register individual change callback.
|
||||||
|
p.allowList.SetOnChange(func(ip netip.Addr, isAdded bool) {
|
||||||
|
fwState.mu.Lock()
|
||||||
|
defer fwState.mu.Unlock()
|
||||||
|
|
||||||
|
if isAdded {
|
||||||
|
fwState.pendingAdds = append(fwState.pendingAdds, ip)
|
||||||
|
} else {
|
||||||
|
fwState.pendingRemoves = append(fwState.pendingRemoves, ip)
|
||||||
|
}
|
||||||
|
fwState.scheduleBatchFlush(p)
|
||||||
|
})
|
||||||
|
|
||||||
|
// DNS responses may have populated the allowlist before platform callbacks
|
||||||
|
// were registered. Add permit filters for that snapshot so WFP starts with
|
||||||
|
// the same view as the in-memory allowlist.
|
||||||
|
fwState.populateFilters(p)
|
||||||
|
|
||||||
|
p.Info().Msg("Firewall: WFP enforcement initialized with block-all base filters")
|
||||||
|
}
|
||||||
|
|
||||||
|
// scheduleBatchFlush starts or resets the batch timer. Must be called with fwState.mu held.
|
||||||
|
func (s *wfpFirewallState) scheduleBatchFlush(p *prog) {
|
||||||
|
if s.batchTimer != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.batchTimer = time.AfterFunc(wfpFirewallBatchInterval, func() {
|
||||||
|
s.flushBatch(p)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// flushBatch applies accumulated WFP filter changes.
|
||||||
|
func (s *wfpFirewallState) flushBatch(p *prog) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
adds := s.pendingAdds
|
||||||
|
removes := s.pendingRemoves
|
||||||
|
s.pendingAdds = nil
|
||||||
|
s.pendingRemoves = nil
|
||||||
|
s.batchTimer = nil
|
||||||
|
|
||||||
|
// Collapse add/remove deltas into the current primary allowlist state. This
|
||||||
|
// avoids leaving WFP opposite the allowlist when an Add and Remove for the
|
||||||
|
// same IP land in one batch window.
|
||||||
|
ipsToSync := make(map[netip.Addr]struct{}, len(adds)+len(removes))
|
||||||
|
for _, ip := range adds {
|
||||||
|
ipsToSync[ip] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, ip := range removes {
|
||||||
|
ipsToSync[ip] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
for ip := range ipsToSync {
|
||||||
|
key := ip.String()
|
||||||
|
allowed := p.allowList != nil && p.allowList.Contains(ip)
|
||||||
|
if !allowed {
|
||||||
|
s.removePermitFilterLocked(p, key)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, exists := s.filterMap[key]; exists {
|
||||||
|
continue // Already has a permit filter.
|
||||||
|
}
|
||||||
|
|
||||||
|
filterID, err := p.addWFPFirewallPermitFilter(s, ip)
|
||||||
|
if err != nil {
|
||||||
|
p.Warn().Err(err).Msgf("Firewall: failed to add WFP permit filter for %s", key)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.filterMap[key] = filterID
|
||||||
|
p.Debug().Msgf("Firewall: added WFP permit filter for %s (ID: %d)", key, filterID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// removePermitFilterLocked removes one dynamic WFP permit filter. s.mu must be held.
|
||||||
|
func (s *wfpFirewallState) removePermitFilterLocked(p *prog, key string) {
|
||||||
|
filterID, ok := s.filterMap[key]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r1, _, _ := procFwpmFilterDeleteById0.Call(s.engineHandle, uintptr(filterID))
|
||||||
|
if r1 != 0 {
|
||||||
|
p.Debug().Msgf("Firewall: failed to remove WFP filter for %s (HRESULT 0x%x, may already be gone)", key, r1)
|
||||||
|
} else {
|
||||||
|
p.Debug().Msgf("Firewall: removed WFP permit filter for %s", key)
|
||||||
|
}
|
||||||
|
delete(s.filterMap, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// flushAll synchronously removes every dynamic WFP permit filter and clears any
|
||||||
|
// queued batch work. The block-all filters stay installed.
|
||||||
|
func (s *wfpFirewallState) flushAll(p *prog) {
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.batchTimer != nil {
|
||||||
|
s.batchTimer.Stop()
|
||||||
|
s.batchTimer = nil
|
||||||
|
}
|
||||||
|
s.pendingAdds = nil
|
||||||
|
s.pendingRemoves = nil
|
||||||
|
|
||||||
|
filters := make(map[string]uint64, len(s.filterMap))
|
||||||
|
for key, filterID := range s.filterMap {
|
||||||
|
filters[key] = filterID
|
||||||
|
}
|
||||||
|
s.filterMap = make(map[string]uint64)
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
for key, filterID := range filters {
|
||||||
|
r1, _, _ := procFwpmFilterDeleteById0.Call(s.engineHandle, uintptr(filterID))
|
||||||
|
if r1 != 0 {
|
||||||
|
p.Debug().Msgf("Firewall: failed to remove WFP filter for %s during flush (HRESULT 0x%x, may already be gone)", key, r1)
|
||||||
|
} else {
|
||||||
|
p.Debug().Msgf("Firewall: removed WFP permit filter for %s during flush", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// shutdown removes every WFP filter owned by firewall mode.
|
||||||
|
func (s *wfpFirewallState) shutdown(p *prog) {
|
||||||
|
s.flushAll(p)
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
permanentFilters := make(map[string]uint64, len(s.permanentFilterMap))
|
||||||
|
for key, filterID := range s.permanentFilterMap {
|
||||||
|
permanentFilters[key] = filterID
|
||||||
|
}
|
||||||
|
s.permanentFilterMap = make(map[string]uint64)
|
||||||
|
|
||||||
|
blockIDs := []uint64{s.blockFilterIDv4, s.blockFilterIDv6}
|
||||||
|
s.blockFilterIDv4 = 0
|
||||||
|
s.blockFilterIDv6 = 0
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
for key, filterID := range permanentFilters {
|
||||||
|
if r1, _, _ := procFwpmFilterDeleteById0.Call(s.engineHandle, uintptr(filterID)); r1 != 0 {
|
||||||
|
p.Debug().Msgf("Firewall: failed to remove permanent WFP filter for %s during shutdown (HRESULT 0x%x, may already be gone)", key, r1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, filterID := range blockIDs {
|
||||||
|
if filterID == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r1, _, _ := procFwpmFilterDeleteById0.Call(s.engineHandle, uintptr(filterID)); r1 != 0 {
|
||||||
|
p.Debug().Msgf("Firewall: failed to remove WFP block filter %d during shutdown (HRESULT 0x%x, may already be gone)", filterID, r1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// populatePermanentFilters mirrors the in-memory permanent allowlist into WFP
|
||||||
|
// permit filters so the base block-all rule does not block ctrld itself, local
|
||||||
|
// network traffic, or upstream resolver endpoints.
|
||||||
|
func (s *wfpFirewallState) populatePermanentFilters(p *prog) {
|
||||||
|
if p.allowList == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addrs, prefixes := p.allowList.PermanentEntries()
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
for _, ip := range addrs {
|
||||||
|
key := "addr:" + ip.String()
|
||||||
|
if _, exists := s.permanentFilterMap[key]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filterID, err := p.addWFPFirewallPermitFilter(s, ip)
|
||||||
|
if err != nil {
|
||||||
|
p.Warn().Err(err).Msgf("Firewall: failed to add permanent WFP permit for %s", ip)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.permanentFilterMap[key] = filterID
|
||||||
|
p.Debug().Msgf("Firewall: added permanent WFP permit for %s (ID: %d)", ip, filterID)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, prefix := range prefixes {
|
||||||
|
key := "prefix:" + prefix.String()
|
||||||
|
if _, exists := s.permanentFilterMap[key]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filterID, err := p.addWFPFirewallPermitPrefix(s, prefix)
|
||||||
|
if err != nil {
|
||||||
|
p.Warn().Err(err).Msgf("Firewall: failed to add permanent WFP permit for %s", prefix)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.permanentFilterMap[key] = filterID
|
||||||
|
p.Debug().Msgf("Firewall: added permanent WFP permit for %s (ID: %d)", prefix, filterID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// populateFilters installs permit filters for IPs already present in the allowlist
|
||||||
|
// before WFP callbacks were registered.
|
||||||
|
func (s *wfpFirewallState) populateFilters(p *prog) {
|
||||||
|
if p.allowList == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ips := p.allowList.AllowedIPs()
|
||||||
|
if len(ips) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
for _, ip := range ips {
|
||||||
|
key := ip.String()
|
||||||
|
if _, exists := s.filterMap[key]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
filterID, err := p.addWFPFirewallPermitFilter(s, ip)
|
||||||
|
if err != nil {
|
||||||
|
p.Warn().Err(err).Msgf("Firewall: failed to add initial WFP permit filter for %s", key)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.filterMap[key] = filterID
|
||||||
|
p.Debug().Msgf("Firewall: added initial WFP permit filter for %s (ID: %d)", key, filterID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// addWFPFirewallBlockFilters installs the base block-all outbound filters.
|
||||||
|
// These block ALL non-loopback outbound TCP/UDP traffic. Per-IP permit filters
|
||||||
|
// (added dynamically from the allowlist) override these for resolved IPs.
|
||||||
|
func (p *prog) addWFPFirewallBlockFilters(fwState *wfpFirewallState) error {
|
||||||
|
// Block all outbound IPv4 TCP/UDP.
|
||||||
|
filterName, _ := windows.UTF16PtrFromString("ctrld Firewall Block All IPv4")
|
||||||
|
filter := fwpmFilter0{
|
||||||
|
subLayerKey: ctrldSubLayerGUID,
|
||||||
|
weight: fwpValue0{
|
||||||
|
valueType: fwpUint8, // FWP_UINT8
|
||||||
|
value: 1, // Must be lower than DNS permits (10). Firewall IP permits (5) override this.
|
||||||
|
},
|
||||||
|
action: fwpmAction0{
|
||||||
|
actionType: fwpActionBlock, // FWP_ACTION_BLOCK
|
||||||
|
},
|
||||||
|
layerKey: fwpmLayerALEAuthConnectV4,
|
||||||
|
}
|
||||||
|
filter.displayData.name = filterName
|
||||||
|
|
||||||
|
var filterID uint64
|
||||||
|
r1, _, _ := procFwpmFilterAdd0.Call(
|
||||||
|
fwState.engineHandle,
|
||||||
|
uintptr(unsafe.Pointer(&filter)),
|
||||||
|
0,
|
||||||
|
uintptr(unsafe.Pointer(&filterID)),
|
||||||
|
)
|
||||||
|
if r1 != 0 {
|
||||||
|
return fmt.Errorf("FwpmFilterAdd0 (block IPv4) failed: HRESULT 0x%x", r1)
|
||||||
|
}
|
||||||
|
fwState.blockFilterIDv4 = filterID
|
||||||
|
|
||||||
|
// Block all outbound IPv6 TCP/UDP.
|
||||||
|
filterNameV6, _ := windows.UTF16PtrFromString("ctrld Firewall Block All IPv6")
|
||||||
|
filterV6 := fwpmFilter0{
|
||||||
|
subLayerKey: ctrldSubLayerGUID,
|
||||||
|
weight: fwpValue0{
|
||||||
|
valueType: fwpUint8,
|
||||||
|
value: 1,
|
||||||
|
},
|
||||||
|
action: fwpmAction0{
|
||||||
|
actionType: fwpActionBlock,
|
||||||
|
},
|
||||||
|
layerKey: fwpmLayerALEAuthConnectV6,
|
||||||
|
}
|
||||||
|
filterV6.displayData.name = filterNameV6
|
||||||
|
|
||||||
|
var filterIDv6 uint64
|
||||||
|
r1, _, _ = procFwpmFilterAdd0.Call(
|
||||||
|
fwState.engineHandle,
|
||||||
|
uintptr(unsafe.Pointer(&filterV6)),
|
||||||
|
0,
|
||||||
|
uintptr(unsafe.Pointer(&filterIDv6)),
|
||||||
|
)
|
||||||
|
if r1 != 0 {
|
||||||
|
if fwState.blockFilterIDv4 != 0 {
|
||||||
|
deleteResult, _, _ := procFwpmFilterDeleteById0.Call(fwState.engineHandle, uintptr(fwState.blockFilterIDv4))
|
||||||
|
if deleteResult != 0 {
|
||||||
|
p.Debug().Msgf("Firewall: failed to roll back IPv4 block filter %d after IPv6 setup failure (HRESULT 0x%x)", fwState.blockFilterIDv4, deleteResult)
|
||||||
|
}
|
||||||
|
fwState.blockFilterIDv4 = 0
|
||||||
|
}
|
||||||
|
return fmt.Errorf("FwpmFilterAdd0 (block IPv6) failed: HRESULT 0x%x", r1)
|
||||||
|
}
|
||||||
|
fwState.blockFilterIDv6 = filterIDv6
|
||||||
|
|
||||||
|
p.Info().Msgf("Firewall: WFP block-all filters installed (v4 ID: %d, v6 ID: %d)", filterID, filterIDv6)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// addWFPFirewallPermitPrefix adds a WFP permit filter for a permanent CIDR prefix.
|
||||||
|
func (p *prog) addWFPFirewallPermitPrefix(fwState *wfpFirewallState, prefix netip.Prefix) (uint64, error) {
|
||||||
|
prefix = prefix.Masked()
|
||||||
|
if prefix.Addr().Is4() {
|
||||||
|
return p.addWFPFirewallPermitIPv4Prefix(fwState, prefix)
|
||||||
|
}
|
||||||
|
return p.addWFPFirewallPermitIPv6Prefix(fwState, prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *prog) addWFPFirewallPermitIPv4Prefix(fwState *wfpFirewallState, prefix netip.Prefix) (uint64, error) {
|
||||||
|
addr4 := prefix.Addr().As4()
|
||||||
|
addr := uint32(addr4[0])<<24 | uint32(addr4[1])<<16 | uint32(addr4[2])<<8 | uint32(addr4[3])
|
||||||
|
bits := prefix.Bits()
|
||||||
|
var mask uint32
|
||||||
|
if bits == 0 {
|
||||||
|
mask = 0
|
||||||
|
} else {
|
||||||
|
mask = ^uint32(0) << uint(32-bits)
|
||||||
|
}
|
||||||
|
addrMask := fwpV4AddrAndMask{addr: addr & mask, mask: mask}
|
||||||
|
|
||||||
|
filterName, _ := windows.UTF16PtrFromString("ctrld Firewall Permit " + prefix.String())
|
||||||
|
condition := fwpmFilterCondition0{
|
||||||
|
fieldKey: fwpmConditionIPRemoteAddress,
|
||||||
|
matchType: fwpMatchEqual,
|
||||||
|
}
|
||||||
|
condition.condValue.valueType = fwpV4AddrMask
|
||||||
|
condition.condValue.value = uint64(uintptr(unsafe.Pointer(&addrMask)))
|
||||||
|
|
||||||
|
filter := fwpmFilter0{
|
||||||
|
subLayerKey: ctrldSubLayerGUID,
|
||||||
|
numFilterConds: 1,
|
||||||
|
filterCondition: (*fwpmFilterCondition0)(unsafe.Pointer(&condition)),
|
||||||
|
weight: fwpValue0{
|
||||||
|
valueType: fwpUint8,
|
||||||
|
value: 5,
|
||||||
|
},
|
||||||
|
action: fwpmAction0{
|
||||||
|
actionType: fwpActionPermit,
|
||||||
|
},
|
||||||
|
layerKey: fwpmLayerALEAuthConnectV4,
|
||||||
|
}
|
||||||
|
filter.displayData.name = filterName
|
||||||
|
|
||||||
|
var filterID uint64
|
||||||
|
r1, _, _ := procFwpmFilterAdd0.Call(
|
||||||
|
fwState.engineHandle,
|
||||||
|
uintptr(unsafe.Pointer(&filter)),
|
||||||
|
0,
|
||||||
|
uintptr(unsafe.Pointer(&filterID)),
|
||||||
|
)
|
||||||
|
runtime.KeepAlive(&addrMask)
|
||||||
|
if r1 != 0 {
|
||||||
|
return 0, fmt.Errorf("FwpmFilterAdd0 (permit IPv4 prefix %s) failed: HRESULT 0x%x", prefix, r1)
|
||||||
|
}
|
||||||
|
return filterID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *prog) addWFPFirewallPermitIPv6Prefix(fwState *wfpFirewallState, prefix netip.Prefix) (uint64, error) {
|
||||||
|
addrMask := fwpV6AddrAndMask{addr: prefix.Addr().As16(), prefixLength: uint8(prefix.Bits())}
|
||||||
|
|
||||||
|
filterName, _ := windows.UTF16PtrFromString("ctrld Firewall Permit " + prefix.String())
|
||||||
|
condition := fwpmFilterCondition0{
|
||||||
|
fieldKey: fwpmConditionIPRemoteAddress,
|
||||||
|
matchType: fwpMatchEqual,
|
||||||
|
}
|
||||||
|
condition.condValue.valueType = fwpV6AddrMask
|
||||||
|
condition.condValue.value = uint64(uintptr(unsafe.Pointer(&addrMask)))
|
||||||
|
|
||||||
|
filter := fwpmFilter0{
|
||||||
|
subLayerKey: ctrldSubLayerGUID,
|
||||||
|
numFilterConds: 1,
|
||||||
|
filterCondition: (*fwpmFilterCondition0)(unsafe.Pointer(&condition)),
|
||||||
|
weight: fwpValue0{
|
||||||
|
valueType: fwpUint8,
|
||||||
|
value: 5,
|
||||||
|
},
|
||||||
|
action: fwpmAction0{
|
||||||
|
actionType: fwpActionPermit,
|
||||||
|
},
|
||||||
|
layerKey: fwpmLayerALEAuthConnectV6,
|
||||||
|
}
|
||||||
|
filter.displayData.name = filterName
|
||||||
|
|
||||||
|
var filterID uint64
|
||||||
|
r1, _, _ := procFwpmFilterAdd0.Call(
|
||||||
|
fwState.engineHandle,
|
||||||
|
uintptr(unsafe.Pointer(&filter)),
|
||||||
|
0,
|
||||||
|
uintptr(unsafe.Pointer(&filterID)),
|
||||||
|
)
|
||||||
|
runtime.KeepAlive(&addrMask)
|
||||||
|
if r1 != 0 {
|
||||||
|
return 0, fmt.Errorf("FwpmFilterAdd0 (permit IPv6 prefix %s) failed: HRESULT 0x%x", prefix, r1)
|
||||||
|
}
|
||||||
|
return filterID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// addWFPFirewallPermitFilter adds a WFP permit filter for a single IP address.
|
||||||
|
// Returns the filter ID for later removal.
|
||||||
|
func (p *prog) addWFPFirewallPermitFilter(fwState *wfpFirewallState, ip netip.Addr) (uint64, error) {
|
||||||
|
ip = ip.Unmap()
|
||||||
|
|
||||||
|
if ip.Is4() {
|
||||||
|
return p.addWFPFirewallPermitIPv4(fwState, ip)
|
||||||
|
}
|
||||||
|
return p.addWFPFirewallPermitIPv6(fwState, ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
// addWFPFirewallPermitIPv4 adds a WFP permit filter for an IPv4 address.
|
||||||
|
func (p *prog) addWFPFirewallPermitIPv4(fwState *wfpFirewallState, ip netip.Addr) (uint64, error) {
|
||||||
|
addr4 := ip.As4()
|
||||||
|
ipUint32 := uint32(addr4[0])<<24 | uint32(addr4[1])<<16 | uint32(addr4[2])<<8 | uint32(addr4[3])
|
||||||
|
|
||||||
|
filterName, _ := windows.UTF16PtrFromString("ctrld Firewall Permit " + ip.String())
|
||||||
|
|
||||||
|
condition := fwpmFilterCondition0{
|
||||||
|
fieldKey: fwpmConditionIPRemoteAddress,
|
||||||
|
matchType: fwpMatchEqual,
|
||||||
|
}
|
||||||
|
condition.condValue.valueType = fwpUint32
|
||||||
|
condition.condValue.value = uint64(ipUint32)
|
||||||
|
|
||||||
|
filter := fwpmFilter0{
|
||||||
|
subLayerKey: ctrldSubLayerGUID,
|
||||||
|
numFilterConds: 1,
|
||||||
|
filterCondition: (*fwpmFilterCondition0)(unsafe.Pointer(&condition)),
|
||||||
|
weight: fwpValue0{
|
||||||
|
valueType: fwpUint8,
|
||||||
|
value: 5, // Higher than block-all (1), lower than DNS permits (10).
|
||||||
|
},
|
||||||
|
action: fwpmAction0{
|
||||||
|
actionType: fwpActionPermit, // FWP_ACTION_PERMIT
|
||||||
|
},
|
||||||
|
layerKey: fwpmLayerALEAuthConnectV4,
|
||||||
|
}
|
||||||
|
filter.displayData.name = filterName
|
||||||
|
|
||||||
|
var filterID uint64
|
||||||
|
r1, _, _ := procFwpmFilterAdd0.Call(
|
||||||
|
fwState.engineHandle,
|
||||||
|
uintptr(unsafe.Pointer(&filter)),
|
||||||
|
0,
|
||||||
|
uintptr(unsafe.Pointer(&filterID)),
|
||||||
|
)
|
||||||
|
if r1 != 0 {
|
||||||
|
return 0, fmt.Errorf("FwpmFilterAdd0 (permit IPv4 %s) failed: HRESULT 0x%x", ip, r1)
|
||||||
|
}
|
||||||
|
return filterID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// addWFPFirewallPermitIPv6 adds a WFP permit filter for an IPv6 address.
|
||||||
|
func (p *prog) addWFPFirewallPermitIPv6(fwState *wfpFirewallState, ip netip.Addr) (uint64, error) {
|
||||||
|
addr16 := ip.As16()
|
||||||
|
|
||||||
|
filterName, _ := windows.UTF16PtrFromString("ctrld Firewall Permit " + ip.String())
|
||||||
|
|
||||||
|
condition := fwpmFilterCondition0{
|
||||||
|
fieldKey: fwpmConditionIPRemoteAddress,
|
||||||
|
matchType: fwpMatchEqual,
|
||||||
|
}
|
||||||
|
condition.condValue.valueType = fwpByteArray16Type
|
||||||
|
condition.condValue.value = uint64(uintptr(unsafe.Pointer(&addr16)))
|
||||||
|
|
||||||
|
filter := fwpmFilter0{
|
||||||
|
subLayerKey: ctrldSubLayerGUID,
|
||||||
|
numFilterConds: 1,
|
||||||
|
filterCondition: (*fwpmFilterCondition0)(unsafe.Pointer(&condition)),
|
||||||
|
weight: fwpValue0{
|
||||||
|
valueType: fwpUint8,
|
||||||
|
value: 5, // Higher than block-all (1), lower than DNS permits (10).
|
||||||
|
},
|
||||||
|
action: fwpmAction0{
|
||||||
|
actionType: fwpActionPermit,
|
||||||
|
},
|
||||||
|
layerKey: fwpmLayerALEAuthConnectV6,
|
||||||
|
}
|
||||||
|
filter.displayData.name = filterName
|
||||||
|
|
||||||
|
var filterID uint64
|
||||||
|
r1, _, _ := procFwpmFilterAdd0.Call(
|
||||||
|
fwState.engineHandle,
|
||||||
|
uintptr(unsafe.Pointer(&filter)),
|
||||||
|
0,
|
||||||
|
uintptr(unsafe.Pointer(&filterID)),
|
||||||
|
)
|
||||||
|
runtime.KeepAlive(addr16)
|
||||||
|
if r1 != 0 {
|
||||||
|
return 0, fmt.Errorf("FwpmFilterAdd0 (permit IPv6 %s) failed: HRESULT 0x%x", ip, r1)
|
||||||
|
}
|
||||||
|
return filterID, nil
|
||||||
|
}
|
||||||
@@ -4,11 +4,15 @@ import "regexp"
|
|||||||
|
|
||||||
// validHostname reports whether hostname is a valid hostname.
|
// validHostname reports whether hostname is a valid hostname.
|
||||||
// A valid hostname contains 3 -> 64 characters and conform to RFC1123.
|
// 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 {
|
func validHostname(hostname string) bool {
|
||||||
hostnameLen := len(hostname)
|
hostnameLen := len(hostname)
|
||||||
if hostnameLen < 3 || hostnameLen > 64 {
|
if hostnameLen < 3 || hostnameLen > 64 {
|
||||||
return false
|
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])$`)
|
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)
|
return validHostnameRfc1123.MatchString(hostname)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HTTP log server endpoint constants
|
||||||
|
const (
|
||||||
|
httpLogEndpointPing = "/ping"
|
||||||
|
httpLogEndpointLogs = "/logs"
|
||||||
|
httpLogEndpointExit = "/exit"
|
||||||
|
)
|
||||||
|
|
||||||
|
// httpLogClient sends logs to an HTTP server via POST requests.
|
||||||
|
// This replaces the logConn functionality with HTTP-based communication.
|
||||||
|
type httpLogClient struct {
|
||||||
|
baseURL string
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// newHTTPLogClient creates a new HTTP log client
|
||||||
|
func newHTTPLogClient(sockPath string) *httpLogClient {
|
||||||
|
return &httpLogClient{
|
||||||
|
baseURL: "http://unix",
|
||||||
|
client: &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return net.Dial("unix", sockPath)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write sends log data to the HTTP server via POST request
|
||||||
|
func (hlc *httpLogClient) Write(b []byte) (int, error) {
|
||||||
|
// Send log data via HTTP POST to /logs endpoint
|
||||||
|
resp, err := hlc.client.Post(hlc.baseURL+httpLogEndpointLogs, "text/plain", bytes.NewReader(b))
|
||||||
|
if err != nil {
|
||||||
|
// Ignore errors to prevent log pollution, just like the original logConn
|
||||||
|
return len(b), nil
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
return len(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ping tests if the HTTP log server is available
|
||||||
|
func (hlc *httpLogClient) Ping() error {
|
||||||
|
resp, err := hlc.client.Get(hlc.baseURL + httpLogEndpointPing)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close sends exit signal to the HTTP server
|
||||||
|
func (hlc *httpLogClient) Close() error {
|
||||||
|
// Send exit signal via HTTP POST with empty body
|
||||||
|
resp, err := hlc.client.Post(hlc.baseURL+httpLogEndpointExit, "text/plain", bytes.NewReader([]byte{}))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLogs retrieves all collected logs from the HTTP server
|
||||||
|
func (hlc *httpLogClient) GetLogs() ([]byte, error) {
|
||||||
|
resp, err := hlc.client.Get(hlc.baseURL + httpLogEndpointLogs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusNoContent {
|
||||||
|
return []byte{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return io.ReadAll(resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpLogServer starts an HTTP server listening on unix socket to collect logs from runCmd.
|
||||||
|
func httpLogServer(sockPath string, stopLogCh chan struct{}) error {
|
||||||
|
addr, err := net.ResolveUnixAddr("unix", sockPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid log sock path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ln, err := net.ListenUnix("unix", addr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not listen log socket: %w", err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
|
||||||
|
// Create a log writer to store all logs
|
||||||
|
logWriter := newLogWriter()
|
||||||
|
|
||||||
|
// Use a sync.Once to ensure channel is only closed once
|
||||||
|
var channelClosed sync.Once
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc(httpLogEndpointPing, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc(httpLogEndpointLogs, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodPost:
|
||||||
|
// POST /logs - Store log data
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store log data in log writer
|
||||||
|
logWriter.Write(body)
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
case http.MethodGet:
|
||||||
|
// GET /logs - Retrieve all logs
|
||||||
|
// Get all logs from the log writer
|
||||||
|
logWriter.mu.Lock()
|
||||||
|
logs := logWriter.buf.Bytes()
|
||||||
|
logWriter.mu.Unlock()
|
||||||
|
|
||||||
|
if len(logs) == 0 {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(logs)
|
||||||
|
|
||||||
|
default:
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc(httpLogEndpointExit, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the stop channel to signal completion (only once)
|
||||||
|
channelClosed.Do(func() {
|
||||||
|
close(stopLogCh)
|
||||||
|
})
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
server := &http.Server{Handler: mux}
|
||||||
|
return server.Serve(ln)
|
||||||
|
}
|
||||||
@@ -0,0 +1,747 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/net/nettest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func unixDomainSocketPath(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
sockPath, err := nettest.LocalPath()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temporary directory: %v", err)
|
||||||
|
}
|
||||||
|
return sockPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPLogServer(t *testing.T) {
|
||||||
|
sockPath := unixDomainSocketPath(t)
|
||||||
|
|
||||||
|
// Create log channel
|
||||||
|
stopLogCh := make(chan struct{})
|
||||||
|
|
||||||
|
// Start HTTP log server in a goroutine
|
||||||
|
serverErr := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
serverErr <- httpLogServer(sockPath, stopLogCh)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait a bit for server to start
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// Create HTTP client
|
||||||
|
client := &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return net.Dial("unix", sockPath)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("Ping endpoint", func(t *testing.T) {
|
||||||
|
resp, err := client.Get("http://unix" + httpLogEndpointPing)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to ping server: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("Expected status 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Ping endpoint wrong method", func(t *testing.T) {
|
||||||
|
resp, err := client.Post("http://unix"+httpLogEndpointPing, "text/plain", bytes.NewReader([]byte("test")))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to send POST to ping: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("Expected status 405, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Log endpoint", func(t *testing.T) {
|
||||||
|
testLog := "test log message"
|
||||||
|
resp, err := client.Post("http://unix"+httpLogEndpointLogs, "text/plain", bytes.NewReader([]byte(testLog)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to send log: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("Expected status 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if log was stored by retrieving it
|
||||||
|
logsResp, err := client.Get("http://unix" + httpLogEndpointLogs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get logs: %v", err)
|
||||||
|
}
|
||||||
|
defer logsResp.Body.Close()
|
||||||
|
|
||||||
|
if logsResp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("Expected status 200 for logs, got %d", logsResp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(logsResp.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read logs: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(string(body), testLog) {
|
||||||
|
t.Errorf("Expected log '%s' not found in stored logs", testLog)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Log endpoint wrong method", func(t *testing.T) {
|
||||||
|
// Test unsupported method (PUT) on /logs endpoint
|
||||||
|
req, err := http.NewRequest("PUT", "http://unix"+httpLogEndpointLogs, bytes.NewReader([]byte("test")))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create PUT request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to send PUT to logs: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("Expected status 405, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Exit endpoint", func(t *testing.T) {
|
||||||
|
resp, err := client.Post("http://unix"+httpLogEndpointExit, "text/plain", bytes.NewReader([]byte{}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to send exit: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("Expected status 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if channel is closed by trying to read from it
|
||||||
|
select {
|
||||||
|
case _, ok := <-stopLogCh:
|
||||||
|
if ok {
|
||||||
|
t.Error("Expected channel to be closed, but it's still open")
|
||||||
|
}
|
||||||
|
case <-time.After(1 * time.Second):
|
||||||
|
t.Error("Timeout waiting for channel closure")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Exit endpoint wrong method", func(t *testing.T) {
|
||||||
|
resp, err := client.Get("http://unix" + httpLogEndpointExit)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to send GET to exit: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("Expected status 405, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Multiple log messages", func(t *testing.T) {
|
||||||
|
logs := []string{"log1", "log2", "log3"}
|
||||||
|
|
||||||
|
for _, log := range logs {
|
||||||
|
resp, err := client.Post("http://unix"+httpLogEndpointLogs, "text/plain", bytes.NewReader([]byte(log+"\n")))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to send log '%s': %v", log, err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all logs were stored by retrieving them
|
||||||
|
logsResp, err := client.Get("http://unix" + httpLogEndpointLogs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get logs: %v", err)
|
||||||
|
}
|
||||||
|
defer logsResp.Body.Close()
|
||||||
|
|
||||||
|
if logsResp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("Expected status 200 for logs, got %d", logsResp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(logsResp.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read logs: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logContent := string(body)
|
||||||
|
for i, expectedLog := range logs {
|
||||||
|
if !strings.Contains(logContent, expectedLog) {
|
||||||
|
t.Errorf("Log %d: expected '%s' not found in stored logs", i, expectedLog)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Large log message", func(t *testing.T) {
|
||||||
|
largeLog := strings.Repeat("a", 1024*10) // 10KB log message
|
||||||
|
resp, err := client.Post("http://unix"+httpLogEndpointLogs, "text/plain", bytes.NewReader([]byte(largeLog)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to send large log: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("Expected status 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if large log was stored by retrieving it
|
||||||
|
logsResp, err := client.Get("http://unix" + httpLogEndpointLogs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get logs: %v", err)
|
||||||
|
}
|
||||||
|
defer logsResp.Body.Close()
|
||||||
|
|
||||||
|
if logsResp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("Expected status 200 for logs, got %d", logsResp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(logsResp.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read logs: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(string(body), largeLog) {
|
||||||
|
t.Error("Large log message was not stored correctly")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
os.Remove(sockPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPLogServerInvalidSocketPath(t *testing.T) {
|
||||||
|
// Test with invalid socket path
|
||||||
|
invalidPath := "/invalid/path/that/does/not/exist.sock"
|
||||||
|
stopLogCh := make(chan struct{})
|
||||||
|
|
||||||
|
err := httpLogServer(invalidPath, stopLogCh)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected error for invalid socket path")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), "could not listen log socket") {
|
||||||
|
t.Errorf("Expected 'could not listen log socket' error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPLogServerSocketInUse(t *testing.T) {
|
||||||
|
// Create a temporary socket path
|
||||||
|
sockPath := unixDomainSocketPath(t)
|
||||||
|
defer os.Remove(sockPath)
|
||||||
|
|
||||||
|
// Create the first server
|
||||||
|
stopLogCh1 := make(chan struct{})
|
||||||
|
serverErr1 := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
serverErr1 <- httpLogServer(sockPath, stopLogCh1)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for first server to start
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// Try to create a second server on the same socket
|
||||||
|
stopLogCh2 := make(chan struct{})
|
||||||
|
err := httpLogServer(sockPath, stopLogCh2)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected error when socket is already in use")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), "could not listen log socket") {
|
||||||
|
t.Errorf("Expected 'could not listen log socket' error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPLogServerConcurrentRequests(t *testing.T) {
|
||||||
|
// Create a temporary socket path
|
||||||
|
sockPath := unixDomainSocketPath(t)
|
||||||
|
defer os.Remove(sockPath)
|
||||||
|
|
||||||
|
// Create log channel
|
||||||
|
stopLogCh := make(chan struct{})
|
||||||
|
|
||||||
|
// Start HTTP log server in a goroutine
|
||||||
|
serverErr := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
serverErr <- httpLogServer(sockPath, stopLogCh)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for server to start
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// Create HTTP client
|
||||||
|
client := &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return net.Dial("unix", sockPath)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send concurrent requests
|
||||||
|
numRequests := 10
|
||||||
|
done := make(chan bool, numRequests)
|
||||||
|
|
||||||
|
for i := 0; i < numRequests; i++ {
|
||||||
|
go func(i int) {
|
||||||
|
defer func() { done <- true }()
|
||||||
|
|
||||||
|
logMsg := fmt.Sprintf("concurrent log %d", i)
|
||||||
|
resp, err := client.Post("http://unix"+httpLogEndpointLogs, "text/plain", bytes.NewReader([]byte(logMsg)))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Failed to send concurrent log %d: %v", i, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("Expected status 200 for request %d, got %d", i, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for all requests to complete
|
||||||
|
for i := 0; i < numRequests; i++ {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
// Request completed
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Errorf("Timeout waiting for concurrent request %d", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all logs were stored by retrieving them
|
||||||
|
logsResp, err := client.Get("http://unix" + httpLogEndpointLogs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get logs: %v", err)
|
||||||
|
}
|
||||||
|
defer logsResp.Body.Close()
|
||||||
|
|
||||||
|
if logsResp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("Expected status 200 for logs, got %d", logsResp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(logsResp.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read logs: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logContent := string(body)
|
||||||
|
// Verify all logs were stored
|
||||||
|
for i := 0; i < numRequests; i++ {
|
||||||
|
expectedLog := fmt.Sprintf("concurrent log %d", i)
|
||||||
|
if !strings.Contains(logContent, expectedLog) {
|
||||||
|
t.Errorf("Log '%s' was not stored", expectedLog)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPLogServerErrorHandling(t *testing.T) {
|
||||||
|
// Create a temporary socket path
|
||||||
|
sockPath := unixDomainSocketPath(t)
|
||||||
|
defer os.Remove(sockPath)
|
||||||
|
|
||||||
|
// Create log channel
|
||||||
|
stopLogCh := make(chan struct{})
|
||||||
|
|
||||||
|
// Start HTTP log server in a goroutine
|
||||||
|
serverErr := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
serverErr <- httpLogServer(sockPath, stopLogCh)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for server to start
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// Create HTTP client
|
||||||
|
client := &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return net.Dial("unix", sockPath)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("Invalid request body", func(t *testing.T) {
|
||||||
|
// Test with malformed request - this will fail at HTTP level, not server level
|
||||||
|
// The server will return 400 Bad Request for invalid body
|
||||||
|
resp, err := client.Post("http://unix"+httpLogEndpointLogs, "text/plain", strings.NewReader(""))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to send request: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Empty body should still be processed successfully
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("Expected status 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkHTTPLogServer(b *testing.B) {
|
||||||
|
// Create a temporary socket path
|
||||||
|
tmpDir := b.TempDir()
|
||||||
|
sockPath := filepath.Join(tmpDir, "bench.sock")
|
||||||
|
|
||||||
|
// Create log channel
|
||||||
|
stopLogCh := make(chan struct{})
|
||||||
|
|
||||||
|
// Start HTTP log server in a goroutine
|
||||||
|
go func() {
|
||||||
|
httpLogServer(sockPath, stopLogCh)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for server to start
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// Create HTTP client
|
||||||
|
client := &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return net.Dial("unix", sockPath)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Benchmark log sending
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
logMsg := fmt.Sprintf("benchmark log %d", i)
|
||||||
|
resp, err := client.Post("http://unix"+httpLogEndpointLogs, "text/plain", bytes.NewReader([]byte(logMsg)))
|
||||||
|
if err != nil {
|
||||||
|
b.Fatalf("Failed to send log: %v", err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
os.Remove(sockPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPLogClient(t *testing.T) {
|
||||||
|
// Create a temporary socket path
|
||||||
|
sockPath := unixDomainSocketPath(t)
|
||||||
|
defer os.Remove(sockPath)
|
||||||
|
|
||||||
|
// Create log channel
|
||||||
|
stopLogCh := make(chan struct{})
|
||||||
|
|
||||||
|
// Start HTTP log server in a goroutine
|
||||||
|
serverErr := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
serverErr <- httpLogServer(sockPath, stopLogCh)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for server to start
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// Create HTTP log client
|
||||||
|
client := newHTTPLogClient(sockPath)
|
||||||
|
|
||||||
|
t.Run("Ping server", func(t *testing.T) {
|
||||||
|
err := client.Ping()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Ping failed: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Write logs", func(t *testing.T) {
|
||||||
|
testLog := "test log message from client"
|
||||||
|
n, err := client.Write([]byte(testLog))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Write failed: %v", err)
|
||||||
|
}
|
||||||
|
if n != len(testLog) {
|
||||||
|
t.Errorf("Expected to write %d bytes, wrote %d", len(testLog), n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if log was stored by retrieving it
|
||||||
|
logs, err := client.GetLogs()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get logs: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(string(logs), testLog) {
|
||||||
|
t.Errorf("Expected log '%s' not found in stored logs", testLog)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Close client", func(t *testing.T) {
|
||||||
|
err := client.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Close failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if channel is closed (signaling completion)
|
||||||
|
select {
|
||||||
|
case _, ok := <-stopLogCh:
|
||||||
|
if ok {
|
||||||
|
t.Error("Expected channel to be closed, but it's still open")
|
||||||
|
}
|
||||||
|
case <-time.After(1 * time.Second):
|
||||||
|
t.Error("Timeout waiting for channel closure")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPLogClientServerUnavailable(t *testing.T) {
|
||||||
|
// Create client with non-existent socket
|
||||||
|
sockPath := "/non/existent/socket.sock"
|
||||||
|
client := newHTTPLogClient(sockPath)
|
||||||
|
|
||||||
|
t.Run("Ping unavailable server", func(t *testing.T) {
|
||||||
|
err := client.Ping()
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected ping to fail for unavailable server")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Write to unavailable server", func(t *testing.T) {
|
||||||
|
testLog := "test log message"
|
||||||
|
n, err := client.Write([]byte(testLog))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Write should not return error (ignores errors): %v", err)
|
||||||
|
}
|
||||||
|
if n != len(testLog) {
|
||||||
|
t.Errorf("Expected to write %d bytes, wrote %d", len(testLog), n)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Close unavailable server", func(t *testing.T) {
|
||||||
|
err := client.Close()
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected close to fail for unavailable server")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkHTTPLogClient(b *testing.B) {
|
||||||
|
// Create a temporary socket path
|
||||||
|
tmpDir := b.TempDir()
|
||||||
|
sockPath := filepath.Join(tmpDir, "bench.sock")
|
||||||
|
|
||||||
|
// Create log channel
|
||||||
|
stopLogCh := make(chan struct{})
|
||||||
|
|
||||||
|
// Start HTTP log server in a goroutine
|
||||||
|
go func() {
|
||||||
|
httpLogServer(sockPath, stopLogCh)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for server to start
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// Create HTTP log client
|
||||||
|
client := newHTTPLogClient(sockPath)
|
||||||
|
|
||||||
|
// Benchmark client writes
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
logMsg := fmt.Sprintf("benchmark write %d", i)
|
||||||
|
client.Write([]byte(logMsg))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
os.Remove(sockPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPLogServerWithLogWriter(t *testing.T) {
|
||||||
|
// Create a temporary socket path
|
||||||
|
sockPath := unixDomainSocketPath(t)
|
||||||
|
defer os.Remove(sockPath)
|
||||||
|
|
||||||
|
// Create log channel
|
||||||
|
stopLogCh := make(chan struct{})
|
||||||
|
|
||||||
|
// Start HTTP log server in a goroutine
|
||||||
|
serverErr := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
serverErr <- httpLogServer(sockPath, stopLogCh)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait a bit for server to start
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// Create HTTP client
|
||||||
|
client := &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return net.Dial("unix", sockPath)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("Store and retrieve logs", func(t *testing.T) {
|
||||||
|
// Send multiple log messages
|
||||||
|
logs := []string{"log message 1", "log message 2", "log message 3"}
|
||||||
|
|
||||||
|
for _, log := range logs {
|
||||||
|
resp, err := client.Post("http://unix"+httpLogEndpointLogs, "text/plain", bytes.NewReader([]byte(log+"\n")))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to send log '%s': %v", log, err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve all logs
|
||||||
|
resp, err := client.Get("http://unix" + httpLogEndpointLogs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get logs: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("Expected status 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read logs response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logContent := string(body)
|
||||||
|
for _, log := range logs {
|
||||||
|
if !strings.Contains(logContent, log) {
|
||||||
|
t.Errorf("Expected log '%s' not found in retrieved logs", log)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Empty logs endpoint", func(t *testing.T) {
|
||||||
|
// Create a new server for this test
|
||||||
|
sockPath2 := unixDomainSocketPath(t)
|
||||||
|
stopLogCh2 := make(chan struct{})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
httpLogServer(sockPath2, stopLogCh2)
|
||||||
|
}()
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
client2 := &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return net.Dial("unix", sockPath2)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client2.Get("http://unix" + httpLogEndpointLogs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get logs: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Errorf("Expected status 204, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Remove(sockPath2)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Channel closure on exit", func(t *testing.T) {
|
||||||
|
// Send exit signal
|
||||||
|
resp, err := client.Post("http://unix"+httpLogEndpointExit, "text/plain", bytes.NewReader([]byte{}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to send exit: %v", err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("Expected status 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if channel is closed by trying to read from it
|
||||||
|
select {
|
||||||
|
case _, ok := <-stopLogCh:
|
||||||
|
if ok {
|
||||||
|
t.Error("Expected channel to be closed, but it's still open")
|
||||||
|
}
|
||||||
|
case <-time.After(1 * time.Second):
|
||||||
|
t.Error("Timeout waiting for channel closure")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPLogClientGetLogs(t *testing.T) {
|
||||||
|
// Create a temporary socket path
|
||||||
|
sockPath := unixDomainSocketPath(t)
|
||||||
|
defer os.Remove(sockPath)
|
||||||
|
|
||||||
|
// Create log channel
|
||||||
|
stopLogCh := make(chan struct{})
|
||||||
|
|
||||||
|
// Start HTTP log server in a goroutine
|
||||||
|
go func() {
|
||||||
|
httpLogServer(sockPath, stopLogCh)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait a bit for server to start
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// Create HTTP log client
|
||||||
|
client := newHTTPLogClient(sockPath)
|
||||||
|
|
||||||
|
t.Run("Get logs from client", func(t *testing.T) {
|
||||||
|
// Send some logs
|
||||||
|
testLogs := []string{"client log 1", "client log 2", "client log 3"}
|
||||||
|
for _, log := range testLogs {
|
||||||
|
client.Write([]byte(log + "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve logs using client method
|
||||||
|
logs, err := client.GetLogs()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get logs: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logContent := string(logs)
|
||||||
|
for _, log := range testLogs {
|
||||||
|
if !strings.Contains(logContent, log) {
|
||||||
|
t.Errorf("Expected log '%s' not found in retrieved logs", log)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Get empty logs", func(t *testing.T) {
|
||||||
|
// Create a new client for empty logs test
|
||||||
|
sockPath2 := unixDomainSocketPath(t)
|
||||||
|
stopLogCh2 := make(chan struct{})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
httpLogServer(sockPath2, stopLogCh2)
|
||||||
|
}()
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
client2 := newHTTPLogClient(sockPath2)
|
||||||
|
logs, err := client2.GetLogs()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get empty logs: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(logs) != 0 {
|
||||||
|
t.Errorf("Expected empty logs, got %d bytes", len(logs))
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Remove(sockPath2)
|
||||||
|
})
|
||||||
|
}
|
||||||
+19
-3
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
// AppCallback provides hooks for injecting certain functionalities
|
// AppCallback provides hooks for injecting certain functionalities
|
||||||
// from mobile platforms to main ctrld cli.
|
// from mobile platforms to main ctrld cli.
|
||||||
|
// This allows mobile applications to customize behavior without modifying core CLI code
|
||||||
type AppCallback struct {
|
type AppCallback struct {
|
||||||
HostName func() string
|
HostName func() string
|
||||||
LanIp func() string
|
LanIp func() string
|
||||||
@@ -17,6 +18,7 @@ type AppCallback struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AppConfig allows overwriting ctrld cli flags from mobile platforms.
|
// AppConfig allows overwriting ctrld cli flags from mobile platforms.
|
||||||
|
// This provides a clean interface for mobile apps to configure ctrld behavior
|
||||||
type AppConfig struct {
|
type AppConfig struct {
|
||||||
CdUID string
|
CdUID string
|
||||||
ProvisionID string
|
ProvisionID string
|
||||||
@@ -27,18 +29,29 @@ type AppConfig struct {
|
|||||||
LogPath string
|
LogPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Network and HTTP configuration constants
|
||||||
const (
|
const (
|
||||||
|
// defaultHTTPTimeout provides reasonable timeout for HTTP operations
|
||||||
|
// This prevents hanging requests while allowing sufficient time for network delays
|
||||||
defaultHTTPTimeout = 30 * time.Second
|
defaultHTTPTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
// defaultMaxRetries provides retry attempts for failed HTTP requests
|
||||||
|
// This improves reliability in unstable network conditions
|
||||||
defaultMaxRetries = 3
|
defaultMaxRetries = 3
|
||||||
|
|
||||||
|
// downloadServerIp is the fallback IP for download operations
|
||||||
|
// This ensures downloads work even when DNS resolution fails
|
||||||
downloadServerIp = "23.171.240.151"
|
downloadServerIp = "23.171.240.151"
|
||||||
)
|
)
|
||||||
|
|
||||||
// httpClientWithFallback returns an HTTP client configured with timeout and IPv4 fallback
|
// 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 {
|
func httpClientWithFallback(timeout time.Duration) *http.Client {
|
||||||
return &http.Client{
|
return &http.Client{
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
Transport: &http.Transport{
|
Transport: &http.Transport{
|
||||||
// Prefer IPv4 over IPv6
|
// Prefer IPv4 over IPv6
|
||||||
|
// This improves compatibility with networks that have IPv6 issues
|
||||||
DialContext: (&net.Dialer{
|
DialContext: (&net.Dialer{
|
||||||
Timeout: 10 * time.Second,
|
Timeout: 10 * time.Second,
|
||||||
KeepAlive: 30 * time.Second,
|
KeepAlive: 30 * time.Second,
|
||||||
@@ -49,6 +62,7 @@ func httpClientWithFallback(timeout time.Duration) *http.Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// doWithRetry performs an HTTP request with retries
|
// 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) {
|
func doWithRetry(req *http.Request, maxRetries int, ip string) (*http.Response, error) {
|
||||||
var lastErr error
|
var lastErr error
|
||||||
client := httpClientWithFallback(defaultHTTPTimeout)
|
client := httpClientWithFallback(defaultHTTPTimeout)
|
||||||
@@ -60,7 +74,8 @@ func doWithRetry(req *http.Request, maxRetries int, ip string) (*http.Response,
|
|||||||
}
|
}
|
||||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||||
if attempt > 0 {
|
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)
|
resp, err := client.Do(req)
|
||||||
@@ -68,8 +83,8 @@ func doWithRetry(req *http.Request, maxRetries int, ip string) (*http.Response,
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
if ipReq != nil {
|
if ipReq != nil {
|
||||||
mainLog.Load().Warn().Err(err).Msgf("dial to %q failed", req.Host)
|
mainLog.Load().Warn().Err(err).Msgf("Dial to %q failed", req.Host)
|
||||||
mainLog.Load().Warn().Msgf("fallback to direct IP to download prod version: %q", ip)
|
mainLog.Load().Warn().Msgf("Fallback to direct ip to download prod version: %q", ip)
|
||||||
resp, err = client.Do(ipReq)
|
resp, err = client.Do(ipReq)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return resp, nil
|
return resp, nil
|
||||||
@@ -86,6 +101,7 @@ func doWithRetry(req *http.Request, maxRetries int, ip string) (*http.Response,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper for making GET requests with retries
|
// 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) {
|
func getWithRetry(url string, ip string) (*http.Response, error) {
|
||||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+495
-31
@@ -6,70 +6,303 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
"go.uber.org/zap"
|
||||||
|
"go.uber.org/zap/zapcore"
|
||||||
|
|
||||||
"github.com/Control-D-Inc/ctrld"
|
"github.com/Control-D-Inc/ctrld"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Log writer constants for buffer management and log formatting
|
||||||
const (
|
const (
|
||||||
|
// 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
|
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
|
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
|
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
|
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"
|
logWriterInitEndMarker = "\n\n=== INIT_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"
|
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 {
|
type logViewResponse struct {
|
||||||
Data string `json:"data"`
|
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 {
|
type logSentResponse struct {
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
Error string `json:"error"`
|
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 {
|
type logReader struct {
|
||||||
r io.ReadCloser
|
r io.ReadCloser
|
||||||
size int64
|
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.
|
// 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 {
|
type logWriter struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
buf bytes.Buffer
|
buf bytes.Buffer
|
||||||
size int
|
size int
|
||||||
|
subscribers []*logSubscriber
|
||||||
|
|
||||||
|
// File persistence fields.
|
||||||
|
logFile *os.File
|
||||||
|
logFilePath string
|
||||||
|
logFileSize int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// newLogWriter creates an internal log writer.
|
// newLogWriter creates an internal log writer.
|
||||||
|
// This provides the default log writer with standard buffer size
|
||||||
func newLogWriter() *logWriter {
|
func newLogWriter() *logWriter {
|
||||||
return newLogWriterWithSize(logWriterSize)
|
return newLogWriterWithSize(logWriterSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
// newSmallLogWriter creates an internal log writer with small buffer size.
|
// newSmallLogWriter creates an internal log writer with small buffer size.
|
||||||
|
// This is used in memory-constrained environments or for temporary logging
|
||||||
func newSmallLogWriter() *logWriter {
|
func newSmallLogWriter() *logWriter {
|
||||||
return newLogWriterWithSize(logWriterSmallSize)
|
return newLogWriterWithSize(logWriterSmallSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
// newLogWriterWithSize creates an internal log writer with a given buffer size.
|
// 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 {
|
func newLogWriterWithSize(size int) *logWriter {
|
||||||
lw := &logWriter{size: size}
|
lw := &logWriter{size: size}
|
||||||
return lw
|
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) {
|
func (lw *logWriter) Write(p []byte) (int, error) {
|
||||||
lw.mu.Lock()
|
lw.mu.Lock()
|
||||||
defer lw.mu.Unlock()
|
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.
|
// 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 {
|
if lw.buf.Len()+len(p) > lw.size {
|
||||||
buf := lw.buf.Bytes()
|
buf := lw.buf.Bytes()
|
||||||
haveEndMarker := false
|
haveEndMarker := false
|
||||||
// If there's init end marker already, preserve the data til the marker.
|
// 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 {
|
if idx := bytes.LastIndex(buf, []byte(logWriterInitEndMarker)); idx >= 0 {
|
||||||
buf = buf[:idx+len(logWriterInitEndMarker)]
|
buf = buf[:idx+len(logWriterInitEndMarker)]
|
||||||
haveEndMarker = true
|
haveEndMarker = true
|
||||||
@@ -95,50 +328,62 @@ func (lw *logWriter) Write(p []byte) (int, error) {
|
|||||||
|
|
||||||
// initLogging initializes global logging setup.
|
// initLogging initializes global logging setup.
|
||||||
func (p *prog) initLogging(backup bool) {
|
func (p *prog) initLogging(backup bool) {
|
||||||
zerolog.TimeFieldFormat = time.RFC3339 + ".000"
|
logCores := initLoggingWithBackup(backup)
|
||||||
logWriters := initLoggingWithBackup(backup)
|
|
||||||
|
|
||||||
// Initializing internal logging after global logging.
|
// 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.
|
// 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() {
|
if !p.needInternalLogging() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
p.initInternalLogWriterOnce.Do(func() {
|
p.initInternalLogWriterOnce.Do(func() {
|
||||||
mainLog.Load().Notice().Msg("internal logging enabled")
|
p.Notice().Msg("Internal logging enabled")
|
||||||
p.internalLogWriter = newLogWriter()
|
p.internalLogWriter = newLogWriter()
|
||||||
p.internalLogSent = time.Now().Add(-logWriterSentInterval)
|
p.internalLogSent = time.Now().Add(-logWriterSentInterval)
|
||||||
p.internalWarnLogWriter = newSmallLogWriter()
|
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()
|
p.mu.Lock()
|
||||||
lw := p.internalLogWriter
|
lw := p.internalLogWriter
|
||||||
wlw := p.internalWarnLogWriter
|
wlw := p.internalWarnLogWriter
|
||||||
p.mu.Unlock()
|
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.
|
// have enough information for troubleshooting.
|
||||||
if verbose == 0 {
|
internalCore := newHumanReadableZapCore(lw, zapcore.DebugLevel)
|
||||||
for i := range writers {
|
cores = append(cores, internalCore)
|
||||||
w := &zerolog.FilteredLevelWriter{
|
|
||||||
Writer: zerolog.LevelWriterAdapter{Writer: writers[i]},
|
// Add core for internal warn log writer
|
||||||
Level: zerolog.NoticeLevel,
|
warnCore := newHumanReadableZapCore(wlw, zapcore.WarnLevel)
|
||||||
}
|
cores = append(cores, warnCore)
|
||||||
writers[i] = w
|
|
||||||
}
|
// Create a multi-core logger
|
||||||
zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
multiCore := zapcore.NewTee(cores...)
|
||||||
}
|
logger := zap.New(multiCore)
|
||||||
writers = append(writers, lw)
|
|
||||||
writers = append(writers, &zerolog.FilteredLevelWriter{
|
mainLog.Store(&ctrld.Logger{Logger: logger})
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// needInternalLogging reports whether prog needs to run internal logging.
|
// needInternalLogging reports whether prog needs to run internal logging.
|
||||||
@@ -154,7 +399,69 @@ func (p *prog) needInternalLogging() bool {
|
|||||||
return true
|
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() {
|
if p.needInternalLogging() {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
lw := p.internalLogWriter
|
lw := p.internalLogWriter
|
||||||
@@ -166,14 +473,22 @@ func (p *prog) logReader() (*logReader, error) {
|
|||||||
if wlw == nil {
|
if wlw == nil {
|
||||||
return nil, errors.New("nil internal warn log writer")
|
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()
|
lw.mu.Lock()
|
||||||
lwReader := bytes.NewReader(lw.buf.Bytes())
|
lwReader := newLogReader(&lw.buf, stripColor)
|
||||||
lwSize := lw.buf.Len()
|
lwSize := lw.buf.Len()
|
||||||
lw.mu.Unlock()
|
lw.mu.Unlock()
|
||||||
// Warn log content.
|
// Warn log content.
|
||||||
wlw.mu.Lock()
|
wlw.mu.Lock()
|
||||||
wlwReader := bytes.NewReader(wlw.buf.Bytes())
|
wlwReader := newLogReader(&wlw.buf, stripColor)
|
||||||
wlwSize := wlw.buf.Len()
|
wlwSize := wlw.buf.Len()
|
||||||
wlw.mu.Unlock()
|
wlw.mu.Unlock()
|
||||||
reader := io.MultiReader(lwReader, bytes.NewReader([]byte(logWriterLogEndMarker)), wlwReader)
|
reader := io.MultiReader(lwReader, bytes.NewReader([]byte(logWriterLogEndMarker)), wlwReader)
|
||||||
@@ -202,3 +517,152 @@ func (p *prog) logReader() (*logReader, error) {
|
|||||||
}
|
}
|
||||||
return lr, nil
|
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
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"go.uber.org/zap/zapcore"
|
||||||
|
|
||||||
|
"github.com/Control-D-Inc/ctrld"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Test_logWriter_Write(t *testing.T) {
|
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())
|
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
|
// See: https://thekelleys.org.uk/dnsmasq/docs/dnsmasq-man.html
|
||||||
func (p *prog) checkDnsLoop() {
|
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)
|
upstream := make(map[string]*ctrld.UpstreamConfig)
|
||||||
p.loopMu.Lock()
|
p.loopMu.Lock()
|
||||||
for n, uc := range p.cfg.Upstream {
|
for n, uc := range p.cfg.Upstream {
|
||||||
@@ -93,7 +93,7 @@ func (p *prog) checkDnsLoop() {
|
|||||||
}
|
}
|
||||||
// Do not send test query to external upstream.
|
// Do not send test query to external upstream.
|
||||||
if !canBeLocalUpstream(uc.Domain) {
|
if !canBeLocalUpstream(uc.Domain) {
|
||||||
mainLog.Load().Debug().Msgf("skipping external: upstream.%s", n)
|
p.Debug().Msgf("Skipping external: upstream.%s", n)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
uid := uc.UID()
|
uid := uc.UID()
|
||||||
@@ -102,6 +102,7 @@ func (p *prog) checkDnsLoop() {
|
|||||||
}
|
}
|
||||||
p.loopMu.Unlock()
|
p.loopMu.Unlock()
|
||||||
|
|
||||||
|
loggerCtx := ctrld.LoggerCtx(context.Background(), p.logger.Load())
|
||||||
for uid := range p.loop {
|
for uid := range p.loop {
|
||||||
msg := loopTestMsg(uid)
|
msg := loopTestMsg(uid)
|
||||||
uc := upstream[uid]
|
uc := upstream[uid]
|
||||||
@@ -109,16 +110,16 @@ func (p *prog) checkDnsLoop() {
|
|||||||
if uc == nil {
|
if uc == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
resolver, err := ctrld.NewResolver(uc)
|
resolver, err := ctrld.NewResolver(loggerCtx, uc)
|
||||||
if err != nil {
|
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
|
continue
|
||||||
}
|
}
|
||||||
if _, err := resolver.Resolve(context.Background(), msg); err != nil {
|
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.
|
// 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 {
|
func loopTestMsg(uid string) *dns.Msg {
|
||||||
msg := new(dns.Msg)
|
msg := new(dns.Msg)
|
||||||
msg.SetQuestion(dns.Fqdn(uid+loopTestDomain), loopTestQtype)
|
msg.SetQuestion(dns.Fqdn(uid+loopTestDomain), loopTestQtype)
|
||||||
|
|||||||
+121
-46
@@ -1,18 +1,23 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/hex"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kardianos/service"
|
"github.com/kardianos/service"
|
||||||
"github.com/rs/zerolog"
|
"go.uber.org/zap"
|
||||||
|
"go.uber.org/zap/zapcore"
|
||||||
|
|
||||||
"github.com/Control-D-Inc/ctrld"
|
"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 (
|
var (
|
||||||
configPath string
|
configPath string
|
||||||
configBase64 string
|
configBase64 string
|
||||||
@@ -40,33 +45,56 @@ var (
|
|||||||
cleanup bool
|
cleanup bool
|
||||||
startOnly bool
|
startOnly bool
|
||||||
rfc1918 bool
|
rfc1918 bool
|
||||||
|
interceptMode string // "", "dns", or "hard" — set via --intercept-mode flag or config
|
||||||
|
dnsIntercept bool // derived: interceptMode == "dns" || interceptMode == "hard"
|
||||||
|
hardIntercept bool // derived: interceptMode == "hard"
|
||||||
|
firewallMode string // "off" or "on" — set via --firewall-mode flag or config
|
||||||
|
firewallModeFlagChanged bool // true when --firewall-mode was explicitly provided
|
||||||
|
|
||||||
mainLog atomic.Pointer[zerolog.Logger]
|
mainLog atomic.Pointer[ctrld.Logger]
|
||||||
consoleWriter zerolog.ConsoleWriter
|
consoleWriter zapcore.Core
|
||||||
|
consoleWriterLevel zapcore.Level
|
||||||
noConfigStart bool
|
noConfigStart bool
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Flag name constants for consistent reference across the codebase
|
||||||
|
// Using constants prevents typos and makes refactoring easier
|
||||||
const (
|
const (
|
||||||
cdUidFlagName = "cd"
|
cdUidFlagName = "cd"
|
||||||
cdOrgFlagName = "cd-org"
|
cdOrgFlagName = "cd-org"
|
||||||
customHostnameFlagName = "custom-hostname"
|
customHostnameFlagName = "custom-hostname"
|
||||||
nextdnsFlagName = "nextdns"
|
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() {
|
func init() {
|
||||||
l := zerolog.New(io.Discard)
|
l := zap.NewNop()
|
||||||
mainLog.Store(&l)
|
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() {
|
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")
|
ctrld.InitConfig(v, "ctrld")
|
||||||
initCLI()
|
rootCmd := initCLI()
|
||||||
if err := rootCmd.Execute(); err != nil {
|
if err := rootCmd.Execute(); err != nil {
|
||||||
mainLog.Load().Error().Msg(err.Error())
|
mainLog.Load().Error().Msg(err.Error())
|
||||||
os.Exit(1)
|
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 {
|
func normalizeLogFilePath(logFilePath string) string {
|
||||||
if logFilePath == "" || filepath.IsAbs(logFilePath) || service.Interactive() {
|
if logFilePath == "" || filepath.IsAbs(logFilePath) || service.Interactive() {
|
||||||
return logFilePath
|
return logFilePath
|
||||||
@@ -82,40 +110,36 @@ func normalizeLogFilePath(logFilePath string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// initConsoleLogging initializes console logging, then storing to mainLog.
|
// initConsoleLogging initializes console logging, then storing to mainLog.
|
||||||
|
// This sets up human-readable logging output for interactive use
|
||||||
func initConsoleLogging() {
|
func initConsoleLogging() {
|
||||||
consoleWriter = zerolog.NewConsoleWriter(func(w *zerolog.ConsoleWriter) {
|
consoleWriterLevel = ctrld.NoticeLevel
|
||||||
w.TimeFormat = time.StampMilli
|
|
||||||
})
|
|
||||||
multi := zerolog.MultiLevelWriter(consoleWriter)
|
|
||||||
l := mainLog.Load().Output(multi).With().Timestamp().Logger()
|
|
||||||
mainLog.Store(&l)
|
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case silent:
|
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:
|
case verbose == 1:
|
||||||
ctrld.ProxyLogger.Store(&l)
|
// Info level provides basic operational information
|
||||||
zerolog.SetGlobalLevel(zerolog.InfoLevel)
|
consoleWriterLevel = zapcore.InfoLevel
|
||||||
case verbose > 1:
|
case verbose > 1:
|
||||||
ctrld.ProxyLogger.Store(&l)
|
// Debug level provides detailed diagnostic information
|
||||||
zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
consoleWriterLevel = zapcore.DebugLevel
|
||||||
default:
|
|
||||||
zerolog.SetGlobalLevel(zerolog.NoticeLevel)
|
|
||||||
}
|
}
|
||||||
|
consoleWriter = newHumanReadableZapCore(os.Stdout, consoleWriterLevel)
|
||||||
|
l := zap.New(consoleWriter)
|
||||||
|
mainLog.Store(&ctrld.Logger{Logger: l})
|
||||||
}
|
}
|
||||||
|
|
||||||
// initInteractiveLogging is like initLogging, but the ProxyLogger is discarded
|
// initInteractiveLogging is like initLogging, but the ProxyLogger is discarded
|
||||||
// to be used for all interactive commands.
|
// to be used for all interactive commands.
|
||||||
//
|
//
|
||||||
// Current log file config will also be ignored.
|
// Current log file config will also be ignored.
|
||||||
|
// This prevents log file conflicts during interactive command execution
|
||||||
func initInteractiveLogging() {
|
func initInteractiveLogging() {
|
||||||
old := cfg.Service.LogPath
|
old := cfg.Service.LogPath
|
||||||
cfg.Service.LogPath = ""
|
cfg.Service.LogPath = ""
|
||||||
zerolog.TimeFieldFormat = time.RFC3339 + ".000"
|
|
||||||
initLoggingWithBackup(false)
|
initLoggingWithBackup(false)
|
||||||
cfg.Service.LogPath = old
|
cfg.Service.LogPath = old
|
||||||
l := zerolog.New(io.Discard)
|
|
||||||
ctrld.ProxyLogger.Store(&l)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// initLoggingWithBackup initializes log setup base on current config.
|
// 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
|
// 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
|
// change in cd mode. Without special reason, the caller should use initLogging
|
||||||
// wrapper instead of calling this function directly.
|
// wrapper instead of calling this function directly.
|
||||||
func initLoggingWithBackup(doBackup bool) []io.Writer {
|
func initLoggingWithBackup(doBackup bool) []zapcore.Core {
|
||||||
var writers []io.Writer
|
var writers []io.Writer
|
||||||
if logFilePath := normalizeLogFilePath(cfg.Service.LogPath); logFilePath != "" {
|
if logFilePath := normalizeLogFilePath(cfg.Service.LogPath); logFilePath != "" {
|
||||||
// Create parent directory if necessary.
|
// 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 {
|
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)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default open log file in append mode.
|
// Default open log file in append mode.
|
||||||
|
// This preserves existing log entries across restarts
|
||||||
flags := os.O_CREATE | os.O_RDWR | os.O_APPEND
|
flags := os.O_CREATE | os.O_RDWR | os.O_APPEND
|
||||||
if doBackup {
|
if doBackup {
|
||||||
// Backup old log file with .1 suffix.
|
// 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) {
|
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 {
|
} else {
|
||||||
// Backup was created, set flags for truncating old log file.
|
// 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
|
flags = os.O_CREATE | os.O_RDWR
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logFile, err := openLogFile(logFilePath, flags)
|
logFile, err := openLogFile(logFilePath, flags)
|
||||||
if err != nil {
|
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)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
writers = append(writers, logFile)
|
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
|
logLevel := cfg.Service.LogLevel
|
||||||
switch {
|
switch {
|
||||||
case silent:
|
case silent:
|
||||||
zerolog.SetGlobalLevel(zerolog.NoLevel)
|
// For silent mode, use a no-op logger to suppress all output
|
||||||
return writers
|
l := zap.NewNop()
|
||||||
|
mainLog.Store(&ctrld.Logger{Logger: l})
|
||||||
|
return cores
|
||||||
case verbose == 1:
|
case verbose == 1:
|
||||||
logLevel = "info"
|
logLevel = "info"
|
||||||
case verbose > 1:
|
case verbose > 1:
|
||||||
logLevel = "debug"
|
logLevel = "debug"
|
||||||
}
|
}
|
||||||
if logLevel == "" {
|
|
||||||
return writers
|
// Parse log level string to zapcore.Level
|
||||||
}
|
// This provides human-readable log level configuration
|
||||||
level, err := zerolog.ParseLevel(logLevel)
|
var level zapcore.Level
|
||||||
if err != nil {
|
switch logLevel {
|
||||||
mainLog.Load().Warn().Err(err).Msg("could not set log level")
|
case "debug":
|
||||||
return writers
|
level = zapcore.DebugLevel
|
||||||
}
|
case "info":
|
||||||
zerolog.SetGlobalLevel(level)
|
level = zapcore.InfoLevel
|
||||||
return writers
|
case "notice":
|
||||||
|
level = ctrld.NoticeLevel
|
||||||
|
case "warn":
|
||||||
|
level = zapcore.WarnLevel
|
||||||
|
case "error":
|
||||||
|
level = zapcore.ErrorLevel
|
||||||
|
default:
|
||||||
|
level = zapcore.InfoLevel // default level
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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() {
|
func initCache() {
|
||||||
if !cfg.Service.CacheEnable {
|
if !cfg.Service.CacheEnable {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if cfg.Service.CacheSize == 0 {
|
if cfg.Service.CacheSize == 0 {
|
||||||
|
// Default cache size provides good balance between memory usage and performance
|
||||||
cfg.Service.CacheSize = 4096
|
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)
|
||||||
|
}
|
||||||
|
|||||||
+34
-3
@@ -2,16 +2,47 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
"go.uber.org/zap"
|
||||||
|
"go.uber.org/zap/zapcore"
|
||||||
|
|
||||||
|
"github.com/Control-D-Inc/ctrld"
|
||||||
)
|
)
|
||||||
|
|
||||||
var logOutput strings.Builder
|
var logOutput strings.Builder
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
l := zerolog.New(&logOutput)
|
// Create a custom writer that writes to logOutput
|
||||||
mainLog.Store(&l)
|
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())
|
os.Exit(m.Run())
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-4
@@ -15,6 +15,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// metricsServer represents a server to expose Prometheus metrics via HTTP.
|
// metricsServer represents a server to expose Prometheus metrics via HTTP.
|
||||||
|
// This provides monitoring and observability for the DNS proxy service
|
||||||
type metricsServer struct {
|
type metricsServer struct {
|
||||||
server *http.Server
|
server *http.Server
|
||||||
mux *http.ServeMux
|
mux *http.ServeMux
|
||||||
@@ -24,6 +25,7 @@ type metricsServer struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// newMetricsServer returns new metrics server.
|
// newMetricsServer returns new metrics server.
|
||||||
|
// This initializes the HTTP server for exposing Prometheus metrics
|
||||||
func newMetricsServer(addr string, reg *prometheus.Registry) (*metricsServer, error) {
|
func newMetricsServer(addr string, reg *prometheus.Registry) (*metricsServer, error) {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
ms := &metricsServer{
|
ms := &metricsServer{
|
||||||
@@ -37,11 +39,13 @@ func newMetricsServer(addr string, reg *prometheus.Registry) (*metricsServer, er
|
|||||||
}
|
}
|
||||||
|
|
||||||
// register adds handlers for given pattern.
|
// 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) {
|
func (ms *metricsServer) register(pattern string, handler http.Handler) {
|
||||||
ms.mux.Handle(pattern, handler)
|
ms.mux.Handle(pattern, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
// registerMetricsServerHandler adds handlers for metrics server.
|
// registerMetricsServerHandler adds handlers for metrics server.
|
||||||
|
// This sets up both Prometheus format and JSON format endpoints for metrics
|
||||||
func (ms *metricsServer) registerMetricsServerHandler() {
|
func (ms *metricsServer) registerMetricsServerHandler() {
|
||||||
ms.register("/metrics", promhttp.HandlerFor(
|
ms.register("/metrics", promhttp.HandlerFor(
|
||||||
ms.reg,
|
ms.reg,
|
||||||
@@ -74,6 +78,7 @@ func (ms *metricsServer) registerMetricsServerHandler() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// start runs the metricsServer.
|
// start runs the metricsServer.
|
||||||
|
// This starts the HTTP server for metrics exposure
|
||||||
func (ms *metricsServer) start() error {
|
func (ms *metricsServer) start() error {
|
||||||
listener, err := net.Listen("tcp", ms.addr)
|
listener, err := net.Listen("tcp", ms.addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -85,6 +90,7 @@ func (ms *metricsServer) start() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// stop shutdowns the metricsServer within 2 seconds timeout.
|
// stop shutdowns the metricsServer within 2 seconds timeout.
|
||||||
|
// This ensures graceful shutdown of the metrics server
|
||||||
func (ms *metricsServer) stop() error {
|
func (ms *metricsServer) stop() error {
|
||||||
if !ms.started {
|
if !ms.started {
|
||||||
return nil
|
return nil
|
||||||
@@ -95,6 +101,7 @@ func (ms *metricsServer) stop() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// runMetricsServer initializes metrics stats and runs the metrics server if enabled.
|
// 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{}) {
|
func (p *prog) runMetricsServer(ctx context.Context, reloadCh chan struct{}) {
|
||||||
if !p.metricsEnabled() {
|
if !p.metricsEnabled() {
|
||||||
return
|
return
|
||||||
@@ -115,7 +122,7 @@ func (p *prog) runMetricsServer(ctx context.Context, reloadCh chan struct{}) {
|
|||||||
addr := p.cfg.Service.MetricsListener
|
addr := p.cfg.Service.MetricsListener
|
||||||
ms, err := newMetricsServer(addr, reg)
|
ms, err := newMetricsServer(addr, reg)
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
// Only start listener address if defined.
|
// Only start listener address if defined.
|
||||||
@@ -130,9 +137,9 @@ func (p *prog) runMetricsServer(ctx context.Context, reloadCh chan struct{}) {
|
|||||||
statsVersion.WithLabelValues(commit, runtime.Version(), curVersion()).Inc()
|
statsVersion.WithLabelValues(commit, runtime.Version(), curVersion()).Inc()
|
||||||
reg.MustRegister(statsTimeStart)
|
reg.MustRegister(statsTimeStart)
|
||||||
statsTimeStart.Set(float64(time.Now().Unix()))
|
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 {
|
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
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,7 +151,7 @@ func (p *prog) runMetricsServer(ctx context.Context, reloadCh chan struct{}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := ms.stop(); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,28 +49,3 @@ func validInterface(iface *net.Interface, validIfacesMap map[string]struct{}) bo
|
|||||||
_, ok := validIfacesMap[iface.Name]
|
_, ok := validIfacesMap[iface.Name]
|
||||||
return ok
|
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 (
|
import (
|
||||||
"net"
|
"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 }
|
func patchNetIfaceName(iface *net.Interface) (bool, error) { return true, nil }
|
||||||
|
|
||||||
// validInterface reports whether the *net.Interface is a valid one.
|
// validInterface reports whether the *net.Interface is a valid one.
|
||||||
// Only non-virtual interfaces are considered valid.
|
// 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 {
|
func validInterface(iface *net.Interface, validIfacesMap map[string]struct{}) bool {
|
||||||
_, ok := validIfacesMap[iface.Name]
|
_, ok := validIfacesMap[iface.Name]
|
||||||
return ok
|
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 (
|
import (
|
||||||
"net"
|
"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 }
|
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 }
|
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
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net"
|
"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) {
|
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]
|
_, ok := validIfacesMap[iface.Name]
|
||||||
return ok
|
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 (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"maps"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/Control-D-Inc/ctrld"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Test_validInterfaces(t *testing.T) {
|
func Test_validInterfaces(t *testing.T) {
|
||||||
verbose = 3
|
verbose = 3
|
||||||
initConsoleLogging()
|
initConsoleLogging()
|
||||||
start := time.Now()
|
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())
|
t.Logf("Using Windows API takes: %d", time.Since(start).Milliseconds())
|
||||||
|
ifaces := slices.Collect(maps.Keys(im))
|
||||||
|
|
||||||
start = time.Now()
|
start = time.Now()
|
||||||
ifacesPowershell := validInterfacesPowershell()
|
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)
|
var networkManagerCtrldConfFile = filepath.Join(nmConfDir, nmCtrldConfFilename)
|
||||||
|
|
||||||
// hasNetworkManager reports whether NetworkManager executable found.
|
// hasNetworkManager reports whether NetworkManager executable found.
|
||||||
|
// hasNetworkManager checks if NetworkManager is available on the system
|
||||||
func hasNetworkManager() bool {
|
func hasNetworkManager() bool {
|
||||||
exe, _ := exec.LookPath("NetworkManager")
|
exe, _ := exec.LookPath("NetworkManager")
|
||||||
return exe != ""
|
return exe != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupNetworkManager() error {
|
func (p *prog) setupNetworkManager() error {
|
||||||
if !hasNetworkManager() {
|
if !hasNetworkManager() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if content, _ := os.ReadFile(nmCtrldConfContent); string(content) == nmCtrldConfContent {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
err := os.WriteFile(networkManagerCtrldConfFile, []byte(nmCtrldConfContent), os.FileMode(0644))
|
err := os.WriteFile(networkManagerCtrldConfFile, []byte(nmCtrldConfContent), os.FileMode(0644))
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
mainLog.Load().Debug().Msg("NetworkManager is not available")
|
p.Debug().Msg("NetworkManager is not available")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if err != 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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
reloadNetworkManager()
|
p.reloadNetworkManager()
|
||||||
mainLog.Load().Debug().Msg("setup NetworkManager done")
|
p.Debug().Msg("Setup NetworkManager done")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func restoreNetworkManager() error {
|
func (p *prog) restoreNetworkManager() error {
|
||||||
if !hasNetworkManager() {
|
if !hasNetworkManager() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
err := os.Remove(networkManagerCtrldConfFile)
|
err := os.Remove(networkManagerCtrldConfFile)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
mainLog.Load().Debug().Msg("NetworkManager is not available")
|
p.Debug().Msg("NetworkManager is not available")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if err != 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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
reloadNetworkManager()
|
p.reloadNetworkManager()
|
||||||
mainLog.Load().Debug().Msg("restore NetworkManager done")
|
p.Debug().Msg("Restore NetworkManager done")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func reloadNetworkManager() {
|
func (p *prog) reloadNetworkManager() {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
conn, err := dbus.NewSystemConnectionContext(ctx)
|
conn, err := dbus.NewSystemConnectionContext(ctx)
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
waitCh := make(chan string)
|
waitCh := make(chan string)
|
||||||
if _, err := conn.ReloadUnitContext(ctx, nmSystemdUnitName, "ignore-dependencies", waitCh); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
<-waitCh
|
<-waitCh
|
||||||
|
|||||||
@@ -2,14 +2,14 @@
|
|||||||
|
|
||||||
package cli
|
package cli
|
||||||
|
|
||||||
func setupNetworkManager() error {
|
func (p *prog) setupNetworkManager() error {
|
||||||
reloadNetworkManager()
|
p.reloadNetworkManager()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func restoreNetworkManager() error {
|
func (p *prog) restoreNetworkManager() error {
|
||||||
reloadNetworkManager()
|
p.reloadNetworkManager()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func reloadNetworkManager() {}
|
func (p *prog) reloadNetworkManager() {}
|
||||||
|
|||||||
+2
-1
@@ -8,11 +8,12 @@ import (
|
|||||||
|
|
||||||
const nextdnsURL = "https://dns.nextdns.io"
|
const nextdnsURL = "https://dns.nextdns.io"
|
||||||
|
|
||||||
|
// generateNextDNSConfig generates NextDNS configuration for the given UID
|
||||||
func generateNextDNSConfig(uid string) {
|
func generateNextDNSConfig(uid string) {
|
||||||
if uid == "" {
|
if uid == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
mainLog.Load().Info().Msg("generating ctrld config for NextDNS resolver")
|
mainLog.Load().Info().Msg("Generating ctrld config for NextDNS resolver")
|
||||||
cfg = ctrld.Config{
|
cfg = ctrld.Config{
|
||||||
Listener: map[string]*ctrld.ListenerConfig{
|
Listener: map[string]*ctrld.ListenerConfig{
|
||||||
"0": {
|
"0": {
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Control-D-Inc/ctrld"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Default to current behavior: keep recovering indefinitely unless configured.
|
||||||
|
defaultNRPTRecoveryMaxAttempts = 0
|
||||||
|
defaultNRPTRecoveryCooldown = 30 * time.Minute
|
||||||
|
|
||||||
|
// Require more than one good health tick before clearing the circuit. A probe can
|
||||||
|
// pass briefly after delete/re-add even when another agent recreates broken NRPT state.
|
||||||
|
nrptRecoveryStableSuccessesToReset = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
type nrptRecoveryLimiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
attempts int
|
||||||
|
stableSuccesses int
|
||||||
|
cooldownUntil time.Time
|
||||||
|
lastSkipLog time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func nrptRecoveryMaxAttempts(cfg *ctrld.Config) int {
|
||||||
|
if cfg != nil && cfg.Service.NRPTRecoveryMaxAttempts != nil {
|
||||||
|
return *cfg.Service.NRPTRecoveryMaxAttempts
|
||||||
|
}
|
||||||
|
return defaultNRPTRecoveryMaxAttempts
|
||||||
|
}
|
||||||
|
|
||||||
|
func nrptRecoveryCooldown(cfg *ctrld.Config) time.Duration {
|
||||||
|
if cfg != nil && cfg.Service.NRPTRecoveryCooldown != nil {
|
||||||
|
return *cfg.Service.NRPTRecoveryCooldown
|
||||||
|
}
|
||||||
|
return defaultNRPTRecoveryCooldown
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *nrptRecoveryLimiter) allow(now time.Time, cfg *ctrld.Config) (bool, time.Duration) {
|
||||||
|
maxAttempts := nrptRecoveryMaxAttempts(cfg)
|
||||||
|
if maxAttempts <= 0 {
|
||||||
|
return true, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
if now.Before(l.cooldownUntil) {
|
||||||
|
return false, l.cooldownUntil.Sub(now)
|
||||||
|
}
|
||||||
|
return true, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *nrptRecoveryLimiter) recordRecoveryFlow(now time.Time, cfg *ctrld.Config) {
|
||||||
|
maxAttempts := nrptRecoveryMaxAttempts(cfg)
|
||||||
|
if maxAttempts <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cooldown := nrptRecoveryCooldown(cfg)
|
||||||
|
if cooldown <= 0 {
|
||||||
|
cooldown = defaultNRPTRecoveryCooldown
|
||||||
|
}
|
||||||
|
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
l.stableSuccesses = 0
|
||||||
|
l.attempts++
|
||||||
|
if l.attempts >= maxAttempts {
|
||||||
|
l.cooldownUntil = now.Add(cooldown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *nrptRecoveryLimiter) recordStableSuccess() {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
l.stableSuccesses++
|
||||||
|
if l.stableSuccesses >= nrptRecoveryStableSuccessesToReset {
|
||||||
|
l.attempts = 0
|
||||||
|
l.cooldownUntil = time.Time{}
|
||||||
|
l.lastSkipLog = time.Time{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *nrptRecoveryLimiter) shouldLogSkip(now time.Time) bool {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
if l.lastSkipLog.IsZero() || now.Sub(l.lastSkipLog) >= 5*time.Minute {
|
||||||
|
l.lastSkipLog = now
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Control-D-Inc/ctrld"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNRPTRecoveryLimiterCooldownAndStableReset(t *testing.T) {
|
||||||
|
maxAttempts := 2
|
||||||
|
cooldown := 10 * time.Minute
|
||||||
|
cfg := &ctrld.Config{}
|
||||||
|
cfg.Service.NRPTRecoveryMaxAttempts = &maxAttempts
|
||||||
|
cfg.Service.NRPTRecoveryCooldown = &cooldown
|
||||||
|
|
||||||
|
limiter := &nrptRecoveryLimiter{}
|
||||||
|
now := time.Unix(100, 0)
|
||||||
|
|
||||||
|
if ok, wait := limiter.allow(now, cfg); !ok || wait != 0 {
|
||||||
|
t.Fatalf("initial allow = %v, %v; want true, 0", ok, wait)
|
||||||
|
}
|
||||||
|
|
||||||
|
limiter.recordRecoveryFlow(now, cfg)
|
||||||
|
if ok, wait := limiter.allow(now.Add(time.Second), cfg); !ok || wait != 0 {
|
||||||
|
t.Fatalf("allow after first flow = %v, %v; want true, 0", ok, wait)
|
||||||
|
}
|
||||||
|
|
||||||
|
limiter.recordRecoveryFlow(now.Add(2*time.Second), cfg)
|
||||||
|
if ok, wait := limiter.allow(now.Add(3*time.Second), cfg); ok || wait <= 0 {
|
||||||
|
t.Fatalf("allow after max flows = %v, %v; want false, positive wait", ok, wait)
|
||||||
|
}
|
||||||
|
|
||||||
|
limiter.recordStableSuccess()
|
||||||
|
if ok, _ := limiter.allow(now.Add(4*time.Second), cfg); ok {
|
||||||
|
t.Fatal("one stable success cleared cooldown; want cooldown to remain")
|
||||||
|
}
|
||||||
|
|
||||||
|
limiter.recordStableSuccess()
|
||||||
|
if ok, wait := limiter.allow(now.Add(5*time.Second), cfg); !ok || wait != 0 {
|
||||||
|
t.Fatalf("allow after stable reset = %v, %v; want true, 0", ok, wait)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNRPTRecoveryLimiterDefaultIsUnlimited(t *testing.T) {
|
||||||
|
cfg := &ctrld.Config{}
|
||||||
|
limiter := &nrptRecoveryLimiter{}
|
||||||
|
now := time.Unix(100, 0)
|
||||||
|
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
limiter.recordRecoveryFlow(now.Add(time.Duration(i)*time.Second), cfg)
|
||||||
|
}
|
||||||
|
if ok, wait := limiter.allow(now.Add(time.Hour), cfg); !ok || wait != 0 {
|
||||||
|
t.Fatalf("default allow after recovery flows = %v, %v; want true, 0", ok, wait)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNRPTRecoveryLimiterUnlimited(t *testing.T) {
|
||||||
|
maxAttempts := 0
|
||||||
|
cfg := &ctrld.Config{}
|
||||||
|
cfg.Service.NRPTRecoveryMaxAttempts = &maxAttempts
|
||||||
|
|
||||||
|
limiter := &nrptRecoveryLimiter{}
|
||||||
|
now := time.Unix(100, 0)
|
||||||
|
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
limiter.recordRecoveryFlow(now.Add(time.Duration(i)*time.Second), cfg)
|
||||||
|
}
|
||||||
|
if ok, wait := limiter.allow(now.Add(time.Hour), cfg); !ok || wait != 0 {
|
||||||
|
t.Fatalf("unlimited allow = %v, %v; want true, 0", ok, wait)
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-6
@@ -8,26 +8,31 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"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
|
// sudo ifconfig lo0 alias 127.0.0.2 up
|
||||||
func allocateIP(ip string) error {
|
func allocateIP(ip string) error {
|
||||||
|
mainLog.Load().Debug().Str("ip", ip).Msg("Allocating IP address")
|
||||||
cmd := exec.Command("ifconfig", "lo0", "alias", ip, "up")
|
cmd := exec.Command("ifconfig", "lo0", "alias", ip, "up")
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
mainLog.Load().Error().Err(err).Msg("allocateIP failed")
|
mainLog.Load().Error().Err(err).Msg("AllocateIP failed")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
mainLog.Load().Debug().Str("ip", ip).Msg("IP address allocated successfully")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deAllocateIP deallocates an IP address from the specified interface
|
||||||
func deAllocateIP(ip string) error {
|
func deAllocateIP(ip string) error {
|
||||||
|
mainLog.Load().Debug().Str("ip", ip).Msg("Deallocating IP address")
|
||||||
cmd := exec.Command("ifconfig", "lo0", "-alias", ip)
|
cmd := exec.Command("ifconfig", "lo0", "-alias", ip)
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
mainLog.Load().Error().Err(err).Msg("deAllocateIP failed")
|
mainLog.Load().Error().Err(err).Msg("DeAllocateIP failed")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
mainLog.Load().Debug().Str("ip", ip).Msg("IP address deallocated successfully")
|
||||||
return nil
|
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
|
// networksetup -setdnsservers Wi-Fi 8.8.8.8 1.1.1.1
|
||||||
// TODO(cuonglm): use system API
|
// TODO(cuonglm): use system API
|
||||||
func setDNS(iface *net.Interface, nameservers []string) error {
|
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,
|
// Note that networksetup won't modify search domains settings,
|
||||||
// This assignment is just a placeholder to silent linter.
|
// This assignment is just a placeholder to silent linter.
|
||||||
_ = searchDomains
|
_ = searchDomains
|
||||||
@@ -56,6 +63,8 @@ func setDNS(iface *net.Interface, nameservers []string) error {
|
|||||||
if out, err := exec.Command(cmd, args...).CombinedOutput(); err != nil {
|
if out, err := exec.Command(cmd, args...).CombinedOutput(); err != nil {
|
||||||
return fmt.Errorf("%v: %w", string(out), err)
|
return fmt.Errorf("%v: %w", string(out), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mainLog.Load().Debug().Str("interface", iface.Name).Msg("DNS configuration set successfully")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,25 +82,30 @@ func resetDnsIgnoreUnusableInterface(iface *net.Interface) error {
|
|||||||
|
|
||||||
// TODO(cuonglm): use system API
|
// TODO(cuonglm): use system API
|
||||||
func resetDNS(iface *net.Interface) error {
|
func resetDNS(iface *net.Interface) error {
|
||||||
|
mainLog.Load().Debug().Str("interface", iface.Name).Msg("Resetting DNS configuration")
|
||||||
|
|
||||||
cmd := "networksetup"
|
cmd := "networksetup"
|
||||||
args := []string{"-setdnsservers", iface.Name, "empty"}
|
args := []string{"-setdnsservers", iface.Name, "empty"}
|
||||||
if out, err := exec.Command(cmd, args...).CombinedOutput(); err != nil {
|
if out, err := exec.Command(cmd, args...).CombinedOutput(); err != nil {
|
||||||
return fmt.Errorf("%v: %w", string(out), err)
|
return fmt.Errorf("%v: %w", string(out), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mainLog.Load().Debug().Str("interface", iface.Name).Msg("DNS configuration reset successfully")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// restoreDNS restores the DNS settings of the given interface.
|
// restoreDNS restores the DNS settings of the given interface.
|
||||||
// this should only be executed upon turning off the ctrld service.
|
// this should only be executed upon turning off the ctrld service.
|
||||||
func restoreDNS(iface *net.Interface) (err error) {
|
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)
|
err = setDNS(iface, ns)
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// currentDNS returns the current DNS servers for the specified interface
|
||||||
func currentDNS(_ *net.Interface) []string {
|
func currentDNS(_ *net.Interface) []string {
|
||||||
return resolvconffile.NameServers()
|
return ctrld.CurrentNameserversFromResolvconf()
|
||||||
}
|
}
|
||||||
|
|
||||||
// currentStaticDNS returns the current static DNS settings of given interface.
|
// currentStaticDNS returns the current static DNS settings of given interface.
|
||||||
|
|||||||
+23
-8
@@ -9,27 +9,32 @@ import (
|
|||||||
"tailscale.com/health"
|
"tailscale.com/health"
|
||||||
"tailscale.com/util/dnsname"
|
"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/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
|
// sudo ifconfig lo0 127.0.0.53 alias
|
||||||
func allocateIP(ip string) error {
|
func allocateIP(ip string) error {
|
||||||
|
mainLog.Load().Debug().Str("ip", ip).Msg("Allocating IP address")
|
||||||
cmd := exec.Command("ifconfig", "lo0", ip, "alias")
|
cmd := exec.Command("ifconfig", "lo0", ip, "alias")
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
mainLog.Load().Error().Err(err).Msg("allocateIP failed")
|
mainLog.Load().Error().Err(err).Msg("allocateIP failed")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
mainLog.Load().Debug().Str("ip", ip).Msg("IP address allocated successfully")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deAllocateIP deallocates an IP address from the specified interface
|
||||||
func deAllocateIP(ip string) error {
|
func deAllocateIP(ip string) error {
|
||||||
|
mainLog.Load().Debug().Str("ip", ip).Msg("Deallocating IP address")
|
||||||
cmd := exec.Command("ifconfig", "lo0", ip, "-alias")
|
cmd := exec.Command("ifconfig", "lo0", ip, "-alias")
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
mainLog.Load().Error().Err(err).Msg("deAllocateIP failed")
|
mainLog.Load().Error().Err(err).Msg("deAllocateIP failed")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
mainLog.Load().Debug().Str("ip", ip).Msg("IP address deallocated successfully")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,9 +45,11 @@ func setDnsIgnoreUnusableInterface(iface *net.Interface, nameservers []string) e
|
|||||||
|
|
||||||
// set the dns server for the provided network interface
|
// set the dns server for the provided network interface
|
||||||
func setDNS(iface *net.Interface, nameservers []string) error {
|
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)
|
r, err := dns.NewOSConfigurator(logf, &health.Tracker{}, &controlknobs.Knobs{}, iface.Name)
|
||||||
if err != nil {
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,13 +65,15 @@ func setDNS(iface *net.Interface, nameservers []string) error {
|
|||||||
if sds, err := searchDomains(); err == nil {
|
if sds, err := searchDomains(); err == nil {
|
||||||
osConfig.SearchDomains = sds
|
osConfig.SearchDomains = sds
|
||||||
} else {
|
} 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 {
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mainLog.Load().Debug().Str("interface", iface.Name).Msg("DNS configuration set successfully")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,17 +82,22 @@ func resetDnsIgnoreUnusableInterface(iface *net.Interface) error {
|
|||||||
return resetDNS(iface)
|
return resetDNS(iface)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resetDNS resets DNS servers for the specified interface
|
||||||
func resetDNS(iface *net.Interface) error {
|
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)
|
r, err := dns.NewOSConfigurator(logf, &health.Tracker{}, &controlknobs.Knobs{}, iface.Name)
|
||||||
if err != nil {
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := r.Close(); err != nil {
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mainLog.Load().Debug().Str("interface", iface.Name).Msg("DNS configuration reset successfully")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,8 +107,9 @@ func restoreDNS(iface *net.Interface) (err error) {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// currentDNS returns the current DNS servers for the specified interface
|
||||||
func currentDNS(_ *net.Interface) []string {
|
func currentDNS(_ *net.Interface) []string {
|
||||||
return resolvconffile.NameServers()
|
return ctrld.CurrentNameserversFromResolvconf()
|
||||||
}
|
}
|
||||||
|
|
||||||
// currentStaticDNS returns the current static DNS settings of given interface.
|
// currentStaticDNS returns the current static DNS settings of given interface.
|
||||||
|
|||||||
+22
-10
@@ -21,30 +21,36 @@ import (
|
|||||||
"tailscale.com/health"
|
"tailscale.com/health"
|
||||||
"tailscale.com/util/dnsname"
|
"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/dns"
|
||||||
ctrldnet "github.com/Control-D-Inc/ctrld/internal/net"
|
ctrldnet "github.com/Control-D-Inc/ctrld/internal/net"
|
||||||
"github.com/Control-D-Inc/ctrld/internal/resolvconffile"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const resolvConfBackupFailedMsg = "open /etc/resolv.pre-ctrld-backup.conf: read-only file system"
|
const resolvConfBackupFailedMsg = "open /etc/resolv.pre-ctrld-backup.conf: read-only file system"
|
||||||
|
|
||||||
|
type getDNS func(iface string) []string
|
||||||
|
|
||||||
// allocate loopback ip
|
// allocate loopback ip
|
||||||
// sudo ip a add 127.0.0.2/24 dev lo
|
// sudo ip a add 127.0.0.2/24 dev lo
|
||||||
func allocateIP(ip string) error {
|
func allocateIP(ip string) error {
|
||||||
|
mainLog.Load().Debug().Str("ip", ip).Msg("Allocating IP address")
|
||||||
cmd := exec.Command("ip", "a", "add", ip+"/24", "dev", "lo")
|
cmd := exec.Command("ip", "a", "add", ip+"/24", "dev", "lo")
|
||||||
if out, err := cmd.CombinedOutput(); err != nil {
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
mainLog.Load().Error().Err(err).Msgf("allocateIP failed: %s", string(out))
|
mainLog.Load().Error().Err(err).Msgf("AllocateIP failed: %s", string(out))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
mainLog.Load().Debug().Str("ip", ip).Msg("IP address allocated successfully")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func deAllocateIP(ip string) error {
|
func deAllocateIP(ip string) error {
|
||||||
|
mainLog.Load().Debug().Str("ip", ip).Msg("Deallocating IP address")
|
||||||
cmd := exec.Command("ip", "a", "del", ip+"/24", "dev", "lo")
|
cmd := exec.Command("ip", "a", "del", ip+"/24", "dev", "lo")
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
mainLog.Load().Error().Err(err).Msg("deAllocateIP failed")
|
mainLog.Load().Error().Err(err).Msg("DeAllocateIP failed")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
mainLog.Load().Debug().Str("ip", ip).Msg("IP address deallocated successfully")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,9 +62,11 @@ func setDnsIgnoreUnusableInterface(iface *net.Interface, nameservers []string) e
|
|||||||
}
|
}
|
||||||
|
|
||||||
func setDNS(iface *net.Interface, nameservers []string) error {
|
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)
|
r, err := dns.NewOSConfigurator(logf, &health.Tracker{}, &controlknobs.Knobs{}, iface.Name)
|
||||||
if err != nil {
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +82,7 @@ func setDNS(iface *net.Interface, nameservers []string) error {
|
|||||||
if sds, err := searchDomains(); err == nil {
|
if sds, err := searchDomains(); err == nil {
|
||||||
osConfig.SearchDomains = sds
|
osConfig.SearchDomains = sds
|
||||||
} else {
|
} else {
|
||||||
mainLog.Load().Debug().Err(err).Msg("failed to get search domains list")
|
mainLog.Load().Debug().Err(err).Msg("Failed to get search domains list")
|
||||||
}
|
}
|
||||||
trySystemdResolve := false
|
trySystemdResolve := false
|
||||||
if err := r.SetDNS(osConfig); err != nil {
|
if err := r.SetDNS(osConfig); err != nil {
|
||||||
@@ -117,6 +125,8 @@ systemdResolve:
|
|||||||
}
|
}
|
||||||
mainLog.Load().Debug().Msg("DNS was not set for some reason")
|
mainLog.Load().Debug().Msg("DNS was not set for some reason")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mainLog.Load().Debug().Str("interface", iface.Name).Msg("DNS configuration set successfully")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +136,8 @@ func resetDnsIgnoreUnusableInterface(iface *net.Interface) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func resetDNS(iface *net.Interface) (err error) {
|
func resetDNS(iface *net.Interface) (err error) {
|
||||||
|
mainLog.Load().Debug().Str("interface", iface.Name).Msg("Resetting DNS configuration")
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return
|
return
|
||||||
@@ -137,7 +149,7 @@ func resetDNS(iface *net.Interface) (err error) {
|
|||||||
if r, oerr := dns.NewOSConfigurator(logf, &health.Tracker{}, &controlknobs.Knobs{}, iface.Name); oerr == nil {
|
if r, oerr := dns.NewOSConfigurator(logf, &health.Tracker{}, &controlknobs.Knobs{}, iface.Name); oerr == nil {
|
||||||
_ = r.SetDNS(dns.OSConfig{})
|
_ = r.SetDNS(dns.OSConfig{})
|
||||||
if err := r.Close(); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
err = nil
|
err = nil
|
||||||
@@ -165,18 +177,18 @@ func resetDNS(iface *net.Interface) (err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TODO(cuonglm): handle DHCPv6 properly.
|
// TODO(cuonglm): handle DHCPv6 properly.
|
||||||
mainLog.Load().Debug().Msg("checking for IPv6 availability")
|
mainLog.Load().Debug().Msg("Checking for ipv6 availability")
|
||||||
if ctrldnet.IPv6Available(ctx) {
|
if ctrldnet.IPv6Available(ctx) {
|
||||||
c := client6.NewClient()
|
c := client6.NewClient()
|
||||||
conversation, err := c.Exchange(iface.Name)
|
conversation, err := c.Exchange(iface.Name)
|
||||||
if err != nil && !errAddrInUse(err) {
|
if err != nil && !errAddrInUse(err) {
|
||||||
mainLog.Load().Debug().Err(err).Msg("could not exchange DHCPv6")
|
mainLog.Load().Debug().Err(err).Msg("Could not exchange dhcpv6")
|
||||||
}
|
}
|
||||||
for _, packet := range conversation {
|
for _, packet := range conversation {
|
||||||
if packet.Type() == dhcpv6.MessageTypeReply {
|
if packet.Type() == dhcpv6.MessageTypeReply {
|
||||||
msg, err := packet.GetInnerMessage()
|
msg, err := packet.GetInnerMessage()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
mainLog.Load().Debug().Err(err).Msg("could not get inner DHCPv6 message")
|
mainLog.Load().Debug().Err(err).Msg("Could not get inner dhcpv6 message")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
nameservers := msg.Options.DNS()
|
nameservers := msg.Options.DNS()
|
||||||
@@ -201,7 +213,7 @@ func restoreDNS(iface *net.Interface) (err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func currentDNS(iface *net.Interface) []string {
|
func currentDNS(iface *net.Interface) []string {
|
||||||
resolvconfFunc := func(_ string) []string { return resolvconffile.NameServers() }
|
resolvconfFunc := func(_ string) []string { return ctrld.CurrentNameserversFromResolvconf() }
|
||||||
for _, fn := range []getDNS{getDNSByResolvectl, getDNSBySystemdResolved, getDNSByNmcli, resolvconfFunc} {
|
for _, fn := range []getDNS{getDNSByResolvectl, getDNSBySystemdResolved, getDNSByNmcli, resolvconfFunc} {
|
||||||
if ns := fn(iface.Name); len(ns) > 0 {
|
if ns := fn(iface.Name); len(ns) > 0 {
|
||||||
return ns
|
return ns
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
package cli
|
package cli
|
||||||
|
|
||||||
// TODO(cuonglm): implement.
|
// allocateIP allocates an IP address on the specified interface
|
||||||
func allocateIP(ip string) error {
|
func allocateIP(ip string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO(cuonglm): implement.
|
// deAllocateIP deallocates an IP address from the specified interface
|
||||||
func deAllocateIP(ip string) error {
|
func deAllocateIP(ip string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-126
@@ -1,21 +1,18 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
|
|
||||||
"golang.org/x/sys/windows"
|
"golang.org/x/sys/windows"
|
||||||
"golang.org/x/sys/windows/registry"
|
"golang.org/x/sys/windows/registry"
|
||||||
"golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
|
"golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
|
||||||
|
|
||||||
|
"github.com/Control-D-Inc/ctrld"
|
||||||
ctrldnet "github.com/Control-D-Inc/ctrld/internal/net"
|
ctrldnet "github.com/Control-D-Inc/ctrld/internal/net"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,11 +21,6 @@ const (
|
|||||||
v6InterfaceKeyPathFormat = `SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters\Interfaces\`
|
v6InterfaceKeyPathFormat = `SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters\Interfaces\`
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
|
||||||
setDNSOnce sync.Once
|
|
||||||
resetDNSOnce sync.Once
|
|
||||||
)
|
|
||||||
|
|
||||||
// setDnsIgnoreUnusableInterface likes setDNS, but return a nil error if the interface is not usable.
|
// setDnsIgnoreUnusableInterface likes setDNS, but return a nil error if the interface is not usable.
|
||||||
func setDnsIgnoreUnusableInterface(iface *net.Interface, nameservers []string) error {
|
func setDnsIgnoreUnusableInterface(iface *net.Interface, nameservers []string) error {
|
||||||
return setDNS(iface, nameservers)
|
return setDNS(iface, nameservers)
|
||||||
@@ -39,49 +31,7 @@ func setDNS(iface *net.Interface, nameservers []string) error {
|
|||||||
if len(nameservers) == 0 {
|
if len(nameservers) == 0 {
|
||||||
return errors.New("empty DNS nameservers")
|
return errors.New("empty DNS nameservers")
|
||||||
}
|
}
|
||||||
setDNSOnce.Do(func() {
|
|
||||||
// If there's a Dns server running, that means we are on AD with Dns feature enabled.
|
|
||||||
// Configuring the Dns server to forward queries to ctrld instead.
|
|
||||||
if hasLocalDnsServerRunning() {
|
|
||||||
mainLog.Load().Debug().Msg("Local DNS server detected, configuring forwarders")
|
|
||||||
|
|
||||||
file := absHomeDir(windowsForwardersFilename)
|
|
||||||
mainLog.Load().Debug().Msgf("Using forwarders file: %s", file)
|
|
||||||
|
|
||||||
oldForwardersContent, err := os.ReadFile(file)
|
|
||||||
if err != nil {
|
|
||||||
mainLog.Load().Debug().Err(err).Msg("Could not read existing forwarders file")
|
|
||||||
} else {
|
|
||||||
mainLog.Load().Debug().Msgf("Existing forwarders content: %s", string(oldForwardersContent))
|
|
||||||
}
|
|
||||||
|
|
||||||
hasLocalIPv6Listener := needLocalIPv6Listener()
|
|
||||||
mainLog.Load().Debug().Bool("has_ipv6_listener", hasLocalIPv6Listener).Msg("IPv6 listener status")
|
|
||||||
|
|
||||||
forwarders := slices.DeleteFunc(slices.Clone(nameservers), func(s string) bool {
|
|
||||||
if !hasLocalIPv6Listener {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return s == "::1"
|
|
||||||
})
|
|
||||||
mainLog.Load().Debug().Strs("forwarders", forwarders).Msg("Filtered forwarders list")
|
|
||||||
|
|
||||||
if err := os.WriteFile(file, []byte(strings.Join(forwarders, ",")), 0600); err != nil {
|
|
||||||
mainLog.Load().Warn().Err(err).Msg("could not save forwarders settings")
|
|
||||||
} else {
|
|
||||||
mainLog.Load().Debug().Msg("Successfully wrote new forwarders file")
|
|
||||||
}
|
|
||||||
|
|
||||||
oldForwarders := strings.Split(string(oldForwardersContent), ",")
|
|
||||||
mainLog.Load().Debug().Strs("old_forwarders", oldForwarders).Msg("Previous forwarders")
|
|
||||||
|
|
||||||
if err := addDnsServerForwarders(forwarders, oldForwarders); err != nil {
|
|
||||||
mainLog.Load().Warn().Err(err).Msg("could not set forwarders settings")
|
|
||||||
} else {
|
|
||||||
mainLog.Load().Debug().Msg("Successfully configured DNS server forwarders")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
luid, err := winipcfg.LUIDFromIndex(uint32(iface.Index))
|
luid, err := winipcfg.LUIDFromIndex(uint32(iface.Index))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("setDNS: %w", err)
|
return fmt.Errorf("setDNS: %w", err)
|
||||||
@@ -125,25 +75,8 @@ func resetDnsIgnoreUnusableInterface(iface *net.Interface) error {
|
|||||||
return resetDNS(iface)
|
return resetDNS(iface)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO(cuonglm): should we use system API?
|
// resetDNS resets DNS servers for the specified interface
|
||||||
func resetDNS(iface *net.Interface) error {
|
func resetDNS(iface *net.Interface) error {
|
||||||
resetDNSOnce.Do(func() {
|
|
||||||
// See corresponding comment in setDNS.
|
|
||||||
if hasLocalDnsServerRunning() {
|
|
||||||
file := absHomeDir(windowsForwardersFilename)
|
|
||||||
content, err := os.ReadFile(file)
|
|
||||||
if err != nil {
|
|
||||||
mainLog.Load().Error().Err(err).Msg("could not read forwarders settings")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
nameservers := strings.Split(string(content), ",")
|
|
||||||
if err := removeDnsServerForwarders(nameservers); err != nil {
|
|
||||||
mainLog.Load().Error().Err(err).Msg("could not remove forwarders settings")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
luid, err := winipcfg.LUIDFromIndex(uint32(iface.Index))
|
luid, err := winipcfg.LUIDFromIndex(uint32(iface.Index))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("resetDNS: %w", err)
|
return fmt.Errorf("resetDNS: %w", err)
|
||||||
@@ -161,7 +94,7 @@ func resetDNS(iface *net.Interface) error {
|
|||||||
// restoreDNS restores the DNS settings of the given interface.
|
// restoreDNS restores the DNS settings of the given interface.
|
||||||
// this should only be executed upon turning off the ctrld service.
|
// this should only be executed upon turning off the ctrld service.
|
||||||
func restoreDNS(iface *net.Interface) (err error) {
|
func restoreDNS(iface *net.Interface) (err error) {
|
||||||
if nss := savedStaticNameservers(iface); len(nss) > 0 {
|
if nss := ctrld.SavedStaticNameservers(iface); len(nss) > 0 {
|
||||||
v4ns := make([]string, 0, 2)
|
v4ns := make([]string, 0, 2)
|
||||||
v6ns := make([]string, 0, 2)
|
v6ns := make([]string, 0, 2)
|
||||||
for _, ns := range nss {
|
for _, ns := range nss {
|
||||||
@@ -178,24 +111,24 @@ func restoreDNS(iface *net.Interface) (err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(v4ns) > 0 {
|
if len(v4ns) > 0 {
|
||||||
mainLog.Load().Debug().Msgf("restoring IPv4 static DNS for interface %q: %v", iface.Name, v4ns)
|
mainLog.Load().Debug().Msgf("Restoring IPv4 static DNS for interface %q: %v", iface.Name, v4ns)
|
||||||
if err := setDNS(iface, v4ns); err != nil {
|
if err := setDNS(iface, v4ns); err != nil {
|
||||||
return fmt.Errorf("restoreDNS (IPv4): %w", err)
|
return fmt.Errorf("restoreDNS (IPv4): %w", err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
mainLog.Load().Debug().Msgf("restoring IPv4 DHCP for interface %q", iface.Name)
|
mainLog.Load().Debug().Msgf("Restoring IPv4 DHCP for interface %q", iface.Name)
|
||||||
if err := luid.SetDNS(windows.AF_INET, nil, nil); err != nil {
|
if err := luid.SetDNS(windows.AF_INET, nil, nil); err != nil {
|
||||||
return fmt.Errorf("restoreDNS (IPv4 clear): %w", err)
|
return fmt.Errorf("restoreDNS (IPv4 clear): %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(v6ns) > 0 {
|
if len(v6ns) > 0 {
|
||||||
mainLog.Load().Debug().Msgf("restoring IPv6 static DNS for interface %q: %v", iface.Name, v6ns)
|
mainLog.Load().Debug().Msgf("Restoring IPv6 static DNS for interface %q: %v", iface.Name, v6ns)
|
||||||
if err := setDNS(iface, v6ns); err != nil {
|
if err := setDNS(iface, v6ns); err != nil {
|
||||||
return fmt.Errorf("restoreDNS (IPv6): %w", err)
|
return fmt.Errorf("restoreDNS (IPv6): %w", err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
mainLog.Load().Debug().Msgf("restoring IPv6 DHCP for interface %q", iface.Name)
|
mainLog.Load().Debug().Msgf("Restoring IPv6 DHCP for interface %q", iface.Name)
|
||||||
if err := luid.SetDNS(windows.AF_INET6, nil, nil); err != nil {
|
if err := luid.SetDNS(windows.AF_INET6, nil, nil); err != nil {
|
||||||
return fmt.Errorf("restoreDNS (IPv6 clear): %w", err)
|
return fmt.Errorf("restoreDNS (IPv6 clear): %w", err)
|
||||||
}
|
}
|
||||||
@@ -204,15 +137,16 @@ func restoreDNS(iface *net.Interface) (err error) {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// currentDNS returns the current DNS servers for the specified interface
|
||||||
func currentDNS(iface *net.Interface) []string {
|
func currentDNS(iface *net.Interface) []string {
|
||||||
luid, err := winipcfg.LUIDFromIndex(uint32(iface.Index))
|
luid, err := winipcfg.LUIDFromIndex(uint32(iface.Index))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
mainLog.Load().Error().Err(err).Msg("failed to get interface LUID")
|
mainLog.Load().Error().Err(err).Msg("Failed to get interface LUID")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
nameservers, err := luid.DNS()
|
nameservers, err := luid.DNS()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
mainLog.Load().Error().Err(err).Msg("failed to get interface DNS")
|
mainLog.Load().Error().Err(err).Msg("Failed to get interface DNS")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
ns := make([]string, 0, len(nameservers))
|
ns := make([]string, 0, len(nameservers))
|
||||||
@@ -240,7 +174,7 @@ func currentStaticDNS(iface *net.Interface) ([]string, error) {
|
|||||||
interfaceKeyPath := path + guid.String()
|
interfaceKeyPath := path + guid.String()
|
||||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE, interfaceKeyPath, registry.QUERY_VALUE)
|
k, err := registry.OpenKey(registry.LOCAL_MACHINE, interfaceKeyPath, registry.QUERY_VALUE)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
mainLog.Load().Debug().Err(err).Msgf("failed to open registry key %q for interface %q; trying next key", interfaceKeyPath, iface.Name)
|
mainLog.Load().Debug().Err(err).Msgf("Failed to open registry key %q for interface %q; trying next key", interfaceKeyPath, iface.Name)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
func() {
|
func() {
|
||||||
@@ -248,11 +182,11 @@ func currentStaticDNS(iface *net.Interface) ([]string, error) {
|
|||||||
for _, keyName := range []string{"NameServer", "ProfileNameServer"} {
|
for _, keyName := range []string{"NameServer", "ProfileNameServer"} {
|
||||||
value, _, err := k.GetStringValue(keyName)
|
value, _, err := k.GetStringValue(keyName)
|
||||||
if err != nil && !errors.Is(err, registry.ErrNotExist) {
|
if err != nil && !errors.Is(err, registry.ErrNotExist) {
|
||||||
mainLog.Load().Debug().Err(err).Msgf("error reading %s registry key", keyName)
|
mainLog.Load().Debug().Err(err).Msgf("Error reading %s registry key", keyName)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if len(value) > 0 {
|
if len(value) > 0 {
|
||||||
mainLog.Load().Debug().Msgf("found static DNS for interface %q: %s", iface.Name, value)
|
mainLog.Load().Debug().Msgf("Found static DNS for interface %q: %s", iface.Name, value)
|
||||||
parsed := parseDNSServers(value)
|
parsed := parseDNSServers(value)
|
||||||
for _, pns := range parsed {
|
for _, pns := range parsed {
|
||||||
if !slices.Contains(ns, pns) {
|
if !slices.Contains(ns, pns) {
|
||||||
@@ -264,7 +198,7 @@ func currentStaticDNS(iface *net.Interface) ([]string, error) {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
if len(ns) == 0 {
|
if len(ns) == 0 {
|
||||||
mainLog.Load().Debug().Msgf("no static DNS values found for interface %q", iface.Name)
|
mainLog.Load().Debug().Msgf("No static DNS values found for interface %q", iface.Name)
|
||||||
}
|
}
|
||||||
return ns, nil
|
return ns, nil
|
||||||
}
|
}
|
||||||
@@ -284,49 +218,3 @@ func parseDNSServers(val string) []string {
|
|||||||
}
|
}
|
||||||
return servers
|
return servers
|
||||||
}
|
}
|
||||||
|
|
||||||
// addDnsServerForwarders adds given nameservers to DNS server forwarders list,
|
|
||||||
// and also removing old forwarders if provided.
|
|
||||||
func addDnsServerForwarders(nameservers, old []string) error {
|
|
||||||
newForwardersMap := make(map[string]struct{})
|
|
||||||
newForwarders := make([]string, len(nameservers))
|
|
||||||
for i := range nameservers {
|
|
||||||
newForwardersMap[nameservers[i]] = struct{}{}
|
|
||||||
newForwarders[i] = fmt.Sprintf("%q", nameservers[i])
|
|
||||||
}
|
|
||||||
oldForwarders := old[:0]
|
|
||||||
for _, fwd := range old {
|
|
||||||
if _, ok := newForwardersMap[fwd]; !ok {
|
|
||||||
oldForwarders = append(oldForwarders, fwd)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// NOTE: It is important to add new forwarder before removing old one.
|
|
||||||
// Testing on Windows Server 2022 shows that removing forwarder1
|
|
||||||
// then adding forwarder2 sometimes ends up adding both of them
|
|
||||||
// to the forwarders list.
|
|
||||||
cmd := fmt.Sprintf("Add-DnsServerForwarder -IPAddress %s", strings.Join(newForwarders, ","))
|
|
||||||
if len(oldForwarders) > 0 {
|
|
||||||
cmd = fmt.Sprintf("%s ; Remove-DnsServerForwarder -IPAddress %s -Force", cmd, strings.Join(oldForwarders, ","))
|
|
||||||
}
|
|
||||||
if out, err := powershell(cmd); err != nil {
|
|
||||||
return fmt.Errorf("%w: %s", err, string(out))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// removeDnsServerForwarders removes given nameservers from DNS server forwarders list.
|
|
||||||
func removeDnsServerForwarders(nameservers []string) error {
|
|
||||||
for _, ns := range nameservers {
|
|
||||||
cmd := fmt.Sprintf("Remove-DnsServerForwarder -IPAddress %s -Force", ns)
|
|
||||||
if out, err := powershell(cmd); err != nil {
|
|
||||||
return fmt.Errorf("%w: %s", err, string(out))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// powershell runs the given powershell command.
|
|
||||||
func powershell(cmd string) ([]byte, error) {
|
|
||||||
out, err := exec.Command("powershell", "-Command", cmd).CombinedOutput()
|
|
||||||
return bytes.TrimSpace(out), err
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"os/exec"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -66,3 +68,9 @@ func currentStaticDnsPowershell(iface *net.Interface) ([]string, error) {
|
|||||||
}
|
}
|
||||||
return ns, nil
|
return ns, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// powershell runs the given powershell command.
|
||||||
|
func powershell(cmd string) ([]byte, error) {
|
||||||
|
out, err := exec.Command("powershell", "-Command", cmd).CombinedOutput()
|
||||||
|
return bytes.TrimSpace(out), err
|
||||||
|
}
|
||||||
|
|||||||
+396
-272
File diff suppressed because it is too large
Load Diff
@@ -4,8 +4,10 @@ import (
|
|||||||
"github.com/kardianos/service"
|
"github.com/kardianos/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// setDependencies sets service dependencies for Darwin
|
||||||
func setDependencies(svc *service.Config) {}
|
func setDependencies(svc *service.Config) {}
|
||||||
|
|
||||||
|
// setWorkingDirectory sets the working directory for the service
|
||||||
func setWorkingDirectory(svc *service.Config, dir string) {
|
func setWorkingDirectory(svc *service.Config, dir string) {
|
||||||
svc.WorkingDirectory = dir
|
svc.WorkingDirectory = dir
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import (
|
|||||||
"github.com/kardianos/service"
|
"github.com/kardianos/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// setDependencies sets service dependencies for FreeBSD
|
||||||
func setDependencies(svc *service.Config) {
|
func setDependencies(svc *service.Config) {
|
||||||
// TODO(cuonglm): remove once https://github.com/kardianos/service/issues/359 fixed.
|
// TODO(cuonglm): remove once https://github.com/kardianos/service/issues/359 fixed.
|
||||||
_ = os.MkdirAll("/usr/local/etc/rc.d", 0755)
|
_ = os.MkdirAll("/usr/local/etc/rc.d", 0755)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setWorkingDirectory sets the working directory for the service
|
||||||
func setWorkingDirectory(svc *service.Config, dir string) {}
|
func setWorkingDirectory(svc *service.Config, dir string) {}
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/kardianos/service"
|
"github.com/kardianos/service"
|
||||||
|
|
||||||
"github.com/Control-D-Inc/ctrld/internal/router"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -23,6 +21,7 @@ func init() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setDependencies sets service dependencies for Linux
|
||||||
func setDependencies(svc *service.Config) {
|
func setDependencies(svc *service.Config) {
|
||||||
svc.Dependencies = []string{
|
svc.Dependencies = []string{
|
||||||
"Wants=network-online.target",
|
"Wants=network-online.target",
|
||||||
@@ -37,11 +36,9 @@ func setDependencies(svc *service.Config) {
|
|||||||
svc.Dependencies = append(svc.Dependencies, "Wants=systemd-networkd-wait-online.service")
|
svc.Dependencies = append(svc.Dependencies, "Wants=systemd-networkd-wait-online.service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if routerDeps := router.ServiceDependencies(); len(routerDeps) > 0 {
|
|
||||||
svc.Dependencies = append(svc.Dependencies, routerDeps...)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setWorkingDirectory sets the working directory for the service
|
||||||
func setWorkingDirectory(svc *service.Config, dir string) {
|
func setWorkingDirectory(svc *service.Config, dir string) {
|
||||||
svc.WorkingDirectory = dir
|
svc.WorkingDirectory = dir
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import "github.com/Control-D-Inc/ctrld"
|
||||||
|
|
||||||
|
// Debug starts a new message with debug level.
|
||||||
|
func (p *prog) Debug() *ctrld.LogEvent {
|
||||||
|
return p.logger.Load().Debug()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn starts a new message with warn level.
|
||||||
|
func (p *prog) Warn() *ctrld.LogEvent {
|
||||||
|
return p.logger.Load().Warn()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info starts a new message with info level.
|
||||||
|
func (p *prog) Info() *ctrld.LogEvent {
|
||||||
|
return p.logger.Load().Info()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fatal starts a new message with fatal level.
|
||||||
|
func (p *prog) Fatal() *ctrld.LogEvent {
|
||||||
|
return p.logger.Load().Fatal()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error starts a new message with error level.
|
||||||
|
func (p *prog) Error() *ctrld.LogEvent {
|
||||||
|
return p.logger.Load().Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notice starts a new message with notice level.
|
||||||
|
func (p *prog) Notice() *ctrld.LogEvent {
|
||||||
|
return p.logger.Load().Notice()
|
||||||
|
}
|
||||||
@@ -4,8 +4,10 @@ package cli
|
|||||||
|
|
||||||
import "github.com/kardianos/service"
|
import "github.com/kardianos/service"
|
||||||
|
|
||||||
|
// setDependencies sets service dependencies for other platforms
|
||||||
func setDependencies(svc *service.Config) {}
|
func setDependencies(svc *service.Config) {}
|
||||||
|
|
||||||
|
// setWorkingDirectory sets the working directory for the service
|
||||||
func setWorkingDirectory(svc *service.Config, dir string) {
|
func setWorkingDirectory(svc *service.Config, dir string) {
|
||||||
// WorkingDirectory is not supported on Windows.
|
// WorkingDirectory is not supported on Windows.
|
||||||
svc.WorkingDirectory = dir
|
svc.WorkingDirectory = dir
|
||||||
|
|||||||
+40
-15
@@ -1,17 +1,46 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"runtime"
|
"context"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Masterminds/semver/v3"
|
"github.com/Masterminds/semver/v3"
|
||||||
"github.com/rs/zerolog"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/Control-D-Inc/ctrld"
|
"github.com/Control-D-Inc/ctrld"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestErrNetworkErrorTreatsNoRouteAsNetworkError(t *testing.T) {
|
||||||
|
err := &net.OpError{Op: "dial", Net: "tcp", Err: syscall.EHOSTUNREACH}
|
||||||
|
assert.True(t, errNetworkError(err))
|
||||||
|
assert.True(t, errUrlNetworkError(&url.Error{Op: "Get", URL: "https://dns.controld.com", Err: err}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSleepWithContext(t *testing.T) {
|
||||||
|
assert.True(t, sleepWithContext(context.Background(), time.Millisecond))
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
assert.False(t, sleepWithContext(ctx, time.Minute))
|
||||||
|
assert.Less(t, time.Since(start), 100*time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnreachableRecoveryBackoff(t *testing.T) {
|
||||||
|
// Streak starts at the base cadence and doubles each attempt, capped at the max.
|
||||||
|
assert.Equal(t, checkUpstreamBackoffSleep, unreachableRecoveryBackoff(0))
|
||||||
|
assert.Equal(t, checkUpstreamBackoffSleep, unreachableRecoveryBackoff(1))
|
||||||
|
assert.Equal(t, 2*checkUpstreamBackoffSleep, unreachableRecoveryBackoff(2))
|
||||||
|
assert.Equal(t, 4*checkUpstreamBackoffSleep, unreachableRecoveryBackoff(3))
|
||||||
|
assert.Equal(t, checkUpstreamUnreachableBackoffMax, unreachableRecoveryBackoff(100))
|
||||||
|
}
|
||||||
|
|
||||||
func Test_prog_dnsWatchdogEnabled(t *testing.T) {
|
func Test_prog_dnsWatchdogEnabled(t *testing.T) {
|
||||||
p := &prog{cfg: &ctrld.Config{}}
|
p := &prog{cfg: &ctrld.Config{}}
|
||||||
|
|
||||||
@@ -174,10 +203,10 @@ func Test_shouldUpgrade(t *testing.T) {
|
|||||||
tc := tc
|
tc := tc
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
// Create test logger
|
// Create test logger
|
||||||
testLogger := zerolog.New(zerolog.NewTestWriter(t)).With().Logger()
|
testLogger := &ctrld.Logger{Logger: zap.NewNop()}
|
||||||
|
|
||||||
// Call the function and capture the result
|
// Call the function and capture the result
|
||||||
result := shouldUpgrade(tc.versionTarget, tc.currentVersion, &testLogger)
|
result := shouldUpgrade(tc.versionTarget, tc.currentVersion, testLogger)
|
||||||
|
|
||||||
// Assert the expected result
|
// Assert the expected result
|
||||||
assert.Equal(t, tc.shouldUpgrade, result, tc.description)
|
assert.Equal(t, tc.shouldUpgrade, result, tc.description)
|
||||||
@@ -186,10 +215,6 @@ func Test_shouldUpgrade(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Test_selfUpgradeCheck(t *testing.T) {
|
func Test_selfUpgradeCheck(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("skipped due to Windows file locking issue on Github Action runners")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to create a version
|
// Helper function to create a version
|
||||||
makeVersion := func(v string) *semver.Version {
|
makeVersion := func(v string) *semver.Version {
|
||||||
ver, err := semver.NewVersion(v)
|
ver, err := semver.NewVersion(v)
|
||||||
@@ -226,10 +251,10 @@ func Test_selfUpgradeCheck(t *testing.T) {
|
|||||||
tc := tc
|
tc := tc
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
// Create test logger
|
// Create test logger
|
||||||
testLogger := zerolog.New(zerolog.NewTestWriter(t)).With().Logger()
|
testLogger := &ctrld.Logger{Logger: zap.NewNop()}
|
||||||
|
|
||||||
// Call the function and capture the result
|
// Call the function and capture the result
|
||||||
result := selfUpgradeCheck(tc.versionTarget, tc.currentVersion, &testLogger)
|
result := selfUpgradeCheck(tc.versionTarget, tc.currentVersion, testLogger)
|
||||||
|
|
||||||
// Assert the expected result
|
// Assert the expected result
|
||||||
assert.Equal(t, tc.shouldUpgrade, result, tc.description)
|
assert.Equal(t, tc.shouldUpgrade, result, tc.description)
|
||||||
@@ -238,10 +263,6 @@ func Test_selfUpgradeCheck(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Test_performUpgrade(t *testing.T) {
|
func Test_performUpgrade(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("skipped due to Windows file locking issue on Github Action runners")
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
versionTarget string
|
versionTarget string
|
||||||
@@ -262,11 +283,15 @@ func Test_performUpgrade(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newUpgradeCmd is stubbed in TestMain so performUpgrade does not re-exec
|
||||||
|
// (and fork-bomb) the test binary; see the comment there.
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
tc := tc
|
tc := tc
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Create test logger
|
||||||
|
testLogger := &ctrld.Logger{Logger: zap.NewNop()}
|
||||||
// Call the function and capture the result
|
// Call the function and capture the result
|
||||||
result := performUpgrade(tc.versionTarget)
|
result := performUpgrade(tc.versionTarget, testLogger)
|
||||||
assert.Equal(t, tc.expectedResult, result, tc.description)
|
assert.Equal(t, tc.expectedResult, result, tc.description)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,10 @@ package cli
|
|||||||
|
|
||||||
import "github.com/kardianos/service"
|
import "github.com/kardianos/service"
|
||||||
|
|
||||||
func setDependencies(svc *service.Config) {
|
// setDependencies sets service dependencies for Windows
|
||||||
if hasLocalDnsServerRunning() {
|
func setDependencies(svc *service.Config) {}
|
||||||
svc.Dependencies = []string{"DNS"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// setWorkingDirectory sets the working directory for the service
|
||||||
func setWorkingDirectory(svc *service.Config, dir string) {
|
func setWorkingDirectory(svc *service.Config, dir string) {
|
||||||
// WorkingDirectory is not supported on Windows.
|
// WorkingDirectory is not supported on Windows.
|
||||||
svc.WorkingDirectory = dir
|
svc.WorkingDirectory = dir
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package cli
|
|||||||
|
|
||||||
import "github.com/prometheus/client_golang/prometheus"
|
import "github.com/prometheus/client_golang/prometheus"
|
||||||
|
|
||||||
|
// Prometheus metrics label constants for consistent labeling across all metrics
|
||||||
|
// These ensure standardized metric labeling for monitoring and alerting
|
||||||
const (
|
const (
|
||||||
metricsLabelListener = "listener"
|
metricsLabelListener = "listener"
|
||||||
metricsLabelClientSourceIP = "client_source_ip"
|
metricsLabelClientSourceIP = "client_source_ip"
|
||||||
@@ -13,17 +15,21 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// statsVersion represent ctrld version.
|
// statsVersion represent ctrld version.
|
||||||
|
// This metric provides version information for monitoring and debugging
|
||||||
var statsVersion = prometheus.NewCounterVec(prometheus.CounterOpts{
|
var statsVersion = prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||||
Name: "ctrld_build_info",
|
Name: "ctrld_build_info",
|
||||||
Help: "Version of ctrld process.",
|
Help: "Version of ctrld process.",
|
||||||
}, []string{"gitref", "goversion", "version"})
|
}, []string{"gitref", "goversion", "version"})
|
||||||
|
|
||||||
// statsTimeStart represents start time of ctrld service.
|
// statsTimeStart represents start time of ctrld service.
|
||||||
|
// This metric tracks service uptime and helps with monitoring service restarts
|
||||||
var statsTimeStart = prometheus.NewGauge(prometheus.GaugeOpts{
|
var statsTimeStart = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||||
Name: "ctrld_time_seconds",
|
Name: "ctrld_time_seconds",
|
||||||
Help: "Start time of the ctrld process since unix epoch in seconds.",
|
Help: "Start time of the ctrld process since unix epoch in seconds.",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// statsQueriesCountLabels defines the labels for query count metrics
|
||||||
|
// These labels provide detailed breakdown of DNS query statistics
|
||||||
var statsQueriesCountLabels = []string{
|
var statsQueriesCountLabels = []string{
|
||||||
metricsLabelListener,
|
metricsLabelListener,
|
||||||
metricsLabelClientSourceIP,
|
metricsLabelClientSourceIP,
|
||||||
@@ -35,6 +41,7 @@ var statsQueriesCountLabels = []string{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// statsQueriesCount counts total number of queries.
|
// statsQueriesCount counts total number of queries.
|
||||||
|
// This provides comprehensive DNS query statistics for monitoring and alerting
|
||||||
var statsQueriesCount = prometheus.NewCounterVec(prometheus.CounterOpts{
|
var statsQueriesCount = prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||||
Name: "ctrld_queries_count",
|
Name: "ctrld_queries_count",
|
||||||
Help: "Total number of queries.",
|
Help: "Total number of queries.",
|
||||||
@@ -44,12 +51,14 @@ var statsQueriesCount = prometheus.NewCounterVec(prometheus.CounterOpts{
|
|||||||
//
|
//
|
||||||
// The labels "client_source_ip", "client_mac", "client_hostname" are unbounded,
|
// The labels "client_source_ip", "client_mac", "client_hostname" are unbounded,
|
||||||
// thus this stat is highly inefficient if there are many devices.
|
// thus this stat is highly inefficient if there are many devices.
|
||||||
|
// This metric should be used carefully in high-client environments
|
||||||
var statsClientQueriesCount = prometheus.NewCounterVec(prometheus.CounterOpts{
|
var statsClientQueriesCount = prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||||
Name: "ctrld_client_queries_count",
|
Name: "ctrld_client_queries_count",
|
||||||
Help: "Total number queries of a client.",
|
Help: "Total number queries of a client.",
|
||||||
}, []string{metricsLabelClientSourceIP, metricsLabelClientMac, metricsLabelClientHostname})
|
}, []string{metricsLabelClientSourceIP, metricsLabelClientMac, metricsLabelClientHostname})
|
||||||
|
|
||||||
// WithLabelValuesInc increases prometheus counter by 1 if query stats is enabled.
|
// WithLabelValuesInc increases prometheus counter by 1 if query stats is enabled.
|
||||||
|
// This provides conditional metric collection to avoid performance impact when metrics are disabled
|
||||||
func (p *prog) WithLabelValuesInc(c *prometheus.CounterVec, lvs ...string) {
|
func (p *prog) WithLabelValuesInc(c *prometheus.CounterVec, lvs ...string) {
|
||||||
if p.metricsQueryStats.Load() {
|
if p.metricsQueryStats.Load() {
|
||||||
c.WithLabelValues(lvs...).Inc()
|
c.WithLabelValues(lvs...).Inc()
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// notifyReloadSigCh sends reload signal to the channel
|
||||||
func notifyReloadSigCh(ch chan os.Signal) {
|
func notifyReloadSigCh(ch chan os.Signal) {
|
||||||
signal.Notify(ch, syscall.SIGUSR1)
|
signal.Notify(ch, syscall.SIGUSR1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sendReloadSignal sends a reload signal to the current process
|
||||||
func (p *prog) sendReloadSignal() error {
|
func (p *prog) sendReloadSignal() error {
|
||||||
return syscall.Kill(syscall.Getpid(), syscall.SIGUSR1)
|
return syscall.Kill(syscall.Getpid(), syscall.SIGUSR1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// notifyReloadSigCh is a no-op on Windows platforms
|
||||||
func notifyReloadSigCh(ch chan os.Signal) {}
|
func notifyReloadSigCh(ch chan os.Signal) {}
|
||||||
|
|
||||||
|
// sendReloadSignal sends a reload signal to the program
|
||||||
func (p *prog) sendReloadSignal() error {
|
func (p *prog) sendReloadSignal() error {
|
||||||
select {
|
select {
|
||||||
case p.reloadCh <- struct{}{}:
|
case p.reloadCh <- struct{}{}:
|
||||||
|
|||||||
+23
-34
@@ -3,59 +3,45 @@ package cli
|
|||||||
import (
|
import (
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/fsnotify/fsnotify"
|
"github.com/fsnotify/fsnotify"
|
||||||
|
|
||||||
|
"github.com/Control-D-Inc/ctrld/internal/resolvconffile"
|
||||||
)
|
)
|
||||||
|
|
||||||
// parseResolvConfNameservers reads the resolv.conf file and returns the nameservers found.
|
// parseResolvConfNameservers reads the resolv.conf file and returns the nameservers found.
|
||||||
// Returns nil if no nameservers are found.
|
// Returns nil if no nameservers are found.
|
||||||
|
// This function parses the system DNS configuration to understand current nameserver settings
|
||||||
func (p *prog) parseResolvConfNameservers(path string) ([]string, error) {
|
func (p *prog) parseResolvConfNameservers(path string) ([]string, error) {
|
||||||
content, err := os.ReadFile(path)
|
return resolvconffile.NameserversFromFile(path)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the file for "nameserver" lines
|
|
||||||
var currentNS []string
|
|
||||||
lines := strings.Split(string(content), "\n")
|
|
||||||
for _, line := range lines {
|
|
||||||
trimmed := strings.TrimSpace(line)
|
|
||||||
if strings.HasPrefix(trimmed, "nameserver") {
|
|
||||||
parts := strings.Fields(trimmed)
|
|
||||||
if len(parts) >= 2 {
|
|
||||||
currentNS = append(currentNS, parts[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return currentNS, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// watchResolvConf watches any changes to /etc/resolv.conf file,
|
// watchResolvConf watches any changes to /etc/resolv.conf file,
|
||||||
// and reverting to the original config set by ctrld.
|
// and reverting to the original config set by ctrld.
|
||||||
|
// This ensures that DNS settings are not overridden by other applications or system processes
|
||||||
func (p *prog) watchResolvConf(iface *net.Interface, ns []netip.Addr, setDnsFn func(iface *net.Interface, ns []netip.Addr) error) {
|
func (p *prog) watchResolvConf(iface *net.Interface, ns []netip.Addr, setDnsFn func(iface *net.Interface, ns []netip.Addr) error) {
|
||||||
resolvConfPath := "/etc/resolv.conf"
|
resolvConfPath := "/etc/resolv.conf"
|
||||||
// Evaluating symbolics link to watch the target file that /etc/resolv.conf point to.
|
// Evaluating symbolics link to watch the target file that /etc/resolv.conf point to.
|
||||||
|
// This handles systems where resolv.conf is a symlink to another location
|
||||||
if rp, _ := filepath.EvalSymlinks(resolvConfPath); rp != "" {
|
if rp, _ := filepath.EvalSymlinks(resolvConfPath); rp != "" {
|
||||||
resolvConfPath = rp
|
resolvConfPath = rp
|
||||||
}
|
}
|
||||||
mainLog.Load().Debug().Msgf("start watching %s file", resolvConfPath)
|
p.Debug().Msgf("Start watching %s file", resolvConfPath)
|
||||||
watcher, err := fsnotify.NewWatcher()
|
watcher, err := fsnotify.NewWatcher()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
mainLog.Load().Warn().Err(err).Msg("could not create watcher for /etc/resolv.conf")
|
p.Warn().Err(err).Msg("Could not create watcher for /etc/resolv.conf")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer watcher.Close()
|
defer watcher.Close()
|
||||||
|
|
||||||
// We watch /etc instead of /etc/resolv.conf directly,
|
// We watch /etc instead of /etc/resolv.conf directly,
|
||||||
// see: https://github.com/fsnotify/fsnotify#watching-a-file-doesnt-work-well
|
// see: https://github.com/fsnotify/fsnotify#watching-a-file-doesnt-work-well
|
||||||
|
// This is necessary because some systems don't properly notify on file changes
|
||||||
watchDir := filepath.Dir(resolvConfPath)
|
watchDir := filepath.Dir(resolvConfPath)
|
||||||
if err := watcher.Add(watchDir); err != nil {
|
if err := watcher.Add(watchDir); err != nil {
|
||||||
mainLog.Load().Warn().Err(err).Msgf("could not add %s to watcher list", watchDir)
|
p.Warn().Err(err).Msgf("Could not add %s to watcher list", watchDir)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +50,7 @@ func (p *prog) watchResolvConf(iface *net.Interface, ns []netip.Addr, setDnsFn f
|
|||||||
case <-p.dnsWatcherStopCh:
|
case <-p.dnsWatcherStopCh:
|
||||||
return
|
return
|
||||||
case <-p.stopCh:
|
case <-p.stopCh:
|
||||||
mainLog.Load().Debug().Msgf("stopping watcher for %s", resolvConfPath)
|
p.Debug().Msgf("Stopping watcher for %s", resolvConfPath)
|
||||||
return
|
return
|
||||||
case event, ok := <-watcher.Events:
|
case event, ok := <-watcher.Events:
|
||||||
if p.recoveryRunning.Load() {
|
if p.recoveryRunning.Load() {
|
||||||
@@ -77,9 +63,10 @@ func (p *prog) watchResolvConf(iface *net.Interface, ns []netip.Addr, setDnsFn f
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) {
|
if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) {
|
||||||
mainLog.Load().Debug().Msgf("/etc/resolv.conf changes detected, reading changes...")
|
p.Debug().Msgf("/etc/resolv.conf changes detected, reading changes...")
|
||||||
|
|
||||||
// Convert expected nameservers to strings for comparison
|
// Convert expected nameservers to strings for comparison
|
||||||
|
// This allows us to detect when the resolv.conf has been modified
|
||||||
expectedNS := make([]string, len(ns))
|
expectedNS := make([]string, len(ns))
|
||||||
for i, addr := range ns {
|
for i, addr := range ns {
|
||||||
expectedNS[i] = addr.String()
|
expectedNS[i] = addr.String()
|
||||||
@@ -92,18 +79,20 @@ func (p *prog) watchResolvConf(iface *net.Interface, ns []netip.Addr, setDnsFn f
|
|||||||
for retry := 0; retry < maxRetries; retry++ {
|
for retry := 0; retry < maxRetries; retry++ {
|
||||||
foundNS, err = p.parseResolvConfNameservers(resolvConfPath)
|
foundNS, err = p.parseResolvConfNameservers(resolvConfPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
mainLog.Load().Error().Err(err).Msg("failed to read resolv.conf content")
|
p.Error().Err(err).Msg("Failed to read resolv.conf content")
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we found nameservers, break out of retry loop
|
// If we found nameservers, break out of retry loop
|
||||||
|
// This handles cases where the file is being written but not yet complete
|
||||||
if len(foundNS) > 0 {
|
if len(foundNS) > 0 {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only retry if we found no nameservers
|
// Only retry if we found no nameservers
|
||||||
|
// This handles temporary file states during updates
|
||||||
if retry < maxRetries-1 {
|
if retry < maxRetries-1 {
|
||||||
mainLog.Load().Debug().Msgf("resolv.conf has no nameserver entries, retry %d/%d in 2 seconds", retry+1, maxRetries)
|
p.Debug().Msgf("resolv.conf has no nameserver entries, retry %d/%d in 2 seconds", retry+1, maxRetries)
|
||||||
select {
|
select {
|
||||||
case <-p.stopCh:
|
case <-p.stopCh:
|
||||||
return
|
return
|
||||||
@@ -113,7 +102,7 @@ func (p *prog) watchResolvConf(iface *net.Interface, ns []netip.Addr, setDnsFn f
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
mainLog.Load().Debug().Msg("resolv.conf remained empty after all retries")
|
p.Debug().Msg("resolv.conf remained empty after all retries")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +119,7 @@ func (p *prog) watchResolvConf(iface *net.Interface, ns []netip.Addr, setDnsFn f
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mainLog.Load().Debug().
|
p.Debug().
|
||||||
Strs("found", foundNS).
|
Strs("found", foundNS).
|
||||||
Strs("expected", expectedNS).
|
Strs("expected", expectedNS).
|
||||||
Bool("matches", matches).
|
Bool("matches", matches).
|
||||||
@@ -139,16 +128,16 @@ func (p *prog) watchResolvConf(iface *net.Interface, ns []netip.Addr, setDnsFn f
|
|||||||
// Only revert if the nameservers don't match
|
// Only revert if the nameservers don't match
|
||||||
if !matches {
|
if !matches {
|
||||||
if err := watcher.Remove(watchDir); err != nil {
|
if err := watcher.Remove(watchDir); err != nil {
|
||||||
mainLog.Load().Error().Err(err).Msg("failed to pause watcher")
|
p.Error().Err(err).Msg("Failed to pause watcher")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := setDnsFn(iface, ns); err != nil {
|
if err := setDnsFn(iface, ns); err != nil {
|
||||||
mainLog.Load().Error().Err(err).Msg("failed to revert /etc/resolv.conf changes")
|
p.Error().Err(err).Msg("Failed to revert /etc/resolv.conf changes")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := watcher.Add(watchDir); err != nil {
|
if err := watcher.Add(watchDir); err != nil {
|
||||||
mainLog.Load().Error().Err(err).Msg("failed to continue running watcher")
|
p.Error().Err(err).Msg("Failed to continue running watcher")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -158,7 +147,7 @@ func (p *prog) watchResolvConf(iface *net.Interface, ns []netip.Addr, setDnsFn f
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
mainLog.Load().Err(err).Msg("could not get event for /etc/resolv.conf")
|
p.Error().Err(err).Msg("Could not get event for /etc/resolv.conf")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
const resolvConfPath = "/etc/resolv.conf"
|
const resolvConfPath = "/etc/resolv.conf"
|
||||||
|
|
||||||
// setResolvConf sets the content of resolv.conf file using the given nameservers list.
|
// setResolvConf sets the content of resolv.conf file using the given nameservers list.
|
||||||
func setResolvConf(iface *net.Interface, ns []netip.Addr) error {
|
func (p *prog) setResolvConf(iface *net.Interface, ns []netip.Addr) error {
|
||||||
servers := make([]string, len(ns))
|
servers := make([]string, len(ns))
|
||||||
for i := range ns {
|
for i := range ns {
|
||||||
servers[i] = ns[i].String()
|
servers[i] = ns[i].String()
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// setResolvConf sets the content of the resolv.conf file using the given nameservers list.
|
// setResolvConf sets the content of the resolv.conf file using the given nameservers list.
|
||||||
func setResolvConf(iface *net.Interface, ns []netip.Addr) error {
|
func (p *prog) setResolvConf(iface *net.Interface, ns []netip.Addr) error {
|
||||||
r, err := newLoopbackOSConfigurator()
|
r, err := newLoopbackOSConfigurator()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -27,7 +27,7 @@ func setResolvConf(iface *net.Interface, ns []netip.Addr) error {
|
|||||||
if sds, err := searchDomains(); err == nil {
|
if sds, err := searchDomains(); err == nil {
|
||||||
oc.SearchDomains = sds
|
oc.SearchDomains = sds
|
||||||
} else {
|
} else {
|
||||||
mainLog.Load().Debug().Err(err).Msg("failed to get search domains list when reverting resolv.conf file")
|
p.Debug().Err(err).Msg("Failed to get search domains list when reverting resolv.conf file")
|
||||||
}
|
}
|
||||||
return r.SetDNS(oc)
|
return r.SetDNS(oc)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
//go:build unix
|
||||||
|
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Control-D-Inc/ctrld/internal/dns/resolvconffile"
|
||||||
|
)
|
||||||
|
|
||||||
|
func oldParseResolvConfNameservers(path string) ([]string, error) {
|
||||||
|
content, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the file for "nameserver" lines
|
||||||
|
var currentNS []string
|
||||||
|
lines := strings.Split(string(content), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(trimmed, "nameserver") {
|
||||||
|
parts := strings.Fields(trimmed)
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
currentNS = append(currentNS, parts[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentNS, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test_prog_parseResolvConfNameservers tests the parsing of nameservers from resolv.conf content.
|
||||||
|
// Note: The previous implementation was removed to reduce code duplication and consolidate
|
||||||
|
// the resolv.conf handling logic into a single unified approach. All resolv.conf parsing
|
||||||
|
// is now handled by the resolvconffile package, which provides a consistent interface
|
||||||
|
// for both reading and modifying resolv.conf files across different platforms.
|
||||||
|
func Test_prog_parseResolvConfNameservers(t *testing.T) {
|
||||||
|
oldNss, _ := oldParseResolvConfNameservers(resolvconffile.Path)
|
||||||
|
p := &prog{}
|
||||||
|
nss, _ := p.parseResolvConfNameservers(resolvconffile.Path)
|
||||||
|
slices.Sort(oldNss)
|
||||||
|
slices.Sort(nss)
|
||||||
|
if !slices.Equal(oldNss, nss) {
|
||||||
|
t.Errorf("result mismatched, old: %v, new: %v", oldNss, nss)
|
||||||
|
}
|
||||||
|
t.Logf("result: %v", nss)
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// setResolvConf sets the content of resolv.conf file using the given nameservers list.
|
// setResolvConf sets the content of resolv.conf file using the given nameservers list.
|
||||||
func setResolvConf(_ *net.Interface, _ []netip.Addr) error {
|
func (p *prog) setResolvConf(_ *net.Interface, _ []netip.Addr) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func searchDomains() ([]dnsname.FQDN, error) {
|
|||||||
for a := aa.FirstDNSSuffix; a != nil; a = a.Next {
|
for a := aa.FirstDNSSuffix; a != nil; a = a.Next {
|
||||||
d, err := dnsname.ToFQDN(a.String())
|
d, err := dnsname.ToFQDN(a.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
mainLog.Load().Debug().Err(err).Msgf("failed to parse domain: %s", a.String())
|
mainLog.Load().Debug().Err(err).Msgf("Failed to parse domain: %s", a.String())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
sds = append(sds, d)
|
sds = append(sds, d)
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ package cli
|
|||||||
|
|
||||||
var supportedSelfDelete = true
|
var supportedSelfDelete = true
|
||||||
|
|
||||||
|
// selfDeleteExe performs self-deletion on non-Windows platforms
|
||||||
func selfDeleteExe() error { return nil }
|
func selfDeleteExe() error { return nil }
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type FILE_DISPOSITION_INFO struct {
|
|||||||
DeleteFile bool
|
DeleteFile bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dsOpenHandle opens a handle to the specified file with DELETE access
|
||||||
func dsOpenHandle(pwPath *uint16) (windows.Handle, error) {
|
func dsOpenHandle(pwPath *uint16) (windows.Handle, error) {
|
||||||
handle, err := windows.CreateFile(
|
handle, err := windows.CreateFile(
|
||||||
pwPath,
|
pwPath,
|
||||||
@@ -51,6 +52,7 @@ func dsOpenHandle(pwPath *uint16) (windows.Handle, error) {
|
|||||||
return handle, nil
|
return handle, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dsRenameHandle renames a file handle to a stream name
|
||||||
func dsRenameHandle(hHandle windows.Handle) error {
|
func dsRenameHandle(hHandle windows.Handle) error {
|
||||||
var fRename FILE_RENAME_INFO
|
var fRename FILE_RENAME_INFO
|
||||||
DS_STREAM_RENAME, err := windows.UTF16FromString(":deadbeef")
|
DS_STREAM_RENAME, err := windows.UTF16FromString(":deadbeef")
|
||||||
@@ -82,6 +84,7 @@ func dsRenameHandle(hHandle windows.Handle) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dsDepositeHandle marks a file handle for deletion
|
||||||
func dsDepositeHandle(hHandle windows.Handle) error {
|
func dsDepositeHandle(hHandle windows.Handle) error {
|
||||||
var fDelete FILE_DISPOSITION_INFO
|
var fDelete FILE_DISPOSITION_INFO
|
||||||
fDelete.DeleteFile = true
|
fDelete.DeleteFile = true
|
||||||
@@ -100,6 +103,7 @@ func dsDepositeHandle(hHandle windows.Handle) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// selfDeleteExe performs self-deletion on Windows platforms
|
||||||
func selfDeleteExe() error {
|
func selfDeleteExe() error {
|
||||||
var wcPath [windows.MAX_PATH + 1]uint16
|
var wcPath [windows.MAX_PATH + 1]uint16
|
||||||
var hCurrent windows.Handle
|
var hCurrent windows.Handle
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ package cli
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
"github.com/Control-D-Inc/ctrld"
|
||||||
)
|
)
|
||||||
|
|
||||||
func selfUninstall(p *prog, logger zerolog.Logger) {
|
// selfUninstall performs self-uninstallation on non-Unix platforms
|
||||||
|
func selfUninstall(p *prog, logger *ctrld.Logger) {
|
||||||
if uninstallInvalidCdUID(p, logger, false) {
|
if uninstallInvalidCdUID(p, logger, false) {
|
||||||
logger.Warn().Msgf("service was uninstalled because device %q does not exist", cdUID)
|
logger.Warn().Msgf("Service was uninstalled because device %q does not exist", cdUID)
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,17 +9,18 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
"github.com/Control-D-Inc/ctrld"
|
||||||
)
|
)
|
||||||
|
|
||||||
func selfUninstall(p *prog, logger zerolog.Logger) {
|
// selfUninstall performs self-uninstallation on Unix platforms
|
||||||
|
func selfUninstall(p *prog, logger *ctrld.Logger) {
|
||||||
if runtime.GOOS == "linux" {
|
if runtime.GOOS == "linux" {
|
||||||
selfUninstallLinux(p, logger)
|
selfUninstallLinux(p, logger)
|
||||||
}
|
}
|
||||||
|
|
||||||
bin, err := os.Executable()
|
bin, err := os.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Fatal().Err(err).Msg("could not determine executable")
|
logger.Fatal().Err(err).Msg("Could not determine executable")
|
||||||
}
|
}
|
||||||
args := []string{"uninstall"}
|
args := []string{"uninstall"}
|
||||||
if deactivationPinSet() {
|
if deactivationPinSet() {
|
||||||
@@ -28,18 +29,19 @@ func selfUninstall(p *prog, logger zerolog.Logger) {
|
|||||||
cmd := exec.Command(bin, args...)
|
cmd := exec.Command(bin, args...)
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
logger.Fatal().Err(err).Msg("could not start self uninstall command")
|
logger.Fatal().Err(err).Msg("Could not start self uninstall command")
|
||||||
}
|
}
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
logger.Warn().Msgf("service was uninstalled because device %q does not exist", cdUID)
|
logger.Warn().Msgf("Service was uninstalled because device %q does not exist", cdUID)
|
||||||
_ = cmd.Wait()
|
_ = cmd.Wait()
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func selfUninstallLinux(p *prog, logger zerolog.Logger) {
|
// selfUninstallLinux performs self-uninstallation on Linux platforms
|
||||||
|
func selfUninstallLinux(p *prog, logger *ctrld.Logger) {
|
||||||
if uninstallInvalidCdUID(p, logger, true) {
|
if uninstallInvalidCdUID(p, logger, true) {
|
||||||
logger.Warn().Msgf("service was uninstalled because device %q does not exist", cdUID)
|
logger.Warn().Msgf("Service was uninstalled because device %q does not exist", cdUID)
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,31 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
|
// semaphore provides a simple synchronization mechanism
|
||||||
type semaphore interface {
|
type semaphore interface {
|
||||||
acquire()
|
acquire()
|
||||||
release()
|
release()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// noopSemaphore is a no-operation implementation of semaphore
|
||||||
type noopSemaphore struct{}
|
type noopSemaphore struct{}
|
||||||
|
|
||||||
|
// acquire performs a no-operation for the noop semaphore
|
||||||
func (n noopSemaphore) acquire() {}
|
func (n noopSemaphore) acquire() {}
|
||||||
|
|
||||||
|
// release performs a no-operation for the noop semaphore
|
||||||
func (n noopSemaphore) release() {}
|
func (n noopSemaphore) release() {}
|
||||||
|
|
||||||
|
// chanSemaphore is a channel-based implementation of semaphore
|
||||||
type chanSemaphore struct {
|
type chanSemaphore struct {
|
||||||
ready chan struct{}
|
ready chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// acquire blocks until a slot is available in the semaphore
|
||||||
func (c *chanSemaphore) acquire() {
|
func (c *chanSemaphore) acquire() {
|
||||||
c.ready <- struct{}{}
|
c.ready <- struct{}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// release signals that a slot has been freed in the semaphore
|
||||||
func (c *chanSemaphore) release() {
|
func (c *chanSemaphore) release() {
|
||||||
<-c.ready
|
<-c.ready
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-51
@@ -11,9 +11,6 @@ import (
|
|||||||
|
|
||||||
"github.com/coreos/go-systemd/v22/unit"
|
"github.com/coreos/go-systemd/v22/unit"
|
||||||
"github.com/kardianos/service"
|
"github.com/kardianos/service"
|
||||||
|
|
||||||
"github.com/Control-D-Inc/ctrld/internal/router"
|
|
||||||
"github.com/Control-D-Inc/ctrld/internal/router/openwrt"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// newService wraps service.New call to return service.Service
|
// newService wraps service.New call to return service.Service
|
||||||
@@ -24,10 +21,6 @@ func newService(i service.Interface, c *service.Config) (service.Service, error)
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
case router.IsOldOpenwrt(), router.IsNetGearOrbi():
|
|
||||||
return &procd{sysV: &sysV{s}, svcConfig: c}, nil
|
|
||||||
case router.IsGLiNet():
|
|
||||||
return &sysV{s}, nil
|
|
||||||
case s.Platform() == "unix-systemv":
|
case s.Platform() == "unix-systemv":
|
||||||
return &sysV{s}, nil
|
return &sysV{s}, nil
|
||||||
case s.Platform() == "linux-systemd":
|
case s.Platform() == "linux-systemd":
|
||||||
@@ -42,7 +35,7 @@ func newService(i service.Interface, c *service.Config) (service.Service, error)
|
|||||||
// sysV wraps a service.Service, and provide start/stop/status command
|
// sysV wraps a service.Service, and provide start/stop/status command
|
||||||
// base on "/etc/init.d/<service_name>".
|
// base on "/etc/init.d/<service_name>".
|
||||||
//
|
//
|
||||||
// Use this on system where "service" command is not available, like GL.iNET router.
|
// Use this on system where "service" command is not available.
|
||||||
type sysV struct {
|
type sysV struct {
|
||||||
service.Service
|
service.Service
|
||||||
}
|
}
|
||||||
@@ -89,37 +82,6 @@ func (s *sysV) Status() (service.Status, error) {
|
|||||||
return unixSystemVServiceStatus()
|
return unixSystemVServiceStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
// procd wraps a service.Service, and provide start/stop command
|
|
||||||
// base on "/etc/init.d/<service_name>", status command base on parsing "ps" command output.
|
|
||||||
//
|
|
||||||
// Use this on system where "/etc/init.d/<service_name> status" command is not available,
|
|
||||||
// like old GL.iNET Opal router.
|
|
||||||
type procd struct {
|
|
||||||
*sysV
|
|
||||||
svcConfig *service.Config
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *procd) Status() (service.Status, error) {
|
|
||||||
if !s.installed() {
|
|
||||||
return service.StatusUnknown, service.ErrNotInstalled
|
|
||||||
}
|
|
||||||
bin := s.svcConfig.Executable
|
|
||||||
if bin == "" {
|
|
||||||
exe, err := os.Executable()
|
|
||||||
if err != nil {
|
|
||||||
return service.StatusUnknown, nil
|
|
||||||
}
|
|
||||||
bin = exe
|
|
||||||
}
|
|
||||||
|
|
||||||
// Looking for something like "/sbin/ctrld run ".
|
|
||||||
shellCmd := fmt.Sprintf("ps | grep -q %q", bin+" [r]un ")
|
|
||||||
if err := exec.Command("sh", "-c", shellCmd).Run(); err != nil {
|
|
||||||
return service.StatusStopped, nil
|
|
||||||
}
|
|
||||||
return service.StatusRunning, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// systemd wraps a service.Service, and provide status command to
|
// systemd wraps a service.Service, and provide status command to
|
||||||
// report the status correctly.
|
// report the status correctly.
|
||||||
type systemd struct {
|
type systemd struct {
|
||||||
@@ -153,7 +115,7 @@ func (s *systemd) Start() error {
|
|||||||
if out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput(); err != nil {
|
if out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput(); err != nil {
|
||||||
return fmt.Errorf("systemctl daemon-reload failed: %w\n%s", err, string(out))
|
return fmt.Errorf("systemctl daemon-reload failed: %w\n%s", err, string(out))
|
||||||
}
|
}
|
||||||
mainLog.Load().Debug().Msg("set KillMode=process successfully")
|
mainLog.Load().Debug().Msg("Set KillMode=process successfully")
|
||||||
}
|
}
|
||||||
return s.Service.Start()
|
return s.Service.Start()
|
||||||
}
|
}
|
||||||
@@ -163,7 +125,7 @@ func (s *systemd) Start() error {
|
|||||||
func ensureSystemdKillMode(r io.Reader) (opts []*unit.UnitOption, change bool) {
|
func ensureSystemdKillMode(r io.Reader) (opts []*unit.UnitOption, change bool) {
|
||||||
opts, err := unit.DeserializeOptions(r)
|
opts, err := unit.DeserializeOptions(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
mainLog.Load().Error().Err(err).Msg("failed to deserialize options")
|
mainLog.Load().Error().Err(err).Msg("Failed to deserialize options")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
change = true
|
change = true
|
||||||
@@ -187,6 +149,7 @@ func ensureSystemdKillMode(r io.Reader) (opts []*unit.UnitOption, change bool) {
|
|||||||
return opts, change
|
return opts, change
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newLaunchd creates a new launchd service wrapper
|
||||||
func newLaunchd(s service.Service) *launchd {
|
func newLaunchd(s service.Service) *launchd {
|
||||||
return &launchd{
|
return &launchd{
|
||||||
Service: s,
|
Service: s,
|
||||||
@@ -216,28 +179,30 @@ type task struct {
|
|||||||
Name string
|
Name string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// doTasks executes a list of tasks and returns success status
|
||||||
func doTasks(tasks []task) bool {
|
func doTasks(tasks []task) bool {
|
||||||
for _, task := range tasks {
|
for _, task := range tasks {
|
||||||
mainLog.Load().Debug().Msgf("Running task %s", task.Name)
|
mainLog.Load().Debug().Msgf("Running task %s", task.Name)
|
||||||
if err := task.f(); err != nil {
|
if err := task.f(); err != nil {
|
||||||
if task.abortOnError {
|
if task.abortOnError {
|
||||||
mainLog.Load().Error().Msgf("error running task %s: %v", task.Name, err)
|
mainLog.Load().Error().Msgf("Error running task %s: %v", task.Name, err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// if this is darwin stop command, dont print debug
|
// if this is darwin stop command, dont print debug
|
||||||
// since launchctl complains on every start
|
// since launchctl complains on every start
|
||||||
if runtime.GOOS != "darwin" || task.Name != "Stop" {
|
if runtime.GOOS != "darwin" || task.Name != "Stop" {
|
||||||
mainLog.Load().Debug().Msgf("error running task %s: %v", task.Name, err)
|
mainLog.Load().Debug().Msgf("Error running task %s: %v", task.Name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkHasElevatedPrivilege checks if the process has elevated privileges and exits if not
|
||||||
func checkHasElevatedPrivilege() {
|
func checkHasElevatedPrivilege() {
|
||||||
ok, err := hasElevatedPrivilege()
|
ok, err := hasElevatedPrivilege()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
mainLog.Load().Error().Msgf("could not detect user privilege: %v", err)
|
mainLog.Load().Error().Msgf("Could not detect user privilege: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -246,16 +211,10 @@ func checkHasElevatedPrivilege() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// unixSystemVServiceStatus checks the status of a Unix System V service
|
||||||
func unixSystemVServiceStatus() (service.Status, error) {
|
func unixSystemVServiceStatus() (service.Status, error) {
|
||||||
out, err := exec.Command("/etc/init.d/ctrld", "status").CombinedOutput()
|
out, err := exec.Command("/etc/init.d/ctrld", "status").CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Specific case for openwrt >= 24.10, it returns non-success code
|
|
||||||
// for above status command, which may not right.
|
|
||||||
if router.Name() == openwrt.Name {
|
|
||||||
if string(bytes.ToLower(bytes.TrimSpace(out))) == "inactive" {
|
|
||||||
return service.StatusStopped, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return service.StatusUnknown, nil
|
return service.StatusUnknown, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const launchdPlistPath = "/Library/LaunchDaemons/ctrld.plist"
|
||||||
|
|
||||||
|
// serviceConfigFileExists returns true if the launchd plist for ctrld exists on disk.
|
||||||
|
// This is more reliable than checking launchctl status, which may report "not found"
|
||||||
|
// if the service was unloaded but the plist file still exists.
|
||||||
|
func serviceConfigFileExists() bool {
|
||||||
|
_, err := os.Stat(launchdPlistPath)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendServiceFlag appends a CLI flag (e.g., "--intercept-mode") to the installed
|
||||||
|
// service's launch arguments. This is used when upgrading an existing installation
|
||||||
|
// to intercept mode without losing the existing --cd flag and other arguments.
|
||||||
|
//
|
||||||
|
// On macOS, this modifies the launchd plist at /Library/LaunchDaemons/ctrld.plist
|
||||||
|
// using the "defaults" command, which is the standard way to edit plists.
|
||||||
|
//
|
||||||
|
// The function is idempotent: if the flag already exists, it's a no-op.
|
||||||
|
func appendServiceFlag(flag string) error {
|
||||||
|
// Read current ProgramArguments from plist.
|
||||||
|
out, err := exec.Command("defaults", "read", launchdPlistPath, "ProgramArguments").CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read plist ProgramArguments: %w (output: %s)", err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the flag is already present (idempotent).
|
||||||
|
args := string(out)
|
||||||
|
if strings.Contains(args, flag) {
|
||||||
|
mainLog.Load().Debug().Msgf("Service flag %q already present in plist, skipping", flag)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use PlistBuddy to append the flag to ProgramArguments array.
|
||||||
|
// PlistBuddy is more reliable than "defaults" for array manipulation.
|
||||||
|
addCmd := exec.Command(
|
||||||
|
"/usr/libexec/PlistBuddy",
|
||||||
|
"-c", fmt.Sprintf("Add :ProgramArguments: string %s", flag),
|
||||||
|
launchdPlistPath,
|
||||||
|
)
|
||||||
|
if out, err := addCmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("failed to append %q to plist ProgramArguments: %w (output: %s)", flag, err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
|
||||||
|
mainLog.Load().Info().Msgf("Appended %q to service launch arguments", flag)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyServiceRegistration is a no-op on macOS (launchd plist verification not needed).
|
||||||
|
func verifyServiceRegistration() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeServiceFlag removes a CLI flag (and its value, if the next argument is not
|
||||||
|
// a flag) from the installed service's launch arguments. For example, removing
|
||||||
|
// "--intercept-mode" also removes the following "dns" or "hard" value argument.
|
||||||
|
//
|
||||||
|
// The function is idempotent: if the flag doesn't exist, it's a no-op.
|
||||||
|
func removeServiceFlag(flag string) error {
|
||||||
|
// Read current ProgramArguments to find the index.
|
||||||
|
out, err := exec.Command("/usr/libexec/PlistBuddy", "-c", "Print :ProgramArguments", launchdPlistPath).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read plist ProgramArguments: %w (output: %s)", err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the PlistBuddy output to find the flag's index.
|
||||||
|
// PlistBuddy prints arrays as:
|
||||||
|
// Array {
|
||||||
|
// /path/to/ctrld
|
||||||
|
// run
|
||||||
|
// --cd=xxx
|
||||||
|
// --intercept-mode
|
||||||
|
// dns
|
||||||
|
// }
|
||||||
|
lines := strings.Split(string(out), "\n")
|
||||||
|
var entries []string
|
||||||
|
for _, line := range lines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed == "Array {" || trimmed == "}" || trimmed == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entries = append(entries, trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
index := -1
|
||||||
|
for i, entry := range entries {
|
||||||
|
if entry == flag {
|
||||||
|
index = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if index < 0 {
|
||||||
|
mainLog.Load().Debug().Msgf("Service flag %q not present in plist, skipping removal", flag)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the next entry is a value (not a flag). If so, delete it first
|
||||||
|
// (deleting by index shifts subsequent entries down, so delete value before flag).
|
||||||
|
hasValue := index+1 < len(entries) && !strings.HasPrefix(entries[index+1], "-")
|
||||||
|
if hasValue {
|
||||||
|
delVal := exec.Command(
|
||||||
|
"/usr/libexec/PlistBuddy",
|
||||||
|
"-c", fmt.Sprintf("Delete :ProgramArguments:%d", index+1),
|
||||||
|
launchdPlistPath,
|
||||||
|
)
|
||||||
|
if out, err := delVal.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove value for %q from plist: %w (output: %s)", flag, err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the flag itself.
|
||||||
|
delCmd := exec.Command(
|
||||||
|
"/usr/libexec/PlistBuddy",
|
||||||
|
"-c", fmt.Sprintf("Delete :ProgramArguments:%d", index),
|
||||||
|
launchdPlistPath,
|
||||||
|
)
|
||||||
|
if out, err := delCmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove %q from plist ProgramArguments: %w (output: %s)", flag, err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
|
||||||
|
mainLog.Load().Info().Msgf("Removed %q from service launch arguments", flag)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
//go:build !darwin && !windows
|
||||||
|
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// serviceConfigFileExists checks common service config file locations on Linux.
|
||||||
|
func serviceConfigFileExists() bool {
|
||||||
|
// systemd unit file
|
||||||
|
if _, err := os.Stat("/etc/systemd/system/ctrld.service"); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// SysV init script
|
||||||
|
if _, err := os.Stat("/etc/init.d/ctrld"); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendServiceFlag is not yet implemented on this platform.
|
||||||
|
// Linux services (systemd) store args in unit files; intercept mode
|
||||||
|
// should be set via the config file (intercept_mode) on these platforms.
|
||||||
|
func appendServiceFlag(flag string) error {
|
||||||
|
return fmt.Errorf("appending service flags is not supported on this platform; use intercept_mode in config instead")
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyServiceRegistration is a no-op on this platform.
|
||||||
|
func verifyServiceRegistration() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeServiceFlag is not yet implemented on this platform.
|
||||||
|
func removeServiceFlag(flag string) error {
|
||||||
|
return fmt.Errorf("removing service flags is not supported on this platform; use intercept_mode in config instead")
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows/svc/mgr"
|
||||||
|
)
|
||||||
|
|
||||||
|
// serviceConfigFileExists returns true if the ctrld Windows service is registered.
|
||||||
|
func serviceConfigFileExists() bool {
|
||||||
|
m, err := mgr.Connect()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer m.Disconnect()
|
||||||
|
s, err := m.OpenService(ctrldServiceName)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s.Close()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendServiceFlag appends a CLI flag (e.g., "--intercept-mode") to the installed
|
||||||
|
// Windows service's BinPath arguments. This is used when upgrading an existing
|
||||||
|
// installation to intercept mode without losing the existing --cd flag.
|
||||||
|
//
|
||||||
|
// The function is idempotent: if the flag already exists, it's a no-op.
|
||||||
|
func appendServiceFlag(flag string) error {
|
||||||
|
m, err := mgr.Connect()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to Windows SCM: %w", err)
|
||||||
|
}
|
||||||
|
defer m.Disconnect()
|
||||||
|
|
||||||
|
s, err := m.OpenService(ctrldServiceName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open service %q: %w", ctrldServiceName, err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
config, err := s.Config()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read service config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if flag already present (idempotent).
|
||||||
|
if strings.Contains(config.BinaryPathName, flag) {
|
||||||
|
mainLog.Load().Debug().Msgf("Service flag %q already present in BinPath, skipping", flag)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append the flag to BinPath.
|
||||||
|
config.BinaryPathName = strings.TrimSpace(config.BinaryPathName) + " " + flag
|
||||||
|
|
||||||
|
if err := s.UpdateConfig(config); err != nil {
|
||||||
|
return fmt.Errorf("failed to update service config with %q: %w", flag, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mainLog.Load().Info().Msgf("Appended %q to service BinPath", flag)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyServiceRegistration opens the Windows Service Control Manager and verifies
|
||||||
|
// that the ctrld service is correctly registered: logs the BinaryPathName, checks
|
||||||
|
// that --intercept-mode is present if expected, and verifies SERVICE_AUTO_START.
|
||||||
|
func verifyServiceRegistration() error {
|
||||||
|
m, err := mgr.Connect()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to Windows SCM: %w", err)
|
||||||
|
}
|
||||||
|
defer m.Disconnect()
|
||||||
|
|
||||||
|
s, err := m.OpenService(ctrldServiceName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open service %q: %w", ctrldServiceName, err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
config, err := s.Config()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read service config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mainLog.Load().Debug().Msgf("Service registry: BinaryPathName = %q", config.BinaryPathName)
|
||||||
|
|
||||||
|
// If intercept mode is set, verify the flag is present in BinPath.
|
||||||
|
if interceptMode == "dns" || interceptMode == "hard" {
|
||||||
|
if !strings.Contains(config.BinaryPathName, "--intercept-mode") {
|
||||||
|
return fmt.Errorf("service registry: --intercept-mode flag missing from BinaryPathName (expected mode %q)", interceptMode)
|
||||||
|
}
|
||||||
|
mainLog.Load().Debug().Msgf("Service registry: --intercept-mode flag present in BinaryPathName")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify auto-start. mgr.StartAutomatic == 2 == SERVICE_AUTO_START.
|
||||||
|
if config.StartType != mgr.StartAutomatic {
|
||||||
|
return fmt.Errorf("service registry: StartType is %d, expected SERVICE_AUTO_START (%d)", config.StartType, mgr.StartAutomatic)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeServiceFlag removes a CLI flag (and its value, if present) from the installed
|
||||||
|
// Windows service's BinPath. For example, removing "--intercept-mode" also removes
|
||||||
|
// the following "dns" or "hard" value. The function is idempotent.
|
||||||
|
func removeServiceFlag(flag string) error {
|
||||||
|
m, err := mgr.Connect()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to Windows SCM: %w", err)
|
||||||
|
}
|
||||||
|
defer m.Disconnect()
|
||||||
|
|
||||||
|
s, err := m.OpenService(ctrldServiceName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open service %q: %w", ctrldServiceName, err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
config, err := s.Config()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read service config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(config.BinaryPathName, flag) {
|
||||||
|
mainLog.Load().Debug().Msgf("Service flag %q not present in BinPath, skipping removal", flag)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split BinPath into parts, find and remove the flag + its value (if any).
|
||||||
|
parts := strings.Fields(config.BinaryPathName)
|
||||||
|
var newParts []string
|
||||||
|
for i := 0; i < len(parts); i++ {
|
||||||
|
if parts[i] == flag {
|
||||||
|
// Skip the flag. Also skip the next part if it's a value (not a flag).
|
||||||
|
if i+1 < len(parts) && !strings.HasPrefix(parts[i+1], "-") {
|
||||||
|
i++ // skip value too
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
newParts = append(newParts, parts[i])
|
||||||
|
}
|
||||||
|
config.BinaryPathName = strings.Join(newParts, " ")
|
||||||
|
|
||||||
|
if err := s.UpdateConfig(config); err != nil {
|
||||||
|
return fmt.Errorf("failed to update service config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mainLog.Load().Info().Msgf("Removed %q from service BinPath", flag)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -6,17 +6,15 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// hasElevatedPrivilege checks if the current process has elevated privileges
|
||||||
func hasElevatedPrivilege() (bool, error) {
|
func hasElevatedPrivilege() (bool, error) {
|
||||||
return os.Geteuid() == 0, nil
|
return os.Geteuid() == 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// openLogFile opens a log file with the specified flags
|
||||||
func openLogFile(path string, flags int) (*os.File, error) {
|
func openLogFile(path string, flags int) (*os.File, error) {
|
||||||
return os.OpenFile(path, flags, os.FileMode(0o600))
|
return os.OpenFile(path, flags, os.FileMode(0o600))
|
||||||
}
|
}
|
||||||
|
|
||||||
// hasLocalDnsServerRunning reports whether we are on Windows and having Dns server running.
|
// ConfigureWindowsServiceFailureActions is a no-op on non-Windows platforms
|
||||||
func hasLocalDnsServerRunning() bool { return false }
|
|
||||||
|
|
||||||
func ConfigureWindowsServiceFailureActions(serviceName string) error { return nil }
|
func ConfigureWindowsServiceFailureActions(serviceName string) error { return nil }
|
||||||
|
|
||||||
func isRunningOnDomainControllerWindows() (bool, int) { return false, 0 }
|
|
||||||
|
|||||||
@@ -2,22 +2,16 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"reflect"
|
|
||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
|
|
||||||
"github.com/microsoft/wmi/pkg/base/host"
|
|
||||||
"github.com/microsoft/wmi/pkg/base/instance"
|
|
||||||
"github.com/microsoft/wmi/pkg/base/query"
|
|
||||||
"github.com/microsoft/wmi/pkg/constant"
|
|
||||||
"golang.org/x/sys/windows"
|
"golang.org/x/sys/windows"
|
||||||
"golang.org/x/sys/windows/svc/mgr"
|
"golang.org/x/sys/windows/svc/mgr"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// hasElevatedPrivilege checks if the current process has elevated privileges on Windows
|
||||||
func hasElevatedPrivilege() (bool, error) {
|
func hasElevatedPrivilege() (bool, error) {
|
||||||
var sid *windows.SID
|
var sid *windows.SID
|
||||||
if err := windows.AllocateAndInitializeSid(
|
if err := windows.AllocateAndInitializeSid(
|
||||||
@@ -100,6 +94,7 @@ func ConfigureWindowsServiceFailureActions(serviceName string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// openLogFile opens a log file with the specified mode on Windows
|
||||||
func openLogFile(path string, mode int) (*os.File, error) {
|
func openLogFile(path string, mode int) (*os.File, error) {
|
||||||
if len(path) == 0 {
|
if len(path) == 0 {
|
||||||
return nil, &os.PathError{Path: path, Op: "open", Err: syscall.ERROR_FILE_NOT_FOUND}
|
return nil, &os.PathError{Path: path, Op: "open", Err: syscall.ERROR_FILE_NOT_FOUND}
|
||||||
@@ -151,77 +146,3 @@ func openLogFile(path string, mode int) (*os.File, error) {
|
|||||||
|
|
||||||
return os.NewFile(uintptr(handle), path), nil
|
return os.NewFile(uintptr(handle), path), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const processEntrySize = uint32(unsafe.Sizeof(windows.ProcessEntry32{}))
|
|
||||||
|
|
||||||
// hasLocalDnsServerRunning reports whether we are on Windows and having Dns server running.
|
|
||||||
func hasLocalDnsServerRunning() bool {
|
|
||||||
h, e := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
|
|
||||||
if e != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
p := windows.ProcessEntry32{Size: processEntrySize}
|
|
||||||
for {
|
|
||||||
e := windows.Process32Next(h, &p)
|
|
||||||
if e != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if strings.ToLower(windows.UTF16ToString(p.ExeFile[:])) == "dns.exe" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func isRunningOnDomainControllerWindows() (bool, int) {
|
|
||||||
whost := host.NewWmiLocalHost()
|
|
||||||
q := query.NewWmiQuery("Win32_ComputerSystem")
|
|
||||||
instances, err := instance.GetWmiInstancesFromHost(whost, string(constant.CimV2), q)
|
|
||||||
if err != nil {
|
|
||||||
mainLog.Load().Debug().Err(err).Msg("WMI query failed")
|
|
||||||
return false, 0
|
|
||||||
}
|
|
||||||
if instances == nil {
|
|
||||||
mainLog.Load().Debug().Msg("WMI query returned nil instances")
|
|
||||||
return false, 0
|
|
||||||
}
|
|
||||||
defer instances.Close()
|
|
||||||
|
|
||||||
if len(instances) == 0 {
|
|
||||||
mainLog.Load().Debug().Msg("no rows returned from Win32_ComputerSystem")
|
|
||||||
return false, 0
|
|
||||||
}
|
|
||||||
|
|
||||||
val, err := instances[0].GetProperty("DomainRole")
|
|
||||||
if err != nil {
|
|
||||||
mainLog.Load().Debug().Err(err).Msg("failed to get DomainRole property")
|
|
||||||
return false, 0
|
|
||||||
}
|
|
||||||
if val == nil {
|
|
||||||
mainLog.Load().Debug().Msg("DomainRole property is nil")
|
|
||||||
return false, 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Safely handle varied types: string or integer
|
|
||||||
var roleInt int
|
|
||||||
switch v := val.(type) {
|
|
||||||
case string:
|
|
||||||
// "4", "5", etc.
|
|
||||||
parsed, parseErr := strconv.Atoi(v)
|
|
||||||
if parseErr != nil {
|
|
||||||
mainLog.Load().Debug().Err(parseErr).Msgf("failed to parse DomainRole value %q", v)
|
|
||||||
return false, 0
|
|
||||||
}
|
|
||||||
roleInt = parsed
|
|
||||||
case int8, int16, int32, int64:
|
|
||||||
roleInt = int(reflect.ValueOf(v).Int())
|
|
||||||
case uint8, uint16, uint32, uint64:
|
|
||||||
roleInt = int(reflect.ValueOf(v).Uint())
|
|
||||||
default:
|
|
||||||
mainLog.Load().Debug().Msgf("unexpected DomainRole type: %T value=%v", v, v)
|
|
||||||
return false, 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if role indicates a domain controller
|
|
||||||
isDC := roleInt == BackupDomainController || roleInt == PrimaryDomainController
|
|
||||||
return isDC, roleInt
|
|
||||||
}
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user