mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-07-16 13:17:19 +02:00
Compare commits
153
Commits
@@ -21,6 +21,6 @@ jobs:
|
|||||||
- run: "go test -race ./..."
|
- run: "go test -race ./..."
|
||||||
- uses: dominikh/staticcheck-action@v1.4.0
|
- uses: dominikh/staticcheck-action@v1.4.0
|
||||||
with:
|
with:
|
||||||
version: "2026.1"
|
version: "2025.1.1"
|
||||||
install-go: false
|
install-go: false
|
||||||
cache-key: ${{ matrix.go }}
|
cache-key: ${{ matrix.go }}
|
||||||
|
|||||||
@@ -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,7 +80,7 @@ docker build -t controldns/ctrld . -f docker/Dockerfile
|
|||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
# Usage
|
## Usage
|
||||||
The cli is self documenting, so feel free to run `--help` on any sub-command to get specific usages.
|
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
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
+4
-32
@@ -1,14 +1,8 @@
|
|||||||
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"
|
"github.com/Control-D-Inc/ctrld/internal/system"
|
||||||
)
|
)
|
||||||
@@ -17,11 +11,11 @@ import (
|
|||||||
func addExtraSplitDnsRule(cfg *ctrld.Config) bool {
|
func addExtraSplitDnsRule(cfg *ctrld.Config) bool {
|
||||||
domain, err := system.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,
|
||||||
@@ -41,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
|
|
||||||
}
|
|
||||||
|
|||||||
+303
-304
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,10 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Control-D-Inc/ctrld"
|
||||||
|
)
|
||||||
|
|
||||||
func TestIsExplicitInterceptListener(t *testing.T) {
|
func TestIsExplicitInterceptListener(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -26,3 +30,87 @@ func TestIsExplicitInterceptListener(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPreserveBoundListeners is a regression test for #551: on reload, the on-disk
|
||||||
|
// generated config still declares 127.0.0.1:53, but the running listener has fallen back
|
||||||
|
// to 127.0.0.1:5354. preserveBoundListeners must keep the in-memory config on the actual
|
||||||
|
// bound port so pf rdr rules and probes do not target the dead default port.
|
||||||
|
func TestPreserveBoundListeners(t *testing.T) {
|
||||||
|
// cur = actual running listener (fell back to 5354); newCfg = freshly read from disk (53).
|
||||||
|
cur := map[string]*ctrld.ListenerConfig{"0": {IP: "127.0.0.1", Port: 5354}}
|
||||||
|
newListeners := map[string]*ctrld.ListenerConfig{"0": {IP: "127.0.0.1", Port: 53}}
|
||||||
|
|
||||||
|
preserveBoundListeners(newListeners, cur)
|
||||||
|
|
||||||
|
if got := newListeners["0"].Port; got != 5354 {
|
||||||
|
t.Errorf("listener port after reload = %d, want 5354 (actual bound port)", got)
|
||||||
|
}
|
||||||
|
if got := newListeners["0"].IP; got != "127.0.0.1" {
|
||||||
|
t.Errorf("listener IP after reload = %q, want 127.0.0.1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPreserveBoundListeners_NoChange verifies that when the on-disk config matches the
|
||||||
|
// running listener, the config is left untouched (a legitimate reload with the same port).
|
||||||
|
func TestPreserveBoundListeners_NoChange(t *testing.T) {
|
||||||
|
cur := map[string]*ctrld.ListenerConfig{"0": {IP: "127.0.0.1", Port: 5354}}
|
||||||
|
newListeners := map[string]*ctrld.ListenerConfig{"0": {IP: "127.0.0.1", Port: 5354}}
|
||||||
|
|
||||||
|
preserveBoundListeners(newListeners, cur)
|
||||||
|
|
||||||
|
if got := newListeners["0"].Port; got != 5354 {
|
||||||
|
t.Errorf("listener port = %d, want 5354", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPreserveBoundListeners_MissingCurrent verifies that a listener present on disk but not
|
||||||
|
// in the current running set (e.g. newly added) is left as configured.
|
||||||
|
func TestPreserveBoundListeners_MissingCurrent(t *testing.T) {
|
||||||
|
cur := map[string]*ctrld.ListenerConfig{"0": {IP: "127.0.0.1", Port: 5354}}
|
||||||
|
newListeners := map[string]*ctrld.ListenerConfig{
|
||||||
|
"0": {IP: "127.0.0.1", Port: 53},
|
||||||
|
"1": {IP: "127.0.0.1", Port: 5355},
|
||||||
|
}
|
||||||
|
|
||||||
|
preserveBoundListeners(newListeners, cur)
|
||||||
|
|
||||||
|
if got := newListeners["0"].Port; got != 5354 {
|
||||||
|
t.Errorf("listener 0 port = %d, want 5354 (preserved)", got)
|
||||||
|
}
|
||||||
|
if got := newListeners["1"].Port; got != 5355 {
|
||||||
|
t.Errorf("listener 1 port = %d, want 5355 (unchanged, no current binding)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPreserveBoundListeners_ExplicitChangeNotMasked verifies that an explicit, non-default
|
||||||
|
// listener in the reloaded config is applied rather than reverted to the old bound listener.
|
||||||
|
// Reverting an explicit change would make the control-server reload comparison return 200
|
||||||
|
// instead of 201, silently dropping the new listener. Regression guard for #551 review.
|
||||||
|
func TestPreserveBoundListeners_ExplicitChangeNotMasked(t *testing.T) {
|
||||||
|
// Running listener fell back to 5354; user reloads with an explicit new listener.
|
||||||
|
cur := map[string]*ctrld.ListenerConfig{"0": {IP: "127.0.0.1", Port: 5354}}
|
||||||
|
newListeners := map[string]*ctrld.ListenerConfig{"0": {IP: "127.0.0.2", Port: 5399}}
|
||||||
|
|
||||||
|
preserveBoundListeners(newListeners, cur)
|
||||||
|
|
||||||
|
if got := newListeners["0"].IP; got != "127.0.0.2" {
|
||||||
|
t.Errorf("explicit listener IP = %q, want 127.0.0.2 (not reverted)", got)
|
||||||
|
}
|
||||||
|
if got := newListeners["0"].Port; got != 5399 {
|
||||||
|
t.Errorf("explicit listener port = %d, want 5399 (not reverted)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPreserveBoundListeners_ExplicitDefaultPreserved verifies that the default
|
||||||
|
// 127.0.0.1:53 listener remains fallback-eligible: when it diverges from the running
|
||||||
|
// fallback port it is still preserved (isExplicitInterceptListener treats :53 as non-explicit).
|
||||||
|
func TestPreserveBoundListeners_ExplicitDefaultPreserved(t *testing.T) {
|
||||||
|
cur := map[string]*ctrld.ListenerConfig{"0": {IP: "127.0.0.1", Port: 5354}}
|
||||||
|
newListeners := map[string]*ctrld.ListenerConfig{"0": {IP: "127.0.0.1", Port: 53}}
|
||||||
|
|
||||||
|
preserveBoundListeners(newListeners, cur)
|
||||||
|
|
||||||
|
if got := newListeners["0"].Port; got != 5354 {
|
||||||
|
t.Errorf("default listener port = %d, want 5354 (preserved fallback)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
-1606
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,141 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/kardianos/service"
|
||||||
|
"github.com/olekukonko/tablewriter"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/Control-D-Inc/ctrld/internal/clientinfo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClientsCommand handles clients-related operations
|
||||||
|
type ClientsCommand struct {
|
||||||
|
controlClient *controlClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClientsCommand creates a new clients command handler
|
||||||
|
func NewClientsCommand() (*ClientsCommand, error) {
|
||||||
|
dir, err := socketDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to find ctrld home dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cc := newControlClient(filepath.Join(dir, ctrldControlUnixSock))
|
||||||
|
return &ClientsCommand{
|
||||||
|
controlClient: cc,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListClients lists all connected clients
|
||||||
|
func (cc *ClientsCommand) ListClients(cmd *cobra.Command, args []string) error {
|
||||||
|
// Check service status first
|
||||||
|
sc := NewServiceCommand()
|
||||||
|
s, _, err := sc.initializeServiceManager()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err := s.Status()
|
||||||
|
if errors.Is(err, service.ErrNotInstalled) {
|
||||||
|
mainLog.Load().Warn().Msg("Service not installed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if status == service.StatusStopped {
|
||||||
|
mainLog.Load().Warn().Msg("Service is not running")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := cc.controlClient.post(listClientsPath, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get clients: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var clients []*clientinfo.Client
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&clients); err != nil {
|
||||||
|
return fmt.Errorf("failed to decode clients result: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
map2Slice := func(m map[string]struct{}) []string {
|
||||||
|
s := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
if k == "" { // skip empty source from output.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s = append(s, k)
|
||||||
|
}
|
||||||
|
sort.Strings(s)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// If metrics is enabled, server set this for all clients, so we can check only the first one.
|
||||||
|
// Ideally, we may have a field in response to indicate that query count should be shown, but
|
||||||
|
// it would break earlier version of ctrld, which only look list of clients in response.
|
||||||
|
withQueryCount := len(clients) > 0 && clients[0].IncludeQueryCount
|
||||||
|
data := make([][]string, len(clients))
|
||||||
|
for i, c := range clients {
|
||||||
|
row := []string{
|
||||||
|
c.IP.String(),
|
||||||
|
c.Hostname,
|
||||||
|
c.Mac,
|
||||||
|
strings.Join(map2Slice(c.Source), ","),
|
||||||
|
}
|
||||||
|
if withQueryCount {
|
||||||
|
row = append(row, strconv.FormatInt(c.QueryCount, 10))
|
||||||
|
}
|
||||||
|
data[i] = row
|
||||||
|
}
|
||||||
|
|
||||||
|
table := tablewriter.NewWriter(os.Stdout)
|
||||||
|
headers := []string{"IP", "Hostname", "Mac", "Discovered"}
|
||||||
|
if withQueryCount {
|
||||||
|
headers = append(headers, "Queries")
|
||||||
|
}
|
||||||
|
table.SetHeader(headers)
|
||||||
|
table.SetAutoFormatHeaders(false)
|
||||||
|
table.AppendBulk(data)
|
||||||
|
table.Render()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitClientsCmd creates the clients command with proper logic
|
||||||
|
func InitClientsCmd(rootCmd *cobra.Command) *cobra.Command {
|
||||||
|
listClientsCmd := &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List clients that ctrld discovered",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
PreRun: func(cmd *cobra.Command, args []string) {
|
||||||
|
checkHasElevatedPrivilege()
|
||||||
|
},
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
cc, err := NewClientsCommand()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return cc.ListClients(cmd, args)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
clientsCmd := &cobra.Command{
|
||||||
|
Use: "clients",
|
||||||
|
Short: "Manage clients",
|
||||||
|
Args: cobra.OnlyValidArgs,
|
||||||
|
ValidArgs: []string{
|
||||||
|
listClientsCmd.Use,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
clientsCmd.AddCommand(listClientsCmd)
|
||||||
|
rootCmd.AddCommand(clientsCmd)
|
||||||
|
|
||||||
|
return clientsCmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InterfacesCommand handles interfaces-related operations
|
||||||
|
type InterfacesCommand struct{}
|
||||||
|
|
||||||
|
// NewInterfacesCommand creates a new interfaces command handler
|
||||||
|
func NewInterfacesCommand() (*InterfacesCommand, error) {
|
||||||
|
return &InterfacesCommand{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListInterfaces lists all network interfaces
|
||||||
|
func (ic *InterfacesCommand) ListInterfaces(cmd *cobra.Command, args []string) error {
|
||||||
|
withEachPhysicalInterfaces("", "Interface list", func(i *net.Interface) error {
|
||||||
|
fmt.Printf("Index : %d\n", i.Index)
|
||||||
|
fmt.Printf("Name : %s\n", i.Name)
|
||||||
|
var status string
|
||||||
|
if i.Flags&net.FlagUp != 0 {
|
||||||
|
status = "Up"
|
||||||
|
} else {
|
||||||
|
status = "Down"
|
||||||
|
}
|
||||||
|
fmt.Printf("Status: %s\n", status)
|
||||||
|
addrs, _ := i.Addrs()
|
||||||
|
for i, ipaddr := range addrs {
|
||||||
|
if i == 0 {
|
||||||
|
fmt.Printf("Addrs : %v\n", ipaddr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Printf(" %v\n", ipaddr)
|
||||||
|
}
|
||||||
|
nss, err := currentStaticDNS(i)
|
||||||
|
if err != nil {
|
||||||
|
mainLog.Load().Warn().Err(err).Msg("Failed to get DNS")
|
||||||
|
}
|
||||||
|
if len(nss) == 0 {
|
||||||
|
nss = currentDNS(i)
|
||||||
|
}
|
||||||
|
for i, dns := range nss {
|
||||||
|
if i == 0 {
|
||||||
|
fmt.Printf("DNS : %s\n", dns)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Printf(" : %s\n", dns)
|
||||||
|
}
|
||||||
|
println()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitInterfacesCmd creates the interfaces command with proper logic
|
||||||
|
func InitInterfacesCmd(_ *cobra.Command) *cobra.Command {
|
||||||
|
listInterfacesCmd := &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List network interfaces",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
PreRun: func(cmd *cobra.Command, args []string) {
|
||||||
|
checkHasElevatedPrivilege()
|
||||||
|
},
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
ic, err := NewInterfacesCommand()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ic.ListInterfaces(cmd, args)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
interfacesCmd := &cobra.Command{
|
||||||
|
Use: "interfaces",
|
||||||
|
Short: "Manage network interfaces",
|
||||||
|
Args: cobra.OnlyValidArgs,
|
||||||
|
ValidArgs: []string{
|
||||||
|
listInterfacesCmd.Use,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
interfacesCmd.AddCommand(listInterfacesCmd)
|
||||||
|
|
||||||
|
return interfacesCmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/docker/go-units"
|
||||||
|
"github.com/kardianos/service"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LogCommand handles log-related operations
|
||||||
|
type LogCommand struct {
|
||||||
|
controlClient *controlClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLogCommand creates a new log command handler
|
||||||
|
func NewLogCommand() (*LogCommand, error) {
|
||||||
|
dir, err := socketDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to find ctrld home dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cc := newControlClient(filepath.Join(dir, ctrldControlUnixSock))
|
||||||
|
return &LogCommand{
|
||||||
|
controlClient: cc,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// warnRuntimeLoggingNotEnabled logs a warning about runtime logging not being enabled
|
||||||
|
func (lc *LogCommand) warnRuntimeLoggingNotEnabled() {
|
||||||
|
mainLog.Load().Warn().Msg("Runtime debug logging is not enabled")
|
||||||
|
mainLog.Load().Warn().Msg(`ctrld may be running without "--cd" flag or logging is already enabled`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendLogs sends runtime debug logs to ControlD
|
||||||
|
func (lc *LogCommand) SendLogs(cmd *cobra.Command, args []string) error {
|
||||||
|
sc := NewServiceCommand()
|
||||||
|
s, _, err := sc.initializeServiceManager()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err := s.Status()
|
||||||
|
if errors.Is(err, service.ErrNotInstalled) {
|
||||||
|
mainLog.Load().Warn().Msg("Service not installed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if status == service.StatusStopped {
|
||||||
|
mainLog.Load().Warn().Msg("Service is not running")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := lc.controlClient.post(sendLogsPath, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to send logs: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
switch resp.StatusCode {
|
||||||
|
case http.StatusServiceUnavailable:
|
||||||
|
mainLog.Load().Warn().Msg("Runtime logs could only be sent once per minute")
|
||||||
|
return nil
|
||||||
|
case http.StatusMovedPermanently:
|
||||||
|
lc.warnRuntimeLoggingNotEnabled()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var logs logSentResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&logs); err != nil {
|
||||||
|
return fmt.Errorf("failed to decode sent logs result: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if logs.Error != "" {
|
||||||
|
return fmt.Errorf("failed to send logs: %s", logs.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
mainLog.Load().Notice().Msgf("Sent %s of runtime logs", units.BytesSize(float64(logs.Size)))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ViewLogs views current runtime debug logs
|
||||||
|
func (lc *LogCommand) ViewLogs(cmd *cobra.Command, args []string) error {
|
||||||
|
sc := NewServiceCommand()
|
||||||
|
s, _, err := sc.initializeServiceManager()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err := s.Status()
|
||||||
|
if errors.Is(err, service.ErrNotInstalled) {
|
||||||
|
mainLog.Load().Warn().Msg("Service not installed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if status == service.StatusStopped {
|
||||||
|
mainLog.Load().Warn().Msg("Service is not running")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := lc.controlClient.post(viewLogsPath, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get logs: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
switch resp.StatusCode {
|
||||||
|
case http.StatusMovedPermanently:
|
||||||
|
lc.warnRuntimeLoggingNotEnabled()
|
||||||
|
return nil
|
||||||
|
case http.StatusBadRequest:
|
||||||
|
mainLog.Load().Warn().Msg("Runtime debug logs are not available")
|
||||||
|
buf, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
mainLog.Load().Fatal().Err(err).Msg("Failed to read response body")
|
||||||
|
}
|
||||||
|
mainLog.Load().Warn().Msgf("ctrld process response:\n\n%s\n", string(buf))
|
||||||
|
return nil
|
||||||
|
case http.StatusOK:
|
||||||
|
}
|
||||||
|
|
||||||
|
var logs logViewResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&logs); err != nil {
|
||||||
|
return fmt.Errorf("failed to decode view logs result: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Print(logs.Data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TailLogs streams live runtime debug logs to the terminal
|
||||||
|
func (lc *LogCommand) TailLogs(cmd *cobra.Command, args []string) error {
|
||||||
|
sc := NewServiceCommand()
|
||||||
|
s, _, err := sc.initializeServiceManager()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err := s.Status()
|
||||||
|
if errors.Is(err, service.ErrNotInstalled) {
|
||||||
|
mainLog.Load().Warn().Msg("Service not installed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if status == service.StatusStopped {
|
||||||
|
mainLog.Load().Warn().Msg("Service is not running")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tailLines, _ := cmd.Flags().GetInt("lines")
|
||||||
|
tailPath := fmt.Sprintf("%s?lines=%d", tailLogsPath, tailLines)
|
||||||
|
resp, err := lc.controlClient.postStream(tailPath, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect for log tailing: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
switch resp.StatusCode {
|
||||||
|
case http.StatusMovedPermanently:
|
||||||
|
lc.warnRuntimeLoggingNotEnabled()
|
||||||
|
return nil
|
||||||
|
case http.StatusOK:
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unexpected response status: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up signal handling for clean shutdown.
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
// Stream output to stdout.
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
for {
|
||||||
|
n, readErr := resp.Body.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
os.Stdout.Write(buf[:n])
|
||||||
|
}
|
||||||
|
if readErr != nil {
|
||||||
|
if readErr != io.EOF {
|
||||||
|
mainLog.Load().Error().Err(readErr).Msg("Error reading log stream")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
if errors.Is(ctx.Err(), context.Canceled) {
|
||||||
|
msg := fmt.Sprintf("\nexiting: %s\n", context.Cause(ctx).Error())
|
||||||
|
os.Stdout.WriteString(msg)
|
||||||
|
}
|
||||||
|
case <-done:
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitLogCmd creates the log command with proper logic
|
||||||
|
func InitLogCmd(rootCmd *cobra.Command) *cobra.Command {
|
||||||
|
lc, err := NewLogCommand()
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("failed to create log command: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
logSendCmd := &cobra.Command{
|
||||||
|
Use: "send",
|
||||||
|
Short: "Send runtime debug logs to ControlD",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
PreRun: func(cmd *cobra.Command, args []string) {
|
||||||
|
checkHasElevatedPrivilege()
|
||||||
|
},
|
||||||
|
RunE: lc.SendLogs,
|
||||||
|
}
|
||||||
|
|
||||||
|
logViewCmd := &cobra.Command{
|
||||||
|
Use: "view",
|
||||||
|
Short: "View current runtime debug logs",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
PreRun: func(cmd *cobra.Command, args []string) {
|
||||||
|
checkHasElevatedPrivilege()
|
||||||
|
},
|
||||||
|
RunE: lc.ViewLogs,
|
||||||
|
}
|
||||||
|
|
||||||
|
logTailCmd := &cobra.Command{
|
||||||
|
Use: "tail",
|
||||||
|
Short: "Tail live runtime debug logs",
|
||||||
|
Long: "Stream live runtime debug logs to the terminal, similar to tail -f. Press Ctrl+C to stop.",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
PreRun: func(cmd *cobra.Command, args []string) {
|
||||||
|
checkHasElevatedPrivilege()
|
||||||
|
},
|
||||||
|
RunE: lc.TailLogs,
|
||||||
|
}
|
||||||
|
logTailCmd.Flags().IntP("lines", "n", 10, "Number of historical lines to show on connect")
|
||||||
|
|
||||||
|
logCmd := &cobra.Command{
|
||||||
|
Use: "log",
|
||||||
|
Short: "Manage runtime debug logs",
|
||||||
|
Args: cobra.OnlyValidArgs,
|
||||||
|
ValidArgs: []string{
|
||||||
|
logSendCmd.Use,
|
||||||
|
logViewCmd.Use,
|
||||||
|
logTailCmd.Use,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
logCmd.AddCommand(logSendCmd)
|
||||||
|
logCmd.AddCommand(logViewCmd)
|
||||||
|
logCmd.AddCommand(logTailCmd)
|
||||||
|
rootCmd.AddCommand(logCmd)
|
||||||
|
|
||||||
|
return logCmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/Control-D-Inc/ctrld"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RunCommand handles run-related operations
|
||||||
|
type RunCommand struct {
|
||||||
|
// Add any dependencies here if needed in the future
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRunCommand creates a new run command handler
|
||||||
|
func NewRunCommand() *RunCommand {
|
||||||
|
return &RunCommand{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run implements the logic for the run command
|
||||||
|
func (rc *RunCommand) Run(cmd *cobra.Command, args []string) {
|
||||||
|
RunCobraCommand(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitRunCmd creates the run command with proper logic
|
||||||
|
func InitRunCmd(rootCmd *cobra.Command) *cobra.Command {
|
||||||
|
rc := NewRunCommand()
|
||||||
|
|
||||||
|
runCmd := &cobra.Command{
|
||||||
|
Use: "run",
|
||||||
|
Short: "Run the DNS proxy server",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
Run: rc.Run,
|
||||||
|
}
|
||||||
|
runCmd.Flags().BoolVarP(&daemon, "daemon", "d", false, "Run as daemon")
|
||||||
|
runCmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
|
||||||
|
runCmd.Flags().StringVarP(&configBase64, "base64_config", "", "", "Base64 encoded config")
|
||||||
|
runCmd.Flags().StringVarP(&listenAddress, "listen", "", "", "Listener address and port, in format: address:port")
|
||||||
|
runCmd.Flags().StringVarP(&primaryUpstream, "primary_upstream", "", "", "Primary upstream endpoint")
|
||||||
|
runCmd.Flags().StringVarP(&secondaryUpstream, "secondary_upstream", "", "", "Secondary upstream endpoint")
|
||||||
|
runCmd.Flags().StringSliceVarP(&domains, "domains", "", nil, "List of domain to apply in a split DNS policy")
|
||||||
|
runCmd.Flags().StringVarP(&logPath, "log", "", "", "Path to log file")
|
||||||
|
runCmd.Flags().IntVarP(&cacheSize, "cache_size", "", 0, "Enable cache with size items")
|
||||||
|
runCmd.Flags().StringVarP(&cdUID, cdUidFlagName, "", "", "Control D resolver uid")
|
||||||
|
runCmd.Flags().StringVarP(&cdOrg, cdOrgFlagName, "", "", "Control D provision token")
|
||||||
|
runCmd.Flags().StringVarP(&customHostname, customHostnameFlagName, "", "", "Custom hostname passed to ControlD API")
|
||||||
|
runCmd.Flags().BoolVarP(&cdDev, "dev", "", false, "Use Control D dev resolver/domain")
|
||||||
|
_ = runCmd.Flags().MarkHidden("dev")
|
||||||
|
runCmd.Flags().StringVarP(&homedir, "homedir", "", "", "")
|
||||||
|
_ = runCmd.Flags().MarkHidden("homedir")
|
||||||
|
runCmd.Flags().StringVarP(&iface, "iface", "", "", `Update DNS setting for iface, "auto" means the default interface gateway`)
|
||||||
|
_ = runCmd.Flags().MarkHidden("iface")
|
||||||
|
runCmd.Flags().StringVarP(&cdUpstreamProto, "proto", "", ctrld.ResolverTypeDOH, `Control D upstream type, either "doh" or "doh3"`)
|
||||||
|
runCmd.Flags().BoolVarP(&rfc1918, "rfc1918", "", false, "Listen on RFC1918 addresses when 127.0.0.1 is the only listener")
|
||||||
|
runCmd.Flags().StringVarP(&interceptMode, "intercept-mode", "", "", "OS-level DNS interception mode: 'dns' (with VPN split routing) or 'hard' (all DNS through ctrld, no VPN split routing)")
|
||||||
|
runCmd.Flags().StringVarP(&firewallMode, "firewall-mode", "", "off", "DNS-resolved IP allowlist: 'on' blocks connections to IPs not resolved by ctrld, 'off' allows all")
|
||||||
|
|
||||||
|
runCmd.FParseErrWhitelist = cobra.FParseErrWhitelist{UnknownFlags: true}
|
||||||
|
rootCmd.AddCommand(runCmd)
|
||||||
|
|
||||||
|
return runCmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/kardianos/service"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// filterEmptyStrings removes empty strings from a slice
|
||||||
|
// This is used to clean up command line arguments and configuration values
|
||||||
|
func filterEmptyStrings(slice []string) []string {
|
||||||
|
var result []string
|
||||||
|
for _, s := range slice {
|
||||||
|
if s != "" {
|
||||||
|
result = append(result, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceCommand handles service-related operations
|
||||||
|
// This encapsulates all service management functionality for the CLI
|
||||||
|
type ServiceCommand struct {
|
||||||
|
serviceManager *ServiceManager
|
||||||
|
}
|
||||||
|
|
||||||
|
// initializeServiceManager creates a service manager with default configuration
|
||||||
|
// This sets up the basic service infrastructure needed for all service operations
|
||||||
|
func (sc *ServiceCommand) initializeServiceManager() (service.Service, *prog, error) {
|
||||||
|
svcConfig := sc.createServiceConfig()
|
||||||
|
return sc.initializeServiceManagerWithServiceConfig(svcConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// initializeServiceManagerWithServiceConfig creates a service manager with the given configuration
|
||||||
|
// This allows for custom service configuration while maintaining the same initialization pattern
|
||||||
|
func (sc *ServiceCommand) initializeServiceManagerWithServiceConfig(svcConfig *service.Config) (service.Service, *prog, error) {
|
||||||
|
p := &prog{}
|
||||||
|
|
||||||
|
s, err := sc.newService(p, svcConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to create service: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sc.serviceManager = &ServiceManager{prog: p, svc: s}
|
||||||
|
return s, p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newService creates a new service instance using the provided program and configuration.
|
||||||
|
// This abstracts the service creation process for different operating systems
|
||||||
|
func (sc *ServiceCommand) newService(p *prog, svcConfig *service.Config) (service.Service, error) {
|
||||||
|
s, err := newService(p, svcConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create service: %w", err)
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServiceCommand creates a new service command handler
|
||||||
|
// This provides a clean factory method for creating service command instances
|
||||||
|
func NewServiceCommand() *ServiceCommand {
|
||||||
|
return &ServiceCommand{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createServiceConfig creates a properly initialized service configuration
|
||||||
|
// This ensures consistent service naming and description across all platforms
|
||||||
|
func (sc *ServiceCommand) createServiceConfig() *service.Config {
|
||||||
|
return &service.Config{
|
||||||
|
Name: ctrldServiceName,
|
||||||
|
DisplayName: "Control-D Helper Service",
|
||||||
|
Description: "A highly configurable, multi-protocol DNS forwarding proxy",
|
||||||
|
Option: service.KeyValue{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitServiceCmd creates the service command with proper logic and aliases
|
||||||
|
// This sets up all service-related subcommands with appropriate permissions and flags
|
||||||
|
func InitServiceCmd(rootCmd *cobra.Command) *cobra.Command {
|
||||||
|
// Create service command handlers
|
||||||
|
sc := NewServiceCommand()
|
||||||
|
|
||||||
|
startCmd, startCmdAlias := createStartCommands(sc)
|
||||||
|
rootCmd.AddCommand(startCmdAlias)
|
||||||
|
|
||||||
|
// Stop command
|
||||||
|
stopCmd := &cobra.Command{
|
||||||
|
Use: "stop",
|
||||||
|
Short: "Stop the ctrld service",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
PreRun: func(cmd *cobra.Command, args []string) {
|
||||||
|
checkHasElevatedPrivilege()
|
||||||
|
},
|
||||||
|
RunE: sc.Stop,
|
||||||
|
}
|
||||||
|
stopCmd.Flags().StringVarP(&iface, "iface", "", "", `Reset DNS setting for iface, "auto" means the default interface gateway`)
|
||||||
|
stopCmd.Flags().Int64VarP(&deactivationPin, "pin", "", defaultDeactivationPin, `Pin code for stopping ctrld`)
|
||||||
|
_ = stopCmd.Flags().MarkHidden("pin")
|
||||||
|
|
||||||
|
// Restart command
|
||||||
|
restartCmd := &cobra.Command{
|
||||||
|
Use: "restart",
|
||||||
|
Short: "Restart the ctrld service",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
PreRun: func(cmd *cobra.Command, args []string) {
|
||||||
|
checkHasElevatedPrivilege()
|
||||||
|
},
|
||||||
|
RunE: sc.Restart,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status command
|
||||||
|
statusCmd := &cobra.Command{
|
||||||
|
Use: "status",
|
||||||
|
Short: "Show status of the ctrld service",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: sc.Status,
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "darwin" {
|
||||||
|
// On darwin, running status command without privileges may return wrong information.
|
||||||
|
statusCmd.PreRun = func(cmd *cobra.Command, args []string) {
|
||||||
|
checkHasElevatedPrivilege()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload command
|
||||||
|
reloadCmd := &cobra.Command{
|
||||||
|
Use: "reload",
|
||||||
|
Short: "Reload the ctrld service",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
PreRun: func(cmd *cobra.Command, args []string) {
|
||||||
|
checkHasElevatedPrivilege()
|
||||||
|
},
|
||||||
|
RunE: sc.Reload,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uninstall command
|
||||||
|
uninstallCmd := &cobra.Command{
|
||||||
|
Use: "uninstall",
|
||||||
|
Short: "Stop and uninstall the ctrld service",
|
||||||
|
Long: `Stop and uninstall the ctrld service.
|
||||||
|
|
||||||
|
NOTE: Uninstalling will set DNS to values provided by DHCP.`,
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
PreRun: func(cmd *cobra.Command, args []string) {
|
||||||
|
checkHasElevatedPrivilege()
|
||||||
|
},
|
||||||
|
RunE: sc.Uninstall,
|
||||||
|
}
|
||||||
|
uninstallCmd.Flags().StringVarP(&iface, "iface", "", "", `Reset DNS setting for iface, "auto" means the default interface gateway`)
|
||||||
|
uninstallCmd.Flags().Int64VarP(&deactivationPin, "pin", "", defaultDeactivationPin, `Pin code for stopping ctrld`)
|
||||||
|
_ = uninstallCmd.Flags().MarkHidden("pin")
|
||||||
|
uninstallCmd.Flags().BoolVarP(&cleanup, "cleanup", "", false, `Removing ctrld binary and config files`)
|
||||||
|
|
||||||
|
// Interfaces command - use the existing InitInterfacesCmd function
|
||||||
|
interfacesCmd := InitInterfacesCmd(rootCmd)
|
||||||
|
|
||||||
|
stopCmdAlias := &cobra.Command{
|
||||||
|
PreRun: func(cmd *cobra.Command, args []string) {
|
||||||
|
checkHasElevatedPrivilege()
|
||||||
|
},
|
||||||
|
Use: "stop",
|
||||||
|
Short: "Quick stop service and remove DNS from interface",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if !cmd.Flags().Changed("iface") {
|
||||||
|
os.Args = append(os.Args, "--iface="+ifaceStartStop)
|
||||||
|
}
|
||||||
|
iface = ifaceStartStop
|
||||||
|
return stopCmd.RunE(cmd, args)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
stopCmdAlias.Flags().StringVarP(&ifaceStartStop, "iface", "", "auto", `Reset DNS setting for iface, "auto" means the default interface gateway`)
|
||||||
|
stopCmdAlias.Flags().AddFlagSet(stopCmd.Flags())
|
||||||
|
rootCmd.AddCommand(stopCmdAlias)
|
||||||
|
|
||||||
|
// Create aliases for other service commands
|
||||||
|
restartCmdAlias := &cobra.Command{
|
||||||
|
PreRun: func(cmd *cobra.Command, args []string) {
|
||||||
|
checkHasElevatedPrivilege()
|
||||||
|
},
|
||||||
|
Use: "restart",
|
||||||
|
Short: "Restart the ctrld service",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return restartCmd.RunE(cmd, args)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
rootCmd.AddCommand(restartCmdAlias)
|
||||||
|
|
||||||
|
reloadCmdAlias := &cobra.Command{
|
||||||
|
PreRun: func(cmd *cobra.Command, args []string) {
|
||||||
|
checkHasElevatedPrivilege()
|
||||||
|
},
|
||||||
|
Use: "reload",
|
||||||
|
Short: "Reload the ctrld service",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return reloadCmd.RunE(cmd, args)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
rootCmd.AddCommand(reloadCmdAlias)
|
||||||
|
|
||||||
|
statusCmdAlias := &cobra.Command{
|
||||||
|
Use: "status",
|
||||||
|
Short: "Show status of the ctrld service",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: statusCmd.RunE,
|
||||||
|
}
|
||||||
|
rootCmd.AddCommand(statusCmdAlias)
|
||||||
|
|
||||||
|
uninstallCmdAlias := &cobra.Command{
|
||||||
|
PreRun: func(cmd *cobra.Command, args []string) {
|
||||||
|
checkHasElevatedPrivilege()
|
||||||
|
},
|
||||||
|
Use: "uninstall",
|
||||||
|
Short: "Stop and uninstall the ctrld service",
|
||||||
|
Long: `Stop and uninstall the ctrld service.
|
||||||
|
|
||||||
|
NOTE: Uninstalling will set DNS to values provided by DHCP.`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if !cmd.Flags().Changed("iface") {
|
||||||
|
os.Args = append(os.Args, "--iface="+ifaceStartStop)
|
||||||
|
}
|
||||||
|
iface = ifaceStartStop
|
||||||
|
return uninstallCmd.RunE(cmd, args)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
uninstallCmdAlias.Flags().StringVarP(&ifaceStartStop, "iface", "", "auto", `Reset DNS setting for iface, "auto" means the default interface gateway`)
|
||||||
|
uninstallCmdAlias.Flags().AddFlagSet(uninstallCmd.Flags())
|
||||||
|
rootCmd.AddCommand(uninstallCmdAlias)
|
||||||
|
|
||||||
|
// Create service command
|
||||||
|
serviceCmd := &cobra.Command{
|
||||||
|
Use: "service",
|
||||||
|
Short: "Manage ctrld service",
|
||||||
|
Args: cobra.OnlyValidArgs,
|
||||||
|
}
|
||||||
|
serviceCmd.ValidArgs = make([]string, 7)
|
||||||
|
serviceCmd.ValidArgs[0] = startCmd.Use
|
||||||
|
serviceCmd.ValidArgs[1] = stopCmd.Use
|
||||||
|
serviceCmd.ValidArgs[2] = restartCmd.Use
|
||||||
|
serviceCmd.ValidArgs[3] = reloadCmd.Use
|
||||||
|
serviceCmd.ValidArgs[4] = statusCmd.Use
|
||||||
|
serviceCmd.ValidArgs[5] = uninstallCmd.Use
|
||||||
|
serviceCmd.ValidArgs[6] = interfacesCmd.Use
|
||||||
|
|
||||||
|
serviceCmd.AddCommand(startCmd)
|
||||||
|
serviceCmd.AddCommand(stopCmd)
|
||||||
|
serviceCmd.AddCommand(restartCmd)
|
||||||
|
serviceCmd.AddCommand(reloadCmd)
|
||||||
|
serviceCmd.AddCommand(statusCmd)
|
||||||
|
serviceCmd.AddCommand(uninstallCmd)
|
||||||
|
serviceCmd.AddCommand(interfacesCmd)
|
||||||
|
|
||||||
|
rootCmd.AddCommand(serviceCmd)
|
||||||
|
|
||||||
|
return serviceCmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// validInterceptMode reports whether the given value is a recognized --intercept-mode.
|
||||||
|
// This is the single source of truth for mode validation — used by the early start
|
||||||
|
// command check, the runtime validation in prog.go, and onlyInterceptFlags below.
|
||||||
|
// Add new modes here to have them recognized everywhere.
|
||||||
|
func validInterceptMode(mode string) bool {
|
||||||
|
switch mode {
|
||||||
|
case "off", "dns", "hard":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// validFirewallMode reports whether the given value is a recognized --firewall-mode.
|
||||||
|
func validFirewallMode(mode string) bool {
|
||||||
|
switch mode {
|
||||||
|
case "off", "on":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// onlyInterceptFlags reports whether args contain only intercept mode
|
||||||
|
// flags (--intercept-mode <value>) and flags that are auto-added by the
|
||||||
|
// start command alias (--iface). This is used to detect "ctrld start --intercept-mode dns"
|
||||||
|
// (or "off" to disable) on an existing installation, where the intent is to modify the
|
||||||
|
// intercept flag on the existing service without replacing other arguments.
|
||||||
|
//
|
||||||
|
// Note: the startCmdAlias appends "--iface=auto" to os.Args when --iface isn't
|
||||||
|
// explicitly provided, so we must allow it here.
|
||||||
|
func onlyInterceptFlags(args []string) bool {
|
||||||
|
hasIntercept := false
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
arg := args[i]
|
||||||
|
switch {
|
||||||
|
case arg == "--intercept-mode":
|
||||||
|
// Next arg must be a valid mode value.
|
||||||
|
if i+1 < len(args) && validInterceptMode(args[i+1]) {
|
||||||
|
hasIntercept = true
|
||||||
|
i++ // skip the value
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(arg, "--intercept-mode="):
|
||||||
|
val := strings.TrimPrefix(arg, "--intercept-mode=")
|
||||||
|
if validInterceptMode(val) {
|
||||||
|
hasIntercept = true
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
case arg == "--iface="+autoIface || arg == "--iface" || arg == autoIface:
|
||||||
|
// Auto-added by startCmdAlias or its value; safe to ignore.
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hasIntercept
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kardianos/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dialSocketControlServerTimeout is the default timeout to wait when ping control server.
|
||||||
|
const dialSocketControlServerTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
// ServiceManager handles service operations
|
||||||
|
type ServiceManager struct {
|
||||||
|
prog *prog
|
||||||
|
svc service.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServiceManager creates a new service manager
|
||||||
|
func NewServiceManager() (*ServiceManager, error) {
|
||||||
|
p := &prog{}
|
||||||
|
|
||||||
|
// Create a proper service configuration
|
||||||
|
svcConfig := &service.Config{
|
||||||
|
Name: ctrldServiceName,
|
||||||
|
DisplayName: "Control-D Helper Service",
|
||||||
|
Description: "A highly configurable, multi-protocol DNS forwarding proxy",
|
||||||
|
Option: service.KeyValue{},
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := newService(p, svcConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create service: %w", err)
|
||||||
|
}
|
||||||
|
return &ServiceManager{prog: p, svc: s}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status returns the current service status
|
||||||
|
func (sm *ServiceManager) Status() (service.Status, error) {
|
||||||
|
return sm.svc.Status()
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/kardianos/service"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reload implements the logic from cmdReload.Run
|
||||||
|
func (sc *ServiceCommand) Reload(cmd *cobra.Command, args []string) error {
|
||||||
|
logger := mainLog.Load()
|
||||||
|
logger.Debug().Msg("Service reload command started")
|
||||||
|
|
||||||
|
s, _, err := sc.initializeServiceManager()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error().Err(err).Msg("Failed to initialize service manager")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err := s.Status()
|
||||||
|
if errors.Is(err, service.ErrNotInstalled) {
|
||||||
|
logger.Warn().Msg("Service not installed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if status == service.StatusStopped {
|
||||||
|
logger.Warn().Msg("Service is not running")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
dir, err := socketDir()
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatal().Err(err).Msg("Failed to find ctrld home dir")
|
||||||
|
}
|
||||||
|
|
||||||
|
cc := newControlClient(filepath.Join(dir, ctrldControlUnixSock))
|
||||||
|
resp, err := cc.post(reloadPath, nil)
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatal().Err(err).Msg("Failed to send reload signal to ctrld")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
switch resp.StatusCode {
|
||||||
|
case http.StatusOK:
|
||||||
|
logger.Notice().Msg("Service reloaded")
|
||||||
|
case http.StatusCreated:
|
||||||
|
logger.Warn().Msg("Service was reloaded, but new config requires service restart.")
|
||||||
|
logger.Warn().Msg("Restarting service")
|
||||||
|
if _, err := s.Status(); errors.Is(err, service.ErrNotInstalled) {
|
||||||
|
logger.Warn().Msg("Service not installed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return sc.Restart(cmd, args)
|
||||||
|
default:
|
||||||
|
buf, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatal().Err(err).Msg("Could not read response from control server")
|
||||||
|
}
|
||||||
|
logger.Error().Err(err).Msgf("Failed to reload ctrld: %s", string(buf))
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debug().Msg("Service reload command completed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kardianos/service"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Restart implements the logic from cmdRestart.Run
|
||||||
|
func (sc *ServiceCommand) Restart(cmd *cobra.Command, args []string) error {
|
||||||
|
logger := mainLog.Load()
|
||||||
|
logger.Debug().Msg("Service restart command started")
|
||||||
|
|
||||||
|
readConfig(false)
|
||||||
|
v.Unmarshal(&cfg)
|
||||||
|
cdUID = curCdUID()
|
||||||
|
cdMode := cdUID != ""
|
||||||
|
|
||||||
|
s, p, err := sc.initializeServiceManager()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error().Err(err).Msg("Failed to initialize service manager")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.Status(); errors.Is(err, service.ErrNotInstalled) {
|
||||||
|
logger.Warn().Msg("Service not installed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
p.cfg = &cfg
|
||||||
|
if iface == "" {
|
||||||
|
iface = autoIface
|
||||||
|
}
|
||||||
|
p.preRun()
|
||||||
|
if ir := runningIface(s); ir != nil {
|
||||||
|
p.runningIface = ir.Name
|
||||||
|
p.requiredMultiNICsConfig = ir.All
|
||||||
|
}
|
||||||
|
|
||||||
|
initInteractiveLogging()
|
||||||
|
|
||||||
|
var validateConfigErr error
|
||||||
|
if cdMode {
|
||||||
|
logger.Debug().Msg("Validating ControlD remote config")
|
||||||
|
validateConfigErr = doValidateCdRemoteConfig(cdUID, false)
|
||||||
|
if validateConfigErr != nil {
|
||||||
|
logger.Warn().Err(validateConfigErr).Msg("ControlD remote config validation failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ir := runningIface(s); ir != nil {
|
||||||
|
iface = ir.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
doRestart := func() bool {
|
||||||
|
logger.Debug().Msg("Starting service restart sequence")
|
||||||
|
|
||||||
|
tasks := []task{
|
||||||
|
{s.Stop, true, "Stop"},
|
||||||
|
{func() error {
|
||||||
|
// restore static DNS settings or DHCP
|
||||||
|
p.resetDNS(false, true)
|
||||||
|
return nil
|
||||||
|
}, false, "Cleanup"},
|
||||||
|
{func() error {
|
||||||
|
time.Sleep(time.Second * 1)
|
||||||
|
return nil
|
||||||
|
}, false, "Waiting for service to stop"},
|
||||||
|
}
|
||||||
|
if !doTasks(tasks) {
|
||||||
|
logger.Error().Msg("Service stop tasks failed")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
tasks = []task{
|
||||||
|
{s.Start, true, "Start"},
|
||||||
|
}
|
||||||
|
success := doTasks(tasks)
|
||||||
|
if success {
|
||||||
|
logger.Debug().Msg("Service restart sequence completed successfully")
|
||||||
|
} else {
|
||||||
|
logger.Error().Msg("Service restart sequence failed")
|
||||||
|
}
|
||||||
|
return success
|
||||||
|
}
|
||||||
|
|
||||||
|
if doRestart() {
|
||||||
|
if dir, err := socketDir(); err == nil {
|
||||||
|
timeout := dialSocketControlServerTimeout
|
||||||
|
if validateConfigErr != nil {
|
||||||
|
timeout = 5 * time.Second
|
||||||
|
}
|
||||||
|
if cc := newSocketControlClientWithTimeout(context.TODO(), s, dir, timeout); cc != nil {
|
||||||
|
_, _ = cc.post(ifacePath, nil)
|
||||||
|
logger.Debug().Msg("Control server ping successful")
|
||||||
|
} else {
|
||||||
|
logger.Warn().Err(err).Msg("Service was restarted, but ctrld process may not be ready yet")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.Warn().Err(err).Msg("Service was restarted, but could not ping the control server")
|
||||||
|
}
|
||||||
|
logger.Notice().Msg("Service restarted")
|
||||||
|
} else {
|
||||||
|
logger.Error().Msg("Service restart failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debug().Msg("Service restart command completed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kardianos/service"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/Control-D-Inc/ctrld"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Start implements the logic from cmdStart.Run
|
||||||
|
func (sc *ServiceCommand) Start(cmd *cobra.Command, args []string) error {
|
||||||
|
logger := mainLog.Load()
|
||||||
|
logger.Debug().Msg("Service start command started")
|
||||||
|
|
||||||
|
firewallModeFlagChanged = cmd.Flags().Changed("firewall-mode")
|
||||||
|
checkStrFlagEmpty(cmd, cdUidFlagName)
|
||||||
|
checkStrFlagEmpty(cmd, cdOrgFlagName)
|
||||||
|
validateCdAndNextDNSFlags()
|
||||||
|
|
||||||
|
svcConfig := sc.createServiceConfig()
|
||||||
|
osArgs := os.Args[2:]
|
||||||
|
osArgs = filterEmptyStrings(osArgs)
|
||||||
|
if os.Args[1] == "service" {
|
||||||
|
osArgs = os.Args[3:]
|
||||||
|
}
|
||||||
|
setDependencies(svcConfig)
|
||||||
|
svcConfig.Arguments = append([]string{"run"}, osArgs...)
|
||||||
|
|
||||||
|
// Validate --intercept-mode early, before installing the service.
|
||||||
|
// Without this, a typo like "--intercept-mode fds" would install the service,
|
||||||
|
// the child process would Fatal() on the invalid value, and the parent would
|
||||||
|
// then uninstall — confusing and destructive.
|
||||||
|
if interceptMode != "" && !validInterceptMode(interceptMode) {
|
||||||
|
logger.Fatal().Msgf("invalid --intercept-mode value %q: must be 'off', 'dns', or 'hard'", interceptMode)
|
||||||
|
}
|
||||||
|
if firewallModeFlagChanged && !validFirewallMode(firewallMode) {
|
||||||
|
logger.Fatal().Msgf("invalid --firewall-mode value %q: must be 'off' or 'on'", firewallMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize service manager with proper configuration
|
||||||
|
s, p, err := sc.initializeServiceManagerWithServiceConfig(svcConfig)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error().Err(err).Msg("Failed to initialize service manager")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
p.cfg = &cfg
|
||||||
|
p.preRun()
|
||||||
|
|
||||||
|
status, err := s.Status()
|
||||||
|
isCtrldRunning := status == service.StatusRunning
|
||||||
|
isCtrldInstalled := !errors.Is(err, service.ErrNotInstalled)
|
||||||
|
|
||||||
|
// Get current running iface, if any.
|
||||||
|
var currentIface *ifaceResponse
|
||||||
|
|
||||||
|
// Handle "ctrld start --intercept-mode dns|hard" on an existing
|
||||||
|
// service BEFORE the pin check. Adding intercept mode is an enhancement, not
|
||||||
|
// deactivation, so it doesn't require the deactivation pin. We modify the
|
||||||
|
// plist/registry directly and restart the service via the OS service manager.
|
||||||
|
osArgsEarly := os.Args[2:]
|
||||||
|
if os.Args[1] == "service" {
|
||||||
|
osArgsEarly = os.Args[3:]
|
||||||
|
}
|
||||||
|
osArgsEarly = filterEmptyStrings(osArgsEarly)
|
||||||
|
interceptOnly := onlyInterceptFlags(osArgsEarly)
|
||||||
|
svcExists := serviceConfigFileExists()
|
||||||
|
logger.Debug().Msgf("intercept upgrade check: args=%v interceptOnly=%v svcConfigExists=%v interceptMode=%q", osArgsEarly, interceptOnly, svcExists, interceptMode)
|
||||||
|
if interceptOnly && svcExists {
|
||||||
|
// Remove any existing intercept flags before applying the new value.
|
||||||
|
_ = removeServiceFlag("--intercept-mode")
|
||||||
|
|
||||||
|
if interceptMode == "off" {
|
||||||
|
// "off" = remove intercept mode entirely (just the removal above).
|
||||||
|
logger.Notice().Msg("Existing service detected — removing --intercept-mode from service arguments")
|
||||||
|
} else {
|
||||||
|
// Add the new mode value.
|
||||||
|
logger.Notice().Msgf("Existing service detected — appending --intercept-mode %s to service arguments", interceptMode)
|
||||||
|
if err := appendServiceFlag("--intercept-mode"); err != nil {
|
||||||
|
logger.Fatal().Err(err).Msg("failed to append intercept flag to service arguments")
|
||||||
|
}
|
||||||
|
if err := appendServiceFlag(interceptMode); err != nil {
|
||||||
|
logger.Fatal().Err(err).Msg("failed to append intercept mode value to service arguments")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop the service if running (bypasses ctrld pin — this is an
|
||||||
|
// enhancement, not deactivation). Then fall through to the normal
|
||||||
|
// startOnly path which handles start, self-check, and reporting.
|
||||||
|
if isCtrldRunning {
|
||||||
|
logger.Notice().Msg("Stopping service for intercept mode upgrade")
|
||||||
|
_ = s.Stop()
|
||||||
|
isCtrldRunning = false
|
||||||
|
}
|
||||||
|
startOnly = true
|
||||||
|
isCtrldInstalled = true
|
||||||
|
// Fall through to startOnly path below.
|
||||||
|
}
|
||||||
|
|
||||||
|
// If pin code was set, do not allow running start command.
|
||||||
|
if isCtrldRunning {
|
||||||
|
if err := checkDeactivationPin(s, nil); isCheckDeactivationPinErr(err) {
|
||||||
|
logger.Error().Msg("Deactivation pin check failed")
|
||||||
|
os.Exit(deactivationPinInvalidExitCode)
|
||||||
|
}
|
||||||
|
currentIface = runningIface(s)
|
||||||
|
logger.Debug().Msgf("Current interface on start: %v", currentIface)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
reportSetDnsOk := func(sockDir string) {
|
||||||
|
if cc := newSocketControlClient(ctx, s, sockDir); cc != nil {
|
||||||
|
if resp, _ := cc.post(ifacePath, nil); resp != nil && resp.StatusCode == http.StatusOK {
|
||||||
|
if iface == autoIface {
|
||||||
|
iface = defaultIfaceName()
|
||||||
|
}
|
||||||
|
res := &ifaceResponse{}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(res); err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("Failed to get iface info")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if res.OK {
|
||||||
|
// In intercept mode, show intercept-specific status instead of
|
||||||
|
// per-interface DNS messages (which are irrelevant).
|
||||||
|
if res.InterceptMode != "" {
|
||||||
|
switch res.InterceptMode {
|
||||||
|
case "hard":
|
||||||
|
logger.Notice().Msg("DNS hard intercept mode active — all DNS traffic intercepted, no VPN split routing")
|
||||||
|
default:
|
||||||
|
logger.Notice().Msg("DNS intercept mode active — all DNS traffic intercepted via OS packet filter")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
name := res.Name
|
||||||
|
if iff, err := net.InterfaceByName(name); err == nil {
|
||||||
|
_, _ = patchNetIfaceName(iff)
|
||||||
|
name = iff.Name
|
||||||
|
}
|
||||||
|
ifaceLogger := logger.With().Str("iface", name)
|
||||||
|
ifaceLogger.Debug().Msg("Setting DNS successfully")
|
||||||
|
if res.All {
|
||||||
|
// Log that DNS is set for other interfaces.
|
||||||
|
withEachPhysicalInterfaces(
|
||||||
|
name,
|
||||||
|
"set DNS",
|
||||||
|
func(i *net.Interface) error { return nil },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No config path, generating config in HOME directory.
|
||||||
|
noConfigStart := isNoConfigStart(cmd)
|
||||||
|
writeDefaultConfig := !noConfigStart && configBase64 == ""
|
||||||
|
|
||||||
|
logServerStarted := make(chan struct{})
|
||||||
|
stopLogCh := make(chan struct{})
|
||||||
|
ud, err := userHomeDir()
|
||||||
|
sockDir := ud
|
||||||
|
var logServerSocketPath string
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("Failed to get user home directory")
|
||||||
|
logger.Warn().Msg("Log server did not start")
|
||||||
|
close(logServerStarted)
|
||||||
|
} else {
|
||||||
|
setWorkingDirectory(svcConfig, ud)
|
||||||
|
if configPath == "" && writeDefaultConfig {
|
||||||
|
defaultConfigFile = filepath.Join(ud, defaultConfigFile)
|
||||||
|
}
|
||||||
|
svcConfig.Arguments = append(svcConfig.Arguments, "--homedir="+ud)
|
||||||
|
if d, err := socketDir(); err == nil {
|
||||||
|
sockDir = d
|
||||||
|
}
|
||||||
|
logServerSocketPath = filepath.Join(sockDir, ctrldLogUnixSock)
|
||||||
|
_ = os.Remove(logServerSocketPath)
|
||||||
|
go func() {
|
||||||
|
defer os.Remove(logServerSocketPath)
|
||||||
|
|
||||||
|
close(logServerStarted)
|
||||||
|
|
||||||
|
// Start HTTP log server
|
||||||
|
if err := httpLogServer(logServerSocketPath, stopLogCh); err != nil && err != http.ErrServerClosed {
|
||||||
|
logger.Warn().Err(err).Msg("Failed to serve HTTP log server")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
<-logServerStarted
|
||||||
|
|
||||||
|
if !startOnly {
|
||||||
|
startOnly = len(osArgs) == 0
|
||||||
|
}
|
||||||
|
// If user run "ctrld start" and ctrld is already installed, starting existing service.
|
||||||
|
if startOnly && isCtrldInstalled {
|
||||||
|
tryReadingConfigWithNotice(false, true)
|
||||||
|
if err := v.Unmarshal(&cfg); err != nil {
|
||||||
|
logger.Fatal().Msgf("Failed to unmarshal config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// if already running, dont restart
|
||||||
|
if isCtrldRunning {
|
||||||
|
logger.Notice().Msg("Service is already running")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
initInteractiveLogging()
|
||||||
|
tasks := []task{
|
||||||
|
{func() error {
|
||||||
|
// Save current DNS so we can restore later.
|
||||||
|
withEachPhysicalInterfaces("", "saveCurrentStaticDNS", func(i *net.Interface) error {
|
||||||
|
if err := saveCurrentStaticDNS(i); !errors.Is(err, errSaveCurrentStaticDNSNotSupported) && err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}, false, "Save current DNS"},
|
||||||
|
{func() error {
|
||||||
|
return ConfigureWindowsServiceFailureActions(ctrldServiceName)
|
||||||
|
}, false, "Configure service failure actions"},
|
||||||
|
{s.Start, true, "Start"},
|
||||||
|
{noticeWritingControlDConfig, false, "Notice writing ControlD config"},
|
||||||
|
}
|
||||||
|
logger.Notice().Msg("Starting existing ctrld service")
|
||||||
|
if doTasks(tasks) {
|
||||||
|
logger.Notice().Msg("Service started")
|
||||||
|
sockDir, err := socketDir()
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("Failed to get socket directory")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
reportSetDnsOk(sockDir)
|
||||||
|
// Verify service registration after successful start.
|
||||||
|
if err := verifyServiceRegistration(); err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("Service registry verification failed")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.Error().Err(err).Msg("Failed to start existing ctrld service")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if cdUID != "" {
|
||||||
|
_ = doValidateCdRemoteConfig(cdUID, true)
|
||||||
|
} else if uid := cdUIDFromProvToken(); uid != "" {
|
||||||
|
cdUID = uid
|
||||||
|
logger.Debug().Msg("Using uid from provision token")
|
||||||
|
removeOrgFlagsFromArgs(svcConfig)
|
||||||
|
// Pass --cd flag to "ctrld run" command, so the provision token takes no effect.
|
||||||
|
svcConfig.Arguments = append(svcConfig.Arguments, "--cd="+cdUID)
|
||||||
|
}
|
||||||
|
if cdUID != "" {
|
||||||
|
validateCdUpstreamProtocol()
|
||||||
|
}
|
||||||
|
|
||||||
|
if configPath != "" {
|
||||||
|
v.SetConfigFile(configPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
tryReadingConfigWithNotice(writeDefaultConfig, true)
|
||||||
|
|
||||||
|
if err := v.Unmarshal(&cfg); err != nil {
|
||||||
|
logger.Fatal().Msgf("Failed to unmarshal config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
initInteractiveLogging()
|
||||||
|
|
||||||
|
if nextdns != "" {
|
||||||
|
removeNextDNSFromArgs(svcConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicitly passing config, so on system where home directory could not be obtained,
|
||||||
|
// or sub-process env is different with the parent, we still behave correctly and use
|
||||||
|
// the expected config file.
|
||||||
|
if configPath == "" {
|
||||||
|
svcConfig.Arguments = append(svcConfig.Arguments, "--config="+defaultConfigFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks := []task{
|
||||||
|
{s.Stop, false, "Stop"},
|
||||||
|
{func() error { return doGenerateNextDNSConfig(nextdns) }, true, "Checking config"},
|
||||||
|
{func() error { return ensureUninstall(s) }, false, "Ensure uninstall"},
|
||||||
|
//resetDnsTask(p, s, isCtrldInstalled, currentIface),
|
||||||
|
{func() error {
|
||||||
|
// Save current DNS so we can restore later.
|
||||||
|
withEachPhysicalInterfaces("", "saveCurrentStaticDNS", func(i *net.Interface) error {
|
||||||
|
if err := saveCurrentStaticDNS(i); !errors.Is(err, errSaveCurrentStaticDNSNotSupported) && err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}, false, "Save current DNS"},
|
||||||
|
{s.Install, false, "Install"},
|
||||||
|
{func() error {
|
||||||
|
return ConfigureWindowsServiceFailureActions(ctrldServiceName)
|
||||||
|
}, false, "Configure Windows service failure actions"},
|
||||||
|
{s.Start, true, "Start"},
|
||||||
|
// Note that startCmd do not actually write ControlD config, but the config file was
|
||||||
|
// generated after s.Start, so we notice users here for consistent with nextdns mode.
|
||||||
|
{noticeWritingControlDConfig, false, "Notice writing ControlD config"},
|
||||||
|
}
|
||||||
|
logger.Notice().Msg("Starting service")
|
||||||
|
if doTasks(tasks) {
|
||||||
|
// add a small delay to ensure the service is started and did not crash
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
|
||||||
|
ok, status, err := selfCheckStatus(ctx, s, sockDir)
|
||||||
|
switch {
|
||||||
|
case ok && status == service.StatusRunning:
|
||||||
|
logger.Notice().Msg("Service started")
|
||||||
|
default:
|
||||||
|
marker := append(bytes.Repeat([]byte("="), 32), '\n')
|
||||||
|
// If ctrld service is not running, emitting log obtained from ctrld process.
|
||||||
|
if status != service.StatusRunning || ctx.Err() != nil {
|
||||||
|
logger.Error().Msg("Ctrld service may not have started due to an error or misconfiguration, service log:")
|
||||||
|
_, _ = logger.Write(marker)
|
||||||
|
|
||||||
|
// Wait for log collection to complete
|
||||||
|
<-stopLogCh
|
||||||
|
|
||||||
|
// Retrieve logs from HTTP server if available
|
||||||
|
if logServerSocketPath != "" {
|
||||||
|
hlc := newHTTPLogClient(logServerSocketPath)
|
||||||
|
logs, err := hlc.GetLogs()
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("Failed to get logs from HTTP log server")
|
||||||
|
}
|
||||||
|
if len(logs) == 0 {
|
||||||
|
logger.Write([]byte("<no log output is obtained from ctrld process>\n"))
|
||||||
|
} else {
|
||||||
|
logger.Write(logs)
|
||||||
|
logger.Write([]byte("\n"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.Write([]byte("<no log output from HTTP log server>\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Report any error if occurred.
|
||||||
|
if err != nil {
|
||||||
|
_, _ = logger.Write(marker)
|
||||||
|
msg := fmt.Sprintf("An error occurred while performing test query: %s\n", err)
|
||||||
|
logger.Write([]byte(msg))
|
||||||
|
}
|
||||||
|
// If ctrld service is running but selfCheckStatus failed, it could be related
|
||||||
|
// to user's system firewall configuration, notice users about it.
|
||||||
|
if status == service.StatusRunning && err == nil {
|
||||||
|
_, _ = logger.Write(marker)
|
||||||
|
logger.Write([]byte("ctrld service was running, but a DNS query could not be sent to its listener\n"))
|
||||||
|
logger.Write([]byte("Please check your system firewall if it is configured to block/intercept/redirect DNS queries\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = logger.Write(marker)
|
||||||
|
uninstall(p, s)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
reportSetDnsOk(sockDir)
|
||||||
|
// Verify service registration after successful start.
|
||||||
|
if err := verifyServiceRegistration(); err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("Service registry verification failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debug().Msg("Service start command completed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createStartCommands creates the start command and its alias
|
||||||
|
func createStartCommands(sc *ServiceCommand) (*cobra.Command, *cobra.Command) {
|
||||||
|
// Start command
|
||||||
|
startCmd := &cobra.Command{
|
||||||
|
Use: "start",
|
||||||
|
Short: "Install and start the ctrld service",
|
||||||
|
Long: `Install and start the ctrld service
|
||||||
|
|
||||||
|
NOTE: running "ctrld start" without any arguments will start already installed ctrld service.`,
|
||||||
|
Args: func(cmd *cobra.Command, args []string) error {
|
||||||
|
args = filterEmptyStrings(args)
|
||||||
|
if len(args) > 0 {
|
||||||
|
return fmt.Errorf("'ctrld start' doesn't accept positional arguments\n" +
|
||||||
|
"Use flags instead (e.g. --cd, --iface) or see 'ctrld start --help' for all options")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
PreRun: func(cmd *cobra.Command, args []string) {
|
||||||
|
checkHasElevatedPrivilege()
|
||||||
|
},
|
||||||
|
RunE: sc.Start,
|
||||||
|
}
|
||||||
|
// Keep these flags in sync with runCmd above, except for "-d"/"--nextdns".
|
||||||
|
startCmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
|
||||||
|
startCmd.Flags().StringVarP(&configBase64, "base64_config", "", "", "Base64 encoded config")
|
||||||
|
startCmd.Flags().StringVarP(&listenAddress, "listen", "", "", "Listener address and port, in format: address:port")
|
||||||
|
startCmd.Flags().StringVarP(&primaryUpstream, "primary_upstream", "", "", "Primary upstream endpoint")
|
||||||
|
startCmd.Flags().StringVarP(&secondaryUpstream, "secondary_upstream", "", "", "Secondary upstream endpoint")
|
||||||
|
startCmd.Flags().StringSliceVarP(&domains, "domains", "", nil, "List of domain to apply in a split DNS policy")
|
||||||
|
startCmd.Flags().StringVarP(&logPath, "log", "", "", "Path to log file")
|
||||||
|
startCmd.Flags().IntVarP(&cacheSize, "cache_size", "", 0, "Enable cache with size items")
|
||||||
|
startCmd.Flags().StringVarP(&cdUID, cdUidFlagName, "", "", "Control D resolver uid")
|
||||||
|
startCmd.Flags().StringVarP(&cdOrg, cdOrgFlagName, "", "", "Control D provision token")
|
||||||
|
startCmd.Flags().StringVarP(&customHostname, customHostnameFlagName, "", "", "Custom hostname passed to ControlD API")
|
||||||
|
startCmd.Flags().BoolVarP(&cdDev, "dev", "", false, "Use Control D dev resolver/domain")
|
||||||
|
_ = startCmd.Flags().MarkHidden("dev")
|
||||||
|
startCmd.Flags().StringVarP(&iface, "iface", "", "", `Update DNS setting for iface, "auto" means the default interface gateway`)
|
||||||
|
startCmd.Flags().StringVarP(&nextdns, nextdnsFlagName, "", "", "NextDNS resolver id")
|
||||||
|
startCmd.Flags().StringVarP(&cdUpstreamProto, "proto", "", ctrld.ResolverTypeDOH, `Control D upstream type, either "doh" or "doh3"`)
|
||||||
|
startCmd.Flags().BoolVarP(&skipSelfChecks, "skip_self_checks", "", false, `Skip self checks after installing ctrld service`)
|
||||||
|
startCmd.Flags().BoolVarP(&startOnly, "start_only", "", false, "Do not install new service")
|
||||||
|
_ = startCmd.Flags().MarkHidden("start_only")
|
||||||
|
startCmd.Flags().BoolVarP(&rfc1918, "rfc1918", "", false, "Listen on RFC1918 addresses when 127.0.0.1 is the only listener")
|
||||||
|
startCmd.Flags().StringVarP(&interceptMode, "intercept-mode", "", "", "OS-level DNS interception mode: 'dns' (with VPN split routing) or 'hard' (all DNS through ctrld, no VPN split routing)")
|
||||||
|
startCmd.Flags().StringVarP(&firewallMode, "firewall-mode", "", "off", "DNS-resolved IP allowlist: 'on' blocks connections to IPs not resolved by ctrld, 'off' allows all")
|
||||||
|
|
||||||
|
// Start command alias
|
||||||
|
startCmdAlias := &cobra.Command{
|
||||||
|
PreRun: func(cmd *cobra.Command, args []string) {
|
||||||
|
checkHasElevatedPrivilege()
|
||||||
|
},
|
||||||
|
Use: "start",
|
||||||
|
Short: "Quick start service and configure DNS on interface",
|
||||||
|
Long: `Quick start service and configure DNS on interface
|
||||||
|
|
||||||
|
NOTE: running "ctrld start" without any arguments will start already installed ctrld service.`,
|
||||||
|
Args: func(cmd *cobra.Command, args []string) error {
|
||||||
|
args = filterEmptyStrings(args)
|
||||||
|
if len(args) > 0 {
|
||||||
|
return fmt.Errorf("'ctrld start' doesn't accept positional arguments\n" +
|
||||||
|
"Use flags instead (e.g. --cd, --iface) or see 'ctrld start --help' for all options")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if len(os.Args) == 2 {
|
||||||
|
startOnly = true
|
||||||
|
}
|
||||||
|
if !cmd.Flags().Changed("iface") {
|
||||||
|
os.Args = append(os.Args, "--iface="+ifaceStartStop)
|
||||||
|
}
|
||||||
|
iface = ifaceStartStop
|
||||||
|
return startCmd.RunE(cmd, args)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
startCmdAlias.Flags().StringVarP(&ifaceStartStop, "iface", "", autoIface, `Update DNS setting for iface, "auto" means the default interface gateway`)
|
||||||
|
startCmdAlias.Flags().AddFlagSet(startCmd.Flags())
|
||||||
|
|
||||||
|
return startCmd, startCmdAlias
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/kardianos/service"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Status implements the logic from cmdStatus.Run
|
||||||
|
func (sc *ServiceCommand) Status(cmd *cobra.Command, args []string) error {
|
||||||
|
logger := mainLog.Load()
|
||||||
|
logger.Debug().Msg("Service status command started")
|
||||||
|
|
||||||
|
s, _, err := sc.initializeServiceManager()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error().Err(err).Msg("Failed to initialize service manager")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err := s.Status()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error().Msg(err.Error())
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch status {
|
||||||
|
case service.StatusUnknown:
|
||||||
|
logger.Notice().Msg("Unknown status")
|
||||||
|
os.Exit(2)
|
||||||
|
case service.StatusRunning:
|
||||||
|
logger.Notice().Msg("Service is running")
|
||||||
|
os.Exit(0)
|
||||||
|
case service.StatusStopped:
|
||||||
|
logger.Notice().Msg("Service is stopped")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debug().Msg("Service status command completed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/kardianos/service"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Stop implements the logic from cmdStop.Run
|
||||||
|
func (sc *ServiceCommand) Stop(cmd *cobra.Command, args []string) error {
|
||||||
|
logger := mainLog.Load()
|
||||||
|
logger.Debug().Msg("Service stop command started")
|
||||||
|
|
||||||
|
readConfig(false)
|
||||||
|
v.Unmarshal(&cfg)
|
||||||
|
|
||||||
|
s, p, err := sc.initializeServiceManager()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error().Err(err).Msg("Failed to initialize service manager")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
p.cfg = &cfg
|
||||||
|
if iface == "" {
|
||||||
|
iface = autoIface
|
||||||
|
}
|
||||||
|
p.preRun()
|
||||||
|
if ir := runningIface(s); ir != nil {
|
||||||
|
p.runningIface = ir.Name
|
||||||
|
p.requiredMultiNICsConfig = ir.All
|
||||||
|
}
|
||||||
|
|
||||||
|
initInteractiveLogging()
|
||||||
|
|
||||||
|
status, err := s.Status()
|
||||||
|
if errors.Is(err, service.ErrNotInstalled) {
|
||||||
|
logger.Warn().Msg("Service not installed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if status == service.StatusStopped {
|
||||||
|
logger.Warn().Msg("Service is already stopped")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := checkDeactivationPin(s, nil); isCheckDeactivationPinErr(err) {
|
||||||
|
logger.Error().Msg("Deactivation pin check failed")
|
||||||
|
os.Exit(deactivationPinInvalidExitCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debug().Msg("Stopping service")
|
||||||
|
if doTasks([]task{{s.Stop, true, "Stop"}}) {
|
||||||
|
logger.Notice().Msg("Service stopped")
|
||||||
|
} else {
|
||||||
|
logger.Error().Msg("Service stop failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debug().Msg("Service stop command completed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/Control-D-Inc/ctrld"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Uninstall implements the logic from cmdUninstall.Run
|
||||||
|
func (sc *ServiceCommand) Uninstall(cmd *cobra.Command, args []string) error {
|
||||||
|
logger := mainLog.Load()
|
||||||
|
logger.Debug().Msg("Service uninstall command started")
|
||||||
|
|
||||||
|
readConfig(false)
|
||||||
|
v.Unmarshal(&cfg)
|
||||||
|
|
||||||
|
s, p, err := sc.initializeServiceManager()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error().Err(err).Msg("Failed to initialize service manager")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
p.cfg = &cfg
|
||||||
|
if iface == "" {
|
||||||
|
iface = autoIface
|
||||||
|
}
|
||||||
|
p.preRun()
|
||||||
|
if ir := runningIface(s); ir != nil {
|
||||||
|
p.runningIface = ir.Name
|
||||||
|
p.requiredMultiNICsConfig = ir.All
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := checkDeactivationPin(s, nil); isCheckDeactivationPinErr(err) {
|
||||||
|
logger.Error().Msg("Deactivation pin check failed")
|
||||||
|
os.Exit(deactivationPinInvalidExitCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debug().Msg("Starting service uninstall")
|
||||||
|
uninstall(p, s)
|
||||||
|
|
||||||
|
if cleanup {
|
||||||
|
logger.Debug().Msg("Performing cleanup operations")
|
||||||
|
var files []string
|
||||||
|
// Config file.
|
||||||
|
files = append(files, v.ConfigFileUsed())
|
||||||
|
// Log file and backup log file.
|
||||||
|
// For safety, only process if log file path is absolute.
|
||||||
|
if logFile := normalizeLogFilePath(cfg.Service.LogPath); filepath.IsAbs(logFile) {
|
||||||
|
files = append(files, logFile)
|
||||||
|
oldLogFile := logFile + oldLogSuffix
|
||||||
|
if _, err := os.Stat(oldLogFile); err == nil {
|
||||||
|
files = append(files, oldLogFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Socket files.
|
||||||
|
if dir, _ := socketDir(); dir != "" {
|
||||||
|
files = append(files, filepath.Join(dir, ctrldControlUnixSock))
|
||||||
|
files = append(files, filepath.Join(dir, ctrldLogUnixSock))
|
||||||
|
}
|
||||||
|
// Static DNS settings files.
|
||||||
|
withEachPhysicalInterfaces("", "", func(i *net.Interface) error {
|
||||||
|
file := ctrld.SavedStaticDnsSettingsFilePath(i)
|
||||||
|
files = append(files, file)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
bin, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("Failed to get executable path")
|
||||||
|
}
|
||||||
|
if bin != "" && supportedSelfDelete {
|
||||||
|
files = append(files, bin)
|
||||||
|
}
|
||||||
|
// Backup file after upgrading.
|
||||||
|
oldBin := bin + oldBinSuffix
|
||||||
|
if _, err := os.Stat(oldBin); err == nil {
|
||||||
|
files = append(files, oldBin)
|
||||||
|
}
|
||||||
|
for _, file := range files {
|
||||||
|
if file == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := os.Remove(file); err == nil {
|
||||||
|
logger.Notice().Str("file", file).Msg("File removed during cleanup")
|
||||||
|
} else {
|
||||||
|
logger.Debug().Err(err).Str("file", file).Msg("Failed to remove file during cleanup")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Self-delete the ctrld binary if supported
|
||||||
|
if err := selfDeleteExe(); err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("Failed to delete ctrld binary")
|
||||||
|
} else {
|
||||||
|
if !supportedSelfDelete {
|
||||||
|
logger.Debug().Msgf("File removed: %s", bin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debug().Msg("Cleanup operations completed")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debug().Msg("Service uninstall command completed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestBasicCommandStructure tests the actual root command structure
|
||||||
|
func TestBasicCommandStructure(t *testing.T) {
|
||||||
|
// Test the actual root command that's returned from initCLI()
|
||||||
|
rootCmd := initCLI()
|
||||||
|
|
||||||
|
// Test that root command has basic properties
|
||||||
|
assert.Equal(t, "ctrld", rootCmd.Use)
|
||||||
|
assert.NotEmpty(t, rootCmd.Short, "Root command should have a short description")
|
||||||
|
|
||||||
|
// Test that root command has subcommands
|
||||||
|
commands := rootCmd.Commands()
|
||||||
|
assert.NotNil(t, commands, "Root command should have subcommands")
|
||||||
|
assert.Greater(t, len(commands), 0, "Root command should have at least one subcommand")
|
||||||
|
|
||||||
|
// Test that expected commands exist
|
||||||
|
expectedCommands := []string{"run", "service", "clients", "upgrade", "log"}
|
||||||
|
for _, cmdName := range expectedCommands {
|
||||||
|
found := false
|
||||||
|
for _, cmd := range commands {
|
||||||
|
if cmd.Name() == cmdName {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, found, "Expected command %s not found in root command", cmdName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestServiceCommandCreation tests service command creation
|
||||||
|
func TestServiceCommandCreation(t *testing.T) {
|
||||||
|
sc := NewServiceCommand()
|
||||||
|
require.NotNil(t, sc, "ServiceCommand should be created")
|
||||||
|
|
||||||
|
// Test service config creation
|
||||||
|
config := sc.createServiceConfig()
|
||||||
|
require.NotNil(t, config, "Service config should be created")
|
||||||
|
assert.Equal(t, ctrldServiceName, config.Name)
|
||||||
|
assert.Equal(t, "Control-D Helper Service", config.DisplayName)
|
||||||
|
assert.Equal(t, "A highly configurable, multi-protocol DNS forwarding proxy", config.Description)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestServiceCommandSubCommands tests service command sub commands
|
||||||
|
func TestServiceCommandSubCommands(t *testing.T) {
|
||||||
|
rootCmd := &cobra.Command{
|
||||||
|
Use: "ctrld",
|
||||||
|
Short: "DNS forwarding proxy",
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceCmd := InitServiceCmd(rootCmd)
|
||||||
|
require.NotNil(t, serviceCmd, "Service command should be created")
|
||||||
|
|
||||||
|
// Test that service command has subcommands
|
||||||
|
subcommands := serviceCmd.Commands()
|
||||||
|
assert.Greater(t, len(subcommands), 0, "Service command should have subcommands")
|
||||||
|
|
||||||
|
// Test specific subcommands exist
|
||||||
|
expectedCommands := []string{"start", "stop", "restart", "reload", "status", "uninstall", "interfaces"}
|
||||||
|
|
||||||
|
for _, cmdName := range expectedCommands {
|
||||||
|
found := false
|
||||||
|
for _, cmd := range subcommands {
|
||||||
|
if cmd.Name() == cmdName {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, found, "Expected service subcommand %s not found", cmdName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCommandHelp tests basic help functionality
|
||||||
|
func TestCommandHelp(t *testing.T) {
|
||||||
|
// Initialize the CLI to set up the root command
|
||||||
|
rootCmd := initCLI()
|
||||||
|
|
||||||
|
// Test help command execution
|
||||||
|
var buf bytes.Buffer
|
||||||
|
rootCmd.SetOut(&buf)
|
||||||
|
rootCmd.SetErr(&buf)
|
||||||
|
|
||||||
|
rootCmd.SetArgs([]string{"--help"})
|
||||||
|
err := rootCmd.Execute()
|
||||||
|
assert.NoError(t, err, "Help command should execute without error")
|
||||||
|
assert.Contains(t, buf.String(), "dns forwarding proxy", "Help output should contain description")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCommandVersion tests version command
|
||||||
|
func TestCommandVersion(t *testing.T) {
|
||||||
|
// Initialize the CLI to set up the root command
|
||||||
|
rootCmd := initCLI()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
rootCmd.SetOut(&buf)
|
||||||
|
rootCmd.SetErr(&buf)
|
||||||
|
|
||||||
|
// Test version command
|
||||||
|
rootCmd.SetArgs([]string{"--version"})
|
||||||
|
err := rootCmd.Execute()
|
||||||
|
assert.NoError(t, err, "Version command should execute without error")
|
||||||
|
assert.Contains(t, buf.String(), "version", "Version output should contain version information")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCommandErrorHandling tests error handling
|
||||||
|
func TestCommandErrorHandling(t *testing.T) {
|
||||||
|
// Initialize the CLI to set up the root command
|
||||||
|
rootCmd := initCLI()
|
||||||
|
|
||||||
|
// Test invalid flag instead of invalid command
|
||||||
|
rootCmd.SetArgs([]string{"--invalid-flag"})
|
||||||
|
err := rootCmd.Execute()
|
||||||
|
assert.Error(t, err, "Invalid flag should return error")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCommandFlags tests flag functionality
|
||||||
|
func TestCommandFlags(t *testing.T) {
|
||||||
|
// Initialize the CLI to set up the root command
|
||||||
|
rootCmd := initCLI()
|
||||||
|
|
||||||
|
// Test that root command has expected flags
|
||||||
|
verboseFlag := rootCmd.PersistentFlags().Lookup("verbose")
|
||||||
|
assert.NotNil(t, verboseFlag, "Verbose flag should exist")
|
||||||
|
assert.Equal(t, "v", verboseFlag.Shorthand)
|
||||||
|
|
||||||
|
silentFlag := rootCmd.PersistentFlags().Lookup("silent")
|
||||||
|
assert.NotNil(t, silentFlag, "Silent flag should exist")
|
||||||
|
assert.Equal(t, "s", silentFlag.Shorthand)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCommandExecution tests basic command execution
|
||||||
|
func TestCommandExecution(t *testing.T) {
|
||||||
|
// Initialize the CLI to set up the root command
|
||||||
|
rootCmd := initCLI()
|
||||||
|
|
||||||
|
// Test that root command can be executed (help command)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
rootCmd.SetOut(&buf)
|
||||||
|
rootCmd.SetErr(&buf)
|
||||||
|
|
||||||
|
rootCmd.SetArgs([]string{"--help"})
|
||||||
|
err := rootCmd.Execute()
|
||||||
|
assert.NoError(t, err, "Root command should execute without error")
|
||||||
|
assert.Contains(t, buf.String(), "dns forwarding proxy", "Help output should contain description")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCommandArgs tests argument handling
|
||||||
|
func TestCommandArgs(t *testing.T) {
|
||||||
|
// Initialize the CLI to set up the root command
|
||||||
|
rootCmd := initCLI()
|
||||||
|
|
||||||
|
// Test that root command can handle arguments properly
|
||||||
|
// Test with no args (should succeed)
|
||||||
|
err := rootCmd.Execute()
|
||||||
|
assert.NoError(t, err, "Root command with no args should execute")
|
||||||
|
|
||||||
|
// Test with help flag (should succeed)
|
||||||
|
rootCmd.SetArgs([]string{"--help"})
|
||||||
|
err = rootCmd.Execute()
|
||||||
|
assert.NoError(t, err, "Root command with help flag should execute")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCommandSubcommands tests subcommand functionality
|
||||||
|
func TestCommandSubcommands(t *testing.T) {
|
||||||
|
// Initialize the CLI to set up the root command
|
||||||
|
rootCmd := initCLI()
|
||||||
|
|
||||||
|
// Test that root command has subcommands
|
||||||
|
commands := rootCmd.Commands()
|
||||||
|
assert.Greater(t, len(commands), 0, "Root command should have subcommands")
|
||||||
|
|
||||||
|
// Test that specific subcommands exist and can be executed
|
||||||
|
expectedSubcommands := []string{"run", "service", "clients", "upgrade", "log"}
|
||||||
|
for _, subCmdName := range expectedSubcommands {
|
||||||
|
// Find the subcommand
|
||||||
|
var subCmd *cobra.Command
|
||||||
|
for _, cmd := range commands {
|
||||||
|
if cmd.Name() == subCmdName {
|
||||||
|
subCmd = cmd
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.NotNil(t, subCmd, "Subcommand %s should exist", subCmdName)
|
||||||
|
|
||||||
|
// Test that subcommand has help
|
||||||
|
assert.NotEmpty(t, subCmd.Short, "Subcommand %s should have a short description", subCmdName)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kardianos/service"
|
||||||
|
"github.com/minio/selfupdate"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
upgradeChannelDev = "dev"
|
||||||
|
upgradeChannelProd = "prod"
|
||||||
|
upgradeChannelDefault = "default"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UpgradeCommand handles upgrade-related operations
|
||||||
|
type UpgradeCommand struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUpgradeCommand creates a new upgrade command handler
|
||||||
|
func NewUpgradeCommand() (*UpgradeCommand, error) {
|
||||||
|
return &UpgradeCommand{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upgrade performs the upgrade operation
|
||||||
|
func (uc *UpgradeCommand) Upgrade(cmd *cobra.Command, args []string) error {
|
||||||
|
upgradeChannel := map[string]string{
|
||||||
|
upgradeChannelDefault: "https://dl.controld.dev",
|
||||||
|
upgradeChannelDev: "https://dl.controld.dev",
|
||||||
|
upgradeChannelProd: "https://dl.controld.com",
|
||||||
|
}
|
||||||
|
if isStableVersion(curVersion()) {
|
||||||
|
upgradeChannel[upgradeChannelDefault] = upgradeChannel[upgradeChannelProd]
|
||||||
|
}
|
||||||
|
|
||||||
|
bin, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
mainLog.Load().Fatal().Err(err).Msg("Failed to get current ctrld binary path")
|
||||||
|
}
|
||||||
|
|
||||||
|
readConfig(false)
|
||||||
|
v.Unmarshal(&cfg)
|
||||||
|
svcCmd := NewServiceCommand()
|
||||||
|
s, p, err := svcCmd.initializeServiceManager()
|
||||||
|
if err != nil {
|
||||||
|
mainLog.Load().Error().Msg(err.Error())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if iface == "" {
|
||||||
|
iface = autoIface
|
||||||
|
}
|
||||||
|
p.preRun()
|
||||||
|
if ir := runningIface(s); ir != nil {
|
||||||
|
p.runningIface = ir.Name
|
||||||
|
p.requiredMultiNICsConfig = ir.All
|
||||||
|
}
|
||||||
|
|
||||||
|
svcInstalled := true
|
||||||
|
if _, err := s.Status(); errors.Is(err, service.ErrNotInstalled) {
|
||||||
|
svcInstalled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
oldBin := bin + oldBinSuffix
|
||||||
|
baseUrl := upgradeChannel[upgradeChannelDefault]
|
||||||
|
if len(args) > 0 {
|
||||||
|
channel := args[0]
|
||||||
|
switch channel {
|
||||||
|
case upgradeChannelProd, upgradeChannelDev: // ok
|
||||||
|
default:
|
||||||
|
mainLog.Load().Fatal().Msgf("Upgrade argument must be either %q or %q", upgradeChannelProd, upgradeChannelDev)
|
||||||
|
}
|
||||||
|
baseUrl = upgradeChannel[channel]
|
||||||
|
}
|
||||||
|
|
||||||
|
dlUrl := upgradeUrl(baseUrl)
|
||||||
|
mainLog.Load().Debug().Msgf("Downloading binary: %s", dlUrl)
|
||||||
|
|
||||||
|
resp, err := getWithRetry(dlUrl, downloadServerIp)
|
||||||
|
if err != nil {
|
||||||
|
mainLog.Load().Fatal().Err(err).Msg("Failed to download binary")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
mainLog.Load().Fatal().Msgf("Could not download binary: %s", http.StatusText(resp.StatusCode))
|
||||||
|
}
|
||||||
|
|
||||||
|
mainLog.Load().Debug().Msg("Updating current binary")
|
||||||
|
if err := selfupdate.Apply(resp.Body, selfupdate.Options{OldSavePath: oldBin}); err != nil {
|
||||||
|
if rerr := selfupdate.RollbackError(err); rerr != nil {
|
||||||
|
mainLog.Load().Error().Err(rerr).Msg("Could not rollback old binary")
|
||||||
|
}
|
||||||
|
mainLog.Load().Fatal().Err(err).Msg("Failed to update current binary")
|
||||||
|
}
|
||||||
|
|
||||||
|
doRestart := func() bool {
|
||||||
|
if !svcInstalled {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
tasks := []task{
|
||||||
|
{s.Stop, true, "Stop"},
|
||||||
|
{func() error {
|
||||||
|
// restore static DNS settings or DHCP
|
||||||
|
p.resetDNS(false, true)
|
||||||
|
return nil
|
||||||
|
}, false, "Cleanup"},
|
||||||
|
{func() error {
|
||||||
|
time.Sleep(time.Second * 1)
|
||||||
|
return nil
|
||||||
|
}, false, "Waiting for service to stop"},
|
||||||
|
}
|
||||||
|
doTasks(tasks)
|
||||||
|
|
||||||
|
tasks = []task{
|
||||||
|
{s.Start, true, "Start"},
|
||||||
|
}
|
||||||
|
if doTasks(tasks) {
|
||||||
|
if dir, err := socketDir(); err == nil {
|
||||||
|
if cc := newSocketControlClient(context.TODO(), s, dir); cc != nil {
|
||||||
|
_, _ = cc.post(ifacePath, nil)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if svcInstalled {
|
||||||
|
mainLog.Load().Debug().Msg("Restarting ctrld service using new binary")
|
||||||
|
}
|
||||||
|
|
||||||
|
if doRestart() {
|
||||||
|
_ = os.Remove(oldBin)
|
||||||
|
_ = os.Chmod(bin, 0755)
|
||||||
|
ver := "unknown version"
|
||||||
|
out, err := exec.Command(bin, "--version").CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
mainLog.Load().Warn().Err(err).Msg("Failed to get new binary version")
|
||||||
|
}
|
||||||
|
if after, found := strings.CutPrefix(string(out), "ctrld version "); found {
|
||||||
|
ver = after
|
||||||
|
}
|
||||||
|
mainLog.Load().Notice().Msgf("Upgrade successful - %s", ver)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
mainLog.Load().Warn().Msgf("Upgrade failed, restoring previous binary: %s", oldBin)
|
||||||
|
if err := os.Remove(bin); err != nil {
|
||||||
|
mainLog.Load().Fatal().Err(err).Msg("Failed to remove new binary")
|
||||||
|
}
|
||||||
|
if err := os.Rename(oldBin, bin); err != nil {
|
||||||
|
mainLog.Load().Fatal().Err(err).Msg("Failed to restore old binary")
|
||||||
|
}
|
||||||
|
if doRestart() {
|
||||||
|
mainLog.Load().Notice().Msg("Restored previous binary successfully")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitUpgradeCmd creates the upgrade command with proper logic
|
||||||
|
func InitUpgradeCmd(rootCmd *cobra.Command) *cobra.Command {
|
||||||
|
upgradeCmd := &cobra.Command{
|
||||||
|
Use: "upgrade",
|
||||||
|
Short: "Upgrading ctrld to latest version",
|
||||||
|
ValidArgs: []string{upgradeChannelDev, upgradeChannelProd},
|
||||||
|
Args: cobra.MaximumNArgs(1),
|
||||||
|
PreRun: func(cmd *cobra.Command, args []string) {
|
||||||
|
checkHasElevatedPrivilege()
|
||||||
|
},
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uc, err := NewUpgradeCommand()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return uc.Upgrade(cmd, args)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
rootCmd.AddCommand(upgradeCmd)
|
||||||
|
|
||||||
|
return upgradeCmd
|
||||||
|
}
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
package cli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// logConn wraps a net.Conn, override the Write behavior.
|
|
||||||
// runCmd uses this wrapper, so as long as startCmd finished,
|
|
||||||
// ctrld log won't be flushed with un-necessary write errors.
|
|
||||||
type logConn struct {
|
|
||||||
conn net.Conn
|
|
||||||
}
|
|
||||||
|
|
||||||
func (lc *logConn) Read(b []byte) (n int, err error) {
|
|
||||||
return lc.conn.Read(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (lc *logConn) Close() error {
|
|
||||||
return lc.conn.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (lc *logConn) LocalAddr() net.Addr {
|
|
||||||
return lc.conn.LocalAddr()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (lc *logConn) RemoteAddr() net.Addr {
|
|
||||||
return lc.conn.RemoteAddr()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (lc *logConn) SetDeadline(t time.Time) error {
|
|
||||||
return lc.conn.SetDeadline(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (lc *logConn) SetReadDeadline(t time.Time) error {
|
|
||||||
return lc.conn.SetReadDeadline(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (lc *logConn) SetWriteDeadline(t time.Time) error {
|
|
||||||
return lc.conn.SetWriteDeadline(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (lc *logConn) Write(b []byte) (int, error) {
|
|
||||||
// Write performs writes with underlying net.Conn, ignore any errors happen.
|
|
||||||
// "ctrld run" command use this wrapper to report errors to "ctrld start".
|
|
||||||
// If no error occurred, "ctrld start" may finish before "ctrld run" attempt
|
|
||||||
// to close the connection, so ignore errors conservatively here, prevent
|
|
||||||
// un-necessary error "write to closed connection" flushed to ctrld log.
|
|
||||||
_, _ = lc.conn.Write(b)
|
|
||||||
return len(b), nil
|
|
||||||
}
|
|
||||||
@@ -8,10 +8,12 @@ import (
|
|||||||
"time"
|
"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{
|
||||||
|
|||||||
+37
-31
@@ -40,12 +40,14 @@ type ifaceResponse struct {
|
|||||||
InterceptMode string `json:"intercept_mode,omitempty"` // "dns", "hard", or "" (not intercepting)
|
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{
|
||||||
@@ -88,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,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 {
|
||||||
@@ -184,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,6 +227,8 @@ 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.
|
// Reject further attempts while locked out due to repeated wrong PINs.
|
||||||
if now := time.Now().Unix(); now < deactivationLockedUntil.Load() {
|
if now := time.Now().Unix(); now < deactivationLockedUntil.Load() {
|
||||||
w.WriteHeader(http.StatusTooManyRequests)
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
@@ -234,17 +238,17 @@ func (p *prog) registerControlServerHandler() {
|
|||||||
// Re-fetch pin code from API.
|
// Re-fetch pin code from API.
|
||||||
rcReq := &controld.ResolverConfigRequest{
|
rcReq := &controld.ResolverConfigRequest{
|
||||||
RawUID: cdUID,
|
RawUID: cdUID,
|
||||||
Version: rootCmd.Version,
|
Version: appVersion,
|
||||||
Metadata: ctrld.SystemMetadataRuntime(context.Background()),
|
Metadata: ctrld.SystemMetadataRuntime(context.Background()),
|
||||||
}
|
}
|
||||||
if rc, err := controld.FetchResolverConfig(rcReq, cdDev); rc != nil {
|
if rc, err := controld.FetchResolverConfig(loggerCtx, rcReq, cdDev); rc != nil {
|
||||||
if rc.DeactivationPin != nil {
|
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.
|
||||||
@@ -256,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,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
|
||||||
@@ -336,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
|
||||||
@@ -349,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 {
|
||||||
@@ -530,6 +535,7 @@ func tailFileLastLines(f *os.File, n int) []byte {
|
|||||||
return lines
|
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
|
|
||||||
+135
-119
@@ -41,6 +41,11 @@ const (
|
|||||||
// 2s re-check misses.
|
// 2s re-check misses.
|
||||||
pfAnchorRecheckDelayLong = 4 * time.Second
|
pfAnchorRecheckDelayLong = 4 * time.Second
|
||||||
|
|
||||||
|
// pfExecFailureBackoff suppresses repeated pfctl/scutil checks briefly after
|
||||||
|
// macOS reports local resource exhaustion. Without this, network-change storms
|
||||||
|
// can turn a pf ruleset race into a fork/file-descriptor exhaustion loop.
|
||||||
|
pfExecFailureBackoff = 5 * time.Second
|
||||||
|
|
||||||
// pfVPNInterfacePrefixes lists interface name prefixes that indicate VPN/tunnel
|
// pfVPNInterfacePrefixes lists interface name prefixes that indicate VPN/tunnel
|
||||||
// interfaces on macOS. Used to add interface-specific DNS intercept rules so that
|
// interfaces on macOS. Used to add interface-specific DNS intercept rules so that
|
||||||
// VPN software with "pass out quick on <iface>" rules cannot bypass our intercept.
|
// VPN software with "pass out quick on <iface>" rules cannot bypass our intercept.
|
||||||
@@ -56,10 +61,6 @@ const (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// pfProbeDomain is the suffix used for pf interception probe queries.
|
// pfProbeDomain is the suffix used for pf interception probe queries.
|
||||||
// The full probe domain is "_pf-probe-<hex>.<pfProbeDomain>".
|
|
||||||
// These queries are sent by a subprocess WITHOUT the _ctrld group GID,
|
|
||||||
// so pf should intercept them and redirect to ctrld. If ctrld receives
|
|
||||||
// the query, pf interception is working. If not (timeout), rdr is broken.
|
|
||||||
// No trailing dot — canonicalName() in the DNS handler strips trailing dots.
|
// No trailing dot — canonicalName() in the DNS handler strips trailing dots.
|
||||||
pfProbeDomain = "pf-probe.ctrld.test"
|
pfProbeDomain = "pf-probe.ctrld.test"
|
||||||
|
|
||||||
@@ -215,10 +216,8 @@ func (p *prog) startDNSIntercept() error {
|
|||||||
|
|
||||||
// Pre-discover VPN DNS configurations before building initial rules.
|
// Pre-discover VPN DNS configurations before building initial rules.
|
||||||
// Without this, there's a startup gap where the initial anchor has no VPN DNS
|
// Without this, there's a startup gap where the initial anchor has no VPN DNS
|
||||||
// exemptions, causing queries to be intercepted and routed to ctrld. The
|
// exemptions, causing queries to be intercepted and routed to ctrld. Stale pf
|
||||||
// vpnDNSManager.Refresh() call later would add the exemptions, but stale pf
|
// state entries from the gap persist even after vpnDNS.Refresh() adds exemptions.
|
||||||
// state entries from the gap persist and keep routing packets to lo0.
|
|
||||||
// By discovering upfront, the initial rules exclude VPN DNS interfaces from interception.
|
|
||||||
var initialExemptions []vpnDNSExemption
|
var initialExemptions []vpnDNSExemption
|
||||||
if !hardIntercept {
|
if !hardIntercept {
|
||||||
initialConfigs := ctrld.DiscoverVPNDNS(context.Background())
|
initialConfigs := ctrld.DiscoverVPNDNS(context.Background())
|
||||||
@@ -374,7 +373,7 @@ func (p *prog) ensurePFAnchorReference() error {
|
|||||||
|
|
||||||
// Inject our references if missing. PREPEND both references to ensure our
|
// Inject our references if missing. PREPEND both references to ensure our
|
||||||
// anchor is evaluated BEFORE any other anchors (e.g., Windscribe's
|
// anchor is evaluated BEFORE any other anchors (e.g., Windscribe's
|
||||||
// "vpn_app_traffic"). pf evaluates rules top-to-bottom, so "quick"
|
// "windscribe_vpn_traffic"). pf evaluates rules top-to-bottom, so "quick"
|
||||||
// rules in whichever anchor appears first win. By prepending, our DNS
|
// rules in whichever anchor appears first win. By prepending, our DNS
|
||||||
// intercept rules match port 53 traffic before a VPN app's broader
|
// intercept rules match port 53 traffic before a VPN app's broader
|
||||||
// "pass out quick on <iface> all" rules in their anchor.
|
// "pass out quick on <iface> all" rules in their anchor.
|
||||||
@@ -421,7 +420,7 @@ func (p *prog) ensurePFAnchorReference() error {
|
|||||||
|
|
||||||
// checkAnchorOrdering logs a warning if our anchor reference is not the first
|
// checkAnchorOrdering logs a warning if our anchor reference is not the first
|
||||||
// anchor in the filter ruleset. When another anchor (e.g., Windscribe's
|
// anchor in the filter ruleset. When another anchor (e.g., Windscribe's
|
||||||
// "vpn_app_traffic") appears before ours, its "quick" rules may match
|
// "windscribe_vpn_traffic") appears before ours, its "quick" rules may match
|
||||||
// DNS traffic first. The interface-specific tunnel rules in our anchor provide
|
// DNS traffic first. The interface-specific tunnel rules in our anchor provide
|
||||||
// a secondary defense, but first position is still preferred.
|
// a secondary defense, but first position is still preferred.
|
||||||
func (p *prog) checkAnchorOrdering(filterLines []string, ourAnchorRef string) {
|
func (p *prog) checkAnchorOrdering(filterLines []string, ourAnchorRef string) {
|
||||||
@@ -642,16 +641,9 @@ func (p *prog) removePFAnchorReference() error {
|
|||||||
|
|
||||||
// pfAddressFamily returns "inet" for IPv4 addresses and "inet6" for IPv6 addresses.
|
// pfAddressFamily returns "inet" for IPv4 addresses and "inet6" for IPv6 addresses.
|
||||||
// Used to generate pf rules with the correct address family for each IP.
|
// Used to generate pf rules with the correct address family for each IP.
|
||||||
// flushPFStates flushes ALL pf state entries. Called after anchor reloads to ensure
|
// flushPFStates flushes ALL pf state entries after anchor reloads.
|
||||||
// packets are re-evaluated against the new rules instead of matching stale state
|
// pf checks state table BEFORE rules — stale entries from old rules keep routing
|
||||||
// entries from the old ruleset. This is necessary because pf checks its state table
|
// packets via route-to even after interface-scoped exemptions are added.
|
||||||
// BEFORE rule evaluation — a state entry created by a route-to rule will keep
|
|
||||||
// routing packets to lo0 even after VPN DNS interfaces are excluded from interception.
|
|
||||||
//
|
|
||||||
// We flush all states (not just port 53) because:
|
|
||||||
// 1. pfctl doesn't support port-based state killing
|
|
||||||
// 2. State flush is fast and brief — existing TCP connections (DoH) will
|
|
||||||
// re-establish quickly, and UDP connections are stateless at the transport level
|
|
||||||
func flushPFStates() {
|
func flushPFStates() {
|
||||||
if out, err := exec.Command("pfctl", "-F", "states").CombinedOutput(); err != nil {
|
if out, err := exec.Command("pfctl", "-F", "states").CombinedOutput(); err != nil {
|
||||||
mainLog.Load().Warn().Err(err).Msgf("DNS intercept: failed to flush pf states (output: %s)", strings.TrimSpace(string(out)))
|
mainLog.Load().Warn().Err(err).Msgf("DNS intercept: failed to flush pf states (output: %s)", strings.TrimSpace(string(out)))
|
||||||
@@ -795,12 +787,9 @@ func (p *prog) buildPFAnchorRules(vpnExemptions []vpnDNSExemption) string {
|
|||||||
// evaluation, and its implicit state alone is insufficient for response delivery —
|
// evaluation, and its implicit state alone is insufficient for response delivery —
|
||||||
// proven by commit 51cf029 where responses were silently dropped.
|
// proven by commit 51cf029 where responses were silently dropped.
|
||||||
rules.WriteString("# --- Translation rules (rdr) ---\n")
|
rules.WriteString("# --- Translation rules (rdr) ---\n")
|
||||||
|
|
||||||
rules.WriteString("# Redirect DNS on loopback to ctrld's listener.\n")
|
rules.WriteString("# Redirect DNS on loopback to ctrld's listener.\n")
|
||||||
rules.WriteString(fmt.Sprintf("rdr on lo0 inet proto udp from any to ! %s port 53 -> %s\n", listenerIP, listenerAddr))
|
rules.WriteString(fmt.Sprintf("rdr on lo0 inet proto udp from any to ! %s port 53 -> %s\n", listenerIP, listenerAddr))
|
||||||
rules.WriteString(fmt.Sprintf("rdr on lo0 inet proto tcp from any to ! %s port 53 -> %s\n", listenerIP, listenerAddr))
|
rules.WriteString(fmt.Sprintf("rdr on lo0 inet proto tcp from any to ! %s port 53 -> %s\n\n", listenerIP, listenerAddr))
|
||||||
// No IPv6 rdr — IPv6 DNS is blocked at the filter level (see below).
|
|
||||||
rules.WriteString("\n")
|
|
||||||
|
|
||||||
// --- Filtering rules ---
|
// --- Filtering rules ---
|
||||||
rules.WriteString("# --- Filtering rules (pass) ---\n\n")
|
rules.WriteString("# --- Filtering rules (pass) ---\n\n")
|
||||||
@@ -841,16 +830,6 @@ func (p *prog) buildPFAnchorRules(vpnExemptions []vpnDNSExemption) string {
|
|||||||
|
|
||||||
// Build sets of VPN DNS interfaces and server IPs for exclusion from intercept rules.
|
// Build sets of VPN DNS interfaces and server IPs for exclusion from intercept rules.
|
||||||
//
|
//
|
||||||
// VPN DNS handlers that use macOS Network Extensions (Tailscale MagicDNS, modern
|
|
||||||
// Cisco AnyConnect, F5 BIG-IP) intercept packets at the NE layer — BEFORE pf sees
|
|
||||||
// them on the return path, but AFTER pf's outbound rules fire. Any pf rule that
|
|
||||||
// touches packets on a VPN DNS interface (even "pass" with "keep state") interferes
|
|
||||||
// with the NE's packet handling, causing timeouts.
|
|
||||||
//
|
|
||||||
// Solution: exclude VPN DNS interfaces from tunnel intercept rules entirely, and
|
|
||||||
// exclude VPN DNS server IPs from the generic intercept rule. This lets all DNS
|
|
||||||
// traffic to/from VPN DNS flow naturally without any pf interference.
|
|
||||||
//
|
|
||||||
// EXIT MODE EXCEPTION: When a VPN is in exit/full-tunnel mode (VPN DNS server is
|
// EXIT MODE EXCEPTION: When a VPN is in exit/full-tunnel mode (VPN DNS server is
|
||||||
// also the system default resolver), we do NOT exempt the interface. In exit mode,
|
// also the system default resolver), we do NOT exempt the interface. In exit mode,
|
||||||
// all traffic routes through the VPN, so exempting the interface would bypass ctrld
|
// all traffic routes through the VPN, so exempting the interface would bypass ctrld
|
||||||
@@ -858,7 +837,7 @@ func (p *prog) buildPFAnchorRules(vpnExemptions []vpnDNSExemption) string {
|
|||||||
// intercepting and let ctrld's VPN DNS split routing + group exemption handle it.
|
// intercepting and let ctrld's VPN DNS split routing + group exemption handle it.
|
||||||
vpnDNSIfaces := make(map[string]bool) // non-exit interfaces to skip in tunnel intercept
|
vpnDNSIfaces := make(map[string]bool) // non-exit interfaces to skip in tunnel intercept
|
||||||
vpnDNSIfacePassthrough := make(map[string]bool) // non-exit interfaces needing passthrough rules
|
vpnDNSIfacePassthrough := make(map[string]bool) // non-exit interfaces needing passthrough rules
|
||||||
vpnDNSServerIPs := make(map[string]bool) // IPs to exclude from generic intercept
|
vpnDNSServerIPs := make(map[string]bool) // IPs for group exemptions and <vpn_dns> table
|
||||||
for _, ex := range vpnExemptions {
|
for _, ex := range vpnExemptions {
|
||||||
if ex.Interface != "" && !ex.IsExitMode {
|
if ex.Interface != "" && !ex.IsExitMode {
|
||||||
vpnDNSIfaces[ex.Interface] = true
|
vpnDNSIfaces[ex.Interface] = true
|
||||||
@@ -884,28 +863,33 @@ func (p *prog) buildPFAnchorRules(vpnExemptions []vpnDNSExemption) string {
|
|||||||
rules.WriteString("\n")
|
rules.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: IPv6 DNS is now intercepted (not blocked). ctrld listens on [::1] and pf
|
// Block all outbound IPv6 DNS. ctrld only listens on 0.0.0.0:53 (IPv4), so we cannot
|
||||||
// redirects IPv6 DNS the same way as IPv4. This eliminates the ~1s timeout caused by
|
// redirect IPv6 DNS to our listener. Without this rule, macOS may use IPv6 link-local
|
||||||
// blocking IPv6 DNS (BSD doesn't deliver ICMP errors to unconnected UDP sockets).
|
// DNS servers (e.g., fe80::...%en0) assigned by the router, completely bypassing the
|
||||||
|
// IPv4 pf intercept. Blocking forces macOS to fall back to IPv4 DNS, which is intercepted.
|
||||||
|
// This rule must come BEFORE the IPv4 route-to rules (pf evaluates last match by default,
|
||||||
|
// but "quick" makes first-match — and exemptions above are already "quick").
|
||||||
|
rules.WriteString("# Block outbound IPv6 DNS — ctrld listens on IPv4 only (0.0.0.0:53).\n")
|
||||||
|
rules.WriteString("# Without this, macOS may use IPv6 link-local DNS servers from the router,\n")
|
||||||
|
rules.WriteString("# bypassing the IPv4 intercept entirely.\n")
|
||||||
|
rules.WriteString("block out quick on ! lo0 inet6 proto { udp, tcp } from any to any port 53\n\n")
|
||||||
|
|
||||||
// --- VPN DNS interface passthrough (split DNS mode only) ---
|
// --- VPN DNS interface passthrough (split DNS mode only) ---
|
||||||
//
|
//
|
||||||
// In split DNS mode, the VPN's DNS handler (e.g., Tailscale MagicDNS) runs as a
|
// In split DNS mode, the VPN's DNS handler (e.g., Tailscale MagicDNS) runs as a
|
||||||
// Network Extension that intercepts packets on its tunnel interface. MagicDNS then
|
// Network Extension that intercepts packets on its tunnel interface. MagicDNS then
|
||||||
// forwards queries to its own upstream nameservers (e.g., 10.0.0.11) — IPs we
|
// forwards queries to its own upstream nameservers (e.g., 10.3.112.11) — IPs we
|
||||||
// can't know in advance. Without these rules, pf's generic "on !lo0" intercept
|
// can't know in advance. Without these rules, pf's generic "on !lo0" intercept
|
||||||
// catches MagicDNS's upstream queries, routing them back to ctrld in a loop.
|
// catches MagicDNS's upstream queries, routing them back to ctrld in a loop.
|
||||||
//
|
//
|
||||||
// These "pass" rules (no route-to) let MagicDNS's upstream queries pass through.
|
// These "pass" rules (no route-to) let MagicDNS's upstream queries pass through.
|
||||||
// Traffic TO the VPN DNS server itself (e.g., 100.100.100.100) is excluded so those
|
// Traffic TO the VPN DNS server (e.g., 100.100.100.100) is excluded via <vpn_dns>
|
||||||
// queries get intercepted → ctrld enforces its profile on non-search-domain queries.
|
// so those queries get intercepted → ctrld enforces its profile on non-search-domain queries.
|
||||||
//
|
//
|
||||||
// NOT applied in exit mode — in exit mode, all traffic routes through the VPN
|
// NOT applied in exit mode — in exit mode, all traffic routes through the VPN
|
||||||
// interface, so exempting it would bypass ctrld's profile enforcement entirely.
|
// interface, so exempting it would bypass ctrld's profile enforcement entirely.
|
||||||
if len(vpnDNSIfacePassthrough) > 0 {
|
if len(vpnDNSIfacePassthrough) > 0 {
|
||||||
// Build table of VPN DNS server IPs to exclude from passthrough.
|
// Build table of VPN DNS server IPs to exclude from passthrough.
|
||||||
// Queries TO these IPs must still be intercepted (profile enforcement).
|
|
||||||
// Only MagicDNS's upstream queries to other IPs should pass through.
|
|
||||||
var vpnDNSTableMembers []string
|
var vpnDNSTableMembers []string
|
||||||
for ip := range vpnDNSServerIPs {
|
for ip := range vpnDNSServerIPs {
|
||||||
if net.ParseIP(ip) != nil && net.ParseIP(ip).To4() != nil {
|
if net.ParseIP(ip) != nil && net.ParseIP(ip).To4() != nil {
|
||||||
@@ -931,66 +915,31 @@ func (p *prog) buildPFAnchorRules(vpnExemptions []vpnDNSExemption) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Interface-specific VPN/tunnel intercept rules ---
|
// --- Interface-specific VPN/tunnel intercept rules ---
|
||||||
// VPN apps (e.g., Windscribe, Cisco AnyConnect) often add pf rules like:
|
|
||||||
// pass out quick on ipsec0 inet all flags S/SA keep state
|
|
||||||
// inside their own anchors. If their anchor is evaluated before ours, their
|
|
||||||
// "quick" match on the VPN interface captures DNS traffic before our generic
|
|
||||||
// "on ! lo0" rule can intercept it. To counter this, we add explicit intercept
|
|
||||||
// rules for each active tunnel interface. These use "quick" and match port 53
|
|
||||||
// specifically, so they take priority over the VPN app's broader "all" rules
|
|
||||||
// regardless of anchor ordering.
|
|
||||||
//
|
|
||||||
// NOTE: If anchor ordering alone proves insufficient in the future, a "nuclear
|
|
||||||
// option" is available: inject DNS intercept rules directly into the MAIN pf
|
|
||||||
// ruleset (not inside our anchor). Main ruleset rules are evaluated before ALL
|
|
||||||
// anchors, making them impossible for another app to override without explicitly
|
|
||||||
// removing them. See docs/dns-intercept-mode.md for details.
|
|
||||||
tunnelIfaces := discoverTunnelInterfaces()
|
tunnelIfaces := discoverTunnelInterfaces()
|
||||||
if len(tunnelIfaces) > 0 {
|
if len(tunnelIfaces) > 0 {
|
||||||
rules.WriteString("# --- VPN/tunnel interface intercept rules ---\n")
|
rules.WriteString("# --- VPN/tunnel interface intercept rules ---\n")
|
||||||
rules.WriteString("# Explicit intercept on tunnel interfaces prevents VPN apps from capturing\n")
|
rules.WriteString("# Explicit intercept on tunnel interfaces prevents VPN apps from capturing\n")
|
||||||
rules.WriteString("# DNS traffic with their own broad \"pass out quick on <iface>\" rules.\n")
|
rules.WriteString("# DNS traffic with their own broad \"pass out quick on <iface>\" rules.\n")
|
||||||
rules.WriteString("# These port-53-specific rules take priority over broader \"all\" matches.\n")
|
rules.WriteString("# VPN DNS interfaces (split DNS mode) are excluded — passthrough rules above handle them.\n")
|
||||||
rules.WriteString("#\n")
|
|
||||||
rules.WriteString("# Interfaces with VPN DNS servers (from scutil) are EXCLUDED — those carry\n")
|
|
||||||
rules.WriteString("# DNS traffic for Network Extension-based VPNs (e.g., Tailscale MagicDNS)\n")
|
|
||||||
rules.WriteString("# that must flow without any pf interference.\n")
|
|
||||||
for _, iface := range tunnelIfaces {
|
for _, iface := range tunnelIfaces {
|
||||||
if vpnDNSIfaces[iface] {
|
if vpnDNSIfaces[iface] {
|
||||||
rules.WriteString(fmt.Sprintf("# Skipped %s — VPN DNS interface (Network Extension needs unintercepted flow)\n", iface))
|
rules.WriteString(fmt.Sprintf("# Skipped %s — VPN DNS interface (passthrough rules handle this)\n", iface))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
rules.WriteString(fmt.Sprintf("pass out quick on %s route-to lo0 inet proto udp from any to ! %s port 53\n", iface, listenerIP))
|
rules.WriteString(fmt.Sprintf("pass out quick on %s route-to lo0 inet proto udp from any to ! %s port 53\n", iface, listenerIP))
|
||||||
rules.WriteString(fmt.Sprintf("pass out quick on %s route-to lo0 inet proto tcp from any to ! %s port 53\n", iface, listenerIP))
|
rules.WriteString(fmt.Sprintf("pass out quick on %s route-to lo0 inet proto tcp from any to ! %s port 53\n", iface, listenerIP))
|
||||||
// No IPv6 route-to — IPv6 DNS is blocked, not intercepted.
|
|
||||||
}
|
}
|
||||||
rules.WriteString("\n")
|
rules.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force all remaining outbound IPv4 DNS through loopback for interception.
|
// Force all remaining outbound IPv4 DNS through loopback for interception.
|
||||||
// VPN DNS server IPs are excluded — those must reach their VPN DNS handler
|
// route-to rules use stateful tracking (keep state, the default). State is floating
|
||||||
// without pf interference (especially for Network Extension-based VPNs).
|
// (matches on any interface), but "pass out on lo0 no state" below ensures no state
|
||||||
//
|
// is created on the lo0 outbound path, allowing rdr to fire on lo0 inbound.
|
||||||
// IMPORTANT: pf expands negated lists like { !a, !b } into separate rules where
|
|
||||||
// each rule matches everything the other excludes — effectively matching ALL addresses.
|
|
||||||
// This is a well-documented pf pitfall (OpenBSD FAQ, "negated lists").
|
|
||||||
// Fix: use a pf table with a single negated match: "to ! <table>".
|
|
||||||
// Force all remaining outbound IPv4 DNS through loopback for interception.
|
|
||||||
// Only loopback (127.0.0.1) is excluded — ctrld's own outbound queries to VPN DNS
|
|
||||||
// servers are handled by the group-scoped exemption rules above (group _ctrld).
|
|
||||||
rules.WriteString("# Force remaining outbound IPv4 DNS through loopback for interception.\n")
|
rules.WriteString("# Force remaining outbound IPv4 DNS through loopback for interception.\n")
|
||||||
rules.WriteString(fmt.Sprintf("pass out quick on ! lo0 route-to lo0 inet proto udp from any to ! %s port 53\n", listenerIP))
|
rules.WriteString(fmt.Sprintf("pass out quick on ! lo0 route-to lo0 inet proto udp from any to ! %s port 53\n", listenerIP))
|
||||||
rules.WriteString(fmt.Sprintf("pass out quick on ! lo0 route-to lo0 inet proto tcp from any to ! %s port 53\n\n", listenerIP))
|
rules.WriteString(fmt.Sprintf("pass out quick on ! lo0 route-to lo0 inet proto tcp from any to ! %s port 53\n\n", listenerIP))
|
||||||
|
|
||||||
// Block all outbound IPv6 DNS. ctrld only intercepts IPv4 DNS via the loopback
|
|
||||||
// redirect. IPv6 DNS interception on macOS is not feasible because the kernel rejects
|
|
||||||
// sendmsg from [::1] to global unicast IPv6 (EINVAL), and pf's nat-on-lo0 doesn't
|
|
||||||
// fire for route-to'd packets. Blocking forces macOS to fall back to IPv4 DNS,
|
|
||||||
// which is fully intercepted. See docs/pf-dns-intercept.md for details.
|
|
||||||
rules.WriteString("# Block outbound IPv6 DNS — ctrld intercepts IPv4 only.\n")
|
|
||||||
rules.WriteString("# macOS falls back to IPv4 DNS automatically.\n")
|
|
||||||
rules.WriteString("block out quick on ! lo0 inet6 proto { udp, tcp } from any to any port 53\n\n")
|
|
||||||
|
|
||||||
// Allow route-to'd DNS packets to pass outbound on lo0.
|
// Allow route-to'd DNS packets to pass outbound on lo0.
|
||||||
// Without this, VPN firewalls with "block drop all" (e.g., Windscribe) drop the packet
|
// Without this, VPN firewalls with "block drop all" (e.g., Windscribe) drop the packet
|
||||||
// after route-to redirects it to lo0 but before it can reflect inbound for rdr processing.
|
// after route-to redirects it to lo0 but before it can reflect inbound for rdr processing.
|
||||||
@@ -1000,9 +949,7 @@ func (p *prog) buildPFAnchorRules(vpnExemptions []vpnDNSExemption) string {
|
|||||||
// rdr entirely. With "no state", the inbound packet gets fresh evaluation and rdr fires.
|
// rdr entirely. With "no state", the inbound packet gets fresh evaluation and rdr fires.
|
||||||
rules.WriteString("# Pass route-to'd DNS outbound on lo0 — no state to avoid bypassing rdr inbound.\n")
|
rules.WriteString("# Pass route-to'd DNS outbound on lo0 — no state to avoid bypassing rdr inbound.\n")
|
||||||
rules.WriteString(fmt.Sprintf("pass out quick on lo0 inet proto udp from any to ! %s port 53 no state\n", listenerIP))
|
rules.WriteString(fmt.Sprintf("pass out quick on lo0 inet proto udp from any to ! %s port 53 no state\n", listenerIP))
|
||||||
rules.WriteString(fmt.Sprintf("pass out quick on lo0 inet proto tcp from any to ! %s port 53 no state\n", listenerIP))
|
rules.WriteString(fmt.Sprintf("pass out quick on lo0 inet proto tcp from any to ! %s port 53 no state\n\n", listenerIP))
|
||||||
// No IPv6 lo0 pass — IPv6 DNS is blocked, not routed through lo0.
|
|
||||||
rules.WriteString("\n")
|
|
||||||
|
|
||||||
// Allow the redirected traffic through on loopback (inbound after rdr).
|
// Allow the redirected traffic through on loopback (inbound after rdr).
|
||||||
//
|
//
|
||||||
@@ -1017,7 +964,13 @@ func (p *prog) buildPFAnchorRules(vpnExemptions []vpnDNSExemption) string {
|
|||||||
// (source 127.0.0.1 → original DNS server IP, e.g., 10.255.255.3).
|
// (source 127.0.0.1 → original DNS server IP, e.g., 10.255.255.3).
|
||||||
rules.WriteString("# Accept redirected DNS — reply-to lo0 forces response through loopback.\n")
|
rules.WriteString("# Accept redirected DNS — reply-to lo0 forces response through loopback.\n")
|
||||||
rules.WriteString(fmt.Sprintf("pass in quick on lo0 reply-to lo0 inet proto { udp, tcp } from any to %s\n", listenerAddr))
|
rules.WriteString(fmt.Sprintf("pass in quick on lo0 reply-to lo0 inet proto { udp, tcp } from any to %s\n", listenerAddr))
|
||||||
// No IPv6 pass-in — IPv6 DNS is blocked, not redirected to [::1].
|
|
||||||
|
// Firewall mode: append IP allowlist enforcement rules AFTER DNS intercept rules.
|
||||||
|
// DNS intercept rules must evaluate first so that DNS queries work (they're how
|
||||||
|
// IPs get into the allowlist in the first place).
|
||||||
|
if p.firewallModeEnabled() {
|
||||||
|
rules.WriteString(buildPFFirewallRules())
|
||||||
|
}
|
||||||
|
|
||||||
return rules.String()
|
return rules.String()
|
||||||
}
|
}
|
||||||
@@ -1030,18 +983,15 @@ func (p *prog) verifyPFState() {
|
|||||||
anchorRef := fmt.Sprintf("anchor \"%s\"", pfAnchorName)
|
anchorRef := fmt.Sprintf("anchor \"%s\"", pfAnchorName)
|
||||||
verified := true
|
verified := true
|
||||||
|
|
||||||
// Check main ruleset for anchor references (rdr-anchor in translation rules).
|
// Check main ruleset for anchor references.
|
||||||
natOut, err := exec.Command("pfctl", "-sn").CombinedOutput()
|
natOut, err := exec.Command("pfctl", "-sn").CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
mainLog.Load().Error().Err(err).Msg("DNS intercept: VERIFICATION FAILED — could not dump NAT rules")
|
mainLog.Load().Error().Err(err).Msg("DNS intercept: VERIFICATION FAILED — could not dump NAT rules")
|
||||||
verified = false
|
verified = false
|
||||||
} else {
|
} else if !strings.Contains(string(natOut), rdrAnchorRef) {
|
||||||
natStr := string(natOut)
|
|
||||||
if !strings.Contains(natStr, rdrAnchorRef) {
|
|
||||||
mainLog.Load().Error().Msg("DNS intercept: VERIFICATION FAILED — rdr-anchor reference missing from running NAT rules")
|
mainLog.Load().Error().Msg("DNS intercept: VERIFICATION FAILED — rdr-anchor reference missing from running NAT rules")
|
||||||
verified = false
|
verified = false
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
filterOut, err := exec.Command("pfctl", "-sr").CombinedOutput()
|
filterOut, err := exec.Command("pfctl", "-sr").CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1109,7 +1059,7 @@ func (p *prog) resetUpstreamTransports() {
|
|||||||
if uc == nil {
|
if uc == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
uc.ForceReBootstrap()
|
uc.ForceReBootstrap(ctrld.LoggerCtx(context.Background(), p.logger.Load()))
|
||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
@@ -1173,7 +1123,7 @@ func (p *prog) checkTunnelInterfaceChanges() bool {
|
|||||||
mainLog.Load().Info().Msgf("DNS intercept: tunnel interfaces changed (was %v, now %v) — rebuilding pf anchor rules", prev, current)
|
mainLog.Load().Info().Msgf("DNS intercept: tunnel interfaces changed (was %v, now %v) — rebuilding pf anchor rules", prev, current)
|
||||||
|
|
||||||
// Rebuild anchor rules with the updated tunnel interface list.
|
// Rebuild anchor rules with the updated tunnel interface list.
|
||||||
// Pass current VPN DNS servers so exemptions are preserved for still-active VPNs.
|
// Pass current VPN DNS exemptions so they are preserved for still-active VPNs.
|
||||||
var vpnExemptions []vpnDNSExemption
|
var vpnExemptions []vpnDNSExemption
|
||||||
if p.vpnDNS != nil {
|
if p.vpnDNS != nil {
|
||||||
vpnExemptions = p.vpnDNS.CurrentExemptions()
|
vpnExemptions = p.vpnDNS.CurrentExemptions()
|
||||||
@@ -1189,7 +1139,7 @@ func (p *prog) checkTunnelInterfaceChanges() bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
flushPFStates() // Clear stale states so new rules (incl. VPN DNS exemptions) take effect
|
flushPFStates()
|
||||||
mainLog.Load().Info().Msgf("DNS intercept: rebuilt pf anchor with %d tunnel interfaces", len(current))
|
mainLog.Load().Info().Msgf("DNS intercept: rebuilt pf anchor with %d tunnel interfaces", len(current))
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -1303,6 +1253,15 @@ func (p *prog) ensurePFAnchorActive() bool {
|
|||||||
if p.dnsInterceptState == nil {
|
if p.dnsInterceptState == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if !p.pfEnsureRunning.CompareAndSwap(false, true) {
|
||||||
|
mainLog.Load().Debug().Msg("DNS intercept watchdog: check already running, skipping duplicate")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer p.pfEnsureRunning.Store(false)
|
||||||
|
|
||||||
|
if p.pfExecBackoffActive() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// While stabilizing (VPN connecting), suppress all restores.
|
// While stabilizing (VPN connecting), suppress all restores.
|
||||||
// The stabilization loop will restore once pf settles.
|
// The stabilization loop will restore once pf settles.
|
||||||
@@ -1336,11 +1295,13 @@ func (p *prog) ensurePFAnchorActive() bool {
|
|||||||
// Check 1: anchor references in the main ruleset.
|
// Check 1: anchor references in the main ruleset.
|
||||||
natOut, err := exec.Command("pfctl", "-sn").CombinedOutput()
|
natOut, err := exec.Command("pfctl", "-sn").CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if p.pfBackoffResourceExhaustion(err, natOut, "dump NAT rules") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
mainLog.Load().Warn().Err(err).Msg("DNS intercept watchdog: could not dump NAT rules")
|
mainLog.Load().Warn().Err(err).Msg("DNS intercept watchdog: could not dump NAT rules")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
natStr := string(natOut)
|
if !strings.Contains(string(natOut), rdrAnchorRef) {
|
||||||
if !strings.Contains(natStr, rdrAnchorRef) {
|
|
||||||
mainLog.Load().Warn().Msg("DNS intercept watchdog: rdr-anchor reference missing from running ruleset")
|
mainLog.Load().Warn().Msg("DNS intercept watchdog: rdr-anchor reference missing from running ruleset")
|
||||||
needsRestore = true
|
needsRestore = true
|
||||||
}
|
}
|
||||||
@@ -1348,6 +1309,9 @@ func (p *prog) ensurePFAnchorActive() bool {
|
|||||||
if !needsRestore {
|
if !needsRestore {
|
||||||
filterOut, err := exec.Command("pfctl", "-sr").CombinedOutput()
|
filterOut, err := exec.Command("pfctl", "-sr").CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if p.pfBackoffResourceExhaustion(err, filterOut, "dump filter rules") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
mainLog.Load().Warn().Err(err).Msg("DNS intercept watchdog: could not dump filter rules")
|
mainLog.Load().Warn().Err(err).Msg("DNS intercept watchdog: could not dump filter rules")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -1365,6 +1329,9 @@ func (p *prog) ensurePFAnchorActive() bool {
|
|||||||
if !needsRestore {
|
if !needsRestore {
|
||||||
anchorFilter, err := exec.Command("pfctl", "-a", pfAnchorName, "-sr").CombinedOutput()
|
anchorFilter, err := exec.Command("pfctl", "-a", pfAnchorName, "-sr").CombinedOutput()
|
||||||
if err != nil || len(strings.TrimSpace(string(anchorFilter))) == 0 {
|
if err != nil || len(strings.TrimSpace(string(anchorFilter))) == 0 {
|
||||||
|
if p.pfBackoffResourceExhaustion(err, anchorFilter, "dump anchor filter rules") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
mainLog.Load().Warn().Msg("DNS intercept watchdog: anchor has no filter rules — content was flushed")
|
mainLog.Load().Warn().Msg("DNS intercept watchdog: anchor has no filter rules — content was flushed")
|
||||||
needsRestore = true
|
needsRestore = true
|
||||||
}
|
}
|
||||||
@@ -1372,6 +1339,9 @@ func (p *prog) ensurePFAnchorActive() bool {
|
|||||||
if !needsRestore {
|
if !needsRestore {
|
||||||
anchorNat, err := exec.Command("pfctl", "-a", pfAnchorName, "-sn").CombinedOutput()
|
anchorNat, err := exec.Command("pfctl", "-a", pfAnchorName, "-sn").CombinedOutput()
|
||||||
if err != nil || len(strings.TrimSpace(string(anchorNat))) == 0 {
|
if err != nil || len(strings.TrimSpace(string(anchorNat))) == 0 {
|
||||||
|
if p.pfBackoffResourceExhaustion(err, anchorNat, "dump anchor NAT rules") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
mainLog.Load().Warn().Msg("DNS intercept watchdog: anchor has no rdr rules — translation was flushed (will cause packet loop on lo0)")
|
mainLog.Load().Warn().Msg("DNS intercept watchdog: anchor has no rdr rules — translation was flushed (will cause packet loop on lo0)")
|
||||||
needsRestore = true
|
needsRestore = true
|
||||||
}
|
}
|
||||||
@@ -1405,6 +1375,7 @@ func (p *prog) ensurePFAnchorActive() bool {
|
|||||||
// Restore: re-inject anchor references into the main ruleset.
|
// Restore: re-inject anchor references into the main ruleset.
|
||||||
mainLog.Load().Info().Msg("DNS intercept watchdog: restoring pf anchor references")
|
mainLog.Load().Info().Msg("DNS intercept watchdog: restoring pf anchor references")
|
||||||
if err := p.ensurePFAnchorReference(); err != nil {
|
if err := p.ensurePFAnchorReference(); err != nil {
|
||||||
|
p.pfBackoffResourceExhaustion(err, nil, "restore anchor references")
|
||||||
mainLog.Load().Error().Err(err).Msg("DNS intercept watchdog: failed to restore anchor references")
|
mainLog.Load().Error().Err(err).Msg("DNS intercept watchdog: failed to restore anchor references")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -1421,6 +1392,7 @@ func (p *prog) ensurePFAnchorActive() bool {
|
|||||||
if err := os.WriteFile(pfAnchorFile, []byte(rulesStr), 0644); err != nil {
|
if err := os.WriteFile(pfAnchorFile, []byte(rulesStr), 0644); err != nil {
|
||||||
mainLog.Load().Error().Err(err).Msg("DNS intercept watchdog: failed to write anchor file")
|
mainLog.Load().Error().Err(err).Msg("DNS intercept watchdog: failed to write anchor file")
|
||||||
} else if out, err := exec.Command("pfctl", "-a", pfAnchorName, "-f", pfAnchorFile).CombinedOutput(); err != nil {
|
} else if out, err := exec.Command("pfctl", "-a", pfAnchorName, "-f", pfAnchorFile).CombinedOutput(); err != nil {
|
||||||
|
p.pfBackoffResourceExhaustion(err, out, "load rebuilt anchor")
|
||||||
mainLog.Load().Error().Err(err).Msgf("DNS intercept watchdog: failed to load rebuilt anchor (output: %s)", strings.TrimSpace(string(out)))
|
mainLog.Load().Error().Err(err).Msgf("DNS intercept watchdog: failed to load rebuilt anchor (output: %s)", strings.TrimSpace(string(out)))
|
||||||
} else {
|
} else {
|
||||||
flushPFStates()
|
flushPFStates()
|
||||||
@@ -1449,6 +1421,48 @@ func (p *prog) ensurePFAnchorActive() bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *prog) pfExecBackoffActive() bool {
|
||||||
|
until := p.pfExecBackoffUntil.Load()
|
||||||
|
if until == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
remaining := time.Until(time.UnixMilli(until))
|
||||||
|
if remaining <= 0 {
|
||||||
|
p.pfExecBackoffUntil.CompareAndSwap(until, 0)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
mainLog.Load().Debug().Msgf("DNS intercept watchdog: suppressed during pf exec backoff (remaining: %s)", remaining)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *prog) pfBackoffResourceExhaustion(err error, output []byte, operation string) bool {
|
||||||
|
if !isResourceExhaustion(err, output) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
until := time.Now().Add(pfExecFailureBackoff)
|
||||||
|
p.pfExecBackoffUntil.Store(until.UnixMilli())
|
||||||
|
mainLog.Load().Warn().Err(err).Msgf("DNS intercept watchdog: backing off after local exec resource exhaustion (operation: %s, backoff: %s)", operation, pfExecFailureBackoff)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isResourceExhaustion(err error, output []byte) bool {
|
||||||
|
if err == nil && len(output) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var msg string
|
||||||
|
if err != nil {
|
||||||
|
msg = err.Error()
|
||||||
|
}
|
||||||
|
if len(output) > 0 {
|
||||||
|
msg += "\n" + string(output)
|
||||||
|
}
|
||||||
|
msg = strings.ToLower(msg)
|
||||||
|
return strings.Contains(msg, "resource temporarily unavailable") ||
|
||||||
|
strings.Contains(msg, "too many open files") ||
|
||||||
|
strings.Contains(msg, "too many processes") ||
|
||||||
|
strings.Contains(msg, "cannot allocate memory")
|
||||||
|
}
|
||||||
|
|
||||||
func (p *prog) scheduleDNSAfterVPNSettleRefresh(reason string, delay time.Duration) {
|
func (p *prog) scheduleDNSAfterVPNSettleRefresh(reason string, delay time.Duration) {
|
||||||
time.AfterFunc(delay, func() {
|
time.AfterFunc(delay, func() {
|
||||||
if p.dnsInterceptState == nil {
|
if p.dnsInterceptState == nil {
|
||||||
@@ -1470,8 +1484,19 @@ func (p *prog) scheduleDNSAfterVPNSettleRefresh(reason string, delay time.Durati
|
|||||||
//
|
//
|
||||||
// Two delays (2s and 4s) cover both fast and slow VPN teardowns.
|
// Two delays (2s and 4s) cover both fast and slow VPN teardowns.
|
||||||
func (p *prog) scheduleDelayedRechecks() {
|
func (p *prog) scheduleDelayedRechecks() {
|
||||||
|
p.pfDelayedRecheckMu.Lock()
|
||||||
|
defer p.pfDelayedRecheckMu.Unlock()
|
||||||
|
|
||||||
|
for _, timer := range p.pfDelayedRecheckTimers {
|
||||||
|
if timer != nil {
|
||||||
|
timer.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.pfDelayedRecheckTimers = p.pfDelayedRecheckTimers[:0]
|
||||||
|
|
||||||
for _, delay := range []time.Duration{pfAnchorRecheckDelay, pfAnchorRecheckDelayLong} {
|
for _, delay := range []time.Duration{pfAnchorRecheckDelay, pfAnchorRecheckDelayLong} {
|
||||||
time.AfterFunc(delay, func() {
|
delay := delay
|
||||||
|
timer := time.AfterFunc(delay, func() {
|
||||||
if p.dnsInterceptState == nil || p.pfStabilizing.Load() {
|
if p.dnsInterceptState == nil || p.pfStabilizing.Load() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1479,12 +1504,14 @@ func (p *prog) scheduleDelayedRechecks() {
|
|||||||
p.checkTunnelInterfaceChanges()
|
p.checkTunnelInterfaceChanges()
|
||||||
// Refresh OS resolver — VPN may have finished DNS cleanup since the
|
// Refresh OS resolver — VPN may have finished DNS cleanup since the
|
||||||
// immediate handler ran. This clears stale LAN nameservers (e.g.,
|
// immediate handler ran. This clears stale LAN nameservers (e.g.,
|
||||||
// a VPN's DNS IP (e.g., 10.255.255.3) lingering in scutil --dns).
|
// Windscribe's 10.255.255.3 lingering in scutil --dns).
|
||||||
ctrld.InitializeOsResolver(true)
|
ctx := ctrld.LoggerCtx(context.Background(), p.logger.Load())
|
||||||
|
ctrld.InitializeOsResolver(ctx, true)
|
||||||
if p.vpnDNS != nil {
|
if p.vpnDNS != nil {
|
||||||
p.vpnDNS.Refresh(true)
|
p.vpnDNS.Refresh(ctx, true)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
p.pfDelayedRecheckTimers = append(p.pfDelayedRecheckTimers, timer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1511,13 +1538,11 @@ func (p *prog) pfWatchdog() {
|
|||||||
restored := p.ensurePFAnchorActive()
|
restored := p.ensurePFAnchorActive()
|
||||||
if !restored {
|
if !restored {
|
||||||
// Rules are intact in text form — also probe actual interception.
|
// Rules are intact in text form — also probe actual interception.
|
||||||
// This catches cases where rules survive but pf's internal translation
|
|
||||||
// state is corrupted (e.g., after a hypervisor reloads pf.conf).
|
|
||||||
if !p.pfStabilizing.Load() && !p.pfMonitorRunning.Load() {
|
if !p.pfStabilizing.Load() && !p.pfMonitorRunning.Load() {
|
||||||
if !p.probePFIntercept() {
|
if !p.probePFIntercept() {
|
||||||
mainLog.Load().Warn().Msg("DNS intercept watchdog: rules intact but probe FAILED — forcing full reload")
|
mainLog.Load().Warn().Msg("DNS intercept watchdog: rules intact but probe FAILED — forcing full reload")
|
||||||
p.forceReloadPFMainRuleset()
|
p.forceReloadPFMainRuleset()
|
||||||
restored = true // treat as a restore for logging
|
restored = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1546,9 +1571,9 @@ func (p *prog) pfWatchdog() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// exemptVPNDNSServers rebuilds the pf anchor rules to exclude VPN DNS interfaces
|
// exemptVPNDNSServers updates the pf anchor rules with interface-scoped exemptions
|
||||||
// and server IPs from interception. VPN DNS handlers using Network Extensions
|
// for VPN DNS servers, allowing VPN local DNS handlers (e.g., Tailscale MagicDNS
|
||||||
// (e.g., Tailscale MagicDNS) need DNS traffic to flow without any pf interference.
|
// via Network Extension) to receive DNS queries from all processes on their interface.
|
||||||
//
|
//
|
||||||
// Called by vpnDNSManager.Refresh() whenever VPN DNS servers change.
|
// Called by vpnDNSManager.Refresh() whenever VPN DNS servers change.
|
||||||
func (p *prog) exemptVPNDNSServers(exemptions []vpnDNSExemption) error {
|
func (p *prog) exemptVPNDNSServers(exemptions []vpnDNSExemption) error {
|
||||||
@@ -1567,9 +1592,7 @@ func (p *prog) exemptVPNDNSServers(exemptions []vpnDNSExemption) error {
|
|||||||
return fmt.Errorf("dns intercept: failed to reload pf anchor: %w (output: %s)", err, strings.TrimSpace(string(out)))
|
return fmt.Errorf("dns intercept: failed to reload pf anchor: %w (output: %s)", err, strings.TrimSpace(string(out)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flush pf states after anchor reload so packets are re-evaluated against new rules.
|
// Flush stale pf states so packets are re-evaluated against new rules.
|
||||||
// Stale state entries from previous rules would keep routing packets via route-to
|
|
||||||
// even after VPN DNS interfaces/IPs are excluded from interception.
|
|
||||||
flushPFStates()
|
flushPFStates()
|
||||||
|
|
||||||
// Ensure the anchor reference still exists in the main ruleset.
|
// Ensure the anchor reference still exists in the main ruleset.
|
||||||
@@ -1578,15 +1601,8 @@ func (p *prog) exemptVPNDNSServers(exemptions []vpnDNSExemption) error {
|
|||||||
mainLog.Load().Warn().Err(err).Msg("DNS intercept: failed to verify anchor reference during VPN DNS update")
|
mainLog.Load().Warn().Err(err).Msg("DNS intercept: failed to verify anchor reference during VPN DNS update")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count unique excluded interfaces for logging.
|
mainLog.Load().Info().Msgf("DNS intercept: updated pf rules — exempted %d VPN DNS + %d OS resolver servers",
|
||||||
excludedIfaces := make(map[string]bool)
|
len(exemptions), len(ctrld.OsResolverNameservers()))
|
||||||
for _, ex := range exemptions {
|
|
||||||
if ex.Interface != "" {
|
|
||||||
excludedIfaces[ex.Interface] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mainLog.Load().Info().Msgf("DNS intercept: updated pf rules — %d VPN DNS servers (%d interfaces excluded from intercept), %d OS resolver servers",
|
|
||||||
len(exemptions), len(excludedIfaces), len(ctrld.OsResolverNameservers()))
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -122,6 +123,35 @@ func TestPFBuildAnchorRules_Ordering(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPFBuildAnchorRules_FallbackPort verifies that when the listener falls back
|
||||||
|
// to an alternate local port (e.g. 5354 because mDNSResponder owns *:53), the pf
|
||||||
|
// rdr rules redirect DNS to the ACTUAL bound port, not the configured default 53.
|
||||||
|
// Regression test for #551: pf redirected to a dead port after listener fallback.
|
||||||
|
func TestPFBuildAnchorRules_FallbackPort(t *testing.T) {
|
||||||
|
// Configured/generated listener is 127.0.0.1:53, but the runtime bound port is 5354.
|
||||||
|
p := &prog{cfg: &ctrld.Config{Listener: map[string]*ctrld.ListenerConfig{"0": {IP: "127.0.0.1", Port: 5354}}}}
|
||||||
|
rules := p.buildPFAnchorRules(nil)
|
||||||
|
|
||||||
|
// rdr must redirect to the actual bound port 5354.
|
||||||
|
if !strings.Contains(rules, "rdr on lo0 inet proto udp from any to ! 127.0.0.1 port 53 -> 127.0.0.1 port 5354") {
|
||||||
|
t.Errorf("UDP rdr must redirect to bound port 5354, got:\n%s", rules)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rules, "rdr on lo0 inet proto tcp from any to ! 127.0.0.1 port 53 -> 127.0.0.1 port 5354") {
|
||||||
|
t.Errorf("TCP rdr must redirect to bound port 5354, got:\n%s", rules)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rdr redirect target must NOT point at the dead default port 53.
|
||||||
|
// Match the exact port at line end so "port 5354" is not a false positive.
|
||||||
|
if strings.Contains(rules, "-> 127.0.0.1 port 53\n") {
|
||||||
|
t.Errorf("rdr must not redirect to dead port 53 after fallback, got:\n%s", rules)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The inbound accept rule must also target the actual bound port.
|
||||||
|
if !strings.Contains(rules, "127.0.0.1 port 5354") {
|
||||||
|
t.Errorf("pass in rule must reference bound port 5354, got:\n%s", rules)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestPFAddressFamily tests the pfAddressFamily helper.
|
// TestPFAddressFamily tests the pfAddressFamily helper.
|
||||||
func TestPFAddressFamily(t *testing.T) {
|
func TestPFAddressFamily(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -141,3 +171,47 @@ func TestPFAddressFamily(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIsResourceExhaustion(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
output []byte
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "exec start failure",
|
||||||
|
err: errors.New("fork/exec /sbin/pfctl: resource temporarily unavailable"),
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fd exhaustion from stderr output",
|
||||||
|
err: errors.New("exit status 1"),
|
||||||
|
output: []byte("pfctl: Pipe: Too many open files"),
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "process exhaustion from wrapped restore error",
|
||||||
|
err: errors.New("failed to dump running filter rules: exit status 1 (output: too many processes)"),
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ordinary pf syntax failure",
|
||||||
|
err: errors.New("exit status 1"),
|
||||||
|
output: []byte("pfctl: syntax error"),
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil error and empty output",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := isResourceExhaustion(tt.err, tt.output); got != tt.want {
|
||||||
|
t.Fatalf("isResourceExhaustion() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import "github.com/Control-D-Inc/ctrld"
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/Control-D-Inc/ctrld"
|
||||||
|
)
|
||||||
|
|
||||||
var initializeOsResolver = ctrld.InitializeOsResolver
|
var initializeOsResolver = ctrld.InitializeOsResolver
|
||||||
|
|
||||||
func (p *prog) refreshDNSAfterVPNSettle(reason string) (routes, domainlessServers, exemptions int) {
|
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)
|
mainLog.Load().Info().Msgf("DNS intercept: refreshing OS/VPN DNS route state after VPN settle (%s)", reason)
|
||||||
ns := initializeOsResolver(true)
|
ctx := ctrld.LoggerCtx(context.Background(), mainLog.Load())
|
||||||
|
ns := initializeOsResolver(ctx, true)
|
||||||
mainLog.Load().Debug().Msgf("DNS intercept: post-settle OS resolver nameservers: %v", ns)
|
mainLog.Load().Debug().Msgf("DNS intercept: post-settle OS resolver nameservers: %v", ns)
|
||||||
|
|
||||||
if p.vpnDNS == nil {
|
if p.vpnDNS == nil {
|
||||||
|
|||||||
@@ -12,14 +12,14 @@ func TestRefreshDNSAfterVPNSettleRefreshesOSResolverAndVPNRoutes(t *testing.T) {
|
|||||||
defer func() { initializeOsResolver = oldInitialize }()
|
defer func() { initializeOsResolver = oldInitialize }()
|
||||||
|
|
||||||
var initialized []bool
|
var initialized []bool
|
||||||
initializeOsResolver = func(force bool) []string {
|
initializeOsResolver = func(ctx context.Context, force bool) []string {
|
||||||
initialized = append(initialized, force)
|
initialized = append(initialized, force)
|
||||||
return []string{"10.102.26.10:53"}
|
return []string{"10.102.26.10:53"}
|
||||||
}
|
}
|
||||||
|
|
||||||
var exemptionUpdates [][]vpnDNSExemption
|
var exemptionUpdates [][]vpnDNSExemption
|
||||||
p := &prog{}
|
p := &prog{}
|
||||||
p.vpnDNS = newVPNDNSManager(func(exemptions []vpnDNSExemption) error {
|
p.vpnDNS = newVPNDNSManager(&mainLog, func(exemptions []vpnDNSExemption) error {
|
||||||
exemptionUpdates = append(exemptionUpdates, append([]vpnDNSExemption{}, exemptions...))
|
exemptionUpdates = append(exemptionUpdates, append([]vpnDNSExemption{}, exemptions...))
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|||||||
+207
-148
@@ -112,6 +112,7 @@ const (
|
|||||||
fwpUint32 uint32 = 3 // FWP_UINT32
|
fwpUint32 uint32 = 3 // FWP_UINT32
|
||||||
fwpByteArray16Type uint32 = 11 // FWP_BYTE_ARRAY16_TYPE
|
fwpByteArray16Type uint32 = 11 // FWP_BYTE_ARRAY16_TYPE
|
||||||
fwpV4AddrMask uint32 = 0x100 // FWP_V4_ADDR_MASK (after FWP_SINGLE_DATA_TYPE_MAX=0xff)
|
fwpV4AddrMask uint32 = 0x100 // FWP_V4_ADDR_MASK (after FWP_SINGLE_DATA_TYPE_MAX=0xff)
|
||||||
|
fwpV6AddrMask uint32 = 0x101 // FWP_V6_ADDR_MASK
|
||||||
|
|
||||||
// IP protocol numbers.
|
// IP protocol numbers.
|
||||||
ipprotoUDP uint8 = 17
|
ipprotoUDP uint8 = 17
|
||||||
@@ -228,6 +229,12 @@ type fwpV4AddrAndMask struct {
|
|||||||
mask uint32
|
mask uint32
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fwpV6AddrAndMask represents FWP_V6_ADDR_AND_MASK for IPv6 subnet matching.
|
||||||
|
type fwpV6AddrAndMask struct {
|
||||||
|
addr [16]byte
|
||||||
|
prefixLength uint8
|
||||||
|
}
|
||||||
|
|
||||||
// fwpmAction0 represents FWPM_ACTION0 for specifying what happens on match.
|
// fwpmAction0 represents FWPM_ACTION0 for specifying what happens on match.
|
||||||
// Size: 20 bytes (uint32 + GUID). No padding needed — GUID has 4-byte alignment.
|
// Size: 20 bytes (uint32 + GUID). No padding needed — GUID has 4-byte alignment.
|
||||||
type fwpmAction0 struct {
|
type fwpmAction0 struct {
|
||||||
@@ -240,6 +247,9 @@ type fwpmAction0 struct {
|
|||||||
// All filter IDs are stored so we can remove them individually without
|
// All filter IDs are stored so we can remove them individually without
|
||||||
// needing to enumerate the sublayer's filters via WFP API.
|
// needing to enumerate the sublayer's filters via WFP API.
|
||||||
//
|
//
|
||||||
|
// In "dns" mode, engineHandle is 0 (no WFP filters) and only NRPT is active.
|
||||||
|
// In "hard" mode, both NRPT and WFP filters are active.
|
||||||
|
//
|
||||||
// The engine handle is opened once at startup and kept for the lifetime
|
// The engine handle is opened once at startup and kept for the lifetime
|
||||||
// of the ctrld process. Filter additions/removals happen through this handle.
|
// of the ctrld process. Filter additions/removals happen through this handle.
|
||||||
type wfpState struct {
|
type wfpState struct {
|
||||||
@@ -278,6 +288,9 @@ type wfpState struct {
|
|||||||
loopbackProtectActive bool
|
loopbackProtectActive bool
|
||||||
// loopbackPermitIDs stores the filter IDs for the loopback protect permits.
|
// loopbackPermitIDs stores the filter IDs for the loopback protect permits.
|
||||||
loopbackPermitIDs []uint64
|
loopbackPermitIDs []uint64
|
||||||
|
// nrptRecoveryLimiter prevents repeated Windows policy/Dnscache signaling
|
||||||
|
// when another agent keeps putting NRPT back into a broken state.
|
||||||
|
nrptRecoveryLimiter nrptRecoveryLimiter
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lazy-loaded WFP DLL procedures.
|
// Lazy-loaded WFP DLL procedures.
|
||||||
@@ -343,9 +356,10 @@ const (
|
|||||||
// - GP path: SOFTWARE\Policies\...\DnsPolicyConfig (Group Policy)
|
// - GP path: SOFTWARE\Policies\...\DnsPolicyConfig (Group Policy)
|
||||||
// - Local path: SYSTEM\CurrentControlSet\...\DnsPolicyConfig (service store)
|
// - Local path: SYSTEM\CurrentControlSet\...\DnsPolicyConfig (service store)
|
||||||
//
|
//
|
||||||
// If ANY rules exist in the GP path (from IT policy, VPN, MDM, etc.), DNS Client
|
// If the GP path contains real rules (from IT policy, VPN, MDM, etc.), DNS
|
||||||
// enters "GP mode" and ignores ALL local-path rules entirely. Conversely, if the
|
// Client enters "GP mode" and ignores ALL local-path rules entirely. An empty GP
|
||||||
// GP path is empty/absent, DNS Client reads from the local path only.
|
// parent key is worse: it still puts DNS Client in GP mode, but contributes no
|
||||||
|
// usable rule, so our local catch-all is hidden until that empty parent is gone.
|
||||||
//
|
//
|
||||||
// Strategy (matching Tailscale's approach):
|
// Strategy (matching Tailscale's approach):
|
||||||
// - Always write to the local path (baseline for non-domain machines).
|
// - Always write to the local path (baseline for non-domain machines).
|
||||||
@@ -395,18 +409,20 @@ func otherGPRulesExist() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanGPPath removes our CtrldCatchAll rule from the GP path and deletes
|
// cleanGPPath removes only ctrld's GP-path rule and deletes the GP parent when
|
||||||
// the GP DnsPolicyConfig parent key if no other rules remain. Removing the
|
// no rules remain. The return value tells callers whether the parent key was
|
||||||
// empty GP key is critical: its mere existence forces DNS Client into "GP mode"
|
// actually deleted, which means DNS Client should be signaled once.
|
||||||
// where local-path rules are ignored.
|
//
|
||||||
func cleanGPPath() {
|
// Do not leave an empty GP parent behind: Windows treats the parent key itself
|
||||||
|
// as the policy store boundary, so an empty key can still hide local-path rules.
|
||||||
|
func cleanGPPath() bool {
|
||||||
// Delete our specific rule.
|
// Delete our specific rule.
|
||||||
registry.DeleteKey(registry.LOCAL_MACHINE, nrptBaseKey+`\`+nrptRuleName)
|
registry.DeleteKey(registry.LOCAL_MACHINE, nrptBaseKey+`\`+nrptRuleName)
|
||||||
|
|
||||||
// If the GP parent key is now empty, delete it entirely to exit "GP mode".
|
// If the GP parent key is now empty, delete it entirely to exit "GP mode".
|
||||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE, nrptBaseKey, registry.ENUMERATE_SUB_KEYS)
|
k, err := registry.OpenKey(registry.LOCAL_MACHINE, nrptBaseKey, registry.ENUMERATE_SUB_KEYS)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return // Key doesn't exist — clean state.
|
return false // Key doesn't exist — clean state.
|
||||||
}
|
}
|
||||||
names, err := k.ReadSubKeyNames(-1)
|
names, err := k.ReadSubKeyNames(-1)
|
||||||
k.Close()
|
k.Close()
|
||||||
@@ -414,12 +430,14 @@ func cleanGPPath() {
|
|||||||
if len(names) > 0 {
|
if len(names) > 0 {
|
||||||
mainLog.Load().Debug().Strs("remaining", names).Msg("DNS intercept: GP path has other rules, leaving parent key")
|
mainLog.Load().Debug().Strs("remaining", names).Msg("DNS intercept: GP path has other rules, leaving parent key")
|
||||||
}
|
}
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
// Empty — delete it to exit "GP mode".
|
// Empty — delete it to exit "GP mode".
|
||||||
if err := registry.DeleteKey(registry.LOCAL_MACHINE, nrptBaseKey); err == nil {
|
if err := registry.DeleteKey(registry.LOCAL_MACHINE, nrptBaseKey); err == nil {
|
||||||
mainLog.Load().Info().Msg("DNS intercept: deleted empty GP DnsPolicyConfig key (exits GP mode)")
|
mainLog.Load().Info().Msg("DNS intercept: deleted empty GP DnsPolicyConfig key (exits GP mode)")
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeNRPTRule writes a single NRPT catch-all rule at the given registry keyPath.
|
// writeNRPTRule writes a single NRPT catch-all rule at the given registry keyPath.
|
||||||
@@ -527,8 +545,6 @@ func refreshNRPTPolicy() {
|
|||||||
exec.Command("gpupdate", "/target:computer", "/force").Run()
|
exec.Command("gpupdate", "/target:computer", "/force").Run()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// RefreshPolicyEx(BOOL bMachine, DWORD dwOptions)
|
|
||||||
// bMachine=1 (TRUE) = refresh computer policy, dwOptions=1 (RP_FORCE) = force refresh
|
|
||||||
ret, _, _ := procRefreshPolicyEx.Call(1, 1)
|
ret, _, _ := procRefreshPolicyEx.Call(1, 1)
|
||||||
if ret != 0 {
|
if ret != 0 {
|
||||||
mainLog.Load().Debug().Msg("DNS intercept: triggered machine GP refresh via RefreshPolicyEx")
|
mainLog.Load().Debug().Msg("DNS intercept: triggered machine GP refresh via RefreshPolicyEx")
|
||||||
@@ -540,12 +556,12 @@ func refreshNRPTPolicy() {
|
|||||||
|
|
||||||
// flushDNSCache flushes the Windows DNS Client resolver cache and triggers a
|
// flushDNSCache flushes the Windows DNS Client resolver cache and triggers a
|
||||||
// Group Policy refresh so NRPT changes take effect immediately.
|
// Group Policy refresh so NRPT changes take effect immediately.
|
||||||
// Uses DnsFlushResolverCache from dnsapi.dll + RefreshPolicyEx from userenv.dll.
|
|
||||||
func flushDNSCache() {
|
func flushDNSCache() {
|
||||||
// Step 1: Refresh GP so DNS Client loads the new NRPT rules from registry.
|
|
||||||
refreshNRPTPolicy()
|
refreshNRPTPolicy()
|
||||||
|
flushDNSCacheOnly()
|
||||||
|
}
|
||||||
|
|
||||||
// Step 2: Flush the DNS cache so stale entries from pre-NRPT resolution are cleared.
|
func flushDNSCacheOnly() {
|
||||||
if err := dnsapiDLL.Load(); err == nil {
|
if err := dnsapiDLL.Load(); err == nil {
|
||||||
if err := procDnsFlushResolverCache.Find(); err == nil {
|
if err := procDnsFlushResolverCache.Find(); err == nil {
|
||||||
ret, _, _ := procDnsFlushResolverCache.Call()
|
ret, _, _ := procDnsFlushResolverCache.Call()
|
||||||
@@ -555,7 +571,6 @@ func flushDNSCache() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fallback: use ipconfig /flushdns.
|
|
||||||
if out, err := exec.Command("ipconfig", "/flushdns").CombinedOutput(); err != nil {
|
if out, err := exec.Command("ipconfig", "/flushdns").CombinedOutput(); err != nil {
|
||||||
mainLog.Load().Debug().Msgf("DNS intercept: ipconfig /flushdns failed: %v: %s", err, string(out))
|
mainLog.Load().Debug().Msgf("DNS intercept: ipconfig /flushdns failed: %v: %s", err, string(out))
|
||||||
} else {
|
} else {
|
||||||
@@ -563,6 +578,93 @@ func flushDNSCache() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func signalNRPTChange() {
|
||||||
|
refreshNRPTPolicy()
|
||||||
|
sendParamChange()
|
||||||
|
flushDNSCacheOnly()
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendParamChange sends SERVICE_CONTROL_PARAMCHANGE to the DNS Client (Dnscache)
|
||||||
|
// service, signaling it to re-read its configuration including NRPT rules from
|
||||||
|
// the registry. This is the standard mechanism used by FortiClient, Tailscale,
|
||||||
|
// and other DNS-aware software — it's reliable and non-disruptive unlike
|
||||||
|
// restarting the Dnscache service (which always fails on modern Windows because
|
||||||
|
// Dnscache is a protected shared svchost service).
|
||||||
|
func sendParamChange() {
|
||||||
|
if out, err := exec.Command("sc", "control", "dnscache", "paramchange").CombinedOutput(); err != nil {
|
||||||
|
mainLog.Load().Debug().Err(err).Str("output", string(out)).Msg("DNS intercept: sc control dnscache paramchange failed")
|
||||||
|
} else {
|
||||||
|
mainLog.Load().Debug().Msg("DNS intercept: sent paramchange to Dnscache service")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanEmptyNRPTParent removes empty NRPT parent keys that block activation.
|
||||||
|
// Empty GP and local parents have different failure shapes:
|
||||||
|
// - empty GP parent: DNS Client is in GP mode and ignores local-path rules;
|
||||||
|
// - empty local parent: DNS Client can cache an empty local policy store.
|
||||||
|
//
|
||||||
|
// This helper only changes registry state. The caller sends the single
|
||||||
|
// RefreshPolicyEx/paramchange/flush signal after it knows cleanup occurred.
|
||||||
|
//
|
||||||
|
// Returns true if cleanup was performed (caller should signal DNS Client).
|
||||||
|
func cleanEmptyNRPTParent() bool {
|
||||||
|
// Always clean the GP path — its existence blocks local path activation.
|
||||||
|
cleaned := cleanGPPath()
|
||||||
|
|
||||||
|
// Clean empty local/direct path parent key.
|
||||||
|
if !nrptParentKeyEmpty(nrptDirectKey) {
|
||||||
|
return cleaned
|
||||||
|
}
|
||||||
|
|
||||||
|
mainLog.Load().Warn().Msg("DNS intercept: found empty NRPT local parent key (blocks activation) — removing")
|
||||||
|
if err := registry.DeleteKey(registry.LOCAL_MACHINE, nrptDirectKey); err != nil {
|
||||||
|
mainLog.Load().Warn().Err(err).Msg("DNS intercept: failed to delete empty NRPT local parent key")
|
||||||
|
return cleaned
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func nrptParentKeyEmpty(keyPath string) bool {
|
||||||
|
k, err := registry.OpenKey(registry.LOCAL_MACHINE, keyPath, registry.ENUMERATE_SUB_KEYS)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
names, err := k.ReadSubKeyNames(-1)
|
||||||
|
k.Close()
|
||||||
|
return err == nil && len(names) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// logNRPTParentKeyState logs the state of both NRPT registry paths for diagnostics.
|
||||||
|
func logNRPTParentKeyState(context string) {
|
||||||
|
for _, path := range []struct {
|
||||||
|
name string
|
||||||
|
key string
|
||||||
|
}{
|
||||||
|
{"GP", nrptBaseKey},
|
||||||
|
{"local", nrptDirectKey},
|
||||||
|
} {
|
||||||
|
k, err := registry.OpenKey(registry.LOCAL_MACHINE, path.key, registry.ENUMERATE_SUB_KEYS)
|
||||||
|
if err != nil {
|
||||||
|
mainLog.Load().Debug().Str("context", context).Str("path", path.name).
|
||||||
|
Msg("DNS intercept: NRPT parent key does not exist")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
names, err := k.ReadSubKeyNames(-1)
|
||||||
|
k.Close()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(names) == 0 {
|
||||||
|
mainLog.Load().Warn().Str("context", context).Str("path", path.name).
|
||||||
|
Msg("DNS intercept: NRPT parent key exists but is EMPTY — blocks activation")
|
||||||
|
} else {
|
||||||
|
mainLog.Load().Debug().Str("context", context).Str("path", path.name).
|
||||||
|
Int("subkeys", len(names)).Strs("names", names).
|
||||||
|
Msg("DNS intercept: NRPT parent key state")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// startDNSIntercept activates WFP-based DNS interception on Windows.
|
// startDNSIntercept activates WFP-based DNS interception on Windows.
|
||||||
// It creates a WFP sublayer and adds filters that block all outbound DNS (port 53)
|
// It creates a WFP sublayer and adds filters that block all outbound DNS (port 53)
|
||||||
// traffic except to localhost (127.0.0.1/::1), ensuring all DNS queries must go
|
// traffic except to localhost (127.0.0.1/::1), ensuring all DNS queries must go
|
||||||
@@ -596,23 +698,23 @@ func (p *prog) startDNSIntercept() error {
|
|||||||
// Step 1: Add NRPT catch-all rule (both dns and hard modes).
|
// Step 1: Add NRPT catch-all rule (both dns and hard modes).
|
||||||
// NRPT must succeed before proceeding with WFP in hard mode.
|
// NRPT must succeed before proceeding with WFP in hard mode.
|
||||||
mainLog.Load().Info().Msgf("DNS intercept: initializing (mode: %s)", interceptMode)
|
mainLog.Load().Info().Msgf("DNS intercept: initializing (mode: %s)", interceptMode)
|
||||||
|
|
||||||
logNRPTParentKeyState("pre-write")
|
logNRPTParentKeyState("pre-write")
|
||||||
|
|
||||||
// Two-phase empty parent key recovery: if the GP DnsPolicyConfig key exists
|
// Empty parent key recovery: if the GP DnsPolicyConfig key exists but is
|
||||||
// but is empty, DNS Client has cached a "no rules" state and won't accept
|
// empty, DNS Client enters GP mode and hides local rules. Delete empty
|
||||||
// new rules even after they're written. Delete the empty key and signal DNS
|
// parents first, then send one change signal so DNS Client drops stale state.
|
||||||
// Client to reset before writing our rule.
|
if cleanEmptyNRPTParent() {
|
||||||
// Two-phase recovery handles its own 2s signaling burst internally.
|
signalNRPTChange()
|
||||||
cleanEmptyNRPTParent()
|
}
|
||||||
|
|
||||||
if err := addNRPTCatchAllRule(listenerIP); err != nil {
|
if err := addNRPTCatchAllRule(listenerIP); err != nil {
|
||||||
return fmt.Errorf("dns intercept: failed to add NRPT catch-all rule: %w", err)
|
return fmt.Errorf("dns intercept: failed to add NRPT catch-all rule: %w", err)
|
||||||
}
|
}
|
||||||
logNRPTParentKeyState("post-write")
|
logNRPTParentKeyState("post-write")
|
||||||
|
|
||||||
state.nrptActive = true
|
state.nrptActive = true
|
||||||
refreshNRPTPolicy()
|
signalNRPTChange()
|
||||||
sendParamChange()
|
|
||||||
flushDNSCache()
|
|
||||||
mainLog.Load().Info().Msgf("DNS intercept: NRPT catch-all rule active — all DNS queries directed to %s", listenerIP)
|
mainLog.Load().Info().Msgf("DNS intercept: NRPT catch-all rule active — all DNS queries directed to %s", listenerIP)
|
||||||
|
|
||||||
// Step 2: In hard mode, also set up WFP filters to block non-local DNS.
|
// Step 2: In hard mode, also set up WFP filters to block non-local DNS.
|
||||||
@@ -762,13 +864,13 @@ func (p *prog) startWFPFilters(state *wfpState) error {
|
|||||||
// to work without dynamic per-server exemptions.
|
// to work without dynamic per-server exemptions.
|
||||||
privateRanges := []struct {
|
privateRanges := []struct {
|
||||||
name string
|
name string
|
||||||
addr uint32 // host byte order
|
addr uint32
|
||||||
mask uint32 // host byte order
|
mask uint32
|
||||||
}{
|
}{
|
||||||
{"10.0.0.0/8", 0x0A000000, 0xFF000000},
|
{"10.0.0.0/8", 0x0A000000, 0xFF000000},
|
||||||
{"172.16.0.0/12", 0xAC100000, 0xFFF00000},
|
{"172.16.0.0/12", 0xAC100000, 0xFFF00000},
|
||||||
{"192.168.0.0/16", 0xC0A80000, 0xFFFF0000},
|
{"192.168.0.0/16", 0xC0A80000, 0xFFFF0000},
|
||||||
{"100.64.0.0/10", 0x64400000, 0xFFC00000}, // CGNAT (includes Tailscale)
|
{"100.64.0.0/10", 0x64400000, 0xFFC00000},
|
||||||
}
|
}
|
||||||
for _, r := range privateRanges {
|
for _, r := range privateRanges {
|
||||||
for _, proto := range []struct {
|
for _, proto := range []struct {
|
||||||
@@ -990,7 +1092,6 @@ func wfpSublayerExists(engineHandle uintptr) bool {
|
|||||||
if r1 != 0 {
|
if r1 != 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// Free the returned sublayer struct.
|
|
||||||
if sublayerPtr != 0 {
|
if sublayerPtr != 0 {
|
||||||
procFwpmFreeMemory0.Call(uintptr(unsafe.Pointer(&sublayerPtr)))
|
procFwpmFreeMemory0.Call(uintptr(unsafe.Pointer(&sublayerPtr)))
|
||||||
}
|
}
|
||||||
@@ -1088,8 +1189,6 @@ func (p *prog) cleanupWFPFilters(state *wfpState) {
|
|||||||
// installs WFP block filters via block-outside-dns. The hard permit (with
|
// installs WFP block filters via block-outside-dns. The hard permit (with
|
||||||
// FWPM_FILTER_FLAG_CLEAR_ACTION_RIGHT) in a max-weight sublayer overrides the
|
// FWPM_FILTER_FLAG_CLEAR_ACTION_RIGHT) in a max-weight sublayer overrides the
|
||||||
// third-party blocks without affecting their protection for non-loopback DNS.
|
// third-party blocks without affecting their protection for non-loopback DNS.
|
||||||
//
|
|
||||||
// See: https://gitlab.int.windscribe.com/controld/clients/ctrld/-/issues/526
|
|
||||||
func (p *prog) activateLoopbackWFPProtect(state *wfpState) error {
|
func (p *prog) activateLoopbackWFPProtect(state *wfpState) error {
|
||||||
state.mu.Lock()
|
state.mu.Lock()
|
||||||
defer state.mu.Unlock()
|
defer state.mu.Unlock()
|
||||||
@@ -1294,9 +1393,12 @@ func (p *prog) stopDNSIntercept() error {
|
|||||||
//
|
//
|
||||||
// The function is idempotent: it first removes ALL existing VPN permit filters,
|
// The function is idempotent: it first removes ALL existing VPN permit filters,
|
||||||
// then adds new ones for the current server list. When called with nil/empty
|
// then adds new ones for the current server list. When called with nil/empty
|
||||||
// servers (VPN disconnected), it just removes the old permits — leaving only
|
// exemptions (VPN disconnected), it just removes the old permits — leaving only
|
||||||
// the localhost permits and block-all filters active.
|
// the localhost permits and block-all filters active.
|
||||||
//
|
//
|
||||||
|
// On Windows, WFP filters are process-scoped (not interface-scoped like macOS pf),
|
||||||
|
// so we only use the server IPs from the exemptions.
|
||||||
|
//
|
||||||
// Supports both IPv4 and IPv6 VPN DNS servers.
|
// Supports both IPv4 and IPv6 VPN DNS servers.
|
||||||
//
|
//
|
||||||
// Called by vpnDNSManager.onServersChanged() whenever VPN DNS servers change.
|
// Called by vpnDNSManager.onServersChanged() whenever VPN DNS servers change.
|
||||||
@@ -1323,7 +1425,7 @@ func (p *prog) exemptVPNDNSServers(exemptions []vpnDNSExemption) error {
|
|||||||
}
|
}
|
||||||
state.vpnPermitFilterIDs = nil
|
state.vpnPermitFilterIDs = nil
|
||||||
|
|
||||||
// Extract unique server IPs (WFP doesn't need interface info).
|
// Extract unique server IPs from exemptions (WFP doesn't need interface info).
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
var servers []string
|
var servers []string
|
||||||
for _, ex := range exemptions {
|
for _, ex := range exemptions {
|
||||||
@@ -1541,9 +1643,10 @@ func (p *prog) scheduleDelayedRechecks() {
|
|||||||
}
|
}
|
||||||
// Refresh OS resolver — VPN may have finished DNS cleanup since the
|
// Refresh OS resolver — VPN may have finished DNS cleanup since the
|
||||||
// immediate handler ran.
|
// immediate handler ran.
|
||||||
ctrld.InitializeOsResolver(true)
|
ctx := ctrld.LoggerCtx(context.Background(), p.logger.Load())
|
||||||
|
ctrld.InitializeOsResolver(ctx, true)
|
||||||
if p.vpnDNS != nil {
|
if p.vpnDNS != nil {
|
||||||
p.vpnDNS.Refresh(true)
|
p.vpnDNS.Refresh(ctx, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NRPT watchdog: some VPN software clears NRPT policy rules on
|
// NRPT watchdog: some VPN software clears NRPT policy rules on
|
||||||
@@ -1555,17 +1658,14 @@ func (p *prog) scheduleDelayedRechecks() {
|
|||||||
mainLog.Load().Error().Err(err).Msg("DNS intercept: failed to re-add NRPT catch-all rule")
|
mainLog.Load().Error().Err(err).Msg("DNS intercept: failed to re-add NRPT catch-all rule")
|
||||||
state.nrptActive = false
|
state.nrptActive = false
|
||||||
} else {
|
} else {
|
||||||
flushDNSCache()
|
signalNRPTChange()
|
||||||
mainLog.Load().Info().Msg("DNS intercept: NRPT catch-all rule restored")
|
mainLog.Load().Info().Msg("DNS intercept: NRPT catch-all rule restored")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WFP watchdog: verify our sublayer still exists. If another program
|
// WFP watchdog: verify our sublayer still exists.
|
||||||
// or a crash removed it, the block filters are gone too.
|
|
||||||
if ok && state.engineHandle != 0 && !wfpSublayerExists(state.engineHandle) {
|
if ok && state.engineHandle != 0 && !wfpSublayerExists(state.engineHandle) {
|
||||||
mainLog.Load().Warn().Msg("DNS intercept: WFP sublayer was removed externally — re-creating all filters")
|
mainLog.Load().Warn().Msg("DNS intercept: WFP sublayer was removed externally — re-creating all filters")
|
||||||
// Full teardown + re-init. stopDNSIntercept clears state,
|
|
||||||
// then startDNSIntercept creates everything fresh.
|
|
||||||
_ = p.stopDNSIntercept()
|
_ = p.stopDNSIntercept()
|
||||||
if err := p.startDNSIntercept(); err != nil {
|
if err := p.startDNSIntercept(); err != nil {
|
||||||
mainLog.Load().Error().Err(err).Msg("DNS intercept: failed to re-create WFP filters")
|
mainLog.Load().Error().Err(err).Msg("DNS intercept: failed to re-create WFP filters")
|
||||||
@@ -1590,17 +1690,24 @@ func (p *prog) nrptHealthMonitor(state *wfpState) {
|
|||||||
if !state.nrptActive {
|
if !state.nrptActive {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: Check registry key exists.
|
// Step 1: Check registry key exists.
|
||||||
if !nrptCatchAllRuleExists() {
|
if !nrptCatchAllRuleExists() {
|
||||||
|
now := time.Now()
|
||||||
|
if ok, wait := state.nrptRecoveryLimiter.allow(now, p.cfg); !ok {
|
||||||
|
if state.nrptRecoveryLimiter.shouldLogSkip(now) {
|
||||||
|
mainLog.Load().Warn().Str("remaining", wait.String()).
|
||||||
|
Msg("DNS intercept: NRPT rule restore suppressed after repeated recovery flows")
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
mainLog.Load().Warn().Msg("DNS intercept: NRPT health check — catch-all rule missing, restoring")
|
mainLog.Load().Warn().Msg("DNS intercept: NRPT health check — catch-all rule missing, restoring")
|
||||||
if err := addNRPTCatchAllRule(state.listenerIP); err != nil {
|
if err := addNRPTCatchAllRule(state.listenerIP); err != nil {
|
||||||
mainLog.Load().Error().Err(err).Msg("DNS intercept: failed to restore NRPT catch-all rule")
|
mainLog.Load().Error().Err(err).Msg("DNS intercept: failed to restore NRPT catch-all rule")
|
||||||
state.nrptActive = false
|
state.nrptActive = false
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
refreshNRPTPolicy()
|
signalNRPTChange()
|
||||||
flushDNSCache()
|
state.nrptRecoveryLimiter.recordRecoveryFlow(time.Now(), p.cfg)
|
||||||
mainLog.Load().Info().Msg("DNS intercept: NRPT catch-all rule restored by health monitor")
|
mainLog.Load().Info().Msg("DNS intercept: NRPT catch-all rule restored by health monitor")
|
||||||
// After restoring, verify it's actually working.
|
// After restoring, verify it's actually working.
|
||||||
go p.nrptProbeAndHeal()
|
go p.nrptProbeAndHeal()
|
||||||
@@ -1612,6 +1719,8 @@ func (p *prog) nrptHealthMonitor(state *wfpState) {
|
|||||||
if !p.probeNRPT() {
|
if !p.probeNRPT() {
|
||||||
mainLog.Load().Warn().Msg("DNS intercept: NRPT health check — rule present but probe failed, running heal cycle")
|
mainLog.Load().Warn().Msg("DNS intercept: NRPT health check — rule present but probe failed, running heal cycle")
|
||||||
go p.nrptProbeAndHeal()
|
go p.nrptProbeAndHeal()
|
||||||
|
} else {
|
||||||
|
state.nrptRecoveryLimiter.recordStableSuccess()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: In hard mode, also verify WFP sublayer.
|
// Step 3: In hard mode, also verify WFP sublayer.
|
||||||
@@ -1695,106 +1804,42 @@ func (p *prog) probeNRPT() bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendParamChange sends SERVICE_CONTROL_PARAMCHANGE to the DNS Client (Dnscache)
|
|
||||||
// service, signaling it to re-read its configuration including NRPT rules from
|
|
||||||
// the registry. This is the standard mechanism used by FortiClient, Tailscale,
|
|
||||||
// and other DNS-aware software — it's reliable and non-disruptive unlike
|
|
||||||
// restarting the Dnscache service (which always fails on modern Windows because
|
|
||||||
// Dnscache is a protected shared svchost service).
|
|
||||||
func sendParamChange() {
|
|
||||||
if out, err := exec.Command("sc", "control", "dnscache", "paramchange").CombinedOutput(); err != nil {
|
|
||||||
mainLog.Load().Debug().Err(err).Str("output", string(out)).Msg("DNS intercept: sc control dnscache paramchange failed")
|
|
||||||
} else {
|
|
||||||
mainLog.Load().Debug().Msg("DNS intercept: sent paramchange to Dnscache service")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// cleanEmptyNRPTParent removes empty NRPT parent keys that block activation.
|
|
||||||
// An empty DnsPolicyConfig key (exists but no subkeys) causes DNS Client to
|
|
||||||
// cache "no rules" and ignore subsequently-added rules.
|
|
||||||
//
|
|
||||||
// Also cleans the GP path entirely if it has no non-ctrld rules, since the GP
|
|
||||||
// path's existence forces DNS Client into "GP mode" where it ignores the local
|
|
||||||
// service store path.
|
|
||||||
//
|
|
||||||
// Returns true if cleanup was performed (caller should add a delay).
|
|
||||||
func cleanEmptyNRPTParent() bool {
|
|
||||||
cleaned := false
|
|
||||||
|
|
||||||
// Always clean the GP path — its existence blocks local path activation.
|
|
||||||
cleanGPPath()
|
|
||||||
|
|
||||||
// Clean empty local/direct path parent key.
|
|
||||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE, nrptDirectKey, registry.ENUMERATE_SUB_KEYS)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
names, err := k.ReadSubKeyNames(-1)
|
|
||||||
k.Close()
|
|
||||||
if err != nil || len(names) > 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
mainLog.Load().Warn().Msg("DNS intercept: found empty NRPT local parent key (blocks activation) — removing")
|
|
||||||
if err := registry.DeleteKey(registry.LOCAL_MACHINE, nrptDirectKey); err != nil {
|
|
||||||
mainLog.Load().Warn().Err(err).Msg("DNS intercept: failed to delete empty NRPT local parent key")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
cleaned = true
|
|
||||||
|
|
||||||
// Signal DNS Client to process the deletion and reset its internal cache.
|
|
||||||
mainLog.Load().Info().Msg("DNS intercept: empty NRPT parent key removed — signaling DNS Client")
|
|
||||||
sendParamChange()
|
|
||||||
flushDNSCache()
|
|
||||||
return cleaned
|
|
||||||
}
|
|
||||||
|
|
||||||
// logNRPTParentKeyState logs the state of both NRPT registry paths for diagnostics.
|
|
||||||
func logNRPTParentKeyState(context string) {
|
|
||||||
for _, path := range []struct {
|
|
||||||
name string
|
|
||||||
key string
|
|
||||||
}{
|
|
||||||
{"GP", nrptBaseKey},
|
|
||||||
{"local", nrptDirectKey},
|
|
||||||
} {
|
|
||||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE, path.key, registry.ENUMERATE_SUB_KEYS)
|
|
||||||
if err != nil {
|
|
||||||
mainLog.Load().Debug().Str("context", context).Str("path", path.name).
|
|
||||||
Msg("DNS intercept: NRPT parent key does not exist")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
names, err := k.ReadSubKeyNames(-1)
|
|
||||||
k.Close()
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(names) == 0 {
|
|
||||||
mainLog.Load().Warn().Str("context", context).Str("path", path.name).
|
|
||||||
Msg("DNS intercept: NRPT parent key exists but is EMPTY — blocks activation")
|
|
||||||
} else {
|
|
||||||
mainLog.Load().Debug().Str("context", context).Str("path", path.name).
|
|
||||||
Int("subkeys", len(names)).Strs("names", names).
|
|
||||||
Msg("DNS intercept: NRPT parent key state")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// nrptProbeAndHeal runs the NRPT probe with retries and escalating remediation.
|
// nrptProbeAndHeal runs the NRPT probe with retries and escalating remediation.
|
||||||
// Called asynchronously after startup and from the health monitor.
|
// Called asynchronously after startup and from the health monitor.
|
||||||
//
|
//
|
||||||
// Retry sequence (each attempt: GP refresh + paramchange + flush → sleep → probe):
|
// Retry sequence:
|
||||||
// 1. Immediate probe
|
// 1. Immediate probe.
|
||||||
// 2. GP refresh + paramchange + flush → 1s → probe
|
// 2. If the GP parent is empty, clean it immediately, signal once, then probe.
|
||||||
// 3. GP refresh + paramchange + flush → 2s → probe
|
// This is intentionally before the normal retry loop: policy refresh and
|
||||||
// 4. GP refresh + paramchange + flush → 4s → probe
|
// Dnscache paramchange cannot make local rules visible while GP mode is
|
||||||
|
// selected by an empty GP parent.
|
||||||
|
// 3. Otherwise, signal DNS Client with increasing backoff between probes.
|
||||||
func (p *prog) nrptProbeAndHeal() {
|
func (p *prog) nrptProbeAndHeal() {
|
||||||
|
state, _ := p.dnsInterceptState.(*wfpState)
|
||||||
|
if state != nil {
|
||||||
|
now := time.Now()
|
||||||
|
if ok, wait := state.nrptRecoveryLimiter.allow(now, p.cfg); !ok {
|
||||||
|
if state.nrptRecoveryLimiter.shouldLogSkip(now) {
|
||||||
|
mainLog.Load().Warn().Str("remaining", wait.String()).
|
||||||
|
Msg("DNS intercept: NRPT recovery suppressed after repeated failed recovery flows")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !nrptProbeRunning.CompareAndSwap(false, true) {
|
if !nrptProbeRunning.CompareAndSwap(false, true) {
|
||||||
mainLog.Load().Debug().Msg("DNS intercept: NRPT probe already running, skipping")
|
mainLog.Load().Debug().Msg("DNS intercept: NRPT probe already running, skipping")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer nrptProbeRunning.Store(false)
|
defer nrptProbeRunning.Store(false)
|
||||||
|
|
||||||
|
remediated := false
|
||||||
|
defer func() {
|
||||||
|
if remediated && state != nil {
|
||||||
|
state.nrptRecoveryLimiter.recordRecoveryFlow(time.Now(), p.cfg)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
mainLog.Load().Info().Msg("DNS intercept: starting NRPT verification probe sequence")
|
mainLog.Load().Info().Msg("DNS intercept: starting NRPT verification probe sequence")
|
||||||
|
|
||||||
// Log parent key state for diagnostics.
|
// Log parent key state for diagnostics.
|
||||||
@@ -1805,17 +1850,37 @@ func (p *prog) nrptProbeAndHeal() {
|
|||||||
mainLog.Load().Info().Msg("DNS intercept: NRPT verified working")
|
mainLog.Load().Info().Msg("DNS intercept: NRPT verified working")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
remediated = true
|
||||||
|
|
||||||
// Attempts 2-4: GP refresh + paramchange + flush with increasing backoff
|
// If the GP parent exists but is empty, do not burn retries on Windows
|
||||||
|
// signaling. Those retries create SIEM noise but cannot succeed because DNS
|
||||||
|
// Client is still reading the empty GP store instead of the populated local
|
||||||
|
// store. Delete the blocker, send one notification, then re-probe.
|
||||||
|
if nrptParentKeyEmpty(nrptBaseKey) {
|
||||||
|
mainLog.Load().Warn().Msg("DNS intercept: NRPT probe failed with empty GP parent — cleaning before retry signaling")
|
||||||
|
if cleanEmptyNRPTParent() {
|
||||||
|
signalNRPTChange()
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
logNRPTParentKeyState("empty-gp-after-clean")
|
||||||
|
if p.probeNRPT() {
|
||||||
|
mainLog.Load().Info().Msg("DNS intercept: NRPT verified working after empty GP parent cleanup")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if nrptParentKeyEmpty(nrptBaseKey) {
|
||||||
|
mainLog.Load().Warn().Msg("DNS intercept: empty GP NRPT parent still present after cleanup; skipping redundant policy refresh retries")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempts 2-4: signal DNS Client with increasing backoff between probes.
|
||||||
delays := []time.Duration{1 * time.Second, 2 * time.Second, 4 * time.Second}
|
delays := []time.Duration{1 * time.Second, 2 * time.Second, 4 * time.Second}
|
||||||
for i, delay := range delays {
|
for i, delay := range delays {
|
||||||
attempt := i + 2
|
attempt := i + 2
|
||||||
mainLog.Load().Info().Int("attempt", attempt).Dur("delay", delay).
|
mainLog.Load().Info().Int("attempt", attempt).Str("delay", delay.String()).
|
||||||
Msg("DNS intercept: NRPT probe failed, retrying with GP refresh + paramchange")
|
Msg("DNS intercept: NRPT probe failed, retrying with policy refresh + paramchange")
|
||||||
logNRPTParentKeyState(fmt.Sprintf("probe-attempt-%d", attempt))
|
logNRPTParentKeyState(fmt.Sprintf("probe-attempt-%d", attempt))
|
||||||
refreshNRPTPolicy()
|
signalNRPTChange()
|
||||||
sendParamChange()
|
|
||||||
flushDNSCache()
|
|
||||||
time.Sleep(delay)
|
time.Sleep(delay)
|
||||||
if p.probeNRPT() {
|
if p.probeNRPT() {
|
||||||
mainLog.Load().Info().Int("attempt", attempt).
|
mainLog.Load().Info().Int("attempt", attempt).
|
||||||
@@ -1829,17 +1894,14 @@ func (p *prog) nrptProbeAndHeal() {
|
|||||||
// signal DNS Client to forget it, wait, then re-add and signal again.
|
// signal DNS Client to forget it, wait, then re-add and signal again.
|
||||||
mainLog.Load().Warn().Msg("DNS intercept: all probes failed — attempting two-phase NRPT recovery (delete → signal → re-add)")
|
mainLog.Load().Warn().Msg("DNS intercept: all probes failed — attempting two-phase NRPT recovery (delete → signal → re-add)")
|
||||||
listenerIP := "127.0.0.1"
|
listenerIP := "127.0.0.1"
|
||||||
if state, ok := p.dnsInterceptState.(*wfpState); ok {
|
if state != nil {
|
||||||
listenerIP = state.listenerIP
|
listenerIP = state.listenerIP
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 1: Remove our rule and the parent key if now empty.
|
// Phase 1: Remove our rule and the parent key if now empty.
|
||||||
_ = removeNRPTCatchAllRule()
|
_ = removeNRPTCatchAllRule()
|
||||||
// If parent key is now empty after removing our rule, delete it too.
|
|
||||||
cleanEmptyNRPTParent()
|
cleanEmptyNRPTParent()
|
||||||
refreshNRPTPolicy()
|
signalNRPTChange()
|
||||||
sendParamChange()
|
|
||||||
flushDNSCache()
|
|
||||||
logNRPTParentKeyState("nuclear-after-delete")
|
logNRPTParentKeyState("nuclear-after-delete")
|
||||||
|
|
||||||
// Wait for DNS Client to process the deletion.
|
// Wait for DNS Client to process the deletion.
|
||||||
@@ -1850,9 +1912,7 @@ func (p *prog) nrptProbeAndHeal() {
|
|||||||
mainLog.Load().Error().Err(err).Msg("DNS intercept: failed to re-add NRPT after nuclear recovery")
|
mainLog.Load().Error().Err(err).Msg("DNS intercept: failed to re-add NRPT after nuclear recovery")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
refreshNRPTPolicy()
|
signalNRPTChange()
|
||||||
sendParamChange()
|
|
||||||
flushDNSCache()
|
|
||||||
logNRPTParentKeyState("nuclear-after-readd")
|
logNRPTParentKeyState("nuclear-after-readd")
|
||||||
|
|
||||||
// Final probe after recovery.
|
// Final probe after recovery.
|
||||||
@@ -1870,7 +1930,6 @@ func (p *prog) nrptProbeAndHeal() {
|
|||||||
// installed WFP filters that block DNS to non-tunnel interfaces, including
|
// installed WFP filters that block DNS to non-tunnel interfaces, including
|
||||||
// loopback. A high-priority "hard permit" for localhost DNS overrides these
|
// loopback. A high-priority "hard permit" for localhost DNS overrides these
|
||||||
// blocks and restores NRPT routing to ctrld's listener.
|
// blocks and restores NRPT routing to ctrld's listener.
|
||||||
// See: https://gitlab.int.windscribe.com/controld/clients/ctrld/-/issues/526
|
|
||||||
loopbackState, ok := p.dnsInterceptState.(*wfpState)
|
loopbackState, ok := p.dnsInterceptState.(*wfpState)
|
||||||
if !ok || loopbackState == nil {
|
if !ok || loopbackState == nil {
|
||||||
mainLog.Load().Error().Msg("DNS intercept: no state available for loopback WFP protect")
|
mainLog.Load().Error().Msg("DNS intercept: no state available for loopback WFP protect")
|
||||||
|
|||||||
+1009
-642
File diff suppressed because it is too large
Load Diff
+388
-14
@@ -22,15 +22,15 @@ func Test_wildcardMatches(t *testing.T) {
|
|||||||
domain string
|
domain string
|
||||||
match bool
|
match bool
|
||||||
}{
|
}{
|
||||||
{"domain - prefix parent should not match", "*.example.com", "example.com", false},
|
{"domain - prefix parent should not match", "*.windscribe.com", "windscribe.com", false},
|
||||||
{"domain - prefix", "*.example.com", "anything.example.com", true},
|
{"domain - prefix", "*.windscribe.com", "anything.windscribe.com", true},
|
||||||
{"domain - prefix not match other s", "*.example.com", "other.org", false},
|
{"domain - prefix not match other s", "*.windscribe.com", "example.com", false},
|
||||||
{"domain - prefix not match s in name", "*.example.com", "eexample.com", false},
|
{"domain - prefix not match s in name", "*.windscribe.com", "wwindscribe.com", false},
|
||||||
{"domain - suffix", "suffix.*", "suffix.example.com", true},
|
{"domain - suffix", "suffix.*", "suffix.windscribe.com", true},
|
||||||
{"domain - suffix not match other", "suffix.*", "suffix1.example.com", false},
|
{"domain - suffix not match other", "suffix.*", "suffix1.windscribe.com", false},
|
||||||
{"domain - both", "suffix.*.example.com", "suffix.anything.example.com", true},
|
{"domain - both", "suffix.*.windscribe.com", "suffix.anything.windscribe.com", true},
|
||||||
{"domain - both not match", "suffix.*.example.com", "suffix1.suffix.example.com", false},
|
{"domain - both not match", "suffix.*.windscribe.com", "suffix1.suffix.windscribe.com", false},
|
||||||
{"domain - case-insensitive", "*.EXAMPLE.com", "anything.example.com", true},
|
{"domain - case-insensitive", "*.WINDSCRIBE.com", "anything.windscribe.com", true},
|
||||||
{"mac - prefix", "*:98:05:b4:2b", "d4:67:98:05:b4:2b", true},
|
{"mac - prefix", "*:98:05:b4:2b", "d4:67:98:05:b4:2b", true},
|
||||||
{"mac - prefix not match other s", "*:98:05:b4:2b", "0d:ba:54:09:94:2c", false},
|
{"mac - prefix not match other s", "*:98:05:b4:2b", "0d:ba:54:09:94:2c", false},
|
||||||
{"mac - prefix not match s in name", "*:98:05:b4:2b", "e4:67:97:05:b4:2b", false},
|
{"mac - prefix not match s in name", "*:98:05:b4:2b", "e4:67:97:05:b4:2b", false},
|
||||||
@@ -57,9 +57,9 @@ func Test_canonicalName(t *testing.T) {
|
|||||||
domain string
|
domain string
|
||||||
canonical string
|
canonical string
|
||||||
}{
|
}{
|
||||||
{"fqdn to canonical", "example.com.", "example.com"},
|
{"fqdn to canonical", "windscribe.com.", "windscribe.com"},
|
||||||
{"already canonical", "example.com", "example.com"},
|
{"already canonical", "windscribe.com", "windscribe.com"},
|
||||||
{"case insensitive", "Example.Com.", "example.com"},
|
{"case insensitive", "Windscribe.Com.", "windscribe.com"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
@@ -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 {
|
||||||
@@ -465,8 +558,251 @@ func Test_isWanClient(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Test_shouldStartRecovery(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
reason RecoveryReason
|
||||||
|
hasExistingRecovery bool
|
||||||
|
expectedResult bool
|
||||||
|
description string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "network change with existing recovery",
|
||||||
|
reason: RecoveryReasonNetworkChange,
|
||||||
|
hasExistingRecovery: true,
|
||||||
|
expectedResult: true,
|
||||||
|
description: "should cancel existing recovery and start new one for network change",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "network change without existing recovery",
|
||||||
|
reason: RecoveryReasonNetworkChange,
|
||||||
|
hasExistingRecovery: false,
|
||||||
|
expectedResult: true,
|
||||||
|
description: "should start new recovery for network change",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "regular failure with existing recovery",
|
||||||
|
reason: RecoveryReasonRegularFailure,
|
||||||
|
hasExistingRecovery: true,
|
||||||
|
expectedResult: false,
|
||||||
|
description: "should skip duplicate recovery for regular failure",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "regular failure without existing recovery",
|
||||||
|
reason: RecoveryReasonRegularFailure,
|
||||||
|
hasExistingRecovery: false,
|
||||||
|
expectedResult: true,
|
||||||
|
description: "should start new recovery for regular failure",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "OS failure with existing recovery",
|
||||||
|
reason: RecoveryReasonOSFailure,
|
||||||
|
hasExistingRecovery: true,
|
||||||
|
expectedResult: false,
|
||||||
|
description: "should skip duplicate recovery for OS failure",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "OS failure without existing recovery",
|
||||||
|
reason: RecoveryReasonOSFailure,
|
||||||
|
hasExistingRecovery: false,
|
||||||
|
expectedResult: true,
|
||||||
|
description: "should start new recovery for OS failure",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
p := newTestProg(t)
|
||||||
|
|
||||||
|
// Setup existing recovery if needed
|
||||||
|
if tc.hasExistingRecovery {
|
||||||
|
p.recoveryCancelMu.Lock()
|
||||||
|
p.recoveryCancel = func() {} // Mock cancel function
|
||||||
|
p.recoveryCancelMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
result := p.shouldStartRecovery(tc.reason)
|
||||||
|
assert.Equal(t, tc.expectedResult, result, tc.description)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_createRecoveryContext(t *testing.T) {
|
||||||
|
p := newTestProg(t)
|
||||||
|
|
||||||
|
ctx, cleanup := p.createRecoveryContext()
|
||||||
|
|
||||||
|
// Verify context is created
|
||||||
|
assert.NotNil(t, ctx)
|
||||||
|
assert.NotNil(t, cleanup)
|
||||||
|
|
||||||
|
// Verify recoveryCancel is set
|
||||||
|
p.recoveryCancelMu.Lock()
|
||||||
|
assert.NotNil(t, p.recoveryCancel)
|
||||||
|
p.recoveryCancelMu.Unlock()
|
||||||
|
|
||||||
|
// Test cleanup function
|
||||||
|
cleanup()
|
||||||
|
|
||||||
|
// Verify recoveryCancel is cleared
|
||||||
|
p.recoveryCancelMu.Lock()
|
||||||
|
assert.Nil(t, p.recoveryCancel)
|
||||||
|
p.recoveryCancelMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_prepareForRecovery(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
reason RecoveryReason
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "regular failure",
|
||||||
|
reason: RecoveryReasonRegularFailure,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "network change",
|
||||||
|
reason: RecoveryReasonNetworkChange,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "OS failure",
|
||||||
|
reason: RecoveryReasonOSFailure,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
p := newTestProg(t)
|
||||||
|
|
||||||
|
err := p.prepareForRecovery(tc.reason)
|
||||||
|
|
||||||
|
if tc.wantErr {
|
||||||
|
assert.Error(t, err)
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify recoveryRunning is set to true
|
||||||
|
assert.True(t, p.recoveryRunning.Load())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_completeRecovery(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
reason RecoveryReason
|
||||||
|
recovered string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "regular failure recovery",
|
||||||
|
reason: RecoveryReasonRegularFailure,
|
||||||
|
recovered: "upstream1",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "network change recovery",
|
||||||
|
reason: RecoveryReasonNetworkChange,
|
||||||
|
recovered: "upstream2",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "OS failure recovery",
|
||||||
|
reason: RecoveryReasonOSFailure,
|
||||||
|
recovered: "upstream3",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
p := newTestProg(t)
|
||||||
|
|
||||||
|
err := p.completeRecovery(tc.reason, tc.recovered)
|
||||||
|
|
||||||
|
if tc.wantErr {
|
||||||
|
assert.Error(t, err)
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify recoveryRunning is set to false
|
||||||
|
assert.False(t, p.recoveryRunning.Load())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_reinitializeOSResolver(t *testing.T) {
|
||||||
|
p := newTestProg(t)
|
||||||
|
|
||||||
|
err := p.reinitializeOSResolver("Test message")
|
||||||
|
|
||||||
|
// This function should not return an error under normal circumstances
|
||||||
|
// The actual behavior depends on the OS resolver implementation
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_handleRecovery_Integration(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
reason RecoveryReason
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "network change recovery",
|
||||||
|
reason: RecoveryReasonNetworkChange,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "regular failure recovery",
|
||||||
|
reason: RecoveryReasonRegularFailure,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "OS failure recovery",
|
||||||
|
reason: RecoveryReasonOSFailure,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
p := newTestProg(t)
|
||||||
|
|
||||||
|
// This is an integration test that exercises the full recovery flow
|
||||||
|
// In a real test environment, you would mock the dependencies
|
||||||
|
// For now, we're just testing that the method doesn't panic
|
||||||
|
// and that the recovery logic flows correctly
|
||||||
|
assert.NotPanics(t, func() {
|
||||||
|
// Test only the preparation phase to avoid actual upstream checking
|
||||||
|
if !p.shouldStartRecovery(tc.reason) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, cleanup := p.createRecoveryContext()
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if err := p.prepareForRecovery(tc.reason); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip the actual upstream recovery check for this test
|
||||||
|
// as it requires properly configured upstreams
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func Test_prog_queryFromSelf(t *testing.T) {
|
func Test_prog_queryFromSelf(t *testing.T) {
|
||||||
p := &prog{}
|
p := newTestProg(t)
|
||||||
require.NotPanics(t, func() {
|
require.NotPanics(t, func() {
|
||||||
p.queryFromSelf("")
|
p.queryFromSelf("")
|
||||||
})
|
})
|
||||||
@@ -474,3 +810,41 @@ func Test_prog_queryFromSelf(t *testing.T) {
|
|||||||
p.queryFromSelf("foo")
|
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 {
|
||||||
|
|||||||
+232
-31
@@ -7,36 +7,101 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"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"
|
logFileName = "ctrld.log"
|
||||||
logFileMaxSize = 1024 * 1024 * 5 // 5 MB
|
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
|
||||||
@@ -63,16 +128,19 @@ type logWriter struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -199,6 +267,8 @@ func (lw *logWriter) tailLastLines(n int) []byte {
|
|||||||
return result
|
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()
|
||||||
@@ -227,10 +297,12 @@ func (lw *logWriter) Write(p []byte) (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -256,26 +328,26 @@ 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.
|
// internalLogFilePath returns the path for persisted internal logs.
|
||||||
// The file lives in the ctrld home directory alongside other runtime state.
|
// The file lives in the ctrld home directory alongside other runtime state.
|
||||||
func internalLogFilePath() string {
|
func internalLogFilePath() string {
|
||||||
return absHomeDir(logFileName)
|
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()
|
||||||
@@ -292,28 +364,26 @@ func (p *prog) initInternalLogging(writers []io.Writer) {
|
|||||||
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.
|
||||||
@@ -329,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
|
||||||
@@ -351,12 +483,12 @@ func (p *prog) logReader() (*logReader, error) {
|
|||||||
|
|
||||||
// Fall back to in-memory buffer.
|
// 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)
|
||||||
@@ -386,6 +518,75 @@ 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
|
// logReaderFromFiles builds a logReader that concatenates the backup file
|
||||||
// (if it exists), the current log file, and the in-memory warn log buffer.
|
// (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) {
|
func (p *prog) logReaderFromFiles(current, backup string, wlw *logWriter) (*logReader, error) {
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"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) {
|
||||||
@@ -86,6 +93,331 @@ func Test_logWriter_MarkerInitEnd(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestNoticeLevel tests that the custom NOTICE level works correctly
|
||||||
|
func TestNoticeLevel(t *testing.T) {
|
||||||
|
// Create a buffer to capture log output
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
// Create encoder config with custom NOTICE level support
|
||||||
|
encoderConfig := zap.NewDevelopmentEncoderConfig()
|
||||||
|
encoderConfig.TimeKey = "time"
|
||||||
|
encoderConfig.EncodeTime = zapcore.TimeEncoderOfLayout("15:04:05.000")
|
||||||
|
encoderConfig.EncodeLevel = noticeLevelEncoder
|
||||||
|
|
||||||
|
// Test with NOTICE level
|
||||||
|
encoder := zapcore.NewConsoleEncoder(encoderConfig)
|
||||||
|
core := zapcore.NewCore(encoder, zapcore.AddSync(&buf), ctrld.NoticeLevel)
|
||||||
|
logger := zap.New(core)
|
||||||
|
ctrldLogger := &ctrld.Logger{Logger: logger}
|
||||||
|
|
||||||
|
// Log messages at different levels
|
||||||
|
ctrldLogger.Debug().Msg("This is a DEBUG message")
|
||||||
|
ctrldLogger.Info().Msg("This is an INFO message")
|
||||||
|
ctrldLogger.Notice().Msg("This is a NOTICE message")
|
||||||
|
ctrldLogger.Warn().Msg("This is a WARN message")
|
||||||
|
ctrldLogger.Error().Msg("This is an ERROR message")
|
||||||
|
|
||||||
|
output := buf.String()
|
||||||
|
|
||||||
|
// Verify that DEBUG and INFO messages are NOT logged (filtered out)
|
||||||
|
if strings.Contains(output, "DEBUG") {
|
||||||
|
t.Error("DEBUG message should not be logged when level is NOTICE")
|
||||||
|
}
|
||||||
|
if strings.Contains(output, "INFO") {
|
||||||
|
t.Error("INFO message should not be logged when level is NOTICE")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that NOTICE, WARN, and ERROR messages ARE logged
|
||||||
|
if !strings.Contains(output, "NOTICE") {
|
||||||
|
t.Error("NOTICE message should be logged when level is NOTICE")
|
||||||
|
}
|
||||||
|
if !strings.Contains(output, "WARN") {
|
||||||
|
t.Error("WARN message should be logged when level is NOTICE")
|
||||||
|
}
|
||||||
|
if !strings.Contains(output, "ERROR") {
|
||||||
|
t.Error("ERROR message should be logged when level is NOTICE")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the NOTICE message content
|
||||||
|
if !strings.Contains(output, "This is a NOTICE message") {
|
||||||
|
t.Error("NOTICE message content should be present")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Log output with NOTICE level:\n%s", output)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewLogReader(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
bufContent string
|
||||||
|
stripColor bool
|
||||||
|
expected string
|
||||||
|
description string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty_buffer_no_color_strip",
|
||||||
|
bufContent: "",
|
||||||
|
stripColor: false,
|
||||||
|
expected: "",
|
||||||
|
description: "Empty buffer should return empty reader",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty_buffer_with_color_strip",
|
||||||
|
bufContent: "",
|
||||||
|
stripColor: true,
|
||||||
|
expected: "",
|
||||||
|
description: "Empty buffer with color strip should return empty reader",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "plain_text_no_color_strip",
|
||||||
|
bufContent: "This is plain text without any color codes",
|
||||||
|
stripColor: false,
|
||||||
|
expected: "This is plain text without any color codes",
|
||||||
|
description: "Plain text should be returned as-is when not stripping colors",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "plain_text_with_color_strip",
|
||||||
|
bufContent: "This is plain text without any color codes",
|
||||||
|
stripColor: true,
|
||||||
|
expected: "This is plain text without any color codes",
|
||||||
|
description: "Plain text should be returned as-is when stripping colors",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "text_with_ansi_codes_no_strip",
|
||||||
|
bufContent: "Normal text \x1b[31mred text\x1b[0m normal again",
|
||||||
|
stripColor: false,
|
||||||
|
expected: "Normal text \x1b[31mred text\x1b[0m normal again",
|
||||||
|
description: "ANSI color codes should be preserved when not stripping",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "text_with_ansi_codes_with_strip",
|
||||||
|
bufContent: "Normal text \x1b[31mred text\x1b[0m normal again",
|
||||||
|
stripColor: true,
|
||||||
|
expected: "Normal text red text normal again",
|
||||||
|
description: "ANSI color codes should be removed when stripping colors",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple_ansi_codes_no_strip",
|
||||||
|
bufContent: "\x1b[1mBold\x1b[0m \x1b[32mGreen\x1b[0m \x1b[34mBlue\x1b[0m text",
|
||||||
|
stripColor: false,
|
||||||
|
expected: "\x1b[1mBold\x1b[0m \x1b[32mGreen\x1b[0m \x1b[34mBlue\x1b[0m text",
|
||||||
|
description: "Multiple ANSI codes should be preserved when not stripping",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple_ansi_codes_with_strip",
|
||||||
|
bufContent: "\x1b[1mBold\x1b[0m \x1b[32mGreen\x1b[0m \x1b[34mBlue\x1b[0m text",
|
||||||
|
stripColor: true,
|
||||||
|
expected: "Bold Green Blue text",
|
||||||
|
description: "Multiple ANSI codes should be removed when stripping colors",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "complex_ansi_sequences_no_strip",
|
||||||
|
bufContent: "\x1b[1;31;42mBold red on green\x1b[0m \x1b[38;5;208mOrange\x1b[0m",
|
||||||
|
stripColor: false,
|
||||||
|
expected: "\x1b[1;31;42mBold red on green\x1b[0m \x1b[38;5;208mOrange\x1b[0m",
|
||||||
|
description: "Complex ANSI sequences should be preserved when not stripping",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "complex_ansi_sequences_with_strip",
|
||||||
|
bufContent: "\x1b[1;31;42mBold red on green\x1b[0m \x1b[38;5;208mOrange\x1b[0m",
|
||||||
|
stripColor: true,
|
||||||
|
expected: "Bold red on green Orange",
|
||||||
|
description: "Complex ANSI sequences should be removed when stripping colors",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ansi_codes_with_newlines_no_strip",
|
||||||
|
bufContent: "Line 1\n\x1b[31mRed line\x1b[0m\nLine 3",
|
||||||
|
stripColor: false,
|
||||||
|
expected: "Line 1\n\x1b[31mRed line\x1b[0m\nLine 3",
|
||||||
|
description: "ANSI codes with newlines should be preserved when not stripping",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ansi_codes_with_newlines_with_strip",
|
||||||
|
bufContent: "Line 1\n\x1b[31mRed line\x1b[0m\nLine 3",
|
||||||
|
stripColor: true,
|
||||||
|
expected: "Line 1\nRed line\nLine 3",
|
||||||
|
description: "ANSI codes with newlines should be removed when stripping colors",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "malformed_ansi_codes_no_strip",
|
||||||
|
bufContent: "Text \x1b[invalidm \x1b[0m normal",
|
||||||
|
stripColor: false,
|
||||||
|
expected: "Text \x1b[invalidm \x1b[0m normal",
|
||||||
|
description: "Malformed ANSI codes should be preserved when not stripping",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "malformed_ansi_codes_with_strip",
|
||||||
|
bufContent: "Text \x1b[invalidm \x1b[0m normal",
|
||||||
|
stripColor: true,
|
||||||
|
expected: "Text \x1b[invalidm normal",
|
||||||
|
description: "Non-matching ANSI sequences should be preserved when stripping colors",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "large_buffer_no_strip",
|
||||||
|
bufContent: strings.Repeat("A", 10000) + "\x1b[31m" + strings.Repeat("B", 1000) + "\x1b[0m",
|
||||||
|
stripColor: false,
|
||||||
|
expected: strings.Repeat("A", 10000) + "\x1b[31m" + strings.Repeat("B", 1000) + "\x1b[0m",
|
||||||
|
description: "Large buffer should handle ANSI codes correctly when not stripping",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "large_buffer_with_strip",
|
||||||
|
bufContent: strings.Repeat("A", 10000) + "\x1b[31m" + strings.Repeat("B", 1000) + "\x1b[0m",
|
||||||
|
stripColor: true,
|
||||||
|
expected: strings.Repeat("A", 10000) + strings.Repeat("B", 1000),
|
||||||
|
description: "Large buffer should remove ANSI codes correctly when stripping",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Create a buffer with the test content
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
buf.WriteString(tt.bufContent)
|
||||||
|
|
||||||
|
// Create the log reader
|
||||||
|
reader := newLogReader(buf, tt.stripColor)
|
||||||
|
|
||||||
|
// Read all content from the reader
|
||||||
|
content, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read from log reader: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the content matches expected
|
||||||
|
actual := string(content)
|
||||||
|
if actual != tt.expected {
|
||||||
|
t.Errorf("Expected content: %q, got: %q", tt.expected, actual)
|
||||||
|
t.Logf("Description: %s", tt.description)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewLogReader_ReaderBehavior(t *testing.T) {
|
||||||
|
// Test that the returned reader behaves correctly
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
buf.WriteString("Test content with \x1b[31mred\x1b[0m text")
|
||||||
|
|
||||||
|
// Test with color stripping
|
||||||
|
reader := newLogReader(buf, true)
|
||||||
|
|
||||||
|
// Test reading in chunks
|
||||||
|
chunk1 := make([]byte, 10)
|
||||||
|
n1, err := reader.Read(chunk1)
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
t.Fatalf("Unexpected error reading first chunk: %v", err)
|
||||||
|
}
|
||||||
|
if n1 != 10 {
|
||||||
|
t.Errorf("Expected to read 10 bytes, got %d", n1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test reading remaining content
|
||||||
|
remaining, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read remaining content: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify total content
|
||||||
|
totalContent := string(chunk1[:n1]) + string(remaining)
|
||||||
|
expected := "Test content with red text"
|
||||||
|
if totalContent != expected {
|
||||||
|
t.Errorf("Expected total content: %q, got: %q", expected, totalContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewLogReader_ConcurrentAccess(t *testing.T) {
|
||||||
|
// Test concurrent access to the same buffer
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
buf.WriteString("Concurrent test with \x1b[32mgreen\x1b[0m text")
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
numGoroutines := 10
|
||||||
|
results := make(chan string, numGoroutines)
|
||||||
|
|
||||||
|
for i := 0; i < numGoroutines; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
reader := newLogReader(buf, true)
|
||||||
|
content, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Failed to read content: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
results <- string(content)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
close(results)
|
||||||
|
|
||||||
|
// Verify all goroutines got the same result
|
||||||
|
expected := "Concurrent test with green text"
|
||||||
|
for result := range results {
|
||||||
|
if result != expected {
|
||||||
|
t.Errorf("Expected: %q, got: %q", expected, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewLogReader_ANSIRegexEdgeCases(t *testing.T) {
|
||||||
|
// Test edge cases for ANSI regex matching
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty_escape_sequence",
|
||||||
|
input: "Text \x1b[m normal",
|
||||||
|
expected: "Text normal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple_semicolons",
|
||||||
|
input: "Text \x1b[1;2;3;4m normal",
|
||||||
|
expected: "Text normal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "numeric_only",
|
||||||
|
input: "Text \x1b[123m normal",
|
||||||
|
expected: "Text normal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed_numeric_semicolon",
|
||||||
|
input: "Text \x1b[1;23;456m normal",
|
||||||
|
expected: "Text normal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no_closing_bracket",
|
||||||
|
input: "Text \x1b[31 normal",
|
||||||
|
expected: "Text \x1b[31 normal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no_opening_bracket",
|
||||||
|
input: "Text 31m normal",
|
||||||
|
expected: "Text 31m normal",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
buf.WriteString(tt.input)
|
||||||
|
|
||||||
|
reader := newLogReader(buf, true)
|
||||||
|
content, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read content: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual := string(content)
|
||||||
|
if actual != tt.expected {
|
||||||
|
t.Errorf("Expected: %q, got: %q", tt.expected, actual)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func Test_logWriter_SetLogFile(t *testing.T) {
|
func Test_logWriter_SetLogFile(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "test.log")
|
path := filepath.Join(dir, "test.log")
|
||||||
|
|||||||
+8
-7
@@ -84,7 +84,7 @@ func (p *prog) detectLoop(msg *dns.Msg) {
|
|||||||
//
|
//
|
||||||
// See: https://thekelleys.org.uk/dnsmasq/docs/dnsmasq-man.html
|
// 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)
|
||||||
|
|||||||
+94
-56
@@ -10,11 +10,14 @@ import (
|
|||||||
"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
|
||||||
@@ -45,43 +48,53 @@ var (
|
|||||||
interceptMode string // "", "dns", or "hard" — set via --intercept-mode flag or config
|
interceptMode string // "", "dns", or "hard" — set via --intercept-mode flag or config
|
||||||
dnsIntercept bool // derived: interceptMode == "dns" || interceptMode == "hard"
|
dnsIntercept bool // derived: interceptMode == "dns" || interceptMode == "hard"
|
||||||
hardIntercept bool // derived: 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. This runs before cobra
|
// Fast path for pf interception probe subprocess.
|
||||||
// initialization to minimize startup time. The parent process spawns us with
|
|
||||||
// "pf-probe-send <host> <hex-dns-packet>" and a non-_ctrld GID so pf
|
|
||||||
// intercepts the DNS query. If pf rdr is working, the query reaches ctrld's
|
|
||||||
// listener; if not, it goes to the real DNS server and ctrld detects the miss.
|
|
||||||
if len(os.Args) >= 4 && os.Args[1] == "pf-probe-send" {
|
if len(os.Args) >= 4 && os.Args[1] == "pf-probe-send" {
|
||||||
pfProbeSend(os.Args[2], os.Args[3])
|
pfProbeSend(os.Args[2], os.Args[3])
|
||||||
return
|
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
|
||||||
@@ -97,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.
|
||||||
@@ -139,77 +148,107 @@ 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
|
// pfProbeSend is a minimal subprocess that sends a pre-built DNS query packet
|
||||||
// to the specified host on port 53. It's invoked by probePFIntercept() with a
|
// to the specified host on port 53.
|
||||||
// non-_ctrld GID so pf interception applies to the query.
|
|
||||||
//
|
|
||||||
// Usage: ctrld pf-probe-send <host> <hex-encoded-dns-packet>
|
|
||||||
func pfProbeSend(host, hexPacket string) {
|
func pfProbeSend(host, hexPacket string) {
|
||||||
packet, err := hex.DecodeString(hexPacket)
|
packet, err := hex.DecodeString(hexPacket)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -222,7 +261,6 @@ func pfProbeSend(host, hexPacket string) {
|
|||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
conn.SetDeadline(time.Now().Add(time.Second))
|
conn.SetDeadline(time.Now().Add(time.Second))
|
||||||
_, _ = conn.Write(packet)
|
_, _ = conn.Write(packet)
|
||||||
// Read response (don't care about result, just need the send to happen)
|
|
||||||
buf := make([]byte, 512)
|
buf := make([]byte, 512)
|
||||||
_, _ = conn.Read(buf)
|
_, _ = conn.Read(buf)
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-3
@@ -6,14 +6,29 @@ import (
|
|||||||
"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
|
// 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
|
// builder execs os.Executable() — which under `go test` IS this test binary
|
||||||
|
|||||||
+11
-4
@@ -15,6 +15,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// metricsServer represents a server to expose Prometheus metrics via HTTP.
|
// 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()
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+23
-19
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,17 +80,9 @@ func setDNS(iface *net.Interface, nameservers []string) error {
|
|||||||
SearchDomains: []dnsname.FQDN{},
|
SearchDomains: []dnsname.FQDN{},
|
||||||
}
|
}
|
||||||
if sds, err := searchDomains(); err == nil {
|
if sds, err := searchDomains(); err == nil {
|
||||||
// Filter the root domain, since it's not allowed by systemd.
|
osConfig.SearchDomains = sds
|
||||||
// See https://github.com/systemd/systemd/issues/9515
|
|
||||||
filteredSds := slices.DeleteFunc(sds, func(s dnsname.FQDN) bool {
|
|
||||||
return s == "" || s == "."
|
|
||||||
})
|
|
||||||
if len(filteredSds) != len(sds) {
|
|
||||||
mainLog.Load().Debug().Msg(`Removed root domain "." from search domains list`)
|
|
||||||
}
|
|
||||||
osConfig.SearchDomains = filteredSds
|
|
||||||
} 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 {
|
||||||
@@ -125,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,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
|
||||||
@@ -145,7 +149,7 @@ func resetDNS(iface *net.Interface) (err error) {
|
|||||||
if r, oerr := dns.NewOSConfigurator(logf, &health.Tracker{}, &controlknobs.Knobs{}, iface.Name); oerr == nil {
|
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
|
||||||
@@ -173,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()
|
||||||
@@ -209,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(interceptMode)
|
|
||||||
mainLog.Load().Debug().Bool("has_ipv6_listener", hasLocalIPv6Listener).Msg("IPv6 listener status")
|
|
||||||
|
|
||||||
forwarders := slices.DeleteFunc(slices.Clone(nameservers), func(s string) bool {
|
|
||||||
if !hasLocalIPv6Listener {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return s == "::1"
|
|
||||||
})
|
|
||||||
mainLog.Load().Debug().Strs("forwarders", forwarders).Msg("Filtered forwarders list")
|
|
||||||
|
|
||||||
if err := os.WriteFile(file, []byte(strings.Join(forwarders, ",")), 0600); err != nil {
|
|
||||||
mainLog.Load().Warn().Err(err).Msg("could not save forwarders settings")
|
|
||||||
} else {
|
|
||||||
mainLog.Load().Debug().Msg("Successfully wrote new forwarders file")
|
|
||||||
}
|
|
||||||
|
|
||||||
oldForwarders := strings.Split(string(oldForwardersContent), ",")
|
|
||||||
mainLog.Load().Debug().Strs("old_forwarders", oldForwarders).Msg("Previous forwarders")
|
|
||||||
|
|
||||||
if err := addDnsServerForwarders(forwarders, oldForwarders); err != nil {
|
|
||||||
mainLog.Load().Warn().Err(err).Msg("could not set forwarders settings")
|
|
||||||
} else {
|
|
||||||
mainLog.Load().Debug().Msg("Successfully configured DNS server forwarders")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
luid, err := winipcfg.LUIDFromIndex(uint32(iface.Index))
|
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
|
||||||
|
}
|
||||||
|
|||||||
+277
-293
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
|
||||||
|
|||||||
+38
-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
|
||||||
@@ -267,8 +288,10 @@ func Test_performUpgrade(t *testing.T) {
|
|||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,78 +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
|
|
||||||
}
|
|
||||||
defer windows.CloseHandle(h)
|
|
||||||
p := windows.ProcessEntry32{Size: processEntrySize}
|
|
||||||
for {
|
|
||||||
e := windows.Process32Next(h, &p)
|
|
||||||
if e != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if strings.ToLower(windows.UTF16ToString(p.ExeFile[:])) == "dns.exe" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func isRunningOnDomainControllerWindows() (bool, int) {
|
|
||||||
whost := host.NewWmiLocalHost()
|
|
||||||
q := query.NewWmiQuery("Win32_ComputerSystem")
|
|
||||||
instances, err := instance.GetWmiInstancesFromHost(whost, string(constant.CimV2), q)
|
|
||||||
if err != nil {
|
|
||||||
mainLog.Load().Debug().Err(err).Msg("WMI query failed")
|
|
||||||
return false, 0
|
|
||||||
}
|
|
||||||
if instances == nil {
|
|
||||||
mainLog.Load().Debug().Msg("WMI query returned nil instances")
|
|
||||||
return false, 0
|
|
||||||
}
|
|
||||||
defer instances.Close()
|
|
||||||
|
|
||||||
if len(instances) == 0 {
|
|
||||||
mainLog.Load().Debug().Msg("no rows returned from Win32_ComputerSystem")
|
|
||||||
return false, 0
|
|
||||||
}
|
|
||||||
|
|
||||||
val, err := instances[0].GetProperty("DomainRole")
|
|
||||||
if err != nil {
|
|
||||||
mainLog.Load().Debug().Err(err).Msg("failed to get DomainRole property")
|
|
||||||
return false, 0
|
|
||||||
}
|
|
||||||
if val == nil {
|
|
||||||
mainLog.Load().Debug().Msg("DomainRole property is nil")
|
|
||||||
return false, 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Safely handle varied types: string or integer
|
|
||||||
var roleInt int
|
|
||||||
switch v := val.(type) {
|
|
||||||
case string:
|
|
||||||
// "4", "5", etc.
|
|
||||||
parsed, parseErr := strconv.Atoi(v)
|
|
||||||
if parseErr != nil {
|
|
||||||
mainLog.Load().Debug().Err(parseErr).Msgf("failed to parse DomainRole value %q", v)
|
|
||||||
return false, 0
|
|
||||||
}
|
|
||||||
roleInt = parsed
|
|
||||||
case int8, int16, int32, int64:
|
|
||||||
roleInt = int(reflect.ValueOf(v).Int())
|
|
||||||
case uint8, uint16, uint32, uint64:
|
|
||||||
roleInt = int(reflect.ValueOf(v).Uint())
|
|
||||||
default:
|
|
||||||
mainLog.Load().Debug().Msgf("unexpected DomainRole type: %T value=%v", v, v)
|
|
||||||
return false, 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if role indicates a domain controller
|
|
||||||
isDC := roleInt == BackupDomainController || roleInt == PrimaryDomainController
|
|
||||||
return isDC, roleInt
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
package cli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func Test_hasLocalDnsServerRunning(t *testing.T) {
|
|
||||||
start := time.Now()
|
|
||||||
hasDns := hasLocalDnsServerRunning()
|
|
||||||
t.Logf("Using Windows API takes: %d", time.Since(start).Milliseconds())
|
|
||||||
|
|
||||||
start = time.Now()
|
|
||||||
hasDnsPowershell := hasLocalDnsServerRunningPowershell()
|
|
||||||
t.Logf("Using Powershell takes: %d", time.Since(start).Milliseconds())
|
|
||||||
|
|
||||||
if hasDns != hasDnsPowershell {
|
|
||||||
t.Fatalf("result mismatch, want: %v, got: %v", hasDnsPowershell, hasDns)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasLocalDnsServerRunningPowershell() bool {
|
|
||||||
_, err := powershell("Get-Process -Name DNS")
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// Test_ensureRunningIfaceForInvalidUninstall is a regression test for issue-556:
|
||||||
|
// after a reboot, the invalid-device self-uninstall path could run before the
|
||||||
|
// running interface was known. Because resetDNS (via resetDNSForRunningIface)
|
||||||
|
// silently skips DNS restoration when p.runningIface is empty, the OS was left
|
||||||
|
// pointed at ctrld's local listener with no internet after the service was
|
||||||
|
// removed. ensureRunningIfaceForInvalidUninstall must populate p.runningIface
|
||||||
|
// before resetDNS runs.
|
||||||
|
func Test_ensureRunningIfaceForInvalidUninstall(t *testing.T) {
|
||||||
|
// preRun mutates the package-level iface global; restore it after the test.
|
||||||
|
origIface := iface
|
||||||
|
t.Cleanup(func() { iface = origIface })
|
||||||
|
|
||||||
|
// newService needs the package service config; it is safe to build here
|
||||||
|
// because ensureRunningIfaceForInvalidUninstall only queries the (absent)
|
||||||
|
// control socket via runningIface, which returns nil when ctrld is not
|
||||||
|
// running, and performs no DNS or service mutation.
|
||||||
|
sc := NewServiceCommand()
|
||||||
|
s, err := sc.newService(&prog{}, sc.createServiceConfig())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newService: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("iface flag already resolved but not yet copied", func(t *testing.T) {
|
||||||
|
iface = "eth-test"
|
||||||
|
p := &prog{}
|
||||||
|
|
||||||
|
// Precondition mirrors the buggy post-reboot state: an empty running
|
||||||
|
// interface would make resetDNS skip restoration entirely.
|
||||||
|
if p.runningIface != "" {
|
||||||
|
t.Fatalf("precondition: runningIface = %q, want empty", p.runningIface)
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureRunningIfaceForInvalidUninstall(p, s)
|
||||||
|
|
||||||
|
if p.runningIface == "" {
|
||||||
|
t.Fatal("runningIface still empty after prepare: resetDNS would skip DNS " +
|
||||||
|
"restoration and leave the OS pointed at ctrld's local listener")
|
||||||
|
}
|
||||||
|
if p.runningIface != "eth-test" {
|
||||||
|
t.Fatalf("runningIface = %q, want the resolved iface %q", p.runningIface, "eth-test")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("iface unset falls back to auto-detected interface", func(t *testing.T) {
|
||||||
|
iface = ""
|
||||||
|
p := &prog{}
|
||||||
|
|
||||||
|
ensureRunningIfaceForInvalidUninstall(p, s)
|
||||||
|
|
||||||
|
// With iface unset the prep resolves "auto" to the default interface
|
||||||
|
// (defaultIfaceName never returns empty on the supported platforms), so
|
||||||
|
// resetDNS has a concrete interface to restore.
|
||||||
|
if p.runningIface == "" {
|
||||||
|
t.Fatal("runningIface still empty after prepare with iface unset: " +
|
||||||
|
"resetDNS would skip DNS restoration")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Control-D-Inc/ctrld"
|
"github.com/Control-D-Inc/ctrld"
|
||||||
@@ -12,11 +13,31 @@ const (
|
|||||||
maxFailureRequest = 50
|
maxFailureRequest = 50
|
||||||
// checkUpstreamBackoffSleep is the time interval between each upstream checks.
|
// checkUpstreamBackoffSleep is the time interval between each upstream checks.
|
||||||
checkUpstreamBackoffSleep = 2 * time.Second
|
checkUpstreamBackoffSleep = 2 * time.Second
|
||||||
|
// checkUpstreamUnreachableBackoffMax caps the recovery retry interval for an
|
||||||
|
// endpoint that keeps failing with a network-unreachable error. It bounds
|
||||||
|
// the backoff so an unroutable endpoint is still re-probed periodically and
|
||||||
|
// recovers once the route returns.
|
||||||
|
checkUpstreamUnreachableBackoffMax = 60 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// unreachableRecoveryBackoff returns the retry interval for the given streak of
|
||||||
|
// consecutive network-unreachable failures. It starts at checkUpstreamBackoffSleep
|
||||||
|
// and doubles each attempt, capped at checkUpstreamUnreachableBackoffMax.
|
||||||
|
func unreachableRecoveryBackoff(streak int) time.Duration {
|
||||||
|
d := checkUpstreamBackoffSleep
|
||||||
|
for i := 1; i < streak; i++ {
|
||||||
|
d *= 2
|
||||||
|
if d >= checkUpstreamUnreachableBackoffMax {
|
||||||
|
return checkUpstreamUnreachableBackoffMax
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
// upstreamMonitor performs monitoring upstreams health.
|
// upstreamMonitor performs monitoring upstreams health.
|
||||||
type upstreamMonitor struct {
|
type upstreamMonitor struct {
|
||||||
cfg *ctrld.Config
|
cfg *ctrld.Config
|
||||||
|
logger atomic.Pointer[ctrld.Logger]
|
||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
checking map[string]bool
|
checking map[string]bool
|
||||||
@@ -28,7 +49,8 @@ type upstreamMonitor struct {
|
|||||||
failureTimerActive map[string]bool
|
failureTimerActive map[string]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func newUpstreamMonitor(cfg *ctrld.Config) *upstreamMonitor {
|
// newUpstreamMonitor creates a new upstream monitor instance
|
||||||
|
func newUpstreamMonitor(cfg *ctrld.Config, logger *ctrld.Logger) *upstreamMonitor {
|
||||||
um := &upstreamMonitor{
|
um := &upstreamMonitor{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
checking: make(map[string]bool),
|
checking: make(map[string]bool),
|
||||||
@@ -37,6 +59,7 @@ func newUpstreamMonitor(cfg *ctrld.Config) *upstreamMonitor {
|
|||||||
recovered: make(map[string]bool),
|
recovered: make(map[string]bool),
|
||||||
failureTimerActive: make(map[string]bool),
|
failureTimerActive: make(map[string]bool),
|
||||||
}
|
}
|
||||||
|
um.logger.Store(logger)
|
||||||
for n := range cfg.Upstream {
|
for n := range cfg.Upstream {
|
||||||
upstream := upstreamPrefix + n
|
upstream := upstreamPrefix + n
|
||||||
um.reset(upstream)
|
um.reset(upstream)
|
||||||
@@ -53,7 +76,7 @@ func (um *upstreamMonitor) increaseFailureCount(upstream string) {
|
|||||||
defer um.mu.Unlock()
|
defer um.mu.Unlock()
|
||||||
|
|
||||||
if um.recovered[upstream] {
|
if um.recovered[upstream] {
|
||||||
mainLog.Load().Debug().Msgf("upstream %q is recovered, skipping failure count increase", upstream)
|
um.logger.Load().Debug().Msgf("Upstream %q is recovered, skipping failure count increase", upstream)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +84,7 @@ func (um *upstreamMonitor) increaseFailureCount(upstream string) {
|
|||||||
failedCount := um.failureReq[upstream]
|
failedCount := um.failureReq[upstream]
|
||||||
|
|
||||||
// Log the updated failure count.
|
// Log the updated failure count.
|
||||||
mainLog.Load().Debug().Msgf("upstream %q failure count updated to %d", upstream, failedCount)
|
um.logger.Load().Debug().Msgf("Upstream %q failure count updated to %d", upstream, failedCount)
|
||||||
|
|
||||||
// If this is the first failure and no timer is running, start a 10-second timer.
|
// If this is the first failure and no timer is running, start a 10-second timer.
|
||||||
if failedCount == 1 && !um.failureTimerActive[upstream] {
|
if failedCount == 1 && !um.failureTimerActive[upstream] {
|
||||||
@@ -74,7 +97,7 @@ func (um *upstreamMonitor) increaseFailureCount(upstream string) {
|
|||||||
// and the upstream is not in a recovered state, mark it as down.
|
// and the upstream is not in a recovered state, mark it as down.
|
||||||
if um.failureReq[upstream] > 0 && !um.recovered[upstream] {
|
if um.failureReq[upstream] > 0 && !um.recovered[upstream] {
|
||||||
um.down[upstream] = true
|
um.down[upstream] = true
|
||||||
mainLog.Load().Warn().Msgf("upstream %q marked as down after 10 seconds (failure count: %d)", upstream, um.failureReq[upstream])
|
um.logger.Load().Warn().Msgf("Upstream %q marked as down after 10 seconds (failure count: %d)", upstream, um.failureReq[upstream])
|
||||||
}
|
}
|
||||||
// Reset the timer flag so that a new timer can be spawned if needed.
|
// Reset the timer flag so that a new timer can be spawned if needed.
|
||||||
um.failureTimerActive[upstream] = false
|
um.failureTimerActive[upstream] = false
|
||||||
@@ -84,7 +107,7 @@ func (um *upstreamMonitor) increaseFailureCount(upstream string) {
|
|||||||
// If the failure count quickly reaches the threshold, mark the upstream as down immediately.
|
// If the failure count quickly reaches the threshold, mark the upstream as down immediately.
|
||||||
if failedCount >= maxFailureRequest {
|
if failedCount >= maxFailureRequest {
|
||||||
um.down[upstream] = true
|
um.down[upstream] = true
|
||||||
mainLog.Load().Warn().Msgf("upstream %q marked as down immediately (failure count: %d)", upstream, failedCount)
|
um.logger.Load().Warn().Msgf("Upstream %q marked as down immediately (failure count: %d)", upstream, failedCount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+61
-32
@@ -6,8 +6,8 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
|
||||||
"tailscale.com/net/netmon"
|
"tailscale.com/net/netmon"
|
||||||
|
|
||||||
"github.com/Control-D-Inc/ctrld"
|
"github.com/Control-D-Inc/ctrld"
|
||||||
@@ -19,7 +19,9 @@ var vpnDNSSettlingEnabled = runtime.GOOS == "windows"
|
|||||||
// including the interface it was discovered on. The interface is used on macOS
|
// including the interface it was discovered on. The interface is used on macOS
|
||||||
// to create interface-scoped pf exemptions that allow the VPN's local DNS
|
// to create interface-scoped pf exemptions that allow the VPN's local DNS
|
||||||
// handler (e.g., Tailscale's MagicDNS Network Extension) to receive queries
|
// handler (e.g., Tailscale's MagicDNS Network Extension) to receive queries
|
||||||
// from all processes — not just ctrld.
|
// from all processes — not just ctrld. Without the interface scope, VPN DNS
|
||||||
|
// handlers that operate at the packet level (Network Extensions) never see
|
||||||
|
// the queries because pf intercepts them first.
|
||||||
type vpnDNSExemption struct {
|
type vpnDNSExemption struct {
|
||||||
Server string // DNS server IP (e.g., "100.100.100.100")
|
Server string // DNS server IP (e.g., "100.100.100.100")
|
||||||
Interface string // Interface name from scutil (e.g., "utun11"), may be empty
|
Interface string // Interface name from scutil (e.g., "utun11"), may be empty
|
||||||
@@ -28,6 +30,8 @@ type vpnDNSExemption struct {
|
|||||||
|
|
||||||
// vpnDNSExemptFunc is called when VPN DNS servers change, to update
|
// vpnDNSExemptFunc is called when VPN DNS servers change, to update
|
||||||
// the intercept layer (WFP/pf) to permit VPN DNS traffic.
|
// the intercept layer (WFP/pf) to permit VPN DNS traffic.
|
||||||
|
// On macOS, exemptions are interface-scoped to allow VPN local DNS handlers
|
||||||
|
// (e.g., Tailscale MagicDNS) to receive queries from all processes.
|
||||||
type vpnDNSExemptFunc func(exemptions []vpnDNSExemption) error
|
type vpnDNSExemptFunc func(exemptions []vpnDNSExemption) error
|
||||||
|
|
||||||
// vpnDNSManager tracks active VPN DNS configurations and provides
|
// vpnDNSManager tracks active VPN DNS configurations and provides
|
||||||
@@ -37,6 +41,7 @@ type vpnDNSManager struct {
|
|||||||
configs []ctrld.VPNDNSConfig
|
configs []ctrld.VPNDNSConfig
|
||||||
// Map of domain suffix → DNS servers for fast lookup
|
// Map of domain suffix → DNS servers for fast lookup
|
||||||
routes map[string][]string
|
routes map[string][]string
|
||||||
|
logger *atomic.Pointer[ctrld.Logger]
|
||||||
// DNS servers from VPN interfaces that have no domain/suffix config.
|
// DNS servers from VPN interfaces that have no domain/suffix config.
|
||||||
// These are NOT added to the global OS resolver. They're only used
|
// These are NOT added to the global OS resolver. They're only used
|
||||||
// as additional nameservers for queries that match split-DNS rules
|
// as additional nameservers for queries that match split-DNS rules
|
||||||
@@ -50,6 +55,9 @@ type vpnDNSManager struct {
|
|||||||
// discoverVPNDNS is injected for tests so Refresh does not depend on the
|
// discoverVPNDNS is injected for tests so Refresh does not depend on the
|
||||||
// runner host's real VPN/virtual adapter state.
|
// runner host's real VPN/virtual adapter state.
|
||||||
discoverVPNDNS func(context.Context) []ctrld.VPNDNSConfig
|
discoverVPNDNS func(context.Context) []ctrld.VPNDNSConfig
|
||||||
|
// refreshRunning keeps noisy network-change storms from running overlapping
|
||||||
|
// scutil/networksetup VPN DNS discovery work.
|
||||||
|
refreshRunning atomic.Bool
|
||||||
// Called when VPN DNS server list changes, to update intercept exemptions.
|
// Called when VPN DNS server list changes, to update intercept exemptions.
|
||||||
onServersChanged vpnDNSExemptFunc
|
onServersChanged vpnDNSExemptFunc
|
||||||
}
|
}
|
||||||
@@ -57,9 +65,10 @@ type vpnDNSManager struct {
|
|||||||
// newVPNDNSManager creates a new manager. Only call when dnsIntercept is active.
|
// newVPNDNSManager creates a new manager. Only call when dnsIntercept is active.
|
||||||
// exemptFunc is called whenever VPN DNS servers are discovered/changed, to update
|
// exemptFunc is called whenever VPN DNS servers are discovered/changed, to update
|
||||||
// the OS-level intercept rules to permit ctrld's outbound queries to those IPs.
|
// the OS-level intercept rules to permit ctrld's outbound queries to those IPs.
|
||||||
func newVPNDNSManager(exemptFunc vpnDNSExemptFunc) *vpnDNSManager {
|
func newVPNDNSManager(logger *atomic.Pointer[ctrld.Logger], exemptFunc vpnDNSExemptFunc) *vpnDNSManager {
|
||||||
return &vpnDNSManager{
|
return &vpnDNSManager{
|
||||||
routes: make(map[string][]string),
|
routes: make(map[string][]string),
|
||||||
|
logger: logger,
|
||||||
discoverVPNDNS: ctrld.DiscoverVPNDNS,
|
discoverVPNDNS: ctrld.DiscoverVPNDNS,
|
||||||
onServersChanged: exemptFunc,
|
onServersChanged: exemptFunc,
|
||||||
}
|
}
|
||||||
@@ -67,25 +76,31 @@ func newVPNDNSManager(exemptFunc vpnDNSExemptFunc) *vpnDNSManager {
|
|||||||
|
|
||||||
// Refresh re-discovers VPN DNS configs from the OS.
|
// Refresh re-discovers VPN DNS configs from the OS.
|
||||||
// Called on network change events.
|
// Called on network change events.
|
||||||
func (m *vpnDNSManager) Refresh(guardAgainstNoNameservers bool) {
|
func (m *vpnDNSManager) Refresh(ctx context.Context, guardAgainstNoNameservers ...bool) {
|
||||||
logger := mainLog.Load()
|
logger := ctrld.LoggerFromCtx(ctx)
|
||||||
|
guardedRefresh := len(guardAgainstNoNameservers) > 0 && guardAgainstNoNameservers[0]
|
||||||
|
if !m.refreshRunning.CompareAndSwap(false, true) {
|
||||||
|
ctrld.Log(ctx, logger.Debug(), "VPN DNS refresh already running, skipping duplicate")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer m.refreshRunning.Store(false)
|
||||||
|
|
||||||
logger.Debug().Msg("Refreshing VPN DNS configurations")
|
ctrld.Log(ctx, logger.Debug(), "Refreshing VPN DNS configurations")
|
||||||
discoverVPNDNS := m.discoverVPNDNS
|
discoverVPNDNS := m.discoverVPNDNS
|
||||||
if discoverVPNDNS == nil {
|
if discoverVPNDNS == nil {
|
||||||
discoverVPNDNS = ctrld.DiscoverVPNDNS
|
discoverVPNDNS = ctrld.DiscoverVPNDNS
|
||||||
}
|
}
|
||||||
configs := discoverVPNDNS(context.Background())
|
configs := discoverVPNDNS(ctx)
|
||||||
|
|
||||||
// Detect exit mode: if the default route goes through a VPN DNS interface,
|
// Detect exit mode: if the default route goes through a VPN DNS interface,
|
||||||
// the VPN is routing ALL traffic (exit node / full tunnel). This is more
|
// the VPN is routing ALL traffic (exit node / full tunnel). This is more
|
||||||
// reliable than scutil flag parsing because the routing table is the ground
|
// reliable than scutil flag parsing because the routing table is the ground
|
||||||
// truth for traffic flow, regardless of how the VPN presents itself in scutil.
|
// truth for traffic flow.
|
||||||
if dri, err := netmon.DefaultRouteInterface(); err == nil && dri != "" {
|
if dri, err := netmon.DefaultRouteInterface(); err == nil && dri != "" {
|
||||||
for i := range configs {
|
for i := range configs {
|
||||||
if configs[i].InterfaceName == dri {
|
if configs[i].InterfaceName == dri {
|
||||||
if !configs[i].IsExitMode {
|
if !configs[i].IsExitMode {
|
||||||
logger.Info().Msgf("VPN DNS on %s: default route interface match — EXIT MODE (route-based detection)", dri)
|
ctrld.Log(ctx, logger.Info(), "VPN DNS on %s: default route interface match — EXIT MODE (route-based detection)", dri)
|
||||||
}
|
}
|
||||||
configs[i].IsExitMode = true
|
configs[i].IsExitMode = true
|
||||||
}
|
}
|
||||||
@@ -97,21 +112,21 @@ func (m *vpnDNSManager) Refresh(guardAgainstNoNameservers bool) {
|
|||||||
|
|
||||||
previousExemptions := m.currentExemptionsLocked()
|
previousExemptions := m.currentExemptionsLocked()
|
||||||
|
|
||||||
if vpnDNSSettlingEnabled && len(configs) == 0 && guardAgainstNoNameservers && m.hasVPNDNSStateLocked() {
|
if vpnDNSSettlingEnabled && len(configs) == 0 && guardedRefresh && m.hasVPNDNSStateLocked() {
|
||||||
if !m.retainedAfterEmptyDiscovery {
|
if !m.retainedAfterEmptyDiscovery {
|
||||||
exemptions := m.currentExemptionsLocked()
|
exemptions := m.currentExemptionsLocked()
|
||||||
m.retainedAfterEmptyDiscovery = true
|
m.retainedAfterEmptyDiscovery = true
|
||||||
logger.Debug().Msgf(
|
ctrld.Log(ctx, logger.Debug(),
|
||||||
"VPN DNS discovery empty; retaining last-known VPN DNS state for one guarded refresh (%d domainless servers, %d exemptions)",
|
"VPN DNS discovery empty; retaining last-known VPN DNS state for one guarded refresh (%d domainless servers, %d exemptions)",
|
||||||
len(m.domainlessServers), len(exemptions))
|
len(m.domainlessServers), len(exemptions))
|
||||||
if m.onServersChanged != nil {
|
if m.onServersChanged != nil {
|
||||||
if err := m.onServersChanged(exemptions); err != nil {
|
if err := m.onServersChanged(exemptions); err != nil {
|
||||||
logger.Error().Err(err).Msg("Failed to re-apply retained VPN DNS exemptions")
|
ctrld.Log(ctx, logger.Error().Err(err), "Failed to re-apply retained VPN DNS exemptions")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
logger.Debug().Msgf(
|
ctrld.Log(ctx, logger.Debug(),
|
||||||
"VPN DNS discovery still empty on next guarded refresh; clearing retained VPN DNS state (%d domainless servers)",
|
"VPN DNS discovery still empty on next guarded refresh; clearing retained VPN DNS state (%d domainless servers)",
|
||||||
len(m.domainlessServers))
|
len(m.domainlessServers))
|
||||||
}
|
}
|
||||||
@@ -125,24 +140,26 @@ func (m *vpnDNSManager) Refresh(guardAgainstNoNameservers bool) {
|
|||||||
|
|
||||||
// Build domain -> DNS servers mapping
|
// Build domain -> DNS servers mapping
|
||||||
for _, config := range configs {
|
for _, config := range configs {
|
||||||
logger.Debug().Msgf("Processing VPN interface %s with %d domains and %d servers",
|
ctrld.Log(ctx, logger.Debug(), "Processing VPN interface %s with %d domains and %d servers",
|
||||||
config.InterfaceName, len(config.Domains), len(config.Servers))
|
config.InterfaceName, len(config.Domains), len(config.Servers))
|
||||||
|
|
||||||
for _, domain := range config.Domains {
|
for _, domain := range config.Domains {
|
||||||
// Normalize domain: remove leading dot, Linux routing domain prefix (~),
|
// Normalize domain: remove leading dot, Linux routing domain prefix (~),
|
||||||
// and convert to lowercase.
|
// and convert to lowercase.
|
||||||
domain = strings.TrimPrefix(domain, "~")
|
domain = strings.TrimPrefix(domain, "~") // Linux resolvectl routing domain prefix
|
||||||
domain = strings.TrimPrefix(domain, ".")
|
domain = strings.TrimPrefix(domain, ".")
|
||||||
domain = strings.ToLower(domain)
|
domain = strings.ToLower(domain)
|
||||||
|
|
||||||
if domain != "" {
|
if domain != "" {
|
||||||
m.routes[domain] = append([]string{}, config.Servers...)
|
m.routes[domain] = append([]string{}, config.Servers...)
|
||||||
logger.Debug().Msgf("Added VPN DNS route: %s -> %v", domain, config.Servers)
|
ctrld.Log(ctx, logger.Debug(), "Added VPN DNS route: %s -> %v", domain, config.Servers)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect unique VPN DNS exemptions (server + interface) for pf/WFP rules.
|
// Collect unique VPN DNS exemptions (server + interface) for pf/WFP rules.
|
||||||
|
// We track server+interface pairs because the same server IP on different
|
||||||
|
// interfaces needs separate exemptions (interface-scoped on macOS).
|
||||||
type exemptionKey struct{ server, iface string }
|
type exemptionKey struct{ server, iface string }
|
||||||
seen := make(map[exemptionKey]bool)
|
seen := make(map[exemptionKey]bool)
|
||||||
var exemptions []vpnDNSExemption
|
var exemptions []vpnDNSExemption
|
||||||
@@ -165,22 +182,22 @@ func (m *vpnDNSManager) Refresh(guardAgainstNoNameservers bool) {
|
|||||||
// they're stored separately and only used for queries that match existing
|
// they're stored separately and only used for queries that match existing
|
||||||
// split-DNS rules (from ctrld config, AD domain, or VPN suffix config).
|
// split-DNS rules (from ctrld config, AD domain, or VPN suffix config).
|
||||||
var domainlessServers []string
|
var domainlessServers []string
|
||||||
seen2 := make(map[string]bool)
|
seenDomainless := make(map[string]bool)
|
||||||
for _, config := range configs {
|
for _, config := range configs {
|
||||||
if len(config.Domains) == 0 && len(config.Servers) > 0 {
|
if len(config.Domains) == 0 && len(config.Servers) > 0 {
|
||||||
logger.Debug().Msgf("VPN interface %s has DNS servers but no domains, storing as split-rule fallback: %v",
|
ctrld.Log(ctx, logger.Debug(), "VPN interface %s has DNS servers but no domains, storing as split-rule fallback: %v",
|
||||||
config.InterfaceName, config.Servers)
|
config.InterfaceName, config.Servers)
|
||||||
for _, s := range config.Servers {
|
for _, server := range config.Servers {
|
||||||
if !seen2[s] {
|
if !seenDomainless[server] {
|
||||||
seen2[s] = true
|
seenDomainless[server] = true
|
||||||
domainlessServers = append(domainlessServers, s)
|
domainlessServers = append(domainlessServers, server)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.domainlessServers = domainlessServers
|
m.domainlessServers = domainlessServers
|
||||||
|
|
||||||
logger.Debug().Msgf("VPN DNS refresh completed: %d configs, %d routes, %d domainless servers, %d unique exemptions",
|
ctrld.Log(ctx, logger.Debug(), "VPN DNS refresh completed: %d configs, %d routes, %d domainless servers, %d unique exemptions",
|
||||||
len(m.configs), len(m.routes), len(m.domainlessServers), len(exemptions))
|
len(m.configs), len(m.routes), len(m.domainlessServers), len(exemptions))
|
||||||
|
|
||||||
// Update intercept rules to permit VPN DNS traffic only when the exemption set
|
// Update intercept rules to permit VPN DNS traffic only when the exemption set
|
||||||
@@ -189,19 +206,19 @@ func (m *vpnDNSManager) Refresh(guardAgainstNoNameservers bool) {
|
|||||||
// a self-triggering network-change loop. Empty exemptions are still applied
|
// a self-triggering network-change loop. Empty exemptions are still applied
|
||||||
// when they differ from the previous set, so stale VPN exemptions are cleared
|
// when they differ from the previous set, so stale VPN exemptions are cleared
|
||||||
// on disconnect.
|
// on disconnect.
|
||||||
m.updateInterceptExemptionsIfChanged(logger, previousExemptions, exemptions, "VPN DNS")
|
m.updateInterceptExemptionsIfChanged(ctx, logger, previousExemptions, exemptions, "VPN DNS")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *vpnDNSManager) updateInterceptExemptionsIfChanged(logger *zerolog.Logger, before, after []vpnDNSExemption, reason string) {
|
func (m *vpnDNSManager) updateInterceptExemptionsIfChanged(ctx context.Context, logger *ctrld.Logger, before, after []vpnDNSExemption, reason string) {
|
||||||
if m.onServersChanged == nil {
|
if m.onServersChanged == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if vpnDNSExemptionsEqual(before, after) {
|
if vpnDNSExemptionsEqual(before, after) {
|
||||||
logger.Debug().Msgf("VPN DNS exemptions unchanged after %s refresh; skipping intercept rule update", reason)
|
ctrld.Log(ctx, logger.Debug(), "VPN DNS exemptions unchanged after %s refresh; skipping intercept rule update", reason)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := m.onServersChanged(after); err != nil {
|
if err := m.onServersChanged(after); err != nil {
|
||||||
logger.Error().Err(err).Msg("Failed to update intercept exemptions for VPN DNS servers")
|
ctrld.Log(ctx, logger.Error().Err(err), "Failed to update intercept exemptions for VPN DNS servers")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,9 +320,12 @@ func (m *vpnDNSManager) ShouldFailClosedAfterVPNDNSTransportFailure(domain strin
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
mainLog.Load().Debug().Msgf(
|
logger := m.logger.Load()
|
||||||
|
if logger != nil {
|
||||||
|
logger.Debug().Msgf(
|
||||||
"VPN DNS transport failed for %s while retained VPN DNS state is active; suppressing OS fallback for this query (servers=%v)",
|
"VPN DNS transport failed for %s while retained VPN DNS state is active; suppressing OS fallback for this query (servers=%v)",
|
||||||
domain, servers)
|
domain, servers)
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,13 +336,17 @@ func (m *vpnDNSManager) VPNDNSReachable() {
|
|||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
if m.retainedAfterEmptyDiscovery {
|
if m.retainedAfterEmptyDiscovery {
|
||||||
mainLog.Load().Debug().Msg("VPN DNS transport recovered; clearing retained-empty-discovery state")
|
logger := m.logger.Load()
|
||||||
|
if logger != nil {
|
||||||
|
logger.Debug().Msg("VPN DNS transport recovered; clearing retained-empty-discovery state")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
m.retainedAfterEmptyDiscovery = false
|
m.retainedAfterEmptyDiscovery = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpstreamForDomain checks if the domain matches any VPN search domain.
|
// UpstreamForDomain checks if the domain matches any VPN search domain.
|
||||||
// Returns VPN DNS servers if matched, nil otherwise.
|
// Returns VPN DNS servers if matched, nil otherwise.
|
||||||
|
// Uses suffix matching: "foo.provisur.local" matches "provisur.local"
|
||||||
func (m *vpnDNSManager) UpstreamForDomain(domain string) []string {
|
func (m *vpnDNSManager) UpstreamForDomain(domain string) []string {
|
||||||
if domain == "" {
|
if domain == "" {
|
||||||
return nil
|
return nil
|
||||||
@@ -331,16 +355,19 @@ func (m *vpnDNSManager) UpstreamForDomain(domain string) []string {
|
|||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
// Normalize domain (remove trailing dot, convert to lowercase)
|
||||||
domain = strings.TrimSuffix(domain, ".")
|
domain = strings.TrimSuffix(domain, ".")
|
||||||
domain = strings.ToLower(domain)
|
domain = strings.ToLower(domain)
|
||||||
|
|
||||||
|
// First try exact match
|
||||||
if servers, ok := m.routes[domain]; ok {
|
if servers, ok := m.routes[domain]; ok {
|
||||||
return append([]string{}, servers...)
|
return append([]string{}, servers...) // Return copy to avoid race conditions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try suffix matching - check if domain ends with any of our VPN domains
|
||||||
for vpnDomain, servers := range m.routes {
|
for vpnDomain, servers := range m.routes {
|
||||||
if strings.HasSuffix(domain, "."+vpnDomain) {
|
if strings.HasSuffix(domain, "."+vpnDomain) {
|
||||||
return append([]string{}, servers...)
|
return append([]string{}, servers...) // Return copy
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,6 +384,7 @@ func (m *vpnDNSManager) DomainlessServers() []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CurrentServers returns the current set of unique VPN DNS server IPs.
|
// CurrentServers returns the current set of unique VPN DNS server IPs.
|
||||||
|
// Used by pf anchor rebuild to include VPN DNS exemptions without a full Refresh().
|
||||||
func (m *vpnDNSManager) CurrentServers() []string {
|
func (m *vpnDNSManager) CurrentServers() []string {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
@@ -375,6 +403,7 @@ func (m *vpnDNSManager) CurrentServers() []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CurrentExemptions returns VPN DNS server + interface pairs for pf exemption rules.
|
// CurrentExemptions returns VPN DNS server + interface pairs for pf exemption rules.
|
||||||
|
// Used by pf anchor rebuild paths that need interface-scoped exemptions.
|
||||||
func (m *vpnDNSManager) CurrentExemptions() []vpnDNSExemption {
|
func (m *vpnDNSManager) CurrentExemptions() []vpnDNSExemption {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
@@ -406,6 +435,6 @@ func (m *vpnDNSManager) upstreamConfigFor(server string) *ctrld.UpstreamConfig {
|
|||||||
Name: "VPN DNS",
|
Name: "VPN DNS",
|
||||||
Type: ctrld.ResolverTypeLegacy,
|
Type: ctrld.ResolverTypeLegacy,
|
||||||
Endpoint: endpoint,
|
Endpoint: endpoint,
|
||||||
Timeout: 2000,
|
Timeout: 2000, // 2 second timeout for VPN DNS queries
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-8
@@ -2,6 +2,8 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/Control-D-Inc/ctrld"
|
"github.com/Control-D-Inc/ctrld"
|
||||||
@@ -14,10 +16,40 @@ func withVPNDNSSettlingEnabled(t *testing.T) {
|
|||||||
t.Cleanup(func() { vpnDNSSettlingEnabled = old })
|
t.Cleanup(func() { vpnDNSSettlingEnabled = old })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestVPNDNSRefreshSkipsConcurrentDuplicate(t *testing.T) {
|
||||||
|
m := newVPNDNSManager(&mainLog, nil)
|
||||||
|
started := make(chan struct{})
|
||||||
|
release := make(chan struct{})
|
||||||
|
done := make(chan struct{})
|
||||||
|
var once sync.Once
|
||||||
|
var calls atomic.Int32
|
||||||
|
|
||||||
|
m.discoverVPNDNS = func(context.Context) []ctrld.VPNDNSConfig {
|
||||||
|
calls.Add(1)
|
||||||
|
once.Do(func() { close(started) })
|
||||||
|
<-release
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
m.Refresh(context.Background(), true)
|
||||||
|
}()
|
||||||
|
|
||||||
|
<-started
|
||||||
|
m.Refresh(context.Background(), true)
|
||||||
|
close(release)
|
||||||
|
<-done
|
||||||
|
|
||||||
|
if calls.Load() != 1 {
|
||||||
|
t.Fatalf("expected overlapping refresh to be skipped, got %d discovery calls", calls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestVPNDNSRefreshRetainsStateForOneGuardedEmptyDiscovery(t *testing.T) {
|
func TestVPNDNSRefreshRetainsStateForOneGuardedEmptyDiscovery(t *testing.T) {
|
||||||
withVPNDNSSettlingEnabled(t)
|
withVPNDNSSettlingEnabled(t)
|
||||||
var gotExemptions []vpnDNSExemption
|
var gotExemptions []vpnDNSExemption
|
||||||
m := newVPNDNSManager(func(exemptions []vpnDNSExemption) error {
|
m := newVPNDNSManager(&mainLog, func(exemptions []vpnDNSExemption) error {
|
||||||
gotExemptions = exemptions
|
gotExemptions = exemptions
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -28,7 +60,7 @@ func TestVPNDNSRefreshRetainsStateForOneGuardedEmptyDiscovery(t *testing.T) {
|
|||||||
}}
|
}}
|
||||||
m.domainlessServers = []string{"10.25.37.21", "10.25.37.22"}
|
m.domainlessServers = []string{"10.25.37.21", "10.25.37.22"}
|
||||||
|
|
||||||
m.Refresh(true)
|
m.Refresh(context.Background(), true)
|
||||||
|
|
||||||
if got := m.DomainlessServers(); len(got) != 2 {
|
if got := m.DomainlessServers(); len(got) != 2 {
|
||||||
t.Fatalf("expected retained domainless servers, got %v", got)
|
t.Fatalf("expected retained domainless servers, got %v", got)
|
||||||
@@ -44,7 +76,7 @@ func TestVPNDNSRefreshRetainsStateForOneGuardedEmptyDiscovery(t *testing.T) {
|
|||||||
func TestVPNDNSRefreshClearsOnSecondGuardedEmptyDiscovery(t *testing.T) {
|
func TestVPNDNSRefreshClearsOnSecondGuardedEmptyDiscovery(t *testing.T) {
|
||||||
withVPNDNSSettlingEnabled(t)
|
withVPNDNSSettlingEnabled(t)
|
||||||
var gotExemptions []vpnDNSExemption
|
var gotExemptions []vpnDNSExemption
|
||||||
m := newVPNDNSManager(func(exemptions []vpnDNSExemption) error {
|
m := newVPNDNSManager(&mainLog, func(exemptions []vpnDNSExemption) error {
|
||||||
gotExemptions = exemptions
|
gotExemptions = exemptions
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -56,7 +88,7 @@ func TestVPNDNSRefreshClearsOnSecondGuardedEmptyDiscovery(t *testing.T) {
|
|||||||
m.domainlessServers = []string{"10.25.37.21"}
|
m.domainlessServers = []string{"10.25.37.21"}
|
||||||
m.retainedAfterEmptyDiscovery = true
|
m.retainedAfterEmptyDiscovery = true
|
||||||
|
|
||||||
m.Refresh(true)
|
m.Refresh(context.Background(), true)
|
||||||
|
|
||||||
if got := m.DomainlessServers(); len(got) != 0 {
|
if got := m.DomainlessServers(); len(got) != 0 {
|
||||||
t.Fatalf("expected domainless servers to be cleared on second empty discovery, got %v", got)
|
t.Fatalf("expected domainless servers to be cleared on second empty discovery, got %v", got)
|
||||||
@@ -71,7 +103,7 @@ func TestVPNDNSRefreshClearsOnSecondGuardedEmptyDiscovery(t *testing.T) {
|
|||||||
|
|
||||||
func TestVPNDNSRefreshSkipsUnchangedInterceptExemptions(t *testing.T) {
|
func TestVPNDNSRefreshSkipsUnchangedInterceptExemptions(t *testing.T) {
|
||||||
var updates [][]vpnDNSExemption
|
var updates [][]vpnDNSExemption
|
||||||
m := newVPNDNSManager(func(exemptions []vpnDNSExemption) error {
|
m := newVPNDNSManager(&mainLog, func(exemptions []vpnDNSExemption) error {
|
||||||
updates = append(updates, append([]vpnDNSExemption{}, exemptions...))
|
updates = append(updates, append([]vpnDNSExemption{}, exemptions...))
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -83,8 +115,8 @@ func TestVPNDNSRefreshSkipsUnchangedInterceptExemptions(t *testing.T) {
|
|||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
m.Refresh(true)
|
m.Refresh(context.Background(), true)
|
||||||
m.Refresh(true)
|
m.Refresh(context.Background(), true)
|
||||||
|
|
||||||
if len(updates) != 1 {
|
if len(updates) != 1 {
|
||||||
t.Fatalf("expected exactly one intercept exemption update for unchanged VPN DNS state, got %d", len(updates))
|
t.Fatalf("expected exactly one intercept exemption update for unchanged VPN DNS state, got %d", len(updates))
|
||||||
@@ -96,7 +128,7 @@ func TestVPNDNSRefreshSkipsUnchangedInterceptExemptions(t *testing.T) {
|
|||||||
|
|
||||||
func TestVPNDNSTransportFailureSuppressesFallbackOnlyWhileRetainingState(t *testing.T) {
|
func TestVPNDNSTransportFailureSuppressesFallbackOnlyWhileRetainingState(t *testing.T) {
|
||||||
withVPNDNSSettlingEnabled(t)
|
withVPNDNSSettlingEnabled(t)
|
||||||
m := newVPNDNSManager(nil)
|
m := newVPNDNSManager(&mainLog, nil)
|
||||||
m.domainlessServers = []string{"10.25.37.21"}
|
m.domainlessServers = []string{"10.25.37.21"}
|
||||||
|
|
||||||
if m.ShouldFailClosedAfterVPNDNSTransportFailure("splunk.aws.arena.net.", []string{"10.25.37.21"}) {
|
if m.ShouldFailClosedAfterVPNDNSTransportFailure("splunk.aws.arena.net.", []string{"10.25.37.21"}) {
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func (c *Controller) Start(CdUID string, ProvisionID string, CustomHostname stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// As workaround to avoid circular dependency between cli and ctrld_library module
|
// mapCallback maps the AppCallback interface to cli.AppCallback to avoid circular dependency
|
||||||
func mapCallback(callback AppCallback) cli.AppCallback {
|
func mapCallback(callback AppCallback) cli.AppCallback {
|
||||||
return cli.AppCallback{
|
return cli.AppCallback{
|
||||||
HostName: func() string {
|
HostName: func() string {
|
||||||
|
|||||||
@@ -117,6 +117,10 @@ func SetConfigNameWithPath(v *viper.Viper, name, configPath string) {
|
|||||||
|
|
||||||
// InitConfig initializes default config values for given *viper.Viper instance.
|
// InitConfig initializes default config values for given *viper.Viper instance.
|
||||||
func InitConfig(v *viper.Viper, name string) {
|
func InitConfig(v *viper.Viper, name string) {
|
||||||
|
ctx := context.Background()
|
||||||
|
logger := LoggerFromCtx(ctx)
|
||||||
|
Log(ctx, logger.Debug(), "Config initialization started")
|
||||||
|
|
||||||
v.SetDefault("listener", map[string]*ListenerConfig{
|
v.SetDefault("listener", map[string]*ListenerConfig{
|
||||||
"0": {
|
"0": {
|
||||||
IP: "",
|
IP: "",
|
||||||
@@ -155,6 +159,8 @@ func InitConfig(v *viper.Viper, name string) {
|
|||||||
Timeout: 3000,
|
Timeout: 3000,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Log(ctx, logger.Debug(), "Config initialization completed")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config represents ctrld supported configuration.
|
// Config represents ctrld supported configuration.
|
||||||
@@ -241,6 +247,14 @@ type ServiceConfig struct {
|
|||||||
ForceRefetchWaitTime *int `mapstructure:"force_refetch_wait_time" toml:"force_refetch_wait_time,omitempty"`
|
ForceRefetchWaitTime *int `mapstructure:"force_refetch_wait_time" toml:"force_refetch_wait_time,omitempty"`
|
||||||
LeakOnUpstreamFailure *bool `mapstructure:"leak_on_upstream_failure" toml:"leak_on_upstream_failure,omitempty"`
|
LeakOnUpstreamFailure *bool `mapstructure:"leak_on_upstream_failure" toml:"leak_on_upstream_failure,omitempty"`
|
||||||
InterceptMode string `mapstructure:"intercept_mode" toml:"intercept_mode,omitempty" validate:"omitempty,oneof=off dns hard"`
|
InterceptMode string `mapstructure:"intercept_mode" toml:"intercept_mode,omitempty" validate:"omitempty,oneof=off dns hard"`
|
||||||
|
NRPTRecoveryMaxAttempts *int `mapstructure:"nrpt_recovery_max_attempts" toml:"nrpt_recovery_max_attempts,omitempty" validate:"omitempty,gte=0"`
|
||||||
|
NRPTRecoveryCooldown *time.Duration `mapstructure:"nrpt_recovery_cooldown" toml:"nrpt_recovery_cooldown,omitempty"`
|
||||||
|
// FirewallMode controls the DNS-resolved IP allowlist. When "on", only IPs
|
||||||
|
// that were successfully resolved by ctrld are allowed for outbound connections.
|
||||||
|
// This closes the "DNS gap" where apps bypass DNS policy using hardcoded IPs.
|
||||||
|
// Requires intercept mode to be active for enforcement on desktop platforms.
|
||||||
|
// On mobile, the netstack layer uses the allowlist directly.
|
||||||
|
FirewallMode string `mapstructure:"firewall_mode" toml:"firewall_mode,omitempty" validate:"omitempty,oneof=off on"`
|
||||||
Daemon bool `mapstructure:"-" toml:"-"`
|
Daemon bool `mapstructure:"-" toml:"-"`
|
||||||
AllocateIP bool `mapstructure:"-" toml:"-"`
|
AllocateIP bool `mapstructure:"-" toml:"-"`
|
||||||
}
|
}
|
||||||
@@ -319,6 +333,11 @@ func (lc *ListenerConfig) IsDirectDnsListener() bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MatchingConfig defines the configuration for rule matching behavior
|
||||||
|
type MatchingConfig struct {
|
||||||
|
Order []string `mapstructure:"order" toml:"order,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// ListenerPolicyConfig specifies the policy rules for ctrld to filter incoming requests.
|
// ListenerPolicyConfig specifies the policy rules for ctrld to filter incoming requests.
|
||||||
type ListenerPolicyConfig struct {
|
type ListenerPolicyConfig struct {
|
||||||
Name string `mapstructure:"name" toml:"name,omitempty"`
|
Name string `mapstructure:"name" toml:"name,omitempty"`
|
||||||
@@ -327,6 +346,7 @@ type ListenerPolicyConfig struct {
|
|||||||
Macs []Rule `mapstructure:"macs" toml:"macs,omitempty,inline,multiline" validate:"dive,len=1"`
|
Macs []Rule `mapstructure:"macs" toml:"macs,omitempty,inline,multiline" validate:"dive,len=1"`
|
||||||
FailoverRcodes []string `mapstructure:"failover_rcodes" toml:"failover_rcodes,omitempty" validate:"dive,dnsrcode"`
|
FailoverRcodes []string `mapstructure:"failover_rcodes" toml:"failover_rcodes,omitempty" validate:"dive,dnsrcode"`
|
||||||
FailoverRcodeNumbers []int `mapstructure:"-" toml:"-"`
|
FailoverRcodeNumbers []int `mapstructure:"-" toml:"-"`
|
||||||
|
Matching *MatchingConfig `mapstructure:"-" toml:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rule is a map from source to list of upstreams.
|
// Rule is a map from source to list of upstreams.
|
||||||
@@ -335,12 +355,13 @@ type ListenerPolicyConfig struct {
|
|||||||
type Rule map[string][]string
|
type Rule map[string][]string
|
||||||
|
|
||||||
// Init initialized necessary values for an UpstreamConfig.
|
// Init initialized necessary values for an UpstreamConfig.
|
||||||
func (uc *UpstreamConfig) Init() {
|
func (uc *UpstreamConfig) Init(ctx context.Context) {
|
||||||
|
logger := LoggerFromCtx(ctx)
|
||||||
if err := uc.initDnsStamps(); err != nil {
|
if err := uc.initDnsStamps(); err != nil {
|
||||||
ProxyLogger.Load().Fatal().Err(err).Msg("invalid DNS Stamps")
|
logger.Fatal().Err(err).Msg("Invalid dns stamps")
|
||||||
}
|
}
|
||||||
uc.initDoHScheme()
|
uc.initDoHScheme()
|
||||||
uc.uid = upstreamUID()
|
uc.uid = upstreamUID(ctx)
|
||||||
if u, err := url.Parse(uc.Endpoint); err == nil {
|
if u, err := url.Parse(uc.Endpoint); err == nil {
|
||||||
uc.Domain = u.Hostname()
|
uc.Domain = u.Hostname()
|
||||||
switch uc.Type {
|
switch uc.Type {
|
||||||
@@ -360,6 +381,9 @@ func (uc *UpstreamConfig) Init() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if uc.IPStack == "" {
|
if uc.IPStack == "" {
|
||||||
|
// Set default IP stack based on upstream type
|
||||||
|
// Control-D upstreams use split stack for better IPv4/IPv6 handling,
|
||||||
|
// while other upstreams use both stacks for maximum compatibility
|
||||||
if uc.IsControlD() {
|
if uc.IsControlD() {
|
||||||
uc.IPStack = IpStackSplit
|
uc.IPStack = IpStackSplit
|
||||||
} else {
|
} else {
|
||||||
@@ -421,7 +445,7 @@ func (uc *UpstreamConfig) IsDiscoverable() bool {
|
|||||||
return *uc.Discoverable
|
return *uc.Discoverable
|
||||||
}
|
}
|
||||||
switch uc.Type {
|
switch uc.Type {
|
||||||
case ResolverTypeOS, ResolverTypeLegacy, ResolverTypePrivate, ResolverTypeLocal:
|
case ResolverTypeOS, ResolverTypeLegacy, ResolverTypePrivate:
|
||||||
if ip, err := netip.ParseAddr(uc.Domain); err == nil {
|
if ip, err := netip.ParseAddr(uc.Domain); err == nil {
|
||||||
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || tsaddr.CGNATRange().Contains(ip)
|
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || tsaddr.CGNATRange().Contains(ip)
|
||||||
}
|
}
|
||||||
@@ -444,21 +468,18 @@ func (uc *UpstreamConfig) UID() string {
|
|||||||
return uc.uid
|
return uc.uid
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetupBootstrapIP manually find all available IPs of the upstream.
|
// SetupBootstrapIP sets up bootstrap IPs for the upstream config.
|
||||||
// The first usable IP will be used as bootstrap IP of the upstream.
|
|
||||||
// The upstream domain will be looked up using following orders:
|
|
||||||
//
|
|
||||||
// - Current system DNS settings.
|
|
||||||
// - Direct IPs table for ControlD upstreams.
|
|
||||||
// - ControlD Bootstrap DNS 76.76.2.22
|
|
||||||
//
|
|
||||||
// The setup process will block until there's usable IPs found.
|
// The setup process will block until there's usable IPs found.
|
||||||
func (uc *UpstreamConfig) SetupBootstrapIP() {
|
func (uc *UpstreamConfig) SetupBootstrapIP(ctx context.Context) {
|
||||||
|
logger := LoggerFromCtx(ctx)
|
||||||
|
Log(ctx, logger.Debug(), "Setting up bootstrap IPs for upstream: %s", uc.Name)
|
||||||
|
|
||||||
b := backoff.NewBackoff("setupBootstrapIP", func(format string, args ...any) {}, 10*time.Second)
|
b := backoff.NewBackoff("setupBootstrapIP", func(format string, args ...any) {}, 10*time.Second)
|
||||||
isControlD := uc.IsControlD()
|
isControlD := uc.IsControlD()
|
||||||
nss := initDefaultOsResolver()
|
nss := initDefaultOsResolver(ctx)
|
||||||
for {
|
for {
|
||||||
uc.bootstrapIPs = lookupIP(uc.Domain, uc.Timeout, nss)
|
Log(ctx, logger.Debug(), "Looking up bootstrap IPs for domain: %s", uc.Domain)
|
||||||
|
uc.bootstrapIPs = lookupIP(ctx, uc.Domain, uc.Timeout, nss)
|
||||||
// For ControlD upstream, the bootstrap IPs could not be RFC 1918 addresses,
|
// For ControlD upstream, the bootstrap IPs could not be RFC 1918 addresses,
|
||||||
// filtering them out here to prevent weird behavior.
|
// filtering them out here to prevent weird behavior.
|
||||||
if isControlD {
|
if isControlD {
|
||||||
@@ -473,18 +494,18 @@ func (uc *UpstreamConfig) SetupBootstrapIP() {
|
|||||||
uc.bootstrapIPs = uc.bootstrapIPs[:n]
|
uc.bootstrapIPs = uc.bootstrapIPs[:n]
|
||||||
if len(uc.bootstrapIPs) == 0 {
|
if len(uc.bootstrapIPs) == 0 {
|
||||||
uc.bootstrapIPs = bootstrapIPsFromControlDDomain(uc.Domain)
|
uc.bootstrapIPs = bootstrapIPsFromControlDDomain(uc.Domain)
|
||||||
ProxyLogger.Load().Warn().Msgf("no record found for %q, lookup from direct IP table", uc.Domain)
|
logger.Warn().Msgf("No record found for %q, lookup from direct ip table", uc.Domain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(uc.bootstrapIPs) == 0 {
|
if len(uc.bootstrapIPs) == 0 {
|
||||||
ProxyLogger.Load().Warn().Msgf("no record found for %q, using bootstrap server: %s", uc.Domain, PremiumDNSBoostrapIP)
|
logger.Warn().Msgf("No record found for %q, using bootstrap server: %s", uc.Domain, PremiumDNSBoostrapIP)
|
||||||
uc.bootstrapIPs = lookupIP(uc.Domain, uc.Timeout, []string{net.JoinHostPort(PremiumDNSBoostrapIP, "53")})
|
uc.bootstrapIPs = lookupIP(ctx, uc.Domain, uc.Timeout, []string{net.JoinHostPort(PremiumDNSBoostrapIP, "53")})
|
||||||
|
|
||||||
}
|
}
|
||||||
if len(uc.bootstrapIPs) > 0 {
|
if len(uc.bootstrapIPs) > 0 {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
ProxyLogger.Load().Warn().Msg("could not resolve bootstrap IPs, retrying...")
|
logger.Warn().Msg("Could not resolve bootstrap ips, retrying...")
|
||||||
b.BackOff(context.Background(), errors.New("no bootstrap IPs"))
|
b.BackOff(context.Background(), errors.New("no bootstrap IPs"))
|
||||||
}
|
}
|
||||||
for _, ip := range uc.bootstrapIPs {
|
for _, ip := range uc.bootstrapIPs {
|
||||||
@@ -494,11 +515,12 @@ func (uc *UpstreamConfig) SetupBootstrapIP() {
|
|||||||
uc.bootstrapIPs4 = append(uc.bootstrapIPs4, ip)
|
uc.bootstrapIPs4 = append(uc.bootstrapIPs4, ip)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ProxyLogger.Load().Debug().Msgf("bootstrap IPs: %v", uc.bootstrapIPs)
|
logger.Debug().Msgf("Bootstrap ips: %v", uc.bootstrapIPs)
|
||||||
|
Log(ctx, logger.Debug(), "Bootstrap IP setup completed for upstream: %s", uc.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReBootstrap re-setup the bootstrap IP and the transport.
|
// ReBootstrap re-setup the bootstrap IP and the transport.
|
||||||
func (uc *UpstreamConfig) ReBootstrap() {
|
func (uc *UpstreamConfig) ReBootstrap(ctx context.Context) {
|
||||||
switch uc.Type {
|
switch uc.Type {
|
||||||
case ResolverTypeDOH, ResolverTypeDOH3, ResolverTypeDOQ, ResolverTypeDOT:
|
case ResolverTypeDOH, ResolverTypeDOH3, ResolverTypeDOQ, ResolverTypeDOT:
|
||||||
default:
|
default:
|
||||||
@@ -506,90 +528,47 @@ func (uc *UpstreamConfig) ReBootstrap() {
|
|||||||
}
|
}
|
||||||
_, _, _ = uc.g.Do("ReBootstrap", func() (any, error) {
|
_, _, _ = uc.g.Do("ReBootstrap", func() (any, error) {
|
||||||
if uc.rebootstrap.CompareAndSwap(rebootstrapNotStarted, rebootstrapStarted) {
|
if uc.rebootstrap.CompareAndSwap(rebootstrapNotStarted, rebootstrapStarted) {
|
||||||
ProxyLogger.Load().Debug().Msgf("re-bootstrapping upstream ip for %v", uc)
|
logger := LoggerFromCtx(ctx)
|
||||||
|
Log(ctx, logger.Debug(), "Re-bootstrapping upstream: %s", uc.Name)
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ForceReBootstrap immediately replaces the upstream transport, closing old
|
// ForceReBootstrap immediately creates a new transport (closing old idle
|
||||||
// connections and creating new ones synchronously. Unlike ReBootstrap() which
|
// connections first) without waiting for the lazy re-bootstrap mechanism.
|
||||||
// sets a lazy flag (new transport created on next query), this ensures the
|
// Used after pf state table flushes where existing TCP/QUIC connections
|
||||||
// transport is ready before any queries arrive. Use when external events
|
// are dead and we need fresh connections immediately.
|
||||||
// (e.g. firewall state flush) are known to have killed existing connections.
|
func (uc *UpstreamConfig) ForceReBootstrap(ctx context.Context) {
|
||||||
func (uc *UpstreamConfig) ForceReBootstrap() {
|
|
||||||
switch uc.Type {
|
switch uc.Type {
|
||||||
case ResolverTypeDOH, ResolverTypeDOH3, ResolverTypeDOQ, ResolverTypeDOT:
|
case ResolverTypeDOH, ResolverTypeDOH3, ResolverTypeDOQ, ResolverTypeDOT:
|
||||||
default:
|
default:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ProxyLogger.Load().Debug().Msgf("force re-bootstrapping upstream transport for %v", uc)
|
logger := LoggerFromCtx(ctx)
|
||||||
uc.SetupTransport()
|
Log(ctx, logger.Debug(), "force re-bootstrapping upstream transport for %v", uc)
|
||||||
|
uc.closeTransports()
|
||||||
|
uc.SetupTransport(ctx)
|
||||||
// Clear any pending lazy re-bootstrap flag so ensureSetupTransport()
|
// Clear any pending lazy re-bootstrap flag so ensureSetupTransport()
|
||||||
// doesn't redundantly recreate the transport we just built.
|
// doesn't redundantly recreate the transport we just built.
|
||||||
uc.rebootstrap.Store(rebootstrapNotStarted)
|
uc.rebootstrap.Store(rebootstrapNotStarted)
|
||||||
}
|
}
|
||||||
|
|
||||||
// closeTransports closes idle connections on all existing transports.
|
// closeTransports closes idle connections on all existing transports.
|
||||||
// This is called before creating new transports during re-bootstrap to
|
|
||||||
// force in-flight requests on stale connections to fail quickly, rather
|
|
||||||
// than waiting for the full context deadline (e.g. 5s) after a firewall
|
|
||||||
// state table flush kills the underlying TCP/QUIC connections.
|
|
||||||
func (uc *UpstreamConfig) closeTransports() {
|
func (uc *UpstreamConfig) closeTransports() {
|
||||||
if t := uc.transport; t != nil {
|
if t := uc.transport; t != nil {
|
||||||
t.CloseIdleConnections()
|
t.CloseIdleConnections()
|
||||||
}
|
}
|
||||||
if t := uc.transport4; t != nil {
|
|
||||||
t.CloseIdleConnections()
|
|
||||||
}
|
|
||||||
if t := uc.transport6; t != nil {
|
|
||||||
t.CloseIdleConnections()
|
|
||||||
}
|
|
||||||
if p := uc.doqConnPool; p != nil {
|
|
||||||
p.CloseIdleConnections()
|
|
||||||
}
|
|
||||||
if p := uc.doqConnPool4; p != nil {
|
|
||||||
p.CloseIdleConnections()
|
|
||||||
}
|
|
||||||
if p := uc.doqConnPool6; p != nil {
|
|
||||||
p.CloseIdleConnections()
|
|
||||||
}
|
|
||||||
if p := uc.dotClientPool; p != nil {
|
|
||||||
p.CloseIdleConnections()
|
|
||||||
}
|
|
||||||
if p := uc.dotClientPool4; p != nil {
|
|
||||||
p.CloseIdleConnections()
|
|
||||||
}
|
|
||||||
if p := uc.dotClientPool6; p != nil {
|
|
||||||
p.CloseIdleConnections()
|
|
||||||
}
|
|
||||||
// http3RoundTripper is stored as http.RoundTripper but the concrete type
|
|
||||||
// (*http3.Transport) exposes CloseIdleConnections via this interface.
|
|
||||||
type idleCloser interface {
|
|
||||||
CloseIdleConnections()
|
|
||||||
}
|
|
||||||
for _, rt := range []http.RoundTripper{uc.http3RoundTripper, uc.http3RoundTripper4, uc.http3RoundTripper6} {
|
|
||||||
if c, ok := rt.(idleCloser); ok {
|
|
||||||
c.CloseIdleConnections()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetupTransport initializes the network transport used to connect to upstream servers.
|
// SetupTransport initializes the network transport used to connect to upstream servers.
|
||||||
// For now, DoH/DoH3/DoQ/DoT upstreams are supported.
|
// For now, DoH/DoH3/DoQ/DoT upstreams are supported.
|
||||||
func (uc *UpstreamConfig) SetupTransport() {
|
func (uc *UpstreamConfig) SetupTransport(ctx context.Context) {
|
||||||
switch uc.Type {
|
switch uc.Type {
|
||||||
case ResolverTypeDOH, ResolverTypeDOH3, ResolverTypeDOQ, ResolverTypeDOT:
|
case ResolverTypeDOH, ResolverTypeDOH3, ResolverTypeDOQ, ResolverTypeDOT:
|
||||||
default:
|
default:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close existing transport connections before creating new ones.
|
|
||||||
// This forces in-flight requests on stale connections (e.g. after a
|
|
||||||
// firewall state table flush) to fail fast instead of waiting for
|
|
||||||
// the full context deadline timeout.
|
|
||||||
uc.closeTransports()
|
|
||||||
|
|
||||||
ips := uc.bootstrapIPs
|
ips := uc.bootstrapIPs
|
||||||
switch uc.IPStack {
|
switch uc.IPStack {
|
||||||
case IpStackV4:
|
case IpStackV4:
|
||||||
@@ -597,21 +576,20 @@ func (uc *UpstreamConfig) SetupTransport() {
|
|||||||
case IpStackV6:
|
case IpStackV6:
|
||||||
ips = uc.bootstrapIPs6
|
ips = uc.bootstrapIPs6
|
||||||
}
|
}
|
||||||
|
uc.transport = uc.newDOHTransport(ctx, ips)
|
||||||
uc.transport = uc.newDOHTransport(ips)
|
uc.http3RoundTripper = uc.newDOH3Transport(ctx, ips)
|
||||||
uc.http3RoundTripper = uc.newDOH3Transport(ips)
|
uc.doqConnPool = uc.newDOQConnPool(ctx, ips)
|
||||||
uc.doqConnPool = uc.newDOQConnPool(ips)
|
uc.dotClientPool = uc.newDOTClientPool(ctx, ips)
|
||||||
uc.dotClientPool = uc.newDOTClientPool(ips)
|
|
||||||
if uc.IPStack == IpStackSplit {
|
if uc.IPStack == IpStackSplit {
|
||||||
uc.transport4 = uc.newDOHTransport(uc.bootstrapIPs4)
|
uc.transport4 = uc.newDOHTransport(ctx, uc.bootstrapIPs4)
|
||||||
uc.http3RoundTripper4 = uc.newDOH3Transport(uc.bootstrapIPs4)
|
uc.http3RoundTripper4 = uc.newDOH3Transport(ctx, uc.bootstrapIPs4)
|
||||||
uc.doqConnPool4 = uc.newDOQConnPool(uc.bootstrapIPs4)
|
uc.doqConnPool4 = uc.newDOQConnPool(ctx, uc.bootstrapIPs4)
|
||||||
uc.dotClientPool4 = uc.newDOTClientPool(uc.bootstrapIPs4)
|
uc.dotClientPool4 = uc.newDOTClientPool(ctx, uc.bootstrapIPs4)
|
||||||
if HasIPv6() {
|
if HasIPv6(ctx) {
|
||||||
uc.transport6 = uc.newDOHTransport(uc.bootstrapIPs6)
|
uc.transport6 = uc.newDOHTransport(ctx, uc.bootstrapIPs6)
|
||||||
uc.http3RoundTripper6 = uc.newDOH3Transport(uc.bootstrapIPs6)
|
uc.http3RoundTripper6 = uc.newDOH3Transport(ctx, uc.bootstrapIPs6)
|
||||||
uc.doqConnPool6 = uc.newDOQConnPool(uc.bootstrapIPs6)
|
uc.doqConnPool6 = uc.newDOQConnPool(ctx, uc.bootstrapIPs6)
|
||||||
uc.dotClientPool6 = uc.newDOTClientPool(uc.bootstrapIPs6)
|
uc.dotClientPool6 = uc.newDOTClientPool(ctx, uc.bootstrapIPs6)
|
||||||
} else {
|
} else {
|
||||||
uc.transport6 = uc.transport4
|
uc.transport6 = uc.transport4
|
||||||
uc.http3RoundTripper6 = uc.http3RoundTripper4
|
uc.http3RoundTripper6 = uc.http3RoundTripper4
|
||||||
@@ -621,17 +599,18 @@ func (uc *UpstreamConfig) SetupTransport() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (uc *UpstreamConfig) ensureSetupTransport() {
|
func (uc *UpstreamConfig) ensureSetupTransport(ctx context.Context) {
|
||||||
uc.transportOnce.Do(func() {
|
uc.transportOnce.Do(func() {
|
||||||
uc.SetupTransport()
|
uc.SetupTransport(ctx)
|
||||||
})
|
})
|
||||||
|
|
||||||
if uc.rebootstrap.CompareAndSwap(rebootstrapStarted, rebootstrapInProgress) {
|
if uc.rebootstrap.CompareAndSwap(rebootstrapStarted, rebootstrapInProgress) {
|
||||||
uc.SetupTransport()
|
uc.SetupTransport(ctx)
|
||||||
uc.rebootstrap.Store(rebootstrapNotStarted)
|
uc.rebootstrap.Store(rebootstrapNotStarted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (uc *UpstreamConfig) newDOHTransport(addrs []string) *http.Transport {
|
func (uc *UpstreamConfig) newDOHTransport(ctx context.Context, addrs []string) *http.Transport {
|
||||||
if uc.Type != ResolverTypeDOH {
|
if uc.Type != ResolverTypeDOH {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -655,12 +634,13 @@ func (uc *UpstreamConfig) newDOHTransport(addrs []string) *http.Transport {
|
|||||||
dialerTimeoutMs = uc.Timeout
|
dialerTimeoutMs = uc.Timeout
|
||||||
}
|
}
|
||||||
dialerTimeout := time.Duration(dialerTimeoutMs) * time.Millisecond
|
dialerTimeout := time.Duration(dialerTimeoutMs) * time.Millisecond
|
||||||
|
logger := LoggerFromCtx(ctx)
|
||||||
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
_, port, _ := net.SplitHostPort(addr)
|
_, port, _ := net.SplitHostPort(addr)
|
||||||
if uc.BootstrapIP != "" {
|
if uc.BootstrapIP != "" {
|
||||||
dialer := net.Dialer{Timeout: dialerTimeout, KeepAlive: dialerTimeout}
|
dialer := net.Dialer{Timeout: dialerTimeout, KeepAlive: dialerTimeout}
|
||||||
addr := net.JoinHostPort(uc.BootstrapIP, port)
|
addr := net.JoinHostPort(uc.BootstrapIP, port)
|
||||||
Log(ctx, ProxyLogger.Load().Debug(), "sending doh request to: %s", addr)
|
Log(ctx, logger.Debug(), "Sending doh request to: %s", addr)
|
||||||
return dialer.DialContext(ctx, network, addr)
|
return dialer.DialContext(ctx, network, addr)
|
||||||
}
|
}
|
||||||
pd := &ctrldnet.ParallelDialer{}
|
pd := &ctrldnet.ParallelDialer{}
|
||||||
@@ -670,11 +650,11 @@ func (uc *UpstreamConfig) newDOHTransport(addrs []string) *http.Transport {
|
|||||||
for i := range addrs {
|
for i := range addrs {
|
||||||
dialAddrs[i] = net.JoinHostPort(addrs[i], port)
|
dialAddrs[i] = net.JoinHostPort(addrs[i], port)
|
||||||
}
|
}
|
||||||
conn, err := pd.DialContext(ctx, network, dialAddrs, ProxyLogger.Load())
|
conn, err := pd.DialContext(ctx, network, dialAddrs, logger.Logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
Log(ctx, ProxyLogger.Load().Debug(), "sending doh request to: %s", conn.RemoteAddr())
|
Log(ctx, logger.Debug(), "Sending doh request to: %s", conn.RemoteAddr())
|
||||||
return conn, nil
|
return conn, nil
|
||||||
}
|
}
|
||||||
runtime.SetFinalizer(transport, func(transport *http.Transport) {
|
runtime.SetFinalizer(transport, func(transport *http.Transport) {
|
||||||
@@ -684,19 +664,20 @@ func (uc *UpstreamConfig) newDOHTransport(addrs []string) *http.Transport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Ping warms up the connection to DoH/DoH3 upstream.
|
// Ping warms up the connection to DoH/DoH3 upstream.
|
||||||
func (uc *UpstreamConfig) Ping() {
|
func (uc *UpstreamConfig) Ping(ctx context.Context) {
|
||||||
if err := uc.ping(); err != nil {
|
if err := uc.ping(ctx); err != nil {
|
||||||
ProxyLogger.Load().Debug().Err(err).Msgf("upstream ping failed: %s", uc.Endpoint)
|
logger := LoggerFromCtx(ctx)
|
||||||
_ = uc.FallbackToDirectIP()
|
logger.Debug().Err(err).Msgf("Upstream ping failed: %s", uc.Endpoint)
|
||||||
|
_ = uc.FallbackToDirectIP(ctx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ErrorPing is like Ping, but return an error if any.
|
// ErrorPing is like Ping, but return an error if any.
|
||||||
func (uc *UpstreamConfig) ErrorPing() error {
|
func (uc *UpstreamConfig) ErrorPing(ctx context.Context) error {
|
||||||
return uc.ping()
|
return uc.ping(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (uc *UpstreamConfig) ping() error {
|
func (uc *UpstreamConfig) ping(ctx context.Context) error {
|
||||||
switch uc.Type {
|
switch uc.Type {
|
||||||
case ResolverTypeDOH, ResolverTypeDOH3, ResolverTypeDOQ:
|
case ResolverTypeDOH, ResolverTypeDOH3, ResolverTypeDOQ:
|
||||||
default:
|
default:
|
||||||
@@ -725,21 +706,21 @@ func (uc *UpstreamConfig) ping() error {
|
|||||||
for _, typ := range []uint16{dns.TypeA, dns.TypeAAAA} {
|
for _, typ := range []uint16{dns.TypeA, dns.TypeAAAA} {
|
||||||
switch uc.Type {
|
switch uc.Type {
|
||||||
case ResolverTypeDOH:
|
case ResolverTypeDOH:
|
||||||
if err := ping(uc.dohTransport(typ)); err != nil {
|
if err := ping(uc.dohTransport(ctx, typ)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
case ResolverTypeDOH3:
|
case ResolverTypeDOH3:
|
||||||
if err := ping(uc.doh3Transport(typ)); err != nil {
|
if err := ping(uc.doh3Transport(ctx, typ)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
case ResolverTypeDOQ:
|
case ResolverTypeDOQ:
|
||||||
// For DoQ, we just ensure transport is set up by calling doqTransport
|
// For DoQ, we just ensure transport is set up by calling doqTransport
|
||||||
// DoQ doesn't use HTTP, so we can't ping it the same way
|
// DoQ doesn't use HTTP, so we can't ping it the same way
|
||||||
_ = uc.doqTransport(typ)
|
_ = uc.doqTransport(ctx, typ)
|
||||||
case ResolverTypeDOT:
|
case ResolverTypeDOT:
|
||||||
// For DoT, we just ensure transport is set up by calling dotTransport
|
// For DoT, we just ensure transport is set up by calling dotTransport
|
||||||
// DoT doesn't use HTTP, so we can't ping it the same way
|
// DoT doesn't use HTTP, so we can't ping it the same way
|
||||||
_ = uc.dotTransport(typ)
|
_ = uc.dotTransport(ctx, typ)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -772,12 +753,12 @@ func (uc *UpstreamConfig) isNextDNS() bool {
|
|||||||
return domain == "dns.nextdns.io"
|
return domain == "dns.nextdns.io"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (uc *UpstreamConfig) dohTransport(dnsType uint16) http.RoundTripper {
|
func (uc *UpstreamConfig) dohTransport(ctx context.Context, dnsType uint16) http.RoundTripper {
|
||||||
uc.ensureSetupTransport()
|
uc.ensureSetupTransport(ctx)
|
||||||
return transportByIpStack(uc.IPStack, dnsType, uc.transport, uc.transport4, uc.transport6)
|
return transportByIpStack(uc.IPStack, dnsType, uc.transport, uc.transport4, uc.transport6)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (uc *UpstreamConfig) netForDNSType(dnsType uint16) (string, string) {
|
func (uc *UpstreamConfig) netForDNSType(ctx context.Context, dnsType uint16) (string, string) {
|
||||||
switch uc.IPStack {
|
switch uc.IPStack {
|
||||||
case IpStackBoth:
|
case IpStackBoth:
|
||||||
return "tcp-tls", "udp"
|
return "tcp-tls", "udp"
|
||||||
@@ -790,7 +771,7 @@ func (uc *UpstreamConfig) netForDNSType(dnsType uint16) (string, string) {
|
|||||||
case dns.TypeA:
|
case dns.TypeA:
|
||||||
return "tcp4-tls", "udp4"
|
return "tcp4-tls", "udp4"
|
||||||
default:
|
default:
|
||||||
if HasIPv6() {
|
if HasIPv6(ctx) {
|
||||||
return "tcp6-tls", "udp6"
|
return "tcp6-tls", "udp6"
|
||||||
}
|
}
|
||||||
return "tcp4-tls", "udp4"
|
return "tcp4-tls", "udp4"
|
||||||
@@ -871,7 +852,7 @@ func (uc *UpstreamConfig) Context(ctx context.Context) (context.Context, context
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FallbackToDirectIP changes ControlD upstream endpoint to use direct IP instead of domain.
|
// FallbackToDirectIP changes ControlD upstream endpoint to use direct IP instead of domain.
|
||||||
func (uc *UpstreamConfig) FallbackToDirectIP() bool {
|
func (uc *UpstreamConfig) FallbackToDirectIP(ctx context.Context) bool {
|
||||||
if !uc.IsControlD() {
|
if !uc.IsControlD() {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -890,7 +871,8 @@ func (uc *UpstreamConfig) FallbackToDirectIP() bool {
|
|||||||
default:
|
default:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ProxyLogger.Load().Warn().Msgf("using direct IP for %q: %s", uc.Endpoint, ip)
|
logger := LoggerFromCtx(ctx)
|
||||||
|
Log(ctx, logger.Warn(), "Using direct IP for %q: %s", uc.Endpoint, ip)
|
||||||
uc.u.Host = ip
|
uc.u.Host = ip
|
||||||
done = true
|
done = true
|
||||||
})
|
})
|
||||||
@@ -899,12 +881,18 @@ func (uc *UpstreamConfig) FallbackToDirectIP() bool {
|
|||||||
|
|
||||||
// Init initialized necessary values for an ListenerConfig.
|
// Init initialized necessary values for an ListenerConfig.
|
||||||
func (lc *ListenerConfig) Init() {
|
func (lc *ListenerConfig) Init() {
|
||||||
|
logger := LoggerFromCtx(context.Background())
|
||||||
|
Log(context.Background(), logger.Debug(), "Initializing listener config")
|
||||||
|
|
||||||
if lc.Policy != nil {
|
if lc.Policy != nil {
|
||||||
lc.Policy.FailoverRcodeNumbers = make([]int, len(lc.Policy.FailoverRcodes))
|
lc.Policy.FailoverRcodeNumbers = make([]int, len(lc.Policy.FailoverRcodes))
|
||||||
for i, rcode := range lc.Policy.FailoverRcodes {
|
for i, rcode := range lc.Policy.FailoverRcodes {
|
||||||
lc.Policy.FailoverRcodeNumbers[i] = dnsrcode.FromString(rcode)
|
lc.Policy.FailoverRcodeNumbers[i] = dnsrcode.FromString(rcode)
|
||||||
}
|
}
|
||||||
|
Log(context.Background(), logger.Debug(), "Listener policy initialized with %d failover rcodes", len(lc.Policy.FailoverRcodes))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Log(context.Background(), logger.Debug(), "Listener config initialization completed")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateConfig validates the given config.
|
// ValidateConfig validates the given config.
|
||||||
@@ -1020,11 +1008,12 @@ func ResolverTypeFromEndpoint(endpoint string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// upstreamUID generates an unique identifier for an upstream.
|
// upstreamUID generates an unique identifier for an upstream.
|
||||||
func upstreamUID() string {
|
func upstreamUID(ctx context.Context) string {
|
||||||
|
logger := LoggerFromCtx(ctx)
|
||||||
b := make([]byte, 4)
|
b := make([]byte, 4)
|
||||||
for {
|
for {
|
||||||
if _, err := crand.Read(b); err != nil {
|
if _, err := crand.Read(b); err != nil {
|
||||||
ProxyLogger.Load().Warn().Err(err).Msg("could not generate uid for upstream, retrying...")
|
logger.Warn().Err(err).Msg("Could not generate uid for upstream, retrying...")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return hex.EncodeToString(b)
|
return hex.EncodeToString(b)
|
||||||
|
|||||||
+14
-13
@@ -1,6 +1,7 @@
|
|||||||
package ctrld
|
package ctrld
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/url"
|
"net/url"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -37,10 +38,10 @@ func TestUpstreamConfig_SetupBootstrapIP(t *testing.T) {
|
|||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
// Enable parallel tests once https://github.com/microsoft/wmi/issues/165 fixed.
|
// Enable parallel tests once https://github.com/microsoft/wmi/issues/165 fixed.
|
||||||
// t.Parallel()
|
// t.Parallel()
|
||||||
tc.uc.Init()
|
tc.uc.Init(context.Background())
|
||||||
tc.uc.SetupBootstrapIP()
|
tc.uc.SetupBootstrapIP(context.Background())
|
||||||
if len(tc.uc.bootstrapIPs) == 0 {
|
if len(tc.uc.bootstrapIPs) == 0 {
|
||||||
t.Log(defaultNameservers())
|
t.Log(defaultNameservers(context.Background()))
|
||||||
t.Fatalf("could not bootstrap ip: %s", tc.uc.String())
|
t.Fatalf("could not bootstrap ip: %s", tc.uc.String())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -356,7 +357,7 @@ func TestUpstreamConfig_Init(t *testing.T) {
|
|||||||
tc := tc
|
tc := tc
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
tc.uc.Init()
|
tc.uc.Init(context.Background())
|
||||||
tc.uc.uid = "" // we don't care about the uid.
|
tc.uc.uid = "" // we don't care about the uid.
|
||||||
assert.Equal(t, tc.expected, tc.uc)
|
assert.Equal(t, tc.expected, tc.uc)
|
||||||
})
|
})
|
||||||
@@ -498,7 +499,7 @@ func TestUpstreamConfig_IsDiscoverable(t *testing.T) {
|
|||||||
tc := tc
|
tc := tc
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
tc.uc.Init()
|
tc.uc.Init(context.Background())
|
||||||
if got := tc.uc.IsDiscoverable(); got != tc.discoverable {
|
if got := tc.uc.IsDiscoverable(); got != tc.discoverable {
|
||||||
t.Errorf("unexpected result, want: %v, got: %v", tc.discoverable, got)
|
t.Errorf("unexpected result, want: %v, got: %v", tc.discoverable, got)
|
||||||
}
|
}
|
||||||
@@ -515,7 +516,9 @@ func TestRebootstrapRace(t *testing.T) {
|
|||||||
bootstrapIPs: []string{"1.1.1.1", "1.0.0.1"},
|
bootstrapIPs: []string{"1.1.1.1", "1.0.0.1"},
|
||||||
}
|
}
|
||||||
|
|
||||||
uc.SetupTransport()
|
ctx := LoggerCtx(context.Background(), NopLogger)
|
||||||
|
|
||||||
|
uc.SetupTransport(ctx)
|
||||||
|
|
||||||
if uc.transport == nil {
|
if uc.transport == nil {
|
||||||
t.Fatal("initial transport should be set")
|
t.Fatal("initial transport should be set")
|
||||||
@@ -523,7 +526,7 @@ func TestRebootstrapRace(t *testing.T) {
|
|||||||
|
|
||||||
const goroutines = 100
|
const goroutines = 100
|
||||||
|
|
||||||
uc.ReBootstrap()
|
uc.ReBootstrap(ctx)
|
||||||
|
|
||||||
started := make(chan struct{})
|
started := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
@@ -531,7 +534,7 @@ func TestRebootstrapRace(t *testing.T) {
|
|||||||
for {
|
for {
|
||||||
switch uc.rebootstrap.Load() {
|
switch uc.rebootstrap.Load() {
|
||||||
case rebootstrapStarted, rebootstrapInProgress:
|
case rebootstrapStarted, rebootstrapInProgress:
|
||||||
uc.ReBootstrap()
|
uc.ReBootstrap(ctx)
|
||||||
default:
|
default:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -541,12 +544,10 @@ func TestRebootstrapRace(t *testing.T) {
|
|||||||
<-started
|
<-started
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
wg.Add(goroutines)
|
|
||||||
for range goroutines {
|
for range goroutines {
|
||||||
go func() {
|
wg.Go(func() {
|
||||||
defer wg.Done()
|
uc.ensureSetupTransport(ctx)
|
||||||
uc.ensureSetupTransport()
|
})
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|||||||
+14
-13
@@ -13,18 +13,19 @@ import (
|
|||||||
"github.com/quic-go/quic-go/http3"
|
"github.com/quic-go/quic-go/http3"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (uc *UpstreamConfig) newDOH3Transport(addrs []string) http.RoundTripper {
|
func (uc *UpstreamConfig) newDOH3Transport(ctx context.Context, addrs []string) http.RoundTripper {
|
||||||
if uc.Type != ResolverTypeDOH3 {
|
if uc.Type != ResolverTypeDOH3 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
rt := &http3.Transport{}
|
rt := &http3.Transport{}
|
||||||
rt.TLSClientConfig = &tls.Config{RootCAs: uc.certPool, MinVersion: tls.VersionTLS12}
|
rt.TLSClientConfig = &tls.Config{RootCAs: uc.certPool, MinVersion: tls.VersionTLS12}
|
||||||
|
logger := LoggerFromCtx(ctx)
|
||||||
rt.Dial = func(ctx context.Context, addr string, tlsCfg *tls.Config, cfg *quic.Config) (*quic.Conn, error) {
|
rt.Dial = func(ctx context.Context, addr string, tlsCfg *tls.Config, cfg *quic.Config) (*quic.Conn, error) {
|
||||||
_, port, _ := net.SplitHostPort(addr)
|
_, port, _ := net.SplitHostPort(addr)
|
||||||
// if we have a bootstrap ip set, use it to avoid DNS lookup
|
// if we have a bootstrap ip set, use it to avoid DNS lookup
|
||||||
if uc.BootstrapIP != "" {
|
if uc.BootstrapIP != "" {
|
||||||
addr = net.JoinHostPort(uc.BootstrapIP, port)
|
addr = net.JoinHostPort(uc.BootstrapIP, port)
|
||||||
ProxyLogger.Load().Debug().Msgf("sending doh3 request to: %s", addr)
|
Log(ctx, logger.Debug(), "Sending doh3 request to: %s", addr)
|
||||||
udpConn, err := net.ListenUDP("udp", nil)
|
udpConn, err := net.ListenUDP("udp", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -44,7 +45,7 @@ func (uc *UpstreamConfig) newDOH3Transport(addrs []string) http.RoundTripper {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
ProxyLogger.Load().Debug().Msgf("sending doh3 request to: %s", conn.RemoteAddr())
|
Log(ctx, logger.Debug(), "Sending doh3 request to: %s", conn.RemoteAddr())
|
||||||
return conn, err
|
return conn, err
|
||||||
}
|
}
|
||||||
runtime.SetFinalizer(rt, func(rt *http3.Transport) {
|
runtime.SetFinalizer(rt, func(rt *http3.Transport) {
|
||||||
@@ -53,18 +54,18 @@ func (uc *UpstreamConfig) newDOH3Transport(addrs []string) http.RoundTripper {
|
|||||||
return rt
|
return rt
|
||||||
}
|
}
|
||||||
|
|
||||||
func (uc *UpstreamConfig) doh3Transport(dnsType uint16) http.RoundTripper {
|
func (uc *UpstreamConfig) doh3Transport(ctx context.Context, dnsType uint16) http.RoundTripper {
|
||||||
uc.ensureSetupTransport()
|
uc.ensureSetupTransport(ctx)
|
||||||
return transportByIpStack(uc.IPStack, dnsType, uc.http3RoundTripper, uc.http3RoundTripper4, uc.http3RoundTripper6)
|
return transportByIpStack(uc.IPStack, dnsType, uc.http3RoundTripper, uc.http3RoundTripper4, uc.http3RoundTripper6)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (uc *UpstreamConfig) doqTransport(dnsType uint16) *doqConnPool {
|
func (uc *UpstreamConfig) doqTransport(ctx context.Context, dnsType uint16) *doqConnPool {
|
||||||
uc.ensureSetupTransport()
|
uc.ensureSetupTransport(ctx)
|
||||||
return transportByIpStack(uc.IPStack, dnsType, uc.doqConnPool, uc.doqConnPool4, uc.doqConnPool6)
|
return transportByIpStack(uc.IPStack, dnsType, uc.doqConnPool, uc.doqConnPool4, uc.doqConnPool6)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (uc *UpstreamConfig) dotTransport(dnsType uint16) *dotConnPool {
|
func (uc *UpstreamConfig) dotTransport(ctx context.Context, dnsType uint16) *dotConnPool {
|
||||||
uc.ensureSetupTransport()
|
uc.ensureSetupTransport(ctx)
|
||||||
return transportByIpStack(uc.IPStack, dnsType, uc.dotClientPool, uc.dotClientPool4, uc.dotClientPool6)
|
return transportByIpStack(uc.IPStack, dnsType, uc.dotClientPool, uc.dotClientPool4, uc.dotClientPool6)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,16 +159,16 @@ func (d *quicParallelDialer) Dial(ctx context.Context, addrs []string, tlsCfg *t
|
|||||||
return nil, errors.Join(errs...)
|
return nil, errors.Join(errs...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (uc *UpstreamConfig) newDOQConnPool(addrs []string) *doqConnPool {
|
func (uc *UpstreamConfig) newDOQConnPool(ctx context.Context, addrs []string) *doqConnPool {
|
||||||
if uc.Type != ResolverTypeDOQ {
|
if uc.Type != ResolverTypeDOQ {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return newDOQConnPool(uc, addrs)
|
return newDOQConnPool(ctx, uc, addrs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (uc *UpstreamConfig) newDOTClientPool(addrs []string) *dotConnPool {
|
func (uc *UpstreamConfig) newDOTClientPool(ctx context.Context, addrs []string) *dotConnPool {
|
||||||
if uc.Type != ResolverTypeDOT {
|
if uc.Type != ResolverTypeDOT {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return newDOTClientPool(uc, addrs)
|
return newDOTClientPool(ctx, uc, addrs)
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user