Compare commits

..

73 Commits

Author SHA1 Message Date
Cuong Manh Le 5bf26da585 Merge pull request #318 from Control-D-Inc/release-branch-v1.5.3
Release branch v1.5.3
2026-06-22 14:40:36 +07:00
Cuong Manh Le a5d536ab79 Upgrade quic-go to v0.59.1
For fixing CVE-2026-40898.
2026-06-16 15:17:11 +07:00
Codescribe 735590d244 fix: allow intercept fallback for default listener 2026-06-16 15:05:19 +07:00
Codescribe 18f01baa01 fix: flush pf states after forced DNS intercept reload 2026-06-16 15:05:08 +07:00
Cuong Manh Le 723c7827ba fix: stop self-upgrade tests from fork-bombing the windows test runner
The test:windows CI job intermittently failed to clean up .testbin with
"Access to the path '...cmd_cli.test.exe' is denied". This was previously
attributed to Windows Defender scanning the large unsigned test binaries,
and mitigated with Defender exclusions and cleanup retries. That was
treating a symptom.

Root cause: performUpgrade() self-upgrades by running
exec.Command(os.Executable(), "upgrade", "prod", "-vv") as a detached,
windowless child. In the real ctrld binary this re-execs ctrld and is
correct. Under `go test`, os.Executable() is the test binary itself, and
`go test` stops flag parsing at the first positional arg ("upgrade") and
ignores the rest -- so the child silently re-runs the entire test suite.
That child hits the upgrade tests again and spawns more detached children,
recursively: a fork bomb of hidden processes that pins the runner's
CPU/memory and keeps the test binary's image file locked. Windows refuses
to delete the image of a running process, hence the "Access is denied"
during after_script. Whether any children are still alive when cleanup
runs is a timing race, which is why the failure was flaky.

Two tests reached this path: Test_performUpgrade (directly) and
Test_selfUpgradeCheck (via selfUpgradeCheck -> performUpgrade on the
"upgrade allowed" case).

Fix:
- prog.go: extract the command construction into a package-level
  newUpgradeCmd var. Production behavior is unchanged.
- main_test.go: stub newUpgradeCmd once in TestMain so the whole test
  binary self-execs with `-test.run=^$` (matches no tests, exits
  immediately) instead of re-running the suite. This covers every test
  that reaches performUpgrade, present and future, while still exercising
  the cmd.Start() success path.
2026-06-16 14:52:45 +07:00
Dev Scribe 1e1c998c89 Refresh macOS VPN DNS after pf stabilization 2026-06-16 14:52:28 +07:00
Cuong Manh Le da454db8ef docker: update Dockerfile to use bookworm
Stick to go1.25 for now, since using go1.26 causing a runtime panic when
building arm platforms.
2026-06-16 14:49:33 +07:00
Cuong Manh Le 3fe9b27fb4 fix(doh,doq): reject oversized upstream DNS responses
DoH, DoH3, and DoQ response paths previously used io.ReadAll on
attacker-controlled upstream responses before enforcing any protocol-level
size limit. A malicious or compromised upstream could return an oversized
body or stream and force ctrld to buffer unbounded data before eventually
failing DNS parsing.

Cap DoH/DoH3 response bodies at dns.MaxMsgSize and cap DoQ streams at the
2-byte length prefix plus dns.MaxMsgSize. Also limit non-200 DoH error
bodies so error formatting cannot consume large upstream responses.
2026-06-16 14:49:01 +07:00
Cuong Manh Le 35455eb0b9 fix(doq): share QUIC transport, close send side before read (RFC 9250)
DoQ pools now keep a single quic.Transport and UDP socket for all dials,
so parallel dial and reconnect churn no longer allocate a new socket per
attempt or leak the winner's UDP conn when the caller owns the packet
conn.

quicParallelDialer accepts an optional transport: when set, dials use
Transport.DialEarly on that socket; when nil, behavior matches the old
per-dial ListenUDP path (losers close their sockets).

Per RFC 9250 §4.2, close the query stream's send side before reading the
response so strict upstreams see STREAM FIN before answering.

CloseIdleConnections closes the shared transport and underlying UDP
conn so checked-out connections and the OS socket are torn down.

Add a FIN-strict test server, coverage for bootstrap vs parallel-dial
paths, and a Linux-only FD churn regression test.
2026-06-16 14:48:43 +07:00
Cuong Manh Le f1309121ae doq: validate DNS-over-QUIC response framing
DoQ responses are length-prefixed per RFC 9250. The resolver previously
assumed the stream always contained at least two bytes and unpacked from
buf[2:], which could panic on truncated or malicious replies.

Validate the prefix against the bytes read, return a clear error, and
retire the connection from the pool on framing failure. Unpack only the
slice declared by the prefix so a short read cannot be misinterpreted as
a full message.

Add regression coverage with a small test server that returns malformed
raw payloads (empty, one byte, prefix-only, prefix larger than payload).
2026-06-16 14:48:33 +07:00
Cuong Manh Le 06668a2b6c cmd/cli: rate-limit PIN brute-force on control socket
Currently there is no limit on PIN attempts, allowing unlimited
brute force if an attacker gains socket access. While the socket is
root-only by default, rate limiting is cheap defense-in-depth.
2026-06-16 14:47:27 +07:00
Cuong Manh Le 97e5e99b8d cmd/cli: use os.CreateTemp for symlink-safe temp file creation
Current code writes to a predictable path, which on systems without
`fs.protected_symlinks` (e.g. embedded routers) could allow a local
attacker with API compromise to perform symlink attacks.
2026-06-16 14:47:19 +07:00
Cuong Manh Le c54ff701bd internal/router/dnsmasq: use text/template instead of html/template
Since this is a plain-text config, not html.
2026-06-16 14:47:08 +07:00
Cuong Manh Le 33682e2312 all: explicit TLS MinVersion in tls.Config
Go's default is already TLS 1.2+ (since Go 1.18), but making this
explicit satisfies RFC 7858/9250 recommendations and makes the security
intent clear for auditors.
2026-06-16 14:46:42 +07:00
Cuong Manh Le d629ecda33 Merge pull request #317 from Control-D-Inc/update-ci
Update ci
2026-06-02 03:24:30 -04:00
Cuong Manh Le 87ddf03b90 .github/workflows: bump go and staticcheck version 2026-06-02 14:20:05 +07:00
Cuong Manh Le d49a4c67c9 Bump golang.org/x/net to v0.55.0
For GO-2026-5026 security fix.
2026-06-02 14:18:23 +07:00
Cuong Manh Le 2c38ff74c3 Merge pull request #316 from Control-D-Inc/release-branch-v1.5.2
Release v1.5.2
2026-06-02 03:08:55 -04:00
Cuong Manh Le 75e8447c75 test: isolate VPN DNS settling tests from host adapters 2026-06-01 16:30:34 +07:00
Codescribe 4395efcb22 fix: stabilize Windows VPN DNS during adapter settling
Fixes Windows DNS-intercept behavior for AD/internal split-rule domains
during sleep/wake or VPN adapter settling without relying on a fixed
timeout.
2026-06-01 15:41:38 +07:00
Cuong Manh Le 7e6f88b4ed Merge pull request #301 from Control-D-Inc/release-branch-v1.5.1
Release branch v1.5.1
2026-05-25 07:08:15 -04:00
Cuong Manh Le 5dd5846cca cmd/cli: skip upstream.os healthcheck when WFP loopback protect enabled
Since the check will always be failed in this case, causing unnecessary
log spamming.
2026-05-05 22:15:54 +07:00
Codescribe 2b27c148be dns: recovery race condition fix
Three changes to reduce worst-case recovery from ~30s to <3s:

1. debounceRecovery() for network changes (500ms window) — coalesces
   rapid consecutive network changes into a single recovery pass,
   eliminating the cancel-and-restart race.

2. ForceReBootstrap() on recovery entry — closes dead connections and
   creates fresh transports synchronously before probing, replacing
   the lazy ReBootstrap() flag that left stale connections.

3. Combined effect: recovery probes never inherit dead connections
   from a canceled prior recovery attempt.
2026-04-30 19:09:21 +07:00
CodeScribe 8cb383d87e dns_intercept: add WFP loopback protect for VPN block-outside-dns
When third-party VPN software (e.g., OpenVPN) installs WFP block filters via
block-outside-dns, all DNS traffic to non-tunnel interfaces is blocked —
including DNS to 127.0.0.1 (ctrld's NRPT target). This breaks DNS mode
interception because the NRPT catch-all rule routes queries to loopback,
but WFP blocks the connection before it reaches ctrld's listener.

Fix: after exhausting all NRPT recovery attempts, activate a minimal WFP
session with "hard permit" filters (FWPM_FILTER_FLAG_CLEAR_ACTION_RIGHT)
for DNS to localhost in a max-priority sublayer (weight 0xFFFF). This
overrides the VPN's block for loopback DNS only, while preserving the
VPN's DNS leak protection for all other (non-loopback) DNS traffic.

The loopback protect is:
- Only activated when NRPT probes fail (not preemptively)
- Harmless when no conflicting WFP blocks exist (permit-only, no blocks)
- Persistent until ctrld shutdown (survives VPN reconnect cycles)
- Cleaned up by the existing cleanupWFPFilters path on shutdown
2026-04-29 15:21:38 +07:00
Codescribe afed925404 log: persist internal runtime logs to disk
Add file-backed persistence to the internal logWriter so runtime logs
survive service restarts. When internal logging is enabled (CD mode,
no explicit log_path), writes are teed to both the existing in-memory
ring buffer and a rotated file on disk (ctrld.log in the home directory).

File rotation: 5MB max with 1 backup (ctrld.log.1), so max ~10MB on disk.
Log view/send now reads from the persisted files (including backup) to
provide complete history across restarts. Live tail continues to use
the in-memory subscriber mechanism unchanged.

Activation: same conditions as existing internal logging — CD mode only,
no log_path configured. No new config options or dependencies.
2026-04-29 15:12:44 +07:00
Cuong Manh Le d1ea70d688 fix: prevent panic on network change during SetSelfIP
SetSelfIP unconditionally accessed t.dhcp, but t.dhcp is only
initialized when DHCP discovery is enabled. A network change event
can fire SetSelfIP regardless of the discovery configuration,
causing a nil pointer dereference.

Guard the t.dhcp access with a nil check so the self IP is still
updated on the Table even when DHCP discovery is disabled.
2026-04-22 15:30:59 +07:00
Cuong Manh Le ed98104384 doq: use OpenStreamSync and retry on StreamLimitReachedError
Replace conn.OpenStream (non-blocking) with conn.OpenStreamSync so that
the resolver waits for the server's MAX_STREAMS credit replenishment frame
instead of immediately failing when the stream limit is temporarily
exhausted. Also retry on StreamLimitReachedError as defense-in-depth for
servers that are slow or fail to send MAX_STREAMS updates.
2026-04-13 17:56:16 +07:00
Codescribe eaa171f66f doq: configure QUIC keep-alive and retry on idle timeout
Pass a quic.Config with KeepAlivePeriod (15s) to DoQ dial calls instead
of nil, so pooled connections send periodic QUIC PINGs to stay alive and
detect dead paths proactively.

Also add IdleTimeoutError to the DoQ retry conditions alongside io.EOF,
so stale pooled connections trigger a transparent retry instead of
propagating as a query failure.
2026-04-13 17:55:57 +07:00
Cuong Manh Le 839b8236e7 docs: add known issue for daemon crashing on Merlin 2026-04-07 11:34:07 +07:00
Codescribe 3f59cdad1a fix: block IPv6 DNS in intercept mode, remove raw socket approach
IPv6 DNS interception on macOS is not feasible with current pf capabilities.
The kernel rejects sendmsg from [::1] to global unicast (EINVAL), nat on lo0
doesn't fire for route-to'd packets, raw sockets bypass routing but pf doesn't
match them against rdr state, and DIOCNATLOOK can't be used because bind()
fails for non-local addresses.

Replace all IPv6 interception code with a simple pf block rule:
  block out quick on ! lo0 inet6 proto { udp, tcp } from any to any port 53

macOS automatically retries DNS over IPv4 when IPv6 is blocked.

Changes:
- Remove rawipv6_darwin.go and rawipv6_other.go
- Remove [::1] listener spawn on macOS (needLocalIPv6Listener returns false)
- Remove IPv6 rdr, route-to, pass, and reply-to pf rules
- Add block rule for all outbound IPv6 DNS
- Update docs/pf-dns-intercept.md with what was tried and why it failed
2026-04-01 17:35:08 +07:00
Codescribe c55e2a722c fix: declare ipv6Handler as dns.Handler to match wrapIPv6Handler return type
The handler variable is dns.HandlerFunc but wrapIPv6Handler returns
dns.Handler (interface). Go's type inference picked dns.HandlerFunc
for ipv6Handler, causing a compile error on assignment. Explicit
type declaration fixes the mismatch.
2026-04-01 17:24:36 +07:00
Codescribe 22a796f673 fix: use raw IPv6 socket for DNS responses in macOS intercept mode
macOS rejects sendmsg from [::1] to global unicast IPv6 (EINVAL), and
nat on lo0 doesn't fire for route-to'd packets (pf skips translation
on the second interface pass). ULA addresses on lo0 also fail (EHOSTUNREACH
- kernel segregates lo0 routing).

Solution: wrap the [::1] UDP listener's ResponseWriter with rawIPv6Writer
that sends responses via SOCK_RAW (IPPROTO_UDP) on lo0, bypassing the
kernel's routing validation. pf's rdr state reverses the address
translation on the response path.

Changes:
- Add rawipv6_darwin.go: rawIPv6Writer wraps dns.ResponseWriter, sends
  UDP responses via raw IPv6 socket with proper checksum calculation
- Add rawipv6_other.go: no-op wrapIPv6Handler for non-darwin platforms
- Remove nat rules from pf anchor (no longer needed)
- Block IPv6 TCP DNS (block return) - falls back to IPv4 (~1s, rare)
- Remove IPv6 TCP rdr/route-to/pass rules (only UDP intercepted)
2026-04-01 17:24:17 +07:00
Codescribe 95dd871e2d fix: bracket IPv6 addresses in VPN DNS upstream config
upstreamConfigFor() used strings.Contains(":") to detect whether to
append ":53", but IPv6 addresses contain colons, so IPv6 servers were
passed as bare addresses (e.g. "2a0d:6fc0:9b0:3600::1") to net.Dial
which rejects them with "too many colons in address".

Use net.JoinHostPort() which handles both IPv4 and IPv6 correctly,
producing "[2a0d:6fc0:9b0:3600::1]:53" for IPv6.
2026-04-01 17:23:53 +07:00
Codescribe 5c0585b2e8 Add log tail command for live log streaming
This commit adds a new `ctrld log tail` subcommand that streams
runtime debug logs to the terminal in real-time, similar to `tail -f`.

Changes:
- log_writer.go: Add Subscribe/tailLastLines for fan-out to tail clients
- control_server.go: Add /log/tail endpoint with streaming response
  - Internal logging: subscribes to logWriter for live data
  - File-based logging: polls log file for new data (200ms interval)
  - Sends last N lines as initial context on connect
- commands.go: Add `log tail` cobra subcommand with --lines/-n flag
- control_client.go: Add postStream() with no timeout for long-lived connections

Usage:
  sudo ctrld log tail          # shows last 10 lines then follows
  sudo ctrld log tail -n 50    # shows last 50 lines then follows
  Ctrl+C to stop
2026-03-25 13:58:44 +07:00
Codescribe 112d1cb5a9 fix: close handle leak in hasLocalDnsServerRunning()
Add defer windows.CloseHandle(h) after CreateToolhelp32Snapshot to ensure
the process snapshot handle is properly released on all code paths (match
found, enumeration exhausted, or error).
2026-03-25 13:58:24 +07:00
Codescribe bd9bb90dd4 Fix dnsFromResolvConf not filtering loopback IPs
The continue statement only broke out of the inner loop, so
loopback/local IPs (e.g. 127.0.0.1) were never filtered.
This caused ctrld to use itself as bootstrap DNS when already
installed as the system resolver — a self-referential loop.

Use the same isLocal flag pattern as getDNSFromScutil() and
getAllDHCPNameservers().
2026-03-25 13:57:46 +07:00
Codescribe 82fc628bf3 docs: add DNS Intercept Mode section to README 2026-03-25 13:57:35 +07:00
Cuong Manh Le 2926c76b76 Merge pull request #295 from Control-D-Inc/release-branch-v1.5.0
Release branch v1.5.0
2026-03-04 20:56:15 +07:00
Cuong Manh Le fe08f00746 fix(darwin): correct pf rules tests 2026-03-03 15:36:46 +07:00
Cuong Manh Le 9be15aeec8 fix(windows): make staticcheck happy 2026-03-03 15:15:16 +07:00
Codescribe 9b2e51f53a feat: robust username detection and CI updates
Add platform-specific username detection for Control D metadata:
- macOS: directory services (dscl) with console user fallback
- Linux: systemd loginctl, utmp, /etc/passwd traversal
- Windows: WTS session enumeration, registry, token lookup
2026-03-03 14:29:58 +07:00
Codescribe e7040bd9f9 feat: add VPN DNS split routing
Implement VPN DNS discovery and split routing for intercept mode:
- Discover VPN DNS servers from F5 BIG-IP, Tailscale, Network
  Extension VPNs, and traditional VPN adapters
- Exit mode detection (split vs full tunnel) via routing table
- Interface-scoped pf exemptions for VPN DNS traffic (macOS)
- Windows VPN adapter filtering with routable address check
- AD domain controller detection with retry on transient failure
- Cleanup of stale exemptions on VPN disconnect

Squashed from intercept mode development on v1.0 branch (#497).
2026-03-03 14:29:31 +07:00
Codescribe 768cc81855 feat: add Windows NRPT and WFP DNS interception
Implement DNS interception on Windows with dual-mode support:
- NRPT for --intercept-mode=dns: catch-all rule redirecting all DNS
  to ctrld's listener, with GP vs local path detection
- WFP for --intercept-mode=hard: sublayer with callout filters
  intercepting port 53 traffic
- NRPT probe-and-heal for async Group Policy refresh race
- Service registry verification for intercept mode persistence
- NRPT diagnostics script for troubleshooting

Includes WFP technical reference docs and Windows test scripts.

Squashed from intercept mode development on v1.0 branch (#497).
2026-03-03 14:29:09 +07:00
Codescribe 289a46dc2c feat: add macOS pf DNS interception
Implement DNS interception on macOS using pf (packet filter):
- Anchor injection into running ruleset (not /etc/pf.conf)
- route-to lo0 + rdr rules for locally-originated DNS capture
- _ctrld group exemption so ctrld's own queries bypass interception
- Watchdog to detect and restore wiped anchor rules
- Probe-based auto-heal for Parallels VM pf corruption
- IPv6 DNS blocking and block-return for clean timeouts
- Interface-specific tunnel detection for VPN coexistence
- Port 5354 fallback in intercept mode

Includes pf technical reference docs and test scripts.

Squashed from intercept mode development on v1.0 branch (#497).
2026-03-03 14:27:43 +07:00
Codescribe 1e8240bd1c feat: introduce DNS intercept mode infrastructure
Add --intercept-mode flag (dns/hard/off) with configuration support,
recovery bypass for captive portals, probe-based interception
verification, VPN DNS coexistence in the proxy layer, and IPv6
loopback listener guard.

Remove standalone mDNSResponder hack files — the port 53 binding
logic is now handled within the intercept mode infrastructure.

Squashed from intercept mode development on v1.0 branch (#497).
2026-03-03 14:26:39 +07:00
Codescribe 12715e6f24 fix: include hostname hints in metadata for API-side fallback
Send all available hostname sources (ComputerName, LocalHostName,
HostName, os.Hostname) in the metadata map when provisioning.
This allows the API to detect and repair generic hostnames like
'Mac' by picking the best available source server-side.

Belt and suspenders: preferredHostname() picks the right one
client-side, but metadata gives the API a second chance.
2026-03-03 14:25:53 +07:00
Codescribe 147106f2b9 fix(darwin): use scutil for provisioning hostname (#485)
macOS Sequoia with Private Wi-Fi Address enabled causes os.Hostname()
to return generic names like "Mac.lan" from DHCP instead of the real
computer name. The /utility provisioning endpoint sends this raw,
resulting in devices named "Mac-lan" in the dashboard.

Fallback chain: ComputerName → LocalHostName → os.Hostname()

LocalHostName can also be affected by DHCP. ComputerName is the
user-set display name from System Settings, fully immune to network state.
2026-03-03 14:25:41 +07:00
Cuong Manh Le a4f0418811 fix(darwin): handle mDNSResponder on port 53 to avoid bind conflicts
When mDNSResponder is using port 53 on macOS, adjust listener config to
use 0.0.0.0:53, stop mDNSResponder before binding, and run cleanup on
install and uninstall so the DNS server can start reliably.
2026-03-03 14:25:25 +07:00
Cuong Manh Le 40c68a13a1 fix(metadata): detect login user via logname when running under sudo
On Darwin 26.2+, sudo no longer preserves SUDO_USER, LOGNAME, and USER
(CVE-2025-43416), so env-based detection fails. Use the logname(1)
command on Unix first, then fall back to environment variables and
user.Current() so the real login user is still reported correctly.
2026-03-03 14:25:11 +07:00
Cuong Manh Le 3f30ec30d8 refactor(doq): simplify DoQ connection pool implementation
Replace the map-based pool and refCount bookkeeping with a channel-based
pool. Drop the closed state, per-connection address tracking, and extra
mutexes so the pool relies on the channel for concurrency and lifecycle,
matching the approach used in the DoT pool.
2026-03-03 14:24:50 +07:00
Cuong Manh Le 4790eb2c88 refactor(dot): simplify DoT connection pool implementation
Replace the map-based pool and refCount bookkeeping with a channel-based
pool. Drop the closed state, per-connection address tracking, and
extra mutexes so the pool relies on the channel for concurrency and
lifecycle.
2026-03-03 14:24:39 +07:00
Cuong Manh Le da3ea05763 fix(dot): validate connections before reuse to prevent io.EOF errors
Add connection health check in getConn to validate TLS connections
before reusing them from the pool. This prevents io.EOF errors when
reusing connections that were closed by the server (e.g., due to idle
timeout).
2026-03-03 14:24:27 +07:00
Cuong Manh Le 209c9211b9 fix(dns): handle empty and invalid IP addresses gracefully
Add guard checks to prevent panics when processing client info with
empty IP addresses. Replace netip.MustParseAddr with ParseAddr to
handle invalid IP addresses gracefully instead of panicking.

Add test to verify queryFromSelf handles IP addresses safely.
2026-03-03 14:24:07 +07:00
Cuong Manh Le acbebcf7c2 perf(dot): implement connection pooling for improved performance
Implement TCP/TLS connection pooling for DoT resolver to match DoQ
performance. Previously, DoT created a new TCP/TLS connection for every
DNS query, incurring significant TLS handshake overhead. Now connections are
reused across queries, eliminating this overhead for subsequent requests.

The implementation follows the same pattern as DoQ, using parallel dialing
and connection pooling to achieve comparable performance characteristics.
2026-03-03 14:22:55 +07:00
Cuong Manh Le 2e8a0f00a0 fix(config): use three-state atomic for rebootstrap to prevent data race
Replace boolean rebootstrap flag with a three-state atomic integer to
prevent concurrent SetupTransport calls during rebootstrap. The atomic
state machine ensures only one goroutine can proceed from "started" to
"in progress", eliminating the need for a mutex while maintaining
thread safety.

States: NotStarted -> Started -> InProgress -> NotStarted

Note that the race condition is still acceptable because any additional
transports created during the race are functional. Once the connection
is established, the unused transports are safely handled by the garbage
collector.
2026-03-03 14:22:43 +07:00
Cuong Manh Le 1f4c47318e refactor(config): consolidate transport setup and eliminate duplication
Consolidate DoH/DoH3/DoQ transport initialization into a single
SetupTransport method and introduce generic helper functions to eliminate
duplicated IP stack selection logic across transport getters.

This reduces code duplication by ~77 lines while maintaining the same
functionality.
2026-03-03 14:22:32 +07:00
Cuong Manh Le e8d1a4604e perf(doq): implement connection pooling for improved performance
Implement QUIC connection pooling for DoQ resolver to match DoH3
performance. Previously, DoQ created a new QUIC connection for every
DNS query, incurring significant handshake overhead. Now connections are
reused across queries, eliminating this overhead for subsequent requests.

The implementation follows the same pattern as DoH3, using parallel dialing
and connection pooling to achieve comparable performance characteristics.
2026-03-03 14:22:16 +07:00
Cuong Manh Le 8d63a755ba Removing outdated netlink codes 2026-03-03 14:21:46 +07:00
Cuong Manh Le f05519d1c8 refactor(network): consolidate network change monitoring
Remove separate watchLinkState function and integrate link state change
handling directly into monitorNetworkChanges. This consolidates network
monitoring logic into a single place and simplifies the codebase.

Update netlink dependency from v1.2.1-beta.2 to v1.3.1 and netns from
v0.0.4 to v0.0.5 to use stable versions.
2026-03-03 14:21:27 +07:00
Cuong Manh Le 1804e6db67 fix(windows): improve DNS server discovery for domain-joined machines
Add DNS suffix matching for non-physical adapters when domain-joined.
This allows interfaces with matching DNS suffix to be considered valid
even if not in validInterfacesMap, improving DNS server discovery for
remote VPN scenarios.

While at it, also replacing context.Background() with proper ctx
parameter throughout the function for consistent context propagation.
2026-03-03 14:20:14 +07:00
Cuong Manh Le d0341497d1 Merge pull request #276 from Control-D-Inc/release-branch-v1.4.9
Release branch v1.4.9
2026-01-13 21:41:48 +07:00
Cuong Manh Le 27c5be43c2 fix(system): disable ghw warnings to reduce log noise
Disable warnings from ghw library when retrieving chassis information.
These warnings are undesirable but recoverable errors that emit unnecessary
log messages. Using WithDisableWarnings() suppresses them while maintaining
functionality.
2026-01-09 15:10:29 +07:00
Cuong Manh Le 3beffd0dc8 .github/workflows: temporary use actions/setup-go
Since WillAbides/setup-go-faster failed with macOS-latest.

See: https://github.com/WillAbides/setup-go-faster/issues/37
2025-12-18 17:10:43 +07:00
Cuong Manh Le 1f9c586444 docs: add documentation for runtime internal logging 2025-12-18 17:10:43 +07:00
Cuong Manh Le a92e1ca024 Upgrade quic-go to v0.57.1 2025-12-18 17:10:43 +07:00
Cuong Manh Le 705df72110 fix: remove incorrect transport close on DoH3 error
Remove the transport Close() call from DoH3 error handling path.
The transport is shared and reused across requests, and closing it
on error would break subsequent requests. The transport lifecycle
is already properly managed by the http.Client and the finalizer
set in newDOH3Transport().
2025-12-18 17:10:43 +07:00
Cuong Manh Le 22122c45b2 Including system metadata when posting to utility API 2025-12-18 17:10:39 +07:00
Cuong Manh Le 57a9bb9fab Merge pull request #268 from Control-D-Inc/release-branch-v1.4.8
Release branch v1.4.8
2025-12-02 21:39:38 +07:00
Cuong Manh Le 78ea2d6361 .github/workflows: upgrade staticcheck-action to v1.4.0
While at it, also bump go version to 1.24
2025-11-12 15:22:01 +07:00
Cuong Manh Le df3cf7ef62 Upgrade quic-go to v0.56.0 2025-11-12 15:15:16 +07:00
Cuong Manh Le 80e652b8d9 fix: ensure log and cache flags are processed during reload
During reload operations, log and cache flags were not being processed,
which prevented runtime internal logs from working correctly. To fix this,
processLogAndCacheFlags was refactored to accept explicit viper and config
parameters instead of relying on global state, enabling it to be called
during reload with the new configuration. This ensures that log and cache
settings are properly applied when the service reloads its configuration.
2025-11-12 15:15:05 +07:00
Cuong Manh Le 091c7edb19 Fix: Filter root domain from search domains on Linux
Remove empty and root domain (".") entries from search domains list
to prevent systemd-resolved errors. This addresses the issue where
systemd doesn't allow root domain in search domains configuration.

The filtering ensures only valid search domains are passed to
systemd-resolved, preventing DNS operation failures.
2025-11-12 15:14:40 +07:00
Cuong Manh Le 6c550b1d74 Upgrade quic-go to v0.55.0
While at it, also bump required go version to 1.24
2025-11-12 15:14:26 +07:00
212 changed files with 9452 additions and 12270 deletions
+1 -1
View File
@@ -21,6 +21,6 @@ jobs:
- run: "go test -race ./..."
- uses: dominikh/staticcheck-action@v1.4.0
with:
version: "2025.1.1"
version: "2026.1"
install-go: false
cache-key: ${{ matrix.go }}
+36 -15
View File
@@ -11,6 +11,7 @@ A highly configurable DNS forwarding proxy with support for:
- Multiple upstreams with fallbacks
- Multiple network policy driven DNS query steering (via network cidr, MAC address or FQDN)
- Policy driven domain based "split horizon" DNS with wildcard support
- Integrations with common router vendors and firmware
- LAN client discovery via DHCP, mDNS, ARP, NDP, hosts file parsing
- Prometheus metrics exporter
@@ -24,32 +25,50 @@ All DNS protocols are supported, including:
- `DNS-over-HTTP/3` (DOH3)
- `DNS-over-QUIC`
## Use Cases
1. Use secure DNS protocols on networks and devices that don't natively support them (legacy OSes, TVs, smart toasters).
# Use Cases
1. Use secure DNS protocols on networks and devices that don't natively support them (legacy routers, legacy OSes, TVs, smart toasters).
2. Create source IP based DNS routing policies with variable secure DNS upstreams. Subnet 1 (admin) uses upstream resolver A, while Subnet 2 (employee) uses upstream resolver B.
3. Create destination IP based DNS routing policies with variable secure DNS upstreams. Listener 1 uses upstream resolver C, while Listener 2 uses upstream resolver D.
4. Create domain level "split horizon" DNS routing policies to send internal domains (*.company.int) to a local DNS server, while everything else goes to another upstream.
5. Deploy on a router and create LAN client specific DNS routing policies from a web GUI (When using ControlD.com).
## OS Support
- Windows Desktop (386, amd64, arm64)
- Windows (386, amd64, arm)
- Windows Server (386, amd64)
- MacOS (amd64, arm64)
- Linux (386, amd64, arm, arm64, mips, mipsle, mips64)
- FreeBSD (386, amd64, arm, arm64)
- Linux (386, amd64, arm, mips)
- FreeBSD (386, amd64, arm)
- 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`.
## Quick Install
The simplest way to download and install `ctrld` is to use the following installer command on any UNIX-like platform:
```shell
sh -c 'sh -c "$(curl -sL https://api.controld.com/dl?version=2)"'
sh -c 'sh -c "$(curl -sL https://api.controld.com/dl)"'
```
Windows user and prefer Powershell (who doesn't)? No problem, execute this command instead in administrative PowerShell:
```shell
(Invoke-WebRequest -Uri 'https://api.controld.com/dl/ps1?version=2' -UseBasicParsing).Content | Set-Content "$env:TEMP\ctrld_install.ps1"; Invoke-Expression "& '$env:TEMP\ctrld_install.ps1'"
(Invoke-WebRequest -Uri 'https://api.controld.com/dl/ps1' -UseBasicParsing).Content | Set-Content "$env:TEMPctrld_install.ps1"; Invoke-Expression "& '$env:TEMPctrld_install.ps1'"
```
Or you can pull and run a Docker container from [Docker Hub](https://hub.docker.com/r/controldns/ctrld)
@@ -61,7 +80,7 @@ docker run -d --name=ctrld -p 127.0.0.1:53:53/tcp -p 127.0.0.1:53:53/udp control
Alternatively, if you know what you're doing you can download pre-compiled binaries from the [Releases](https://github.com/Control-D-Inc/ctrld/releases) section for the appropriate platform.
## Build
Lastly, you can build `ctrld` from source which requires `go1.24+`:
Lastly, you can build `ctrld` from source which requires `go1.21+`:
```shell
go build ./cmd/ctrld
@@ -80,7 +99,7 @@ docker build -t controldns/ctrld . -f docker/Dockerfile
```
## Usage
# Usage
The cli is self documenting, so feel free to run `--help` on any sub-command to get specific usages.
## Arguments
@@ -111,7 +130,7 @@ Available Commands:
Flags:
-h, --help help for ctrld
-s, --silent do not write any log output
-v, --verbose count verbose log output, "-v" basic logging, "-vv" debug logging
-v, --verbose count verbose log output, "-v" basic logging, "-vv" debug level logging
--version version for ctrld
Use "ctrld [command] --help" for more information about a command.
@@ -142,7 +161,9 @@ You can then run a test query using a DNS client, for example, `dig`:
If `verify.controld.com` resolves, you're successfully using the default Control D upstream. From here, you can start editing the config file that was generated. To enforce a new config, restart the server.
## Service Mode
This mode will run the application as a background system service on any Windows, MacOS, Linux 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.
This mode will run the application as a background system service on any Windows, MacOS, Linux, FreeBSD distribution or supported router. This will create a generic `ctrld.toml` file in the **C:\ControlD** directory (on Windows) or `/etc/controld/` (almost everywhere else), start the system service, and **configure the listener on all physical network interface**. Service will start on OS boot.
When Control D upstreams are used on a router type device, `ctrld` will [relay your network topology](https://docs.controld.com/docs/device-clients) to Control D (LAN IPs, MAC addresses, and hostnames), and you will be able to see your LAN devices in the web panel, view analytics and apply unique profiles to them.
### Command
@@ -175,11 +196,11 @@ Linux or Macos
sudo ctrld service start
```
## Configuration
# Configuration
`ctrld` can be configured in variety of different ways, which include: API, local config file or via cli launch args.
## API Based Auto Configuration
Application can be started with a specific Control D resolver config, instead of the default one. Simply supply your Resolver ID with a `--cd` flag, when using the `start` (service) mode. 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. 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.
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.
@@ -196,7 +217,7 @@ sudo ctrld start --cd abcd1234
Once you run the above command, the following things will happen:
- You resolver configuration will be fetched from the API, and config file templated with the resolver data
- Application will start as a service, and keep running (even after reboot) until you run the `stop` or `uninstall` sub-commands
- All physical network interface will be updated to use the listener started by the service
- All physical network interface will be updated to use the listener started by the service or dnsmasq upstream will be switched to `ctrld`
- All DNS queries will be sent to the listener
## Manual Configuration
+4
View File
@@ -0,0 +1,4 @@
package ctrld
// SelfDiscover reports whether ctrld should only do self discover.
func SelfDiscover() bool { return true }
+6
View File
@@ -0,0 +1,6 @@
//go:build !windows && !darwin
package ctrld
// SelfDiscover reports whether ctrld should only do self discover.
func SelfDiscover() bool { return false }
+18
View File
@@ -0,0 +1,18 @@
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()
}
+5
View File
@@ -8,3 +8,8 @@ import (
// addExtraSplitDnsRule adds split DNS rule if present.
func addExtraSplitDnsRule(_ *ctrld.Config) bool { return false }
// getActiveDirectoryDomain returns AD domain name of this computer.
func getActiveDirectoryDomain() (string, error) {
return "", nil
}
+32 -4
View File
@@ -1,8 +1,14 @@
package cli
import (
"io"
"log"
"os"
"strings"
"github.com/microsoft/wmi/pkg/base/host"
hh "github.com/microsoft/wmi/pkg/hardware/host"
"github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/system"
)
@@ -11,11 +17,11 @@ import (
func addExtraSplitDnsRule(cfg *ctrld.Config) bool {
domain, err := system.GetActiveDirectoryDomain()
if err != nil {
mainLog.Load().Debug().Msgf("Unable to get active directory domain: %v", err)
mainLog.Load().Debug().Msgf("unable to get active directory domain: %v", err)
return false
}
if domain == "" {
mainLog.Load().Debug().Msg("No active directory domain found")
mainLog.Load().Debug().Msg("no active directory domain found")
return false
}
// Network rules are lowercase during toml config marshaling,
@@ -35,12 +41,34 @@ func addSplitDnsRule(cfg *ctrld.Config, domain string) bool {
}
for _, rule := range lc.Policy.Rules {
if _, ok := rule[domain]; ok {
mainLog.Load().Debug().Msgf("Split-rule %q already existed for listener.%s", domain, n)
mainLog.Load().Debug().Msgf("split-rule %q already existed for listener.%s", domain, n)
return false
}
}
mainLog.Load().Debug().Msgf("Adding split-rule %q for listener.%s", domain, n)
mainLog.Load().Debug().Msgf("adding split-rule %q for listener.%s", domain, n)
lc.Policy.Rules = append(lc.Policy.Rules, ctrld.Rule{domain: []string{}})
}
return true
}
// getActiveDirectoryDomain returns AD domain name of this computer.
func getActiveDirectoryDomain() (string, error) {
log.SetOutput(io.Discard)
defer log.SetOutput(os.Stderr)
whost := host.NewWmiLocalHost()
cs, err := hh.GetComputerSystem(whost)
if cs != nil {
defer cs.Close()
}
if err != nil {
return "", err
}
pod, err := cs.GetPropertyPartOfDomain()
if err != nil {
return "", err
}
if pod {
return cs.GetPropertyDomain()
}
return "", nil
}
+304 -303
View File
File diff suppressed because it is too large Load Diff
+1 -89
View File
@@ -1,10 +1,6 @@
package cli
import (
"testing"
"github.com/Control-D-Inc/ctrld"
)
import "testing"
func TestIsExplicitInterceptListener(t *testing.T) {
tests := []struct {
@@ -30,87 +26,3 @@ 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
View File
File diff suppressed because it is too large Load Diff
-141
View File
@@ -1,141 +0,0 @@
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
}
-87
View File
@@ -1,87 +0,0 @@
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
}
-263
View File
@@ -1,263 +0,0 @@
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
}
-61
View File
@@ -1,61 +0,0 @@
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
}
-316
View File
@@ -1,316 +0,0 @@
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
}
-41
View File
@@ -1,41 +0,0 @@
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()
}
-67
View File
@@ -1,67 +0,0 @@
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
}
-111
View File
@@ -1,111 +0,0 @@
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
}
-463
View File
@@ -1,463 +0,0 @@
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
}
-41
View File
@@ -1,41 +0,0 @@
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
}
-61
View File
@@ -1,61 +0,0 @@
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
}
-106
View File
@@ -1,106 +0,0 @@
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
}
-197
View File
@@ -1,197 +0,0 @@
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)
}
}
-192
View File
@@ -1,192 +0,0 @@
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
}
+51
View File
@@ -0,0 +1,51 @@
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
}
-2
View File
@@ -8,12 +8,10 @@ import (
"time"
)
// controlClient represents an HTTP client for communicating with the control server
type controlClient struct {
c *http.Client
}
// newControlClient creates a new control client with Unix socket transport
func newControlClient(addr string) *controlClient {
return &controlClient{c: &http.Client{
Transport: &http.Transport{
+31 -37
View File
@@ -40,14 +40,12 @@ type ifaceResponse struct {
InterceptMode string `json:"intercept_mode,omitempty"` // "dns", "hard", or "" (not intercepting)
}
// controlServer represents an HTTP server for handling control requests
type controlServer struct {
server *http.Server
mux *http.ServeMux
addr string
}
// newControlServer creates a new control server instance
func newControlServer(addr string) (*controlServer, error) {
mux := http.NewServeMux()
s := &controlServer{
@@ -90,34 +88,34 @@ func (s *controlServer) register(pattern string, handler http.Handler) {
func (p *prog) registerControlServerHandler() {
p.cs.register(listClientsPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
p.Debug().Msg("Handling list clients request")
mainLog.Load().Debug().Msg("handling list clients request")
clients := p.ciTable.ListClients()
p.Debug().Int("client_count", len(clients)).Msg("Retrieved clients list")
mainLog.Load().Debug().Int("client_count", len(clients)).Msg("retrieved clients list")
sort.Slice(clients, func(i, j int) bool {
return clients[i].IP.Less(clients[j].IP)
})
p.Debug().Msg("Sorted clients by IP address")
mainLog.Load().Debug().Msg("sorted clients by IP address")
if p.metricsQueryStats.Load() {
p.Debug().Msg("Metrics query stats enabled, collecting query counts")
mainLog.Load().Debug().Msg("metrics query stats enabled, collecting query counts")
for idx, client := range clients {
p.Debug().
mainLog.Load().Debug().
Int("index", idx).
Str("ip", client.IP.String()).
Str("mac", client.Mac).
Str("hostname", client.Hostname).
Msg("Processing client metrics")
Msg("processing client metrics")
client.IncludeQueryCount = true
dm := &dto.Metric{}
if statsClientQueriesCount.MetricVec == nil {
p.Debug().
mainLog.Load().Debug().
Str("client_ip", client.IP.String()).
Msg("Skipping metrics collection: MetricVec is nil")
Msg("skipping metrics collection: MetricVec is nil")
continue
}
@@ -127,44 +125,44 @@ func (p *prog) registerControlServerHandler() {
client.Hostname,
)
if err != nil {
p.Debug().
mainLog.Load().Debug().
Err(err).
Str("client_ip", client.IP.String()).
Str("mac", client.Mac).
Str("hostname", client.Hostname).
Msg("Failed to get metrics for client")
Msg("failed to get metrics for client")
continue
}
if err := m.Write(dm); err == nil && dm.Counter != nil {
client.QueryCount = int64(dm.Counter.GetValue())
p.Debug().
mainLog.Load().Debug().
Str("client_ip", client.IP.String()).
Int64("query_count", client.QueryCount).
Msg("Successfully collected query count")
Msg("successfully collected query count")
} else if err != nil {
p.Debug().
mainLog.Load().Debug().
Err(err).
Str("client_ip", client.IP.String()).
Msg("Failed to write metric")
Msg("failed to write metric")
}
}
} else {
p.Debug().Msg("Metrics query stats disabled, skipping query counts")
mainLog.Load().Debug().Msg("metrics query stats disabled, skipping query counts")
}
if err := json.NewEncoder(w).Encode(&clients); err != nil {
p.Error().
mainLog.Load().Error().
Err(err).
Int("client_count", len(clients)).
Msg("Failed to encode clients response")
Msg("failed to encode clients response")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
p.Debug().
mainLog.Load().Debug().
Int("client_count", len(clients)).
Msg("Successfully sent clients list response")
Msg("successfully sent clients list response")
}))
p.cs.register(startedPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
select {
@@ -186,14 +184,14 @@ func (p *prog) registerControlServerHandler() {
oldSvc := p.cfg.Service
p.mu.Unlock()
if err := p.sendReloadSignal(); err != nil {
p.Error().Err(err).Msg("Could not send reload signal")
mainLog.Load().Err(err).Msg("could not send reload signal")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
select {
case <-p.reloadDoneCh:
case <-time.After(5 * time.Second):
http.Error(w, "Timeout waiting for ctrld reload", http.StatusInternalServerError)
http.Error(w, "timeout waiting for ctrld reload", http.StatusInternalServerError)
return
}
@@ -227,8 +225,6 @@ func (p *prog) registerControlServerHandler() {
return
}
loggerCtx := ctrld.LoggerCtx(context.Background(), p.logger.Load())
// Reject further attempts while locked out due to repeated wrong PINs.
if now := time.Now().Unix(); now < deactivationLockedUntil.Load() {
w.WriteHeader(http.StatusTooManyRequests)
@@ -238,17 +234,17 @@ func (p *prog) registerControlServerHandler() {
// Re-fetch pin code from API.
rcReq := &controld.ResolverConfigRequest{
RawUID: cdUID,
Version: appVersion,
Version: rootCmd.Version,
Metadata: ctrld.SystemMetadataRuntime(context.Background()),
}
if rc, err := controld.FetchResolverConfig(loggerCtx, rcReq, cdDev); rc != nil {
if rc, err := controld.FetchResolverConfig(rcReq, cdDev); rc != nil {
if rc.DeactivationPin != nil {
cdDeactivationPin.Store(*rc.DeactivationPin)
} else {
cdDeactivationPin.Store(defaultDeactivationPin)
}
} else {
p.Warn().Err(err).Msg("Could not re-fetch deactivation pin code")
mainLog.Load().Warn().Err(err).Msg("could not re-fetch deactivation pin code")
}
// If pin code not set, allowing deactivation.
@@ -260,7 +256,7 @@ func (p *prog) registerControlServerHandler() {
var req deactivationRequest
if err := json.NewDecoder(request.Body).Decode(&req); err != nil {
w.WriteHeader(http.StatusPreconditionFailed)
p.Error().Err(err).Msg("Invalid deactivation request")
mainLog.Load().Err(err).Msg("invalid deactivation request")
return
}
@@ -314,7 +310,7 @@ func (p *prog) registerControlServerHandler() {
}
}))
p.cs.register(viewLogsPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
lr, err := p.logReaderRaw()
lr, err := p.logReader()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
@@ -340,7 +336,7 @@ func (p *prog) registerControlServerHandler() {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
r, err := p.logReaderNoColor()
r, err := p.logReader()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
@@ -353,15 +349,14 @@ func (p *prog) registerControlServerHandler() {
UID: cdUID,
Data: r.r,
}
p.Debug().Msg("Sending log file to ControlD server")
mainLog.Load().Debug().Msg("sending log file to ControlD server")
resp := logSentResponse{Size: r.size}
loggerCtx := ctrld.LoggerCtx(context.Background(), p.logger.Load())
if err := controld.SendLogs(loggerCtx, req, cdDev); err != nil {
p.Error().Msgf("Could not send log file to ControlD server: %v", err)
if err := controld.SendLogs(req, cdDev); err != nil {
mainLog.Load().Error().Msgf("could not send log file to ControlD server: %v", err)
resp.Error = err.Error()
w.WriteHeader(http.StatusInternalServerError)
} else {
p.Debug().Msg("Sending log file successfully")
mainLog.Load().Debug().Msg("sending log file successfully")
w.WriteHeader(http.StatusOK)
}
if err := json.NewEncoder(w).Encode(&resp); err != nil {
@@ -535,7 +530,6 @@ func tailFileLastLines(f *os.File, n int) []byte {
return lines
}
// jsonResponse wraps an HTTP handler to set JSON content type
func jsonResponse(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
+4
View File
@@ -0,0 +1,4 @@
package cli
//lint:ignore U1000 use in os_linux.go
type getDNS func(iface string) []string
+121 -137
View File
@@ -41,11 +41,6 @@ const (
// 2s re-check misses.
pfAnchorRecheckDelayLong = 4 * time.Second
// pfExecFailureBackoff suppresses repeated pfctl/scutil checks briefly after
// macOS reports local resource exhaustion. Without this, network-change storms
// can turn a pf ruleset race into a fork/file-descriptor exhaustion loop.
pfExecFailureBackoff = 5 * time.Second
// pfVPNInterfacePrefixes lists interface name prefixes that indicate VPN/tunnel
// interfaces on macOS. Used to add interface-specific DNS intercept rules so that
// VPN software with "pass out quick on <iface>" rules cannot bypass our intercept.
@@ -61,6 +56,10 @@ const (
const (
// pfProbeDomain is the suffix used for pf interception probe queries.
// The full probe domain is "_pf-probe-<hex>.<pfProbeDomain>".
// These queries are sent by a subprocess WITHOUT the _ctrld group GID,
// so pf should intercept them and redirect to ctrld. If ctrld receives
// the query, pf interception is working. If not (timeout), rdr is broken.
// No trailing dot — canonicalName() in the DNS handler strips trailing dots.
pfProbeDomain = "pf-probe.ctrld.test"
@@ -216,8 +215,10 @@ func (p *prog) startDNSIntercept() error {
// Pre-discover VPN DNS configurations before building initial rules.
// Without this, there's a startup gap where the initial anchor has no VPN DNS
// exemptions, causing queries to be intercepted and routed to ctrld. Stale pf
// state entries from the gap persist even after vpnDNS.Refresh() adds exemptions.
// exemptions, causing queries to be intercepted and routed to ctrld. The
// vpnDNSManager.Refresh() call later would add the exemptions, but stale pf
// state entries from the gap persist and keep routing packets to lo0.
// By discovering upfront, the initial rules exclude VPN DNS interfaces from interception.
var initialExemptions []vpnDNSExemption
if !hardIntercept {
initialConfigs := ctrld.DiscoverVPNDNS(context.Background())
@@ -373,7 +374,7 @@ func (p *prog) ensurePFAnchorReference() error {
// Inject our references if missing. PREPEND both references to ensure our
// anchor is evaluated BEFORE any other anchors (e.g., Windscribe's
// "windscribe_vpn_traffic"). pf evaluates rules top-to-bottom, so "quick"
// "vpn_app_traffic"). pf evaluates rules top-to-bottom, so "quick"
// rules in whichever anchor appears first win. By prepending, our DNS
// intercept rules match port 53 traffic before a VPN app's broader
// "pass out quick on <iface> all" rules in their anchor.
@@ -420,7 +421,7 @@ func (p *prog) ensurePFAnchorReference() error {
// checkAnchorOrdering logs a warning if our anchor reference is not the first
// anchor in the filter ruleset. When another anchor (e.g., Windscribe's
// "windscribe_vpn_traffic") appears before ours, its "quick" rules may match
// "vpn_app_traffic") appears before ours, its "quick" rules may match
// DNS traffic first. The interface-specific tunnel rules in our anchor provide
// a secondary defense, but first position is still preferred.
func (p *prog) checkAnchorOrdering(filterLines []string, ourAnchorRef string) {
@@ -641,9 +642,16 @@ func (p *prog) removePFAnchorReference() error {
// pfAddressFamily returns "inet" for IPv4 addresses and "inet6" for IPv6 addresses.
// Used to generate pf rules with the correct address family for each IP.
// flushPFStates flushes ALL pf state entries after anchor reloads.
// pf checks state table BEFORE rules — stale entries from old rules keep routing
// packets via route-to even after interface-scoped exemptions are added.
// flushPFStates flushes ALL pf state entries. Called after anchor reloads to ensure
// packets are re-evaluated against the new rules instead of matching stale state
// entries from the old ruleset. This is necessary because pf checks its state table
// BEFORE rule evaluation — a state entry created by a route-to rule will keep
// routing packets to lo0 even after VPN DNS interfaces are excluded from interception.
//
// We flush all states (not just port 53) because:
// 1. pfctl doesn't support port-based state killing
// 2. State flush is fast and brief — existing TCP connections (DoH) will
// re-establish quickly, and UDP connections are stateless at the transport level
func flushPFStates() {
if out, err := exec.Command("pfctl", "-F", "states").CombinedOutput(); err != nil {
mainLog.Load().Warn().Err(err).Msgf("DNS intercept: failed to flush pf states (output: %s)", strings.TrimSpace(string(out)))
@@ -787,9 +795,12 @@ func (p *prog) buildPFAnchorRules(vpnExemptions []vpnDNSExemption) string {
// evaluation, and its implicit state alone is insufficient for response delivery —
// proven by commit 51cf029 where responses were silently dropped.
rules.WriteString("# --- Translation rules (rdr) ---\n")
rules.WriteString("# Redirect DNS on loopback to ctrld's listener.\n")
rules.WriteString(fmt.Sprintf("rdr on lo0 inet proto udp from any to ! %s port 53 -> %s\n", listenerIP, listenerAddr))
rules.WriteString(fmt.Sprintf("rdr on lo0 inet proto tcp from any to ! %s port 53 -> %s\n\n", listenerIP, listenerAddr))
rules.WriteString(fmt.Sprintf("rdr on lo0 inet proto tcp from any to ! %s port 53 -> %s\n", listenerIP, listenerAddr))
// No IPv6 rdr — IPv6 DNS is blocked at the filter level (see below).
rules.WriteString("\n")
// --- Filtering rules ---
rules.WriteString("# --- Filtering rules (pass) ---\n\n")
@@ -830,6 +841,16 @@ func (p *prog) buildPFAnchorRules(vpnExemptions []vpnDNSExemption) string {
// Build sets of VPN DNS interfaces and server IPs for exclusion from intercept rules.
//
// VPN DNS handlers that use macOS Network Extensions (Tailscale MagicDNS, modern
// Cisco AnyConnect, F5 BIG-IP) intercept packets at the NE layer — BEFORE pf sees
// them on the return path, but AFTER pf's outbound rules fire. Any pf rule that
// touches packets on a VPN DNS interface (even "pass" with "keep state") interferes
// with the NE's packet handling, causing timeouts.
//
// Solution: exclude VPN DNS interfaces from tunnel intercept rules entirely, and
// exclude VPN DNS server IPs from the generic intercept rule. This lets all DNS
// traffic to/from VPN DNS flow naturally without any pf interference.
//
// EXIT MODE EXCEPTION: When a VPN is in exit/full-tunnel mode (VPN DNS server is
// also the system default resolver), we do NOT exempt the interface. In exit mode,
// all traffic routes through the VPN, so exempting the interface would bypass ctrld
@@ -837,7 +858,7 @@ func (p *prog) buildPFAnchorRules(vpnExemptions []vpnDNSExemption) string {
// intercepting and let ctrld's VPN DNS split routing + group exemption handle it.
vpnDNSIfaces := make(map[string]bool) // non-exit interfaces to skip in tunnel intercept
vpnDNSIfacePassthrough := make(map[string]bool) // non-exit interfaces needing passthrough rules
vpnDNSServerIPs := make(map[string]bool) // IPs for group exemptions and <vpn_dns> table
vpnDNSServerIPs := make(map[string]bool) // IPs to exclude from generic intercept
for _, ex := range vpnExemptions {
if ex.Interface != "" && !ex.IsExitMode {
vpnDNSIfaces[ex.Interface] = true
@@ -863,33 +884,28 @@ func (p *prog) buildPFAnchorRules(vpnExemptions []vpnDNSExemption) string {
rules.WriteString("\n")
}
// Block all outbound IPv6 DNS. ctrld only listens on 0.0.0.0:53 (IPv4), so we cannot
// redirect IPv6 DNS to our listener. Without this rule, macOS may use IPv6 link-local
// DNS servers (e.g., fe80::...%en0) assigned by the router, completely bypassing the
// IPv4 pf intercept. Blocking forces macOS to fall back to IPv4 DNS, which is intercepted.
// This rule must come BEFORE the IPv4 route-to rules (pf evaluates last match by default,
// but "quick" makes first-match — and exemptions above are already "quick").
rules.WriteString("# Block outbound IPv6 DNS — ctrld listens on IPv4 only (0.0.0.0:53).\n")
rules.WriteString("# Without this, macOS may use IPv6 link-local DNS servers from the router,\n")
rules.WriteString("# bypassing the IPv4 intercept entirely.\n")
rules.WriteString("block out quick on ! lo0 inet6 proto { udp, tcp } from any to any port 53\n\n")
// NOTE: IPv6 DNS is now intercepted (not blocked). ctrld listens on [::1] and pf
// redirects IPv6 DNS the same way as IPv4. This eliminates the ~1s timeout caused by
// blocking IPv6 DNS (BSD doesn't deliver ICMP errors to unconnected UDP sockets).
// --- VPN DNS interface passthrough (split DNS mode only) ---
//
// In split DNS mode, the VPN's DNS handler (e.g., Tailscale MagicDNS) runs as a
// Network Extension that intercepts packets on its tunnel interface. MagicDNS then
// forwards queries to its own upstream nameservers (e.g., 10.3.112.11) — IPs we
// forwards queries to its own upstream nameservers (e.g., 10.0.0.11) — IPs we
// can't know in advance. Without these rules, pf's generic "on !lo0" intercept
// catches MagicDNS's upstream queries, routing them back to ctrld in a loop.
//
// These "pass" rules (no route-to) let MagicDNS's upstream queries pass through.
// Traffic TO the VPN DNS server (e.g., 100.100.100.100) is excluded via <vpn_dns>
// so those queries get intercepted → ctrld enforces its profile on non-search-domain queries.
// Traffic TO the VPN DNS server itself (e.g., 100.100.100.100) is excluded so those
// queries get intercepted → ctrld enforces its profile on non-search-domain queries.
//
// NOT applied in exit mode — in exit mode, all traffic routes through the VPN
// interface, so exempting it would bypass ctrld's profile enforcement entirely.
if len(vpnDNSIfacePassthrough) > 0 {
// Build table of VPN DNS server IPs to exclude from passthrough.
// Queries TO these IPs must still be intercepted (profile enforcement).
// Only MagicDNS's upstream queries to other IPs should pass through.
var vpnDNSTableMembers []string
for ip := range vpnDNSServerIPs {
if net.ParseIP(ip) != nil && net.ParseIP(ip).To4() != nil {
@@ -915,31 +931,66 @@ func (p *prog) buildPFAnchorRules(vpnExemptions []vpnDNSExemption) string {
}
// --- Interface-specific VPN/tunnel intercept rules ---
// VPN apps (e.g., Windscribe, Cisco AnyConnect) often add pf rules like:
// pass out quick on ipsec0 inet all flags S/SA keep state
// inside their own anchors. If their anchor is evaluated before ours, their
// "quick" match on the VPN interface captures DNS traffic before our generic
// "on ! lo0" rule can intercept it. To counter this, we add explicit intercept
// rules for each active tunnel interface. These use "quick" and match port 53
// specifically, so they take priority over the VPN app's broader "all" rules
// regardless of anchor ordering.
//
// NOTE: If anchor ordering alone proves insufficient in the future, a "nuclear
// option" is available: inject DNS intercept rules directly into the MAIN pf
// ruleset (not inside our anchor). Main ruleset rules are evaluated before ALL
// anchors, making them impossible for another app to override without explicitly
// removing them. See docs/dns-intercept-mode.md for details.
tunnelIfaces := discoverTunnelInterfaces()
if len(tunnelIfaces) > 0 {
rules.WriteString("# --- VPN/tunnel interface intercept rules ---\n")
rules.WriteString("# Explicit intercept on tunnel interfaces prevents VPN apps from capturing\n")
rules.WriteString("# DNS traffic with their own broad \"pass out quick on <iface>\" rules.\n")
rules.WriteString("# VPN DNS interfaces (split DNS mode) are excluded — passthrough rules above handle them.\n")
rules.WriteString("# These port-53-specific rules take priority over broader \"all\" matches.\n")
rules.WriteString("#\n")
rules.WriteString("# Interfaces with VPN DNS servers (from scutil) are EXCLUDED — those carry\n")
rules.WriteString("# DNS traffic for Network Extension-based VPNs (e.g., Tailscale MagicDNS)\n")
rules.WriteString("# that must flow without any pf interference.\n")
for _, iface := range tunnelIfaces {
if vpnDNSIfaces[iface] {
rules.WriteString(fmt.Sprintf("# Skipped %s — VPN DNS interface (passthrough rules handle this)\n", iface))
rules.WriteString(fmt.Sprintf("# Skipped %s — VPN DNS interface (Network Extension needs unintercepted flow)\n", iface))
continue
}
rules.WriteString(fmt.Sprintf("pass out quick on %s route-to lo0 inet proto udp from any to ! %s port 53\n", iface, listenerIP))
rules.WriteString(fmt.Sprintf("pass out quick on %s route-to lo0 inet proto tcp from any to ! %s port 53\n", iface, listenerIP))
// No IPv6 route-to — IPv6 DNS is blocked, not intercepted.
}
rules.WriteString("\n")
}
// Force all remaining outbound IPv4 DNS through loopback for interception.
// route-to rules use stateful tracking (keep state, the default). State is floating
// (matches on any interface), but "pass out on lo0 no state" below ensures no state
// is created on the lo0 outbound path, allowing rdr to fire on lo0 inbound.
// VPN DNS server IPs are excluded — those must reach their VPN DNS handler
// without pf interference (especially for Network Extension-based VPNs).
//
// IMPORTANT: pf expands negated lists like { !a, !b } into separate rules where
// each rule matches everything the other excludes — effectively matching ALL addresses.
// This is a well-documented pf pitfall (OpenBSD FAQ, "negated lists").
// Fix: use a pf table with a single negated match: "to ! <table>".
// Force all remaining outbound IPv4 DNS through loopback for interception.
// Only loopback (127.0.0.1) is excluded — ctrld's own outbound queries to VPN DNS
// servers are handled by the group-scoped exemption rules above (group _ctrld).
rules.WriteString("# Force remaining outbound IPv4 DNS through loopback for interception.\n")
rules.WriteString(fmt.Sprintf("pass out quick on ! lo0 route-to lo0 inet proto udp from any to ! %s port 53\n", listenerIP))
rules.WriteString(fmt.Sprintf("pass out quick on ! lo0 route-to lo0 inet proto tcp from any to ! %s port 53\n\n", listenerIP))
// Block all outbound IPv6 DNS. ctrld only intercepts IPv4 DNS via the loopback
// redirect. IPv6 DNS interception on macOS is not feasible because the kernel rejects
// sendmsg from [::1] to global unicast IPv6 (EINVAL), and pf's nat-on-lo0 doesn't
// fire for route-to'd packets. Blocking forces macOS to fall back to IPv4 DNS,
// which is fully intercepted. See docs/pf-dns-intercept.md for details.
rules.WriteString("# Block outbound IPv6 DNS — ctrld intercepts IPv4 only.\n")
rules.WriteString("# macOS falls back to IPv4 DNS automatically.\n")
rules.WriteString("block out quick on ! lo0 inet6 proto { udp, tcp } from any to any port 53\n\n")
// Allow route-to'd DNS packets to pass outbound on lo0.
// Without this, VPN firewalls with "block drop all" (e.g., Windscribe) drop the packet
// after route-to redirects it to lo0 but before it can reflect inbound for rdr processing.
@@ -949,7 +1000,9 @@ func (p *prog) buildPFAnchorRules(vpnExemptions []vpnDNSExemption) string {
// rdr entirely. With "no state", the inbound packet gets fresh evaluation and rdr fires.
rules.WriteString("# Pass route-to'd DNS outbound on lo0 — no state to avoid bypassing rdr inbound.\n")
rules.WriteString(fmt.Sprintf("pass out quick on lo0 inet proto udp from any to ! %s port 53 no state\n", listenerIP))
rules.WriteString(fmt.Sprintf("pass out quick on lo0 inet proto tcp from any to ! %s port 53 no state\n\n", listenerIP))
rules.WriteString(fmt.Sprintf("pass out quick on lo0 inet proto tcp from any to ! %s port 53 no state\n", listenerIP))
// No IPv6 lo0 pass — IPv6 DNS is blocked, not routed through lo0.
rules.WriteString("\n")
// Allow the redirected traffic through on loopback (inbound after rdr).
//
@@ -964,13 +1017,7 @@ func (p *prog) buildPFAnchorRules(vpnExemptions []vpnDNSExemption) string {
// (source 127.0.0.1 → original DNS server IP, e.g., 10.255.255.3).
rules.WriteString("# Accept redirected DNS — reply-to lo0 forces response through loopback.\n")
rules.WriteString(fmt.Sprintf("pass in quick on lo0 reply-to lo0 inet proto { udp, tcp } from any to %s\n", listenerAddr))
// 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())
}
// No IPv6 pass-in — IPv6 DNS is blocked, not redirected to [::1].
return rules.String()
}
@@ -983,14 +1030,17 @@ func (p *prog) verifyPFState() {
anchorRef := fmt.Sprintf("anchor \"%s\"", pfAnchorName)
verified := true
// Check main ruleset for anchor references.
// Check main ruleset for anchor references (rdr-anchor in translation rules).
natOut, err := exec.Command("pfctl", "-sn").CombinedOutput()
if err != nil {
mainLog.Load().Error().Err(err).Msg("DNS intercept: VERIFICATION FAILED — could not dump NAT rules")
verified = false
} else if !strings.Contains(string(natOut), rdrAnchorRef) {
mainLog.Load().Error().Msg("DNS intercept: VERIFICATION FAILED — rdr-anchor reference missing from running NAT rules")
verified = false
} else {
natStr := string(natOut)
if !strings.Contains(natStr, rdrAnchorRef) {
mainLog.Load().Error().Msg("DNS intercept: VERIFICATION FAILED — rdr-anchor reference missing from running NAT rules")
verified = false
}
}
filterOut, err := exec.Command("pfctl", "-sr").CombinedOutput()
@@ -1059,7 +1109,7 @@ func (p *prog) resetUpstreamTransports() {
if uc == nil {
continue
}
uc.ForceReBootstrap(ctrld.LoggerCtx(context.Background(), p.logger.Load()))
uc.ForceReBootstrap()
count++
}
if count > 0 {
@@ -1123,7 +1173,7 @@ func (p *prog) checkTunnelInterfaceChanges() bool {
mainLog.Load().Info().Msgf("DNS intercept: tunnel interfaces changed (was %v, now %v) — rebuilding pf anchor rules", prev, current)
// Rebuild anchor rules with the updated tunnel interface list.
// Pass current VPN DNS exemptions so they are preserved for still-active VPNs.
// Pass current VPN DNS servers so exemptions are preserved for still-active VPNs.
var vpnExemptions []vpnDNSExemption
if p.vpnDNS != nil {
vpnExemptions = p.vpnDNS.CurrentExemptions()
@@ -1139,7 +1189,7 @@ func (p *prog) checkTunnelInterfaceChanges() bool {
return true
}
flushPFStates()
flushPFStates() // Clear stale states so new rules (incl. VPN DNS exemptions) take effect
mainLog.Load().Info().Msgf("DNS intercept: rebuilt pf anchor with %d tunnel interfaces", len(current))
return true
}
@@ -1253,15 +1303,6 @@ func (p *prog) ensurePFAnchorActive() bool {
if p.dnsInterceptState == nil {
return false
}
if !p.pfEnsureRunning.CompareAndSwap(false, true) {
mainLog.Load().Debug().Msg("DNS intercept watchdog: check already running, skipping duplicate")
return false
}
defer p.pfEnsureRunning.Store(false)
if p.pfExecBackoffActive() {
return false
}
// While stabilizing (VPN connecting), suppress all restores.
// The stabilization loop will restore once pf settles.
@@ -1295,13 +1336,11 @@ func (p *prog) ensurePFAnchorActive() bool {
// Check 1: anchor references in the main ruleset.
natOut, err := exec.Command("pfctl", "-sn").CombinedOutput()
if err != nil {
if p.pfBackoffResourceExhaustion(err, natOut, "dump NAT rules") {
return false
}
mainLog.Load().Warn().Err(err).Msg("DNS intercept watchdog: could not dump NAT rules")
return false
}
if !strings.Contains(string(natOut), rdrAnchorRef) {
natStr := string(natOut)
if !strings.Contains(natStr, rdrAnchorRef) {
mainLog.Load().Warn().Msg("DNS intercept watchdog: rdr-anchor reference missing from running ruleset")
needsRestore = true
}
@@ -1309,9 +1348,6 @@ func (p *prog) ensurePFAnchorActive() bool {
if !needsRestore {
filterOut, err := exec.Command("pfctl", "-sr").CombinedOutput()
if err != nil {
if p.pfBackoffResourceExhaustion(err, filterOut, "dump filter rules") {
return false
}
mainLog.Load().Warn().Err(err).Msg("DNS intercept watchdog: could not dump filter rules")
return false
}
@@ -1329,9 +1365,6 @@ func (p *prog) ensurePFAnchorActive() bool {
if !needsRestore {
anchorFilter, err := exec.Command("pfctl", "-a", pfAnchorName, "-sr").CombinedOutput()
if err != nil || len(strings.TrimSpace(string(anchorFilter))) == 0 {
if p.pfBackoffResourceExhaustion(err, anchorFilter, "dump anchor filter rules") {
return false
}
mainLog.Load().Warn().Msg("DNS intercept watchdog: anchor has no filter rules — content was flushed")
needsRestore = true
}
@@ -1339,9 +1372,6 @@ func (p *prog) ensurePFAnchorActive() bool {
if !needsRestore {
anchorNat, err := exec.Command("pfctl", "-a", pfAnchorName, "-sn").CombinedOutput()
if err != nil || len(strings.TrimSpace(string(anchorNat))) == 0 {
if p.pfBackoffResourceExhaustion(err, anchorNat, "dump anchor NAT rules") {
return false
}
mainLog.Load().Warn().Msg("DNS intercept watchdog: anchor has no rdr rules — translation was flushed (will cause packet loop on lo0)")
needsRestore = true
}
@@ -1375,7 +1405,6 @@ func (p *prog) ensurePFAnchorActive() bool {
// Restore: re-inject anchor references into the main ruleset.
mainLog.Load().Info().Msg("DNS intercept watchdog: restoring pf anchor references")
if err := p.ensurePFAnchorReference(); err != nil {
p.pfBackoffResourceExhaustion(err, nil, "restore anchor references")
mainLog.Load().Error().Err(err).Msg("DNS intercept watchdog: failed to restore anchor references")
return true
}
@@ -1392,7 +1421,6 @@ func (p *prog) ensurePFAnchorActive() bool {
if err := os.WriteFile(pfAnchorFile, []byte(rulesStr), 0644); err != nil {
mainLog.Load().Error().Err(err).Msg("DNS intercept watchdog: failed to write anchor file")
} else if out, err := exec.Command("pfctl", "-a", pfAnchorName, "-f", pfAnchorFile).CombinedOutput(); err != nil {
p.pfBackoffResourceExhaustion(err, out, "load rebuilt anchor")
mainLog.Load().Error().Err(err).Msgf("DNS intercept watchdog: failed to load rebuilt anchor (output: %s)", strings.TrimSpace(string(out)))
} else {
flushPFStates()
@@ -1421,48 +1449,6 @@ func (p *prog) ensurePFAnchorActive() bool {
return true
}
func (p *prog) pfExecBackoffActive() bool {
until := p.pfExecBackoffUntil.Load()
if until == 0 {
return false
}
remaining := time.Until(time.UnixMilli(until))
if remaining <= 0 {
p.pfExecBackoffUntil.CompareAndSwap(until, 0)
return false
}
mainLog.Load().Debug().Msgf("DNS intercept watchdog: suppressed during pf exec backoff (remaining: %s)", remaining)
return true
}
func (p *prog) pfBackoffResourceExhaustion(err error, output []byte, operation string) bool {
if !isResourceExhaustion(err, output) {
return false
}
until := time.Now().Add(pfExecFailureBackoff)
p.pfExecBackoffUntil.Store(until.UnixMilli())
mainLog.Load().Warn().Err(err).Msgf("DNS intercept watchdog: backing off after local exec resource exhaustion (operation: %s, backoff: %s)", operation, pfExecFailureBackoff)
return true
}
func isResourceExhaustion(err error, output []byte) bool {
if err == nil && len(output) == 0 {
return false
}
var msg string
if err != nil {
msg = err.Error()
}
if len(output) > 0 {
msg += "\n" + string(output)
}
msg = strings.ToLower(msg)
return strings.Contains(msg, "resource temporarily unavailable") ||
strings.Contains(msg, "too many open files") ||
strings.Contains(msg, "too many processes") ||
strings.Contains(msg, "cannot allocate memory")
}
func (p *prog) scheduleDNSAfterVPNSettleRefresh(reason string, delay time.Duration) {
time.AfterFunc(delay, func() {
if p.dnsInterceptState == nil {
@@ -1484,19 +1470,8 @@ func (p *prog) scheduleDNSAfterVPNSettleRefresh(reason string, delay time.Durati
//
// Two delays (2s and 4s) cover both fast and slow VPN teardowns.
func (p *prog) scheduleDelayedRechecks() {
p.pfDelayedRecheckMu.Lock()
defer p.pfDelayedRecheckMu.Unlock()
for _, timer := range p.pfDelayedRecheckTimers {
if timer != nil {
timer.Stop()
}
}
p.pfDelayedRecheckTimers = p.pfDelayedRecheckTimers[:0]
for _, delay := range []time.Duration{pfAnchorRecheckDelay, pfAnchorRecheckDelayLong} {
delay := delay
timer := time.AfterFunc(delay, func() {
time.AfterFunc(delay, func() {
if p.dnsInterceptState == nil || p.pfStabilizing.Load() {
return
}
@@ -1504,14 +1479,12 @@ func (p *prog) scheduleDelayedRechecks() {
p.checkTunnelInterfaceChanges()
// Refresh OS resolver — VPN may have finished DNS cleanup since the
// immediate handler ran. This clears stale LAN nameservers (e.g.,
// Windscribe's 10.255.255.3 lingering in scutil --dns).
ctx := ctrld.LoggerCtx(context.Background(), p.logger.Load())
ctrld.InitializeOsResolver(ctx, true)
// a VPN's DNS IP (e.g., 10.255.255.3) lingering in scutil --dns).
ctrld.InitializeOsResolver(true)
if p.vpnDNS != nil {
p.vpnDNS.Refresh(ctx, true)
p.vpnDNS.Refresh(true)
}
})
p.pfDelayedRecheckTimers = append(p.pfDelayedRecheckTimers, timer)
}
}
@@ -1538,11 +1511,13 @@ func (p *prog) pfWatchdog() {
restored := p.ensurePFAnchorActive()
if !restored {
// Rules are intact in text form — also probe actual interception.
// This catches cases where rules survive but pf's internal translation
// state is corrupted (e.g., after a hypervisor reloads pf.conf).
if !p.pfStabilizing.Load() && !p.pfMonitorRunning.Load() {
if !p.probePFIntercept() {
mainLog.Load().Warn().Msg("DNS intercept watchdog: rules intact but probe FAILED — forcing full reload")
p.forceReloadPFMainRuleset()
restored = true
restored = true // treat as a restore for logging
}
}
@@ -1571,9 +1546,9 @@ func (p *prog) pfWatchdog() {
}
}
// exemptVPNDNSServers updates the pf anchor rules with interface-scoped exemptions
// for VPN DNS servers, allowing VPN local DNS handlers (e.g., Tailscale MagicDNS
// via Network Extension) to receive DNS queries from all processes on their interface.
// exemptVPNDNSServers rebuilds the pf anchor rules to exclude VPN DNS interfaces
// and server IPs from interception. VPN DNS handlers using Network Extensions
// (e.g., Tailscale MagicDNS) need DNS traffic to flow without any pf interference.
//
// Called by vpnDNSManager.Refresh() whenever VPN DNS servers change.
func (p *prog) exemptVPNDNSServers(exemptions []vpnDNSExemption) error {
@@ -1592,7 +1567,9 @@ func (p *prog) exemptVPNDNSServers(exemptions []vpnDNSExemption) error {
return fmt.Errorf("dns intercept: failed to reload pf anchor: %w (output: %s)", err, strings.TrimSpace(string(out)))
}
// Flush stale pf states so packets are re-evaluated against new rules.
// Flush pf states after anchor reload so packets are re-evaluated against new rules.
// Stale state entries from previous rules would keep routing packets via route-to
// even after VPN DNS interfaces/IPs are excluded from interception.
flushPFStates()
// Ensure the anchor reference still exists in the main ruleset.
@@ -1601,8 +1578,15 @@ 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().Info().Msgf("DNS intercept: updated pf rules — exempted %d VPN DNS + %d OS resolver servers",
len(exemptions), len(ctrld.OsResolverNameservers()))
// Count unique excluded interfaces for logging.
excludedIfaces := make(map[string]bool)
for _, ex := range exemptions {
if ex.Interface != "" {
excludedIfaces[ex.Interface] = true
}
}
mainLog.Load().Info().Msgf("DNS intercept: updated pf rules — %d VPN DNS servers (%d interfaces excluded from intercept), %d OS resolver servers",
len(exemptions), len(excludedIfaces), len(ctrld.OsResolverNameservers()))
return nil
}
-74
View File
@@ -3,7 +3,6 @@
package cli
import (
"errors"
"strings"
"testing"
@@ -123,35 +122,6 @@ func TestPFBuildAnchorRules_Ordering(t *testing.T) {
}
}
// TestPFBuildAnchorRules_FallbackPort verifies that when the listener falls back
// to an alternate local port (e.g. 5354 because mDNSResponder owns *:53), the pf
// rdr rules redirect DNS to the ACTUAL bound port, not the configured default 53.
// Regression test for #551: pf redirected to a dead port after listener fallback.
func TestPFBuildAnchorRules_FallbackPort(t *testing.T) {
// Configured/generated listener is 127.0.0.1:53, but the runtime bound port is 5354.
p := &prog{cfg: &ctrld.Config{Listener: map[string]*ctrld.ListenerConfig{"0": {IP: "127.0.0.1", Port: 5354}}}}
rules := p.buildPFAnchorRules(nil)
// rdr must redirect to the actual bound port 5354.
if !strings.Contains(rules, "rdr on lo0 inet proto udp from any to ! 127.0.0.1 port 53 -> 127.0.0.1 port 5354") {
t.Errorf("UDP rdr must redirect to bound port 5354, got:\n%s", rules)
}
if !strings.Contains(rules, "rdr on lo0 inet proto tcp from any to ! 127.0.0.1 port 53 -> 127.0.0.1 port 5354") {
t.Errorf("TCP rdr must redirect to bound port 5354, got:\n%s", rules)
}
// The rdr redirect target must NOT point at the dead default port 53.
// Match the exact port at line end so "port 5354" is not a false positive.
if strings.Contains(rules, "-> 127.0.0.1 port 53\n") {
t.Errorf("rdr must not redirect to dead port 53 after fallback, got:\n%s", rules)
}
// The inbound accept rule must also target the actual bound port.
if !strings.Contains(rules, "127.0.0.1 port 5354") {
t.Errorf("pass in rule must reference bound port 5354, got:\n%s", rules)
}
}
// TestPFAddressFamily tests the pfAddressFamily helper.
func TestPFAddressFamily(t *testing.T) {
tests := []struct {
@@ -171,47 +141,3 @@ 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)
}
})
}
}
+2 -7
View File
@@ -1,17 +1,12 @@
package cli
import (
"context"
"github.com/Control-D-Inc/ctrld"
)
import "github.com/Control-D-Inc/ctrld"
var initializeOsResolver = ctrld.InitializeOsResolver
func (p *prog) refreshDNSAfterVPNSettle(reason string) (routes, domainlessServers, exemptions int) {
mainLog.Load().Info().Msgf("DNS intercept: refreshing OS/VPN DNS route state after VPN settle (%s)", reason)
ctx := ctrld.LoggerCtx(context.Background(), mainLog.Load())
ns := initializeOsResolver(ctx, true)
ns := initializeOsResolver(true)
mainLog.Load().Debug().Msgf("DNS intercept: post-settle OS resolver nameservers: %v", ns)
if p.vpnDNS == nil {
+2 -2
View File
@@ -12,14 +12,14 @@ func TestRefreshDNSAfterVPNSettleRefreshesOSResolverAndVPNRoutes(t *testing.T) {
defer func() { initializeOsResolver = oldInitialize }()
var initialized []bool
initializeOsResolver = func(ctx context.Context, force bool) []string {
initializeOsResolver = func(force bool) []string {
initialized = append(initialized, force)
return []string{"10.102.26.10:53"}
}
var exemptionUpdates [][]vpnDNSExemption
p := &prog{}
p.vpnDNS = newVPNDNSManager(&mainLog, func(exemptions []vpnDNSExemption) error {
p.vpnDNS = newVPNDNSManager(func(exemptions []vpnDNSExemption) error {
exemptionUpdates = append(exemptionUpdates, append([]vpnDNSExemption{}, exemptions...))
return nil
})
+148 -207
View File
@@ -112,7 +112,6 @@ const (
fwpUint32 uint32 = 3 // FWP_UINT32
fwpByteArray16Type uint32 = 11 // FWP_BYTE_ARRAY16_TYPE
fwpV4AddrMask uint32 = 0x100 // FWP_V4_ADDR_MASK (after FWP_SINGLE_DATA_TYPE_MAX=0xff)
fwpV6AddrMask uint32 = 0x101 // FWP_V6_ADDR_MASK
// IP protocol numbers.
ipprotoUDP uint8 = 17
@@ -229,12 +228,6 @@ type fwpV4AddrAndMask struct {
mask uint32
}
// fwpV6AddrAndMask represents FWP_V6_ADDR_AND_MASK for IPv6 subnet matching.
type fwpV6AddrAndMask struct {
addr [16]byte
prefixLength uint8
}
// fwpmAction0 represents FWPM_ACTION0 for specifying what happens on match.
// Size: 20 bytes (uint32 + GUID). No padding needed — GUID has 4-byte alignment.
type fwpmAction0 struct {
@@ -247,9 +240,6 @@ type fwpmAction0 struct {
// All filter IDs are stored so we can remove them individually without
// needing to enumerate the sublayer's filters via WFP API.
//
// In "dns" mode, engineHandle is 0 (no WFP filters) and only NRPT is active.
// In "hard" mode, both NRPT and WFP filters are active.
//
// The engine handle is opened once at startup and kept for the lifetime
// of the ctrld process. Filter additions/removals happen through this handle.
type wfpState struct {
@@ -288,9 +278,6 @@ type wfpState struct {
loopbackProtectActive bool
// loopbackPermitIDs stores the filter IDs for the loopback protect permits.
loopbackPermitIDs []uint64
// nrptRecoveryLimiter prevents repeated Windows policy/Dnscache signaling
// when another agent keeps putting NRPT back into a broken state.
nrptRecoveryLimiter nrptRecoveryLimiter
}
// Lazy-loaded WFP DLL procedures.
@@ -356,10 +343,9 @@ const (
// - GP path: SOFTWARE\Policies\...\DnsPolicyConfig (Group Policy)
// - Local path: SYSTEM\CurrentControlSet\...\DnsPolicyConfig (service store)
//
// If the GP path contains real rules (from IT policy, VPN, MDM, etc.), DNS
// Client enters "GP mode" and ignores ALL local-path rules entirely. An empty GP
// parent key is worse: it still puts DNS Client in GP mode, but contributes no
// usable rule, so our local catch-all is hidden until that empty parent is gone.
// If ANY rules exist in the GP path (from IT policy, VPN, MDM, etc.), DNS Client
// enters "GP mode" and ignores ALL local-path rules entirely. Conversely, if the
// GP path is empty/absent, DNS Client reads from the local path only.
//
// Strategy (matching Tailscale's approach):
// - Always write to the local path (baseline for non-domain machines).
@@ -409,20 +395,18 @@ func otherGPRulesExist() bool {
return false
}
// cleanGPPath removes only ctrld's GP-path rule and deletes the GP parent when
// no rules remain. The return value tells callers whether the parent key was
// actually deleted, which means DNS Client should be signaled once.
//
// Do not leave an empty GP parent behind: Windows treats the parent key itself
// as the policy store boundary, so an empty key can still hide local-path rules.
func cleanGPPath() bool {
// cleanGPPath removes our CtrldCatchAll rule from the GP path and deletes
// the GP DnsPolicyConfig parent key if no other rules remain. Removing the
// empty GP key is critical: its mere existence forces DNS Client into "GP mode"
// where local-path rules are ignored.
func cleanGPPath() {
// Delete our specific rule.
registry.DeleteKey(registry.LOCAL_MACHINE, nrptBaseKey+`\`+nrptRuleName)
// If the GP parent key is now empty, delete it entirely to exit "GP mode".
k, err := registry.OpenKey(registry.LOCAL_MACHINE, nrptBaseKey, registry.ENUMERATE_SUB_KEYS)
if err != nil {
return false // Key doesn't exist — clean state.
return // Key doesn't exist — clean state.
}
names, err := k.ReadSubKeyNames(-1)
k.Close()
@@ -430,14 +414,12 @@ func cleanGPPath() bool {
if len(names) > 0 {
mainLog.Load().Debug().Strs("remaining", names).Msg("DNS intercept: GP path has other rules, leaving parent key")
}
return false
return
}
// Empty — delete it to exit "GP mode".
if err := registry.DeleteKey(registry.LOCAL_MACHINE, nrptBaseKey); err == nil {
mainLog.Load().Info().Msg("DNS intercept: deleted empty GP DnsPolicyConfig key (exits GP mode)")
return true
}
return false
}
// writeNRPTRule writes a single NRPT catch-all rule at the given registry keyPath.
@@ -545,6 +527,8 @@ func refreshNRPTPolicy() {
exec.Command("gpupdate", "/target:computer", "/force").Run()
return
}
// RefreshPolicyEx(BOOL bMachine, DWORD dwOptions)
// bMachine=1 (TRUE) = refresh computer policy, dwOptions=1 (RP_FORCE) = force refresh
ret, _, _ := procRefreshPolicyEx.Call(1, 1)
if ret != 0 {
mainLog.Load().Debug().Msg("DNS intercept: triggered machine GP refresh via RefreshPolicyEx")
@@ -556,12 +540,12 @@ func refreshNRPTPolicy() {
// flushDNSCache flushes the Windows DNS Client resolver cache and triggers a
// Group Policy refresh so NRPT changes take effect immediately.
// Uses DnsFlushResolverCache from dnsapi.dll + RefreshPolicyEx from userenv.dll.
func flushDNSCache() {
// Step 1: Refresh GP so DNS Client loads the new NRPT rules from registry.
refreshNRPTPolicy()
flushDNSCacheOnly()
}
func flushDNSCacheOnly() {
// Step 2: Flush the DNS cache so stale entries from pre-NRPT resolution are cleared.
if err := dnsapiDLL.Load(); err == nil {
if err := procDnsFlushResolverCache.Find(); err == nil {
ret, _, _ := procDnsFlushResolverCache.Call()
@@ -571,6 +555,7 @@ func flushDNSCacheOnly() {
}
}
}
// Fallback: use ipconfig /flushdns.
if out, err := exec.Command("ipconfig", "/flushdns").CombinedOutput(); err != nil {
mainLog.Load().Debug().Msgf("DNS intercept: ipconfig /flushdns failed: %v: %s", err, string(out))
} else {
@@ -578,93 +563,6 @@ func flushDNSCacheOnly() {
}
}
func signalNRPTChange() {
refreshNRPTPolicy()
sendParamChange()
flushDNSCacheOnly()
}
// sendParamChange sends SERVICE_CONTROL_PARAMCHANGE to the DNS Client (Dnscache)
// service, signaling it to re-read its configuration including NRPT rules from
// the registry. This is the standard mechanism used by FortiClient, Tailscale,
// and other DNS-aware software — it's reliable and non-disruptive unlike
// restarting the Dnscache service (which always fails on modern Windows because
// Dnscache is a protected shared svchost service).
func sendParamChange() {
if out, err := exec.Command("sc", "control", "dnscache", "paramchange").CombinedOutput(); err != nil {
mainLog.Load().Debug().Err(err).Str("output", string(out)).Msg("DNS intercept: sc control dnscache paramchange failed")
} else {
mainLog.Load().Debug().Msg("DNS intercept: sent paramchange to Dnscache service")
}
}
// cleanEmptyNRPTParent removes empty NRPT parent keys that block activation.
// Empty GP and local parents have different failure shapes:
// - empty GP parent: DNS Client is in GP mode and ignores local-path rules;
// - empty local parent: DNS Client can cache an empty local policy store.
//
// This helper only changes registry state. The caller sends the single
// RefreshPolicyEx/paramchange/flush signal after it knows cleanup occurred.
//
// Returns true if cleanup was performed (caller should signal DNS Client).
func cleanEmptyNRPTParent() bool {
// Always clean the GP path — its existence blocks local path activation.
cleaned := cleanGPPath()
// Clean empty local/direct path parent key.
if !nrptParentKeyEmpty(nrptDirectKey) {
return cleaned
}
mainLog.Load().Warn().Msg("DNS intercept: found empty NRPT local parent key (blocks activation) — removing")
if err := registry.DeleteKey(registry.LOCAL_MACHINE, nrptDirectKey); err != nil {
mainLog.Load().Warn().Err(err).Msg("DNS intercept: failed to delete empty NRPT local parent key")
return cleaned
}
return true
}
func nrptParentKeyEmpty(keyPath string) bool {
k, err := registry.OpenKey(registry.LOCAL_MACHINE, keyPath, registry.ENUMERATE_SUB_KEYS)
if err != nil {
return false
}
names, err := k.ReadSubKeyNames(-1)
k.Close()
return err == nil && len(names) == 0
}
// logNRPTParentKeyState logs the state of both NRPT registry paths for diagnostics.
func logNRPTParentKeyState(context string) {
for _, path := range []struct {
name string
key string
}{
{"GP", nrptBaseKey},
{"local", nrptDirectKey},
} {
k, err := registry.OpenKey(registry.LOCAL_MACHINE, path.key, registry.ENUMERATE_SUB_KEYS)
if err != nil {
mainLog.Load().Debug().Str("context", context).Str("path", path.name).
Msg("DNS intercept: NRPT parent key does not exist")
continue
}
names, err := k.ReadSubKeyNames(-1)
k.Close()
if err != nil {
continue
}
if len(names) == 0 {
mainLog.Load().Warn().Str("context", context).Str("path", path.name).
Msg("DNS intercept: NRPT parent key exists but is EMPTY — blocks activation")
} else {
mainLog.Load().Debug().Str("context", context).Str("path", path.name).
Int("subkeys", len(names)).Strs("names", names).
Msg("DNS intercept: NRPT parent key state")
}
}
}
// startDNSIntercept activates WFP-based DNS interception on Windows.
// It creates a WFP sublayer and adds filters that block all outbound DNS (port 53)
// traffic except to localhost (127.0.0.1/::1), ensuring all DNS queries must go
@@ -698,23 +596,23 @@ func (p *prog) startDNSIntercept() error {
// Step 1: Add NRPT catch-all rule (both dns and hard modes).
// NRPT must succeed before proceeding with WFP in hard mode.
mainLog.Load().Info().Msgf("DNS intercept: initializing (mode: %s)", interceptMode)
logNRPTParentKeyState("pre-write")
// Empty parent key recovery: if the GP DnsPolicyConfig key exists but is
// empty, DNS Client enters GP mode and hides local rules. Delete empty
// parents first, then send one change signal so DNS Client drops stale state.
if cleanEmptyNRPTParent() {
signalNRPTChange()
}
// Two-phase empty parent key recovery: if the GP DnsPolicyConfig key exists
// but is empty, DNS Client has cached a "no rules" state and won't accept
// new rules even after they're written. Delete the empty key and signal DNS
// Client to reset before writing our rule.
// Two-phase recovery handles its own 2s signaling burst internally.
cleanEmptyNRPTParent()
if err := addNRPTCatchAllRule(listenerIP); err != nil {
return fmt.Errorf("dns intercept: failed to add NRPT catch-all rule: %w", err)
}
logNRPTParentKeyState("post-write")
state.nrptActive = true
signalNRPTChange()
refreshNRPTPolicy()
sendParamChange()
flushDNSCache()
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.
@@ -864,13 +762,13 @@ func (p *prog) startWFPFilters(state *wfpState) error {
// to work without dynamic per-server exemptions.
privateRanges := []struct {
name string
addr uint32
mask uint32
addr uint32 // host byte order
mask uint32 // host byte order
}{
{"10.0.0.0/8", 0x0A000000, 0xFF000000},
{"172.16.0.0/12", 0xAC100000, 0xFFF00000},
{"192.168.0.0/16", 0xC0A80000, 0xFFFF0000},
{"100.64.0.0/10", 0x64400000, 0xFFC00000},
{"100.64.0.0/10", 0x64400000, 0xFFC00000}, // CGNAT (includes Tailscale)
}
for _, r := range privateRanges {
for _, proto := range []struct {
@@ -1092,6 +990,7 @@ func wfpSublayerExists(engineHandle uintptr) bool {
if r1 != 0 {
return false
}
// Free the returned sublayer struct.
if sublayerPtr != 0 {
procFwpmFreeMemory0.Call(uintptr(unsafe.Pointer(&sublayerPtr)))
}
@@ -1189,6 +1088,8 @@ func (p *prog) cleanupWFPFilters(state *wfpState) {
// installs WFP block filters via block-outside-dns. The hard permit (with
// FWPM_FILTER_FLAG_CLEAR_ACTION_RIGHT) in a max-weight sublayer overrides the
// third-party blocks without affecting their protection for non-loopback DNS.
//
// See: https://gitlab.int.windscribe.com/controld/clients/ctrld/-/issues/526
func (p *prog) activateLoopbackWFPProtect(state *wfpState) error {
state.mu.Lock()
defer state.mu.Unlock()
@@ -1393,12 +1294,9 @@ func (p *prog) stopDNSIntercept() error {
//
// The function is idempotent: it first removes ALL existing VPN permit filters,
// then adds new ones for the current server list. When called with nil/empty
// exemptions (VPN disconnected), it just removes the old permits — leaving only
// servers (VPN disconnected), it just removes the old permits — leaving only
// the localhost permits and block-all filters active.
//
// On Windows, WFP filters are process-scoped (not interface-scoped like macOS pf),
// so we only use the server IPs from the exemptions.
//
// Supports both IPv4 and IPv6 VPN DNS servers.
//
// Called by vpnDNSManager.onServersChanged() whenever VPN DNS servers change.
@@ -1425,7 +1323,7 @@ func (p *prog) exemptVPNDNSServers(exemptions []vpnDNSExemption) error {
}
state.vpnPermitFilterIDs = nil
// Extract unique server IPs from exemptions (WFP doesn't need interface info).
// Extract unique server IPs (WFP doesn't need interface info).
seen := make(map[string]bool)
var servers []string
for _, ex := range exemptions {
@@ -1643,10 +1541,9 @@ func (p *prog) scheduleDelayedRechecks() {
}
// Refresh OS resolver — VPN may have finished DNS cleanup since the
// immediate handler ran.
ctx := ctrld.LoggerCtx(context.Background(), p.logger.Load())
ctrld.InitializeOsResolver(ctx, true)
ctrld.InitializeOsResolver(true)
if p.vpnDNS != nil {
p.vpnDNS.Refresh(ctx, true)
p.vpnDNS.Refresh(true)
}
// NRPT watchdog: some VPN software clears NRPT policy rules on
@@ -1658,14 +1555,17 @@ func (p *prog) scheduleDelayedRechecks() {
mainLog.Load().Error().Err(err).Msg("DNS intercept: failed to re-add NRPT catch-all rule")
state.nrptActive = false
} else {
signalNRPTChange()
flushDNSCache()
mainLog.Load().Info().Msg("DNS intercept: NRPT catch-all rule restored")
}
}
// WFP watchdog: verify our sublayer still exists.
// WFP watchdog: verify our sublayer still exists. If another program
// or a crash removed it, the block filters are gone too.
if ok && state.engineHandle != 0 && !wfpSublayerExists(state.engineHandle) {
mainLog.Load().Warn().Msg("DNS intercept: WFP sublayer was removed externally — re-creating all filters")
// Full teardown + re-init. stopDNSIntercept clears state,
// then startDNSIntercept creates everything fresh.
_ = p.stopDNSIntercept()
if err := p.startDNSIntercept(); err != nil {
mainLog.Load().Error().Err(err).Msg("DNS intercept: failed to re-create WFP filters")
@@ -1690,24 +1590,17 @@ func (p *prog) nrptHealthMonitor(state *wfpState) {
if !state.nrptActive {
continue
}
// Step 1: Check registry key exists.
if !nrptCatchAllRuleExists() {
now := time.Now()
if ok, wait := state.nrptRecoveryLimiter.allow(now, p.cfg); !ok {
if state.nrptRecoveryLimiter.shouldLogSkip(now) {
mainLog.Load().Warn().Str("remaining", wait.String()).
Msg("DNS intercept: NRPT rule restore suppressed after repeated recovery flows")
}
continue
}
mainLog.Load().Warn().Msg("DNS intercept: NRPT health check — catch-all rule missing, restoring")
if err := addNRPTCatchAllRule(state.listenerIP); err != nil {
mainLog.Load().Error().Err(err).Msg("DNS intercept: failed to restore NRPT catch-all rule")
state.nrptActive = false
continue
}
signalNRPTChange()
state.nrptRecoveryLimiter.recordRecoveryFlow(time.Now(), p.cfg)
refreshNRPTPolicy()
flushDNSCache()
mainLog.Load().Info().Msg("DNS intercept: NRPT catch-all rule restored by health monitor")
// After restoring, verify it's actually working.
go p.nrptProbeAndHeal()
@@ -1719,8 +1612,6 @@ func (p *prog) nrptHealthMonitor(state *wfpState) {
if !p.probeNRPT() {
mainLog.Load().Warn().Msg("DNS intercept: NRPT health check — rule present but probe failed, running heal cycle")
go p.nrptProbeAndHeal()
} else {
state.nrptRecoveryLimiter.recordStableSuccess()
}
// Step 3: In hard mode, also verify WFP sublayer.
@@ -1804,42 +1695,106 @@ func (p *prog) probeNRPT() bool {
}
}
// sendParamChange sends SERVICE_CONTROL_PARAMCHANGE to the DNS Client (Dnscache)
// service, signaling it to re-read its configuration including NRPT rules from
// the registry. This is the standard mechanism used by FortiClient, Tailscale,
// and other DNS-aware software — it's reliable and non-disruptive unlike
// restarting the Dnscache service (which always fails on modern Windows because
// Dnscache is a protected shared svchost service).
func sendParamChange() {
if out, err := exec.Command("sc", "control", "dnscache", "paramchange").CombinedOutput(); err != nil {
mainLog.Load().Debug().Err(err).Str("output", string(out)).Msg("DNS intercept: sc control dnscache paramchange failed")
} else {
mainLog.Load().Debug().Msg("DNS intercept: sent paramchange to Dnscache service")
}
}
// cleanEmptyNRPTParent removes empty NRPT parent keys that block activation.
// An empty DnsPolicyConfig key (exists but no subkeys) causes DNS Client to
// cache "no rules" and ignore subsequently-added rules.
//
// Also cleans the GP path entirely if it has no non-ctrld rules, since the GP
// path's existence forces DNS Client into "GP mode" where it ignores the local
// service store path.
//
// Returns true if cleanup was performed (caller should add a delay).
func cleanEmptyNRPTParent() bool {
cleaned := false
// Always clean the GP path — its existence blocks local path activation.
cleanGPPath()
// Clean empty local/direct path parent key.
k, err := registry.OpenKey(registry.LOCAL_MACHINE, nrptDirectKey, registry.ENUMERATE_SUB_KEYS)
if err != nil {
return false
}
names, err := k.ReadSubKeyNames(-1)
k.Close()
if err != nil || len(names) > 0 {
return false
}
mainLog.Load().Warn().Msg("DNS intercept: found empty NRPT local parent key (blocks activation) — removing")
if err := registry.DeleteKey(registry.LOCAL_MACHINE, nrptDirectKey); err != nil {
mainLog.Load().Warn().Err(err).Msg("DNS intercept: failed to delete empty NRPT local parent key")
return false
}
cleaned = true
// Signal DNS Client to process the deletion and reset its internal cache.
mainLog.Load().Info().Msg("DNS intercept: empty NRPT parent key removed — signaling DNS Client")
sendParamChange()
flushDNSCache()
return cleaned
}
// logNRPTParentKeyState logs the state of both NRPT registry paths for diagnostics.
func logNRPTParentKeyState(context string) {
for _, path := range []struct {
name string
key string
}{
{"GP", nrptBaseKey},
{"local", nrptDirectKey},
} {
k, err := registry.OpenKey(registry.LOCAL_MACHINE, path.key, registry.ENUMERATE_SUB_KEYS)
if err != nil {
mainLog.Load().Debug().Str("context", context).Str("path", path.name).
Msg("DNS intercept: NRPT parent key does not exist")
continue
}
names, err := k.ReadSubKeyNames(-1)
k.Close()
if err != nil {
continue
}
if len(names) == 0 {
mainLog.Load().Warn().Str("context", context).Str("path", path.name).
Msg("DNS intercept: NRPT parent key exists but is EMPTY — blocks activation")
} else {
mainLog.Load().Debug().Str("context", context).Str("path", path.name).
Int("subkeys", len(names)).Strs("names", names).
Msg("DNS intercept: NRPT parent key state")
}
}
}
// nrptProbeAndHeal runs the NRPT probe with retries and escalating remediation.
// Called asynchronously after startup and from the health monitor.
//
// Retry sequence:
// 1. Immediate probe.
// 2. If the GP parent is empty, clean it immediately, signal once, then probe.
// This is intentionally before the normal retry loop: policy refresh and
// Dnscache paramchange cannot make local rules visible while GP mode is
// selected by an empty GP parent.
// 3. Otherwise, signal DNS Client with increasing backoff between probes.
// Retry sequence (each attempt: GP refresh + paramchange + flush → sleep → probe):
// 1. Immediate probe
// 2. GP refresh + paramchange + flush → 1s → probe
// 3. GP refresh + paramchange + flush → 2s → probe
// 4. GP refresh + paramchange + flush → 4s → probe
func (p *prog) nrptProbeAndHeal() {
state, _ := p.dnsInterceptState.(*wfpState)
if state != nil {
now := time.Now()
if ok, wait := state.nrptRecoveryLimiter.allow(now, p.cfg); !ok {
if state.nrptRecoveryLimiter.shouldLogSkip(now) {
mainLog.Load().Warn().Str("remaining", wait.String()).
Msg("DNS intercept: NRPT recovery suppressed after repeated failed recovery flows")
}
return
}
}
if !nrptProbeRunning.CompareAndSwap(false, true) {
mainLog.Load().Debug().Msg("DNS intercept: NRPT probe already running, skipping")
return
}
defer nrptProbeRunning.Store(false)
remediated := false
defer func() {
if remediated && state != nil {
state.nrptRecoveryLimiter.recordRecoveryFlow(time.Now(), p.cfg)
}
}()
mainLog.Load().Info().Msg("DNS intercept: starting NRPT verification probe sequence")
// Log parent key state for diagnostics.
@@ -1850,37 +1805,17 @@ func (p *prog) nrptProbeAndHeal() {
mainLog.Load().Info().Msg("DNS intercept: NRPT verified working")
return
}
remediated = true
// 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.
// Attempts 2-4: GP refresh + paramchange + flush with increasing backoff
delays := []time.Duration{1 * time.Second, 2 * time.Second, 4 * time.Second}
for i, delay := range delays {
attempt := i + 2
mainLog.Load().Info().Int("attempt", attempt).Str("delay", delay.String()).
Msg("DNS intercept: NRPT probe failed, retrying with policy refresh + paramchange")
mainLog.Load().Info().Int("attempt", attempt).Dur("delay", delay).
Msg("DNS intercept: NRPT probe failed, retrying with GP refresh + paramchange")
logNRPTParentKeyState(fmt.Sprintf("probe-attempt-%d", attempt))
signalNRPTChange()
refreshNRPTPolicy()
sendParamChange()
flushDNSCache()
time.Sleep(delay)
if p.probeNRPT() {
mainLog.Load().Info().Int("attempt", attempt).
@@ -1894,14 +1829,17 @@ func (p *prog) nrptProbeAndHeal() {
// signal DNS Client to forget it, wait, then re-add and signal again.
mainLog.Load().Warn().Msg("DNS intercept: all probes failed — attempting two-phase NRPT recovery (delete → signal → re-add)")
listenerIP := "127.0.0.1"
if state != nil {
if state, ok := p.dnsInterceptState.(*wfpState); ok {
listenerIP = state.listenerIP
}
// Phase 1: Remove our rule and the parent key if now empty.
_ = removeNRPTCatchAllRule()
// If parent key is now empty after removing our rule, delete it too.
cleanEmptyNRPTParent()
signalNRPTChange()
refreshNRPTPolicy()
sendParamChange()
flushDNSCache()
logNRPTParentKeyState("nuclear-after-delete")
// Wait for DNS Client to process the deletion.
@@ -1912,7 +1850,9 @@ func (p *prog) nrptProbeAndHeal() {
mainLog.Load().Error().Err(err).Msg("DNS intercept: failed to re-add NRPT after nuclear recovery")
return
}
signalNRPTChange()
refreshNRPTPolicy()
sendParamChange()
flushDNSCache()
logNRPTParentKeyState("nuclear-after-readd")
// Final probe after recovery.
@@ -1930,6 +1870,7 @@ func (p *prog) nrptProbeAndHeal() {
// installed WFP filters that block DNS to non-tunnel interfaces, including
// loopback. A high-priority "hard permit" for localhost DNS overrides these
// blocks and restores NRPT routing to ctrld's listener.
// See: https://gitlab.int.windscribe.com/controld/clients/ctrld/-/issues/526
loopbackState, ok := p.dnsInterceptState.(*wfpState)
if !ok || loopbackState == nil {
mainLog.Load().Error().Msg("DNS intercept: no state available for loopback WFP protect")
+692 -1059
View File
File diff suppressed because it is too large Load Diff
+14 -388
View File
@@ -22,15 +22,15 @@ func Test_wildcardMatches(t *testing.T) {
domain string
match bool
}{
{"domain - prefix parent should not match", "*.windscribe.com", "windscribe.com", false},
{"domain - prefix", "*.windscribe.com", "anything.windscribe.com", true},
{"domain - prefix not match other s", "*.windscribe.com", "example.com", false},
{"domain - prefix not match s in name", "*.windscribe.com", "wwindscribe.com", false},
{"domain - suffix", "suffix.*", "suffix.windscribe.com", true},
{"domain - suffix not match other", "suffix.*", "suffix1.windscribe.com", false},
{"domain - both", "suffix.*.windscribe.com", "suffix.anything.windscribe.com", true},
{"domain - both not match", "suffix.*.windscribe.com", "suffix1.suffix.windscribe.com", false},
{"domain - case-insensitive", "*.WINDSCRIBE.com", "anything.windscribe.com", true},
{"domain - prefix parent should not match", "*.example.com", "example.com", false},
{"domain - prefix", "*.example.com", "anything.example.com", true},
{"domain - prefix not match other s", "*.example.com", "other.org", false},
{"domain - prefix not match s in name", "*.example.com", "eexample.com", false},
{"domain - suffix", "suffix.*", "suffix.example.com", true},
{"domain - suffix not match other", "suffix.*", "suffix1.example.com", false},
{"domain - both", "suffix.*.example.com", "suffix.anything.example.com", true},
{"domain - both not match", "suffix.*.example.com", "suffix1.suffix.example.com", false},
{"domain - case-insensitive", "*.EXAMPLE.com", "anything.example.com", true},
{"mac - prefix", "*:98:05:b4:2b", "d4:67:98:05:b4:2b", true},
{"mac - prefix not match other s", "*:98:05:b4:2b", "0d:ba:54:09:94:2c", false},
{"mac - prefix not match s in name", "*:98:05:b4:2b", "e4:67:97:05:b4:2b", false},
@@ -57,9 +57,9 @@ func Test_canonicalName(t *testing.T) {
domain string
canonical string
}{
{"fqdn to canonical", "windscribe.com.", "windscribe.com"},
{"already canonical", "windscribe.com", "windscribe.com"},
{"case insensitive", "Windscribe.Com.", "windscribe.com"},
{"fqdn to canonical", "example.com.", "example.com"},
{"already canonical", "example.com", "example.com"},
{"case insensitive", "Example.Com.", "example.com"},
}
for _, tc := range tests {
@@ -77,8 +77,7 @@ func Test_prog_upstreamFor(t *testing.T) {
cfg := testhelper.SampleConfig(t)
cfg.Service.LeakOnUpstreamFailure = func(v bool) *bool { return &v }(false)
p := &prog{cfg: cfg}
p.logger.Store(mainLog.Load())
p.um = newUpstreamMonitor(p.cfg, mainLog.Load())
p.um = newUpstreamMonitor(p.cfg)
p.lanLoopGuard = newLoopGuard()
p.ptrLoopGuard = newLoopGuard()
for _, nc := range p.cfg.Network {
@@ -143,94 +142,9 @@ func Test_prog_upstreamFor(t *testing.T) {
}
}
func Test_prog_upstreamForWithCustomMatching(t *testing.T) {
cfg := testhelper.SampleConfig(t)
prog := &prog{cfg: cfg}
prog.logger.Store(mainLog.Load())
for _, nc := range prog.cfg.Network {
for _, cidr := range nc.Cidrs {
_, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
t.Fatal(err)
}
nc.IPNets = append(nc.IPNets, ipNet)
}
}
// Create a custom policy with domain-first matching order
customPolicy := &ctrld.ListenerPolicyConfig{
Name: "Custom Policy",
Networks: []ctrld.Rule{
{"network.0": []string{"upstream.1", "upstream.0"}},
},
Macs: []ctrld.Rule{
{"14:45:A0:67:83:0A": []string{"upstream.2"}},
},
Rules: []ctrld.Rule{
{"*.ru": []string{"upstream.1"}},
},
Matching: &ctrld.MatchingConfig{
Order: []string{"domain", "mac", "network"},
},
}
customListener := &ctrld.ListenerConfig{
Policy: customPolicy,
}
tests := []struct {
name string
ip string
mac string
domain string
upstreams []string
matched bool
}{
{
name: "Domain rule should match first with custom order",
ip: "192.168.0.1:0",
mac: "14:45:A0:67:83:0A",
domain: "example.ru",
upstreams: []string{"upstream.1"},
matched: true,
},
{
name: "MAC rule should match when no domain rule",
ip: "192.168.0.1:0",
mac: "14:45:A0:67:83:0A",
domain: "example.com",
upstreams: []string{"upstream.2"},
matched: true,
},
{
name: "Network rule should match when no domain or MAC rule",
ip: "192.168.0.1:0",
mac: "00:11:22:33:44:55",
domain: "example.com",
upstreams: []string{"upstream.1", "upstream.0"},
matched: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
addr, err := net.ResolveUDPAddr("udp", tc.ip)
require.NoError(t, err)
require.NotNil(t, addr)
ctx := context.WithValue(context.Background(), ctrld.ReqIdCtxKey{}, requestID())
ufr := prog.upstreamFor(ctx, "0", customListener, addr, tc.mac, tc.domain)
assert.Equal(t, tc.matched, ufr.matched)
assert.Equal(t, tc.upstreams, ufr.upstreams)
})
}
}
func TestCache(t *testing.T) {
cfg := testhelper.SampleConfig(t)
prog := &prog{cfg: cfg}
prog.logger.Store(mainLog.Load())
for _, nc := range prog.cfg.Network {
for _, cidr := range nc.Cidrs {
_, ipNet, err := net.ParseCIDR(cidr)
@@ -491,8 +405,6 @@ func Test_isPrivatePtrLookup(t *testing.T) {
{"CGNAT", newDnsMsgPtr("100.66.27.28", t), true},
{"Loopback", newDnsMsgPtr("127.0.0.1", t), true},
{"Link Local Unicast", newDnsMsgPtr("fe80::69f6:e16e:8bdb:433f", t), true},
// RFC 7335 IPv4 Service Continuity Prefix (464XLAT/DS-Lite CLAT), see #552.
{"464XLAT CLAT host", newDnsMsgPtr("192.0.0.2", t), true},
{"Public IP", newDnsMsgPtr("8.8.8.8", t), false},
}
for _, tc := range tests {
@@ -540,11 +452,6 @@ func Test_isWanClient(t *testing.T) {
{"CGNAT", &net.UDPAddr{IP: net.ParseIP("100.66.27.28")}, false},
{"Loopback", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")}, false},
{"Link Local Unicast", &net.UDPAddr{IP: net.ParseIP("fe80::69f6:e16e:8bdb:433f")}, false},
// RFC 7335 IPv4 Service Continuity Prefix (464XLAT/DS-Lite CLAT), see #552.
{"464XLAT PLAT side", &net.UDPAddr{IP: net.ParseIP("192.0.0.1")}, false},
{"464XLAT CLAT host", &net.UDPAddr{IP: net.ParseIP("192.0.0.2")}, false},
// Outside the /29 but inside 192.0.0.0/24: still WAN (fix is scoped to /29).
{"192.0.0.0/24 outside /29", &net.UDPAddr{IP: net.ParseIP("192.0.0.100")}, true},
{"Public", &net.UDPAddr{IP: net.ParseIP("8.8.8.8")}, true},
}
for _, tc := range tests {
@@ -558,251 +465,8 @@ func Test_isWanClient(t *testing.T) {
}
}
func Test_shouldStartRecovery(t *testing.T) {
tests := []struct {
name string
reason RecoveryReason
hasExistingRecovery bool
expectedResult bool
description string
}{
{
name: "network change with existing recovery",
reason: RecoveryReasonNetworkChange,
hasExistingRecovery: true,
expectedResult: true,
description: "should cancel existing recovery and start new one for network change",
},
{
name: "network change without existing recovery",
reason: RecoveryReasonNetworkChange,
hasExistingRecovery: false,
expectedResult: true,
description: "should start new recovery for network change",
},
{
name: "regular failure with existing recovery",
reason: RecoveryReasonRegularFailure,
hasExistingRecovery: true,
expectedResult: false,
description: "should skip duplicate recovery for regular failure",
},
{
name: "regular failure without existing recovery",
reason: RecoveryReasonRegularFailure,
hasExistingRecovery: false,
expectedResult: true,
description: "should start new recovery for regular failure",
},
{
name: "OS failure with existing recovery",
reason: RecoveryReasonOSFailure,
hasExistingRecovery: true,
expectedResult: false,
description: "should skip duplicate recovery for OS failure",
},
{
name: "OS failure without existing recovery",
reason: RecoveryReasonOSFailure,
hasExistingRecovery: false,
expectedResult: true,
description: "should start new recovery for OS failure",
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
p := newTestProg(t)
// Setup existing recovery if needed
if tc.hasExistingRecovery {
p.recoveryCancelMu.Lock()
p.recoveryCancel = func() {} // Mock cancel function
p.recoveryCancelMu.Unlock()
}
result := p.shouldStartRecovery(tc.reason)
assert.Equal(t, tc.expectedResult, result, tc.description)
})
}
}
func Test_createRecoveryContext(t *testing.T) {
p := newTestProg(t)
ctx, cleanup := p.createRecoveryContext()
// Verify context is created
assert.NotNil(t, ctx)
assert.NotNil(t, cleanup)
// Verify recoveryCancel is set
p.recoveryCancelMu.Lock()
assert.NotNil(t, p.recoveryCancel)
p.recoveryCancelMu.Unlock()
// Test cleanup function
cleanup()
// Verify recoveryCancel is cleared
p.recoveryCancelMu.Lock()
assert.Nil(t, p.recoveryCancel)
p.recoveryCancelMu.Unlock()
}
func Test_prepareForRecovery(t *testing.T) {
tests := []struct {
name string
reason RecoveryReason
wantErr bool
}{
{
name: "regular failure",
reason: RecoveryReasonRegularFailure,
wantErr: false,
},
{
name: "network change",
reason: RecoveryReasonNetworkChange,
wantErr: false,
},
{
name: "OS failure",
reason: RecoveryReasonOSFailure,
wantErr: false,
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
p := newTestProg(t)
err := p.prepareForRecovery(tc.reason)
if tc.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
// Verify recoveryRunning is set to true
assert.True(t, p.recoveryRunning.Load())
})
}
}
func Test_completeRecovery(t *testing.T) {
tests := []struct {
name string
reason RecoveryReason
recovered string
wantErr bool
}{
{
name: "regular failure recovery",
reason: RecoveryReasonRegularFailure,
recovered: "upstream1",
wantErr: false,
},
{
name: "network change recovery",
reason: RecoveryReasonNetworkChange,
recovered: "upstream2",
wantErr: false,
},
{
name: "OS failure recovery",
reason: RecoveryReasonOSFailure,
recovered: "upstream3",
wantErr: false,
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
p := newTestProg(t)
err := p.completeRecovery(tc.reason, tc.recovered)
if tc.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
// Verify recoveryRunning is set to false
assert.False(t, p.recoveryRunning.Load())
})
}
}
func Test_reinitializeOSResolver(t *testing.T) {
p := newTestProg(t)
err := p.reinitializeOSResolver("Test message")
// This function should not return an error under normal circumstances
// The actual behavior depends on the OS resolver implementation
assert.NoError(t, err)
}
func Test_handleRecovery_Integration(t *testing.T) {
tests := []struct {
name string
reason RecoveryReason
wantErr bool
}{
{
name: "network change recovery",
reason: RecoveryReasonNetworkChange,
wantErr: false,
},
{
name: "regular failure recovery",
reason: RecoveryReasonRegularFailure,
wantErr: false,
},
{
name: "OS failure recovery",
reason: RecoveryReasonOSFailure,
wantErr: false,
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
p := newTestProg(t)
// This is an integration test that exercises the full recovery flow
// In a real test environment, you would mock the dependencies
// For now, we're just testing that the method doesn't panic
// and that the recovery logic flows correctly
assert.NotPanics(t, func() {
// Test only the preparation phase to avoid actual upstream checking
if !p.shouldStartRecovery(tc.reason) {
return
}
_, cleanup := p.createRecoveryContext()
defer cleanup()
if err := p.prepareForRecovery(tc.reason); err != nil {
return
}
// Skip the actual upstream recovery check for this test
// as it requires properly configured upstreams
})
})
}
}
func Test_prog_queryFromSelf(t *testing.T) {
p := newTestProg(t)
p := &prog{}
require.NotPanics(t, func() {
p.queryFromSelf("")
})
@@ -810,41 +474,3 @@ func Test_prog_queryFromSelf(t *testing.T) {
p.queryFromSelf("foo")
})
}
func Test_sameQuestion(t *testing.T) {
mk := func(name string, qtype uint16) *dns.Msg {
m := new(dns.Msg)
m.SetQuestion(name, qtype)
return m
}
tests := []struct {
name string
req *dns.Msg
answer *dns.Msg
want bool
}{
{"identical", mk("example.com.", dns.TypeA), mk("example.com.", dns.TypeA), true},
{"case insensitive", mk("Example.COM.", dns.TypeA), mk("example.com.", dns.TypeA), true},
{"different name", mk("victim.example.", dns.TypeA), mk("attacker.example.", dns.TypeA), false},
{"different type", mk("example.com.", dns.TypeA), mk("example.com.", dns.TypeAAAA), false},
{"nil req", nil, mk("example.com.", dns.TypeA), false},
{"nil answer", mk("example.com.", dns.TypeA), nil, false},
{"empty answer question", mk("example.com.", dns.TypeA), new(dns.Msg), false},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
if got := sameQuestion(tc.req, tc.answer); got != tc.want {
t.Errorf("sameQuestion() = %v, want %v", got, tc.want)
}
})
}
}
// newTestProg creates a properly initialized *prog for testing.
func newTestProg(t *testing.T) *prog {
p := &prog{cfg: testhelper.SampleConfig(t)}
p.logger.Store(mainLog.Load())
p.um = newUpstreamMonitor(p.cfg, mainLog.Load())
return p
}
-311
View File
@@ -1,311 +0,0 @@
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")
}
-314
View File
@@ -1,314 +0,0 @@
//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()
}
-15
View File
@@ -1,15 +0,0 @@
//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() {}
-26
View File
@@ -1,26 +0,0 @@
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)
}
})
}
}
-614
View File
@@ -1,614 +0,0 @@
//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
View File
@@ -4,15 +4,11 @@ import "regexp"
// validHostname reports whether hostname is a valid hostname.
// A valid hostname contains 3 -> 64 characters and conform to RFC1123.
// This function validates hostnames to ensure they meet DNS naming standards
// and prevents invalid hostnames from being used in DNS configurations
func validHostname(hostname string) bool {
hostnameLen := len(hostname)
if hostnameLen < 3 || hostnameLen > 64 {
return false
}
// RFC1123 regex pattern ensures hostnames follow DNS naming conventions
// This prevents issues with DNS resolution and system compatibility
validHostnameRfc1123 := regexp.MustCompile(`^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$`)
return validHostnameRfc1123.MatchString(hostname)
}
-172
View File
@@ -1,172 +0,0 @@
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)
}
-747
View File
@@ -1,747 +0,0 @@
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)
})
}
+5 -21
View File
@@ -9,7 +9,6 @@ import (
// AppCallback provides hooks for injecting certain functionalities
// from mobile platforms to main ctrld cli.
// This allows mobile applications to customize behavior without modifying core CLI code
type AppCallback struct {
HostName func() string
LanIp func() string
@@ -18,7 +17,6 @@ type AppCallback struct {
}
// AppConfig allows overwriting ctrld cli flags from mobile platforms.
// This provides a clean interface for mobile apps to configure ctrld behavior
type AppConfig struct {
CdUID string
ProvisionID string
@@ -29,29 +27,18 @@ type AppConfig struct {
LogPath string
}
// Network and HTTP configuration constants
const (
// defaultHTTPTimeout provides reasonable timeout for HTTP operations
// This prevents hanging requests while allowing sufficient time for network delays
defaultHTTPTimeout = 30 * time.Second
// defaultMaxRetries provides retry attempts for failed HTTP requests
// This improves reliability in unstable network conditions
defaultMaxRetries = 3
// downloadServerIp is the fallback IP for download operations
// This ensures downloads work even when DNS resolution fails
downloadServerIp = "23.171.240.151"
defaultMaxRetries = 3
downloadServerIp = "23.171.240.151"
)
// httpClientWithFallback returns an HTTP client configured with timeout and IPv4 fallback
// This ensures reliable HTTP operations by preferring IPv4 and handling timeouts gracefully
func httpClientWithFallback(timeout time.Duration) *http.Client {
return &http.Client{
Timeout: timeout,
Transport: &http.Transport{
// Prefer IPv4 over IPv6
// This improves compatibility with networks that have IPv6 issues
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
@@ -62,7 +49,6 @@ func httpClientWithFallback(timeout time.Duration) *http.Client {
}
// doWithRetry performs an HTTP request with retries
// This improves reliability by automatically retrying failed requests with exponential backoff
func doWithRetry(req *http.Request, maxRetries int, ip string) (*http.Response, error) {
var lastErr error
client := httpClientWithFallback(defaultHTTPTimeout)
@@ -74,8 +60,7 @@ func doWithRetry(req *http.Request, maxRetries int, ip string) (*http.Response,
}
for attempt := 0; attempt < maxRetries; attempt++ {
if attempt > 0 {
// Linear backoff reduces server load and improves success rate
time.Sleep(time.Second * time.Duration(attempt+1))
time.Sleep(time.Second * time.Duration(attempt+1)) // Exponential backoff
}
resp, err := client.Do(req)
@@ -83,8 +68,8 @@ func doWithRetry(req *http.Request, maxRetries int, ip string) (*http.Response,
return resp, nil
}
if ipReq != nil {
mainLog.Load().Warn().Err(err).Msgf("Dial to %q failed", req.Host)
mainLog.Load().Warn().Msgf("Fallback to direct ip to download prod version: %q", ip)
mainLog.Load().Warn().Err(err).Msgf("dial to %q failed", req.Host)
mainLog.Load().Warn().Msgf("fallback to direct IP to download prod version: %q", ip)
resp, err = client.Do(ipReq)
if err == nil {
return resp, nil
@@ -101,7 +86,6 @@ func doWithRetry(req *http.Request, maxRetries int, ip string) (*http.Response,
}
// Helper for making GET requests with retries
// This provides a simplified interface for common GET operations with built-in retry logic
func getWithRetry(url string, ip string) (*http.Response, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
+36 -237
View File
@@ -7,101 +7,36 @@ import (
"io"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"github.com/rs/zerolog"
"github.com/Control-D-Inc/ctrld"
)
// Log writer constants for buffer management and log formatting
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
// logWriterSmallSize is used for memory-constrained environments
// This reduces memory footprint while still maintaining log functionality
logWriterSmallSize = 1024 * 1024 * 1 // 1 MB
// logWriterInitialSize is the initial buffer allocation
// This provides immediate space for early log entries
logWriterInitialSize = 32 * 1024 // 32 KB
// logWriterSentInterval controls how often logs are sent to external systems
// This balances real-time logging with system performance
logWriterSentInterval = time.Minute
// logWriterInitEndMarker marks the end of initialization logs
// This helps separate startup logs from runtime logs
logWriterSize = 1024 * 1024 * 5 // 5 MB
logWriterSmallSize = 1024 * 1024 * 1 // 1 MB
logWriterInitialSize = 32 * 1024 // 32 KB
logWriterSentInterval = time.Minute
logWriterInitEndMarker = "\n\n=== INIT_END ===\n\n"
// logWriterLogEndMarker marks the end of log sections
// This provides clear boundaries for log parsing and analysis
logWriterLogEndMarker = "\n\n=== LOG_END ===\n\n"
logWriterLogEndMarker = "\n\n=== LOG_END ===\n\n"
logFileName = "ctrld.log"
logFileMaxSize = 1024 * 1024 * 5 // 5 MB
)
// Custom level encoders that handle NOTICE level
// Since NOTICE and WARN share the same numeric value (1), we handle them specially
// in the encoder to display NOTICE messages with the "NOTICE" prefix.
// Note: WARN messages will also display as "NOTICE" because they share the same level value.
// This is the intended behavior for visual distinction.
// noticeLevelEncoder provides custom level encoding for NOTICE level
// This ensures NOTICE messages are clearly distinguished from other log levels
func noticeLevelEncoder(l zapcore.Level, enc zapcore.PrimitiveArrayEncoder) {
switch l {
case ctrld.NoticeLevel:
enc.AppendString("NOTICE")
default:
zapcore.CapitalLevelEncoder(l, enc)
}
}
// noticeColorLevelEncoder provides colored level encoding for NOTICE level
// This uses cyan color to make NOTICE messages visually distinct in terminal output
func noticeColorLevelEncoder(l zapcore.Level, enc zapcore.PrimitiveArrayEncoder) {
switch l {
case ctrld.NoticeLevel:
enc.AppendString("\x1b[36mNOTICE\x1b[0m") // Cyan color for NOTICE
default:
zapcore.CapitalColorLevelEncoder(l, enc)
}
}
// logViewResponse represents the response structure for log viewing requests
// This provides a consistent JSON format for log data retrieval
type logViewResponse struct {
Data string `json:"data"`
}
// logSentResponse represents the response structure for log sending operations
// This includes size information and error details for debugging
type logSentResponse struct {
Size int64 `json:"size"`
Error string `json:"error"`
}
// logReader provides read access to log data with size information.
//
// This struct encapsulates log reading functionality for external consumers,
// providing both the log content and metadata about the log size. It supports
// reading from both internal log buffers (when no external logging is configured)
// and external log files (when logging to file is enabled).
//
// Fields:
// - r: An io.ReadCloser that provides access to the log content
// - size: The total size of the log data in bytes
//
// The logReader is used by the control server to serve log content to clients
// and by various CLI commands that need to display or process log data.
type logReader struct {
r io.ReadCloser
size int64
@@ -128,19 +63,16 @@ type logWriter struct {
}
// newLogWriter creates an internal log writer.
// This provides the default log writer with standard buffer size
func newLogWriter() *logWriter {
return newLogWriterWithSize(logWriterSize)
}
// newSmallLogWriter creates an internal log writer with small buffer size.
// This is used in memory-constrained environments or for temporary logging
func newSmallLogWriter() *logWriter {
return newLogWriterWithSize(logWriterSmallSize)
}
// newLogWriterWithSize creates an internal log writer with a given buffer size.
// This allows customization of log buffer size based on specific requirements
func newLogWriterWithSize(size int) *logWriter {
lw := &logWriter{size: size}
return lw
@@ -267,8 +199,6 @@ func (lw *logWriter) tailLastLines(n int) []byte {
return result
}
// Write implements io.Writer interface for logWriter
// This manages buffer overflow by discarding old data while preserving important markers
func (lw *logWriter) Write(p []byte) (int, error) {
lw.mu.Lock()
defer lw.mu.Unlock()
@@ -297,12 +227,10 @@ func (lw *logWriter) Write(p []byte) (int, error) {
}
// If writing p causes overflows, discard old data.
// This prevents unbounded memory growth while maintaining recent logs
if lw.buf.Len()+len(p) > lw.size {
buf := lw.buf.Bytes()
haveEndMarker := false
// If there's init end marker already, preserve the data til the marker.
// This ensures initialization logs are always available for debugging
if idx := bytes.LastIndex(buf, []byte(logWriterInitEndMarker)); idx >= 0 {
buf = buf[:idx+len(logWriterInitEndMarker)]
haveEndMarker = true
@@ -328,26 +256,26 @@ func (lw *logWriter) Write(p []byte) (int, error) {
// initLogging initializes global logging setup.
func (p *prog) initLogging(backup bool) {
logCores := initLoggingWithBackup(backup)
zerolog.TimeFieldFormat = time.RFC3339 + ".000"
logWriters := initLoggingWithBackup(backup)
// Initializing internal logging after global logging.
p.initInternalLogging(logCores)
p.logger.Store(mainLog.Load())
p.initInternalLogging(logWriters)
}
// internalLogFilePath returns the path for persisted internal logs.
// The file lives in the ctrld home directory alongside other runtime state.
func internalLogFilePath() string {
return ctrld.AbsHomeDir(logFileName)
return absHomeDir(logFileName)
}
// initInternalLogging performs internal logging if there's no log enabled.
func (p *prog) initInternalLogging(externalCores []zapcore.Core) {
func (p *prog) initInternalLogging(writers []io.Writer) {
if !p.needInternalLogging() {
return
}
p.initInternalLogWriterOnce.Do(func() {
p.Notice().Msg("Internal logging enabled")
mainLog.Load().Notice().Msg("internal logging enabled")
p.internalLogWriter = newLogWriter()
p.internalLogSent = time.Now().Add(-logWriterSentInterval)
p.internalWarnLogWriter = newSmallLogWriter()
@@ -364,26 +292,28 @@ func (p *prog) initInternalLogging(externalCores []zapcore.Core) {
lw := p.internalLogWriter
wlw := p.internalWarnLogWriter
p.mu.Unlock()
// 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
// If ctrld was run without explicit verbose level,
// run the internal logging at debug level, so we could
// have enough information for troubleshooting.
internalCore := newHumanReadableZapCore(lw, zapcore.DebugLevel)
cores = append(cores, internalCore)
// Add core for internal warn log writer
warnCore := newHumanReadableZapCore(wlw, zapcore.WarnLevel)
cores = append(cores, warnCore)
// Create a multi-core logger
multiCore := zapcore.NewTee(cores...)
logger := zap.New(multiCore)
mainLog.Store(&ctrld.Logger{Logger: logger})
if verbose == 0 {
for i := range writers {
w := &zerolog.FilteredLevelWriter{
Writer: zerolog.LevelWriterAdapter{Writer: writers[i]},
Level: zerolog.NoticeLevel,
}
writers[i] = w
}
zerolog.SetGlobalLevel(zerolog.DebugLevel)
}
writers = append(writers, lw)
writers = append(writers, &zerolog.FilteredLevelWriter{
Writer: zerolog.LevelWriterAdapter{Writer: wlw},
Level: zerolog.WarnLevel,
})
multi := zerolog.MultiLevelWriter(writers...)
l := mainLog.Load().Output(multi).With().Logger()
mainLog.Store(&l)
ctrld.ProxyLogger.Store(&l)
}
// needInternalLogging reports whether prog needs to run internal logging.
@@ -399,69 +329,7 @@ func (p *prog) needInternalLogging() bool {
return true
}
// 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) {
func (p *prog) logReader() (*logReader, error) {
if p.needInternalLogging() {
p.mu.Lock()
lw := p.internalLogWriter
@@ -483,12 +351,12 @@ func (p *prog) logReader(stripColor bool) (*logReader, error) {
// Fall back to in-memory buffer.
lw.mu.Lock()
lwReader := newLogReader(&lw.buf, stripColor)
lwReader := bytes.NewReader(lw.buf.Bytes())
lwSize := lw.buf.Len()
lw.mu.Unlock()
// Warn log content.
wlw.mu.Lock()
wlwReader := newLogReader(&wlw.buf, stripColor)
wlwReader := bytes.NewReader(wlw.buf.Bytes())
wlwSize := wlw.buf.Len()
wlw.mu.Unlock()
reader := io.MultiReader(lwReader, bytes.NewReader([]byte(logWriterLogEndMarker)), wlwReader)
@@ -518,75 +386,6 @@ func (p *prog) logReader(stripColor bool) (*logReader, error) {
return lr, nil
}
// newHumanReadableZapCore creates a zap core optimized for human-readable log output.
//
// Features:
// - Uses development encoder configuration for enhanced readability
// - Console encoding with colored log levels for easy visual scanning
// - Millisecond precision timestamps in human-friendly format
// - Structured field output with clear key-value pairs
// - Ideal for development, debugging, and interactive terminal sessions
//
// Parameters:
// - w: The output writer (e.g., os.Stdout, file, buffer)
// - level: Minimum log level to capture (e.g., Debug, Info, Warn, Error)
//
// Returns a zapcore.Core configured for human consumption.
func newHumanReadableZapCore(w io.Writer, level zapcore.Level) zapcore.Core {
encoderConfig := zap.NewDevelopmentEncoderConfig()
encoderConfig.TimeKey = "time"
encoderConfig.EncodeTime = zapcore.TimeEncoderOfLayout(time.StampMilli)
encoderConfig.EncodeLevel = noticeColorLevelEncoder
encoder := zapcore.NewConsoleEncoder(encoderConfig)
return zapcore.NewCore(encoder, zapcore.AddSync(w), level)
}
// newMachineFriendlyZapCore creates a zap core optimized for machine processing and log aggregation.
//
// Features:
// - Uses production encoder configuration for consistent, parseable output
// - Console encoding with non-colored log levels for log parsing tools
// - Millisecond precision timestamps in ISO-like format
// - Structured field output optimized for log aggregation systems
// - Ideal for production environments, log shipping, and automated analysis
//
// Parameters:
// - w: The output writer (e.g., os.Stdout, file, buffer)
// - level: Minimum log level to capture (e.g., Debug, Info, Warn, Error)
//
// Returns a zapcore.Core configured for machine consumption and log aggregation.
func newMachineFriendlyZapCore(w io.Writer, level zapcore.Level) zapcore.Core {
encoderConfig := zap.NewProductionEncoderConfig()
encoderConfig.TimeKey = "time"
encoderConfig.EncodeTime = zapcore.TimeEncoderOfLayout(time.StampMilli)
encoderConfig.EncodeLevel = noticeLevelEncoder
encoder := zapcore.NewConsoleEncoder(encoderConfig)
return zapcore.NewCore(encoder, zapcore.AddSync(w), level)
}
// ansiRegex is a regular expression to match ANSI color codes.
var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`)
// newLogReader creates a reader for log buffer content with optional ANSI color stripping.
//
// This function provides flexible log content access by allowing consumers to choose
// between raw log data (with ANSI color codes) or stripped content (without color codes).
// The color stripping is useful when logs need to be processed by tools that don't
// handle ANSI escape sequences properly, or when storing logs in plain text format.
//
// Parameters:
// - buf: The log buffer containing the log data to read
// - stripColor: If true, strips ANSI color codes from the log content;
// if false, returns raw log content with color codes preserved
//
// Returns an io.Reader that provides access to the processed log content.
func newLogReader(buf *bytes.Buffer, stripColor bool) io.Reader {
if stripColor {
return strings.NewReader(ansiRegex.ReplaceAllString(buf.String(), ""))
}
return strings.NewReader(buf.String())
}
// logReaderFromFiles builds a logReader that concatenates the backup file
// (if it exists), the current log file, and the in-memory warn log buffer.
func (p *prog) logReaderFromFiles(current, backup string, wlw *logWriter) (*logReader, error) {
-332
View File
@@ -1,18 +1,11 @@
package cli
import (
"bytes"
"io"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"github.com/Control-D-Inc/ctrld"
)
func Test_logWriter_Write(t *testing.T) {
@@ -93,331 +86,6 @@ func Test_logWriter_MarkerInitEnd(t *testing.T) {
}
}
// TestNoticeLevel tests that the custom NOTICE level works correctly
func TestNoticeLevel(t *testing.T) {
// Create a buffer to capture log output
var buf bytes.Buffer
// Create encoder config with custom NOTICE level support
encoderConfig := zap.NewDevelopmentEncoderConfig()
encoderConfig.TimeKey = "time"
encoderConfig.EncodeTime = zapcore.TimeEncoderOfLayout("15:04:05.000")
encoderConfig.EncodeLevel = noticeLevelEncoder
// Test with NOTICE level
encoder := zapcore.NewConsoleEncoder(encoderConfig)
core := zapcore.NewCore(encoder, zapcore.AddSync(&buf), ctrld.NoticeLevel)
logger := zap.New(core)
ctrldLogger := &ctrld.Logger{Logger: logger}
// Log messages at different levels
ctrldLogger.Debug().Msg("This is a DEBUG message")
ctrldLogger.Info().Msg("This is an INFO message")
ctrldLogger.Notice().Msg("This is a NOTICE message")
ctrldLogger.Warn().Msg("This is a WARN message")
ctrldLogger.Error().Msg("This is an ERROR message")
output := buf.String()
// Verify that DEBUG and INFO messages are NOT logged (filtered out)
if strings.Contains(output, "DEBUG") {
t.Error("DEBUG message should not be logged when level is NOTICE")
}
if strings.Contains(output, "INFO") {
t.Error("INFO message should not be logged when level is NOTICE")
}
// Verify that NOTICE, WARN, and ERROR messages ARE logged
if !strings.Contains(output, "NOTICE") {
t.Error("NOTICE message should be logged when level is NOTICE")
}
if !strings.Contains(output, "WARN") {
t.Error("WARN message should be logged when level is NOTICE")
}
if !strings.Contains(output, "ERROR") {
t.Error("ERROR message should be logged when level is NOTICE")
}
// Verify the NOTICE message content
if !strings.Contains(output, "This is a NOTICE message") {
t.Error("NOTICE message content should be present")
}
t.Logf("Log output with NOTICE level:\n%s", output)
}
func TestNewLogReader(t *testing.T) {
tests := []struct {
name string
bufContent string
stripColor bool
expected string
description string
}{
{
name: "empty_buffer_no_color_strip",
bufContent: "",
stripColor: false,
expected: "",
description: "Empty buffer should return empty reader",
},
{
name: "empty_buffer_with_color_strip",
bufContent: "",
stripColor: true,
expected: "",
description: "Empty buffer with color strip should return empty reader",
},
{
name: "plain_text_no_color_strip",
bufContent: "This is plain text without any color codes",
stripColor: false,
expected: "This is plain text without any color codes",
description: "Plain text should be returned as-is when not stripping colors",
},
{
name: "plain_text_with_color_strip",
bufContent: "This is plain text without any color codes",
stripColor: true,
expected: "This is plain text without any color codes",
description: "Plain text should be returned as-is when stripping colors",
},
{
name: "text_with_ansi_codes_no_strip",
bufContent: "Normal text \x1b[31mred text\x1b[0m normal again",
stripColor: false,
expected: "Normal text \x1b[31mred text\x1b[0m normal again",
description: "ANSI color codes should be preserved when not stripping",
},
{
name: "text_with_ansi_codes_with_strip",
bufContent: "Normal text \x1b[31mred text\x1b[0m normal again",
stripColor: true,
expected: "Normal text red text normal again",
description: "ANSI color codes should be removed when stripping colors",
},
{
name: "multiple_ansi_codes_no_strip",
bufContent: "\x1b[1mBold\x1b[0m \x1b[32mGreen\x1b[0m \x1b[34mBlue\x1b[0m text",
stripColor: false,
expected: "\x1b[1mBold\x1b[0m \x1b[32mGreen\x1b[0m \x1b[34mBlue\x1b[0m text",
description: "Multiple ANSI codes should be preserved when not stripping",
},
{
name: "multiple_ansi_codes_with_strip",
bufContent: "\x1b[1mBold\x1b[0m \x1b[32mGreen\x1b[0m \x1b[34mBlue\x1b[0m text",
stripColor: true,
expected: "Bold Green Blue text",
description: "Multiple ANSI codes should be removed when stripping colors",
},
{
name: "complex_ansi_sequences_no_strip",
bufContent: "\x1b[1;31;42mBold red on green\x1b[0m \x1b[38;5;208mOrange\x1b[0m",
stripColor: false,
expected: "\x1b[1;31;42mBold red on green\x1b[0m \x1b[38;5;208mOrange\x1b[0m",
description: "Complex ANSI sequences should be preserved when not stripping",
},
{
name: "complex_ansi_sequences_with_strip",
bufContent: "\x1b[1;31;42mBold red on green\x1b[0m \x1b[38;5;208mOrange\x1b[0m",
stripColor: true,
expected: "Bold red on green Orange",
description: "Complex ANSI sequences should be removed when stripping colors",
},
{
name: "ansi_codes_with_newlines_no_strip",
bufContent: "Line 1\n\x1b[31mRed line\x1b[0m\nLine 3",
stripColor: false,
expected: "Line 1\n\x1b[31mRed line\x1b[0m\nLine 3",
description: "ANSI codes with newlines should be preserved when not stripping",
},
{
name: "ansi_codes_with_newlines_with_strip",
bufContent: "Line 1\n\x1b[31mRed line\x1b[0m\nLine 3",
stripColor: true,
expected: "Line 1\nRed line\nLine 3",
description: "ANSI codes with newlines should be removed when stripping colors",
},
{
name: "malformed_ansi_codes_no_strip",
bufContent: "Text \x1b[invalidm \x1b[0m normal",
stripColor: false,
expected: "Text \x1b[invalidm \x1b[0m normal",
description: "Malformed ANSI codes should be preserved when not stripping",
},
{
name: "malformed_ansi_codes_with_strip",
bufContent: "Text \x1b[invalidm \x1b[0m normal",
stripColor: true,
expected: "Text \x1b[invalidm normal",
description: "Non-matching ANSI sequences should be preserved when stripping colors",
},
{
name: "large_buffer_no_strip",
bufContent: strings.Repeat("A", 10000) + "\x1b[31m" + strings.Repeat("B", 1000) + "\x1b[0m",
stripColor: false,
expected: strings.Repeat("A", 10000) + "\x1b[31m" + strings.Repeat("B", 1000) + "\x1b[0m",
description: "Large buffer should handle ANSI codes correctly when not stripping",
},
{
name: "large_buffer_with_strip",
bufContent: strings.Repeat("A", 10000) + "\x1b[31m" + strings.Repeat("B", 1000) + "\x1b[0m",
stripColor: true,
expected: strings.Repeat("A", 10000) + strings.Repeat("B", 1000),
description: "Large buffer should remove ANSI codes correctly when stripping",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create a buffer with the test content
buf := &bytes.Buffer{}
buf.WriteString(tt.bufContent)
// Create the log reader
reader := newLogReader(buf, tt.stripColor)
// Read all content from the reader
content, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("Failed to read from log reader: %v", err)
}
// Verify the content matches expected
actual := string(content)
if actual != tt.expected {
t.Errorf("Expected content: %q, got: %q", tt.expected, actual)
t.Logf("Description: %s", tt.description)
}
})
}
}
func TestNewLogReader_ReaderBehavior(t *testing.T) {
// Test that the returned reader behaves correctly
buf := &bytes.Buffer{}
buf.WriteString("Test content with \x1b[31mred\x1b[0m text")
// Test with color stripping
reader := newLogReader(buf, true)
// Test reading in chunks
chunk1 := make([]byte, 10)
n1, err := reader.Read(chunk1)
if err != nil && err != io.EOF {
t.Fatalf("Unexpected error reading first chunk: %v", err)
}
if n1 != 10 {
t.Errorf("Expected to read 10 bytes, got %d", n1)
}
// Test reading remaining content
remaining, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("Failed to read remaining content: %v", err)
}
// Verify total content
totalContent := string(chunk1[:n1]) + string(remaining)
expected := "Test content with red text"
if totalContent != expected {
t.Errorf("Expected total content: %q, got: %q", expected, totalContent)
}
}
func TestNewLogReader_ConcurrentAccess(t *testing.T) {
// Test concurrent access to the same buffer
buf := &bytes.Buffer{}
buf.WriteString("Concurrent test with \x1b[32mgreen\x1b[0m text")
var wg sync.WaitGroup
numGoroutines := 10
results := make(chan string, numGoroutines)
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func() {
defer wg.Done()
reader := newLogReader(buf, true)
content, err := io.ReadAll(reader)
if err != nil {
t.Errorf("Failed to read content: %v", err)
return
}
results <- string(content)
}()
}
wg.Wait()
close(results)
// Verify all goroutines got the same result
expected := "Concurrent test with green text"
for result := range results {
if result != expected {
t.Errorf("Expected: %q, got: %q", expected, result)
}
}
}
func TestNewLogReader_ANSIRegexEdgeCases(t *testing.T) {
// Test edge cases for ANSI regex matching
tests := []struct {
name string
input string
expected string
}{
{
name: "empty_escape_sequence",
input: "Text \x1b[m normal",
expected: "Text normal",
},
{
name: "multiple_semicolons",
input: "Text \x1b[1;2;3;4m normal",
expected: "Text normal",
},
{
name: "numeric_only",
input: "Text \x1b[123m normal",
expected: "Text normal",
},
{
name: "mixed_numeric_semicolon",
input: "Text \x1b[1;23;456m normal",
expected: "Text normal",
},
{
name: "no_closing_bracket",
input: "Text \x1b[31 normal",
expected: "Text \x1b[31 normal",
},
{
name: "no_opening_bracket",
input: "Text 31m normal",
expected: "Text 31m normal",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
buf := &bytes.Buffer{}
buf.WriteString(tt.input)
reader := newLogReader(buf, true)
content, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("Failed to read content: %v", err)
}
actual := string(content)
if actual != tt.expected {
t.Errorf("Expected: %q, got: %q", tt.expected, actual)
}
})
}
}
func Test_logWriter_SetLogFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.log")
+7 -8
View File
@@ -84,7 +84,7 @@ func (p *prog) detectLoop(msg *dns.Msg) {
//
// See: https://thekelleys.org.uk/dnsmasq/docs/dnsmasq-man.html
func (p *prog) checkDnsLoop() {
p.Debug().Msg("Start checking DNS loop")
mainLog.Load().Debug().Msg("start checking DNS loop")
upstream := make(map[string]*ctrld.UpstreamConfig)
p.loopMu.Lock()
for n, uc := range p.cfg.Upstream {
@@ -93,7 +93,7 @@ func (p *prog) checkDnsLoop() {
}
// Do not send test query to external upstream.
if !canBeLocalUpstream(uc.Domain) {
p.Debug().Msgf("Skipping external: upstream.%s", n)
mainLog.Load().Debug().Msgf("skipping external: upstream.%s", n)
continue
}
uid := uc.UID()
@@ -102,7 +102,6 @@ func (p *prog) checkDnsLoop() {
}
p.loopMu.Unlock()
loggerCtx := ctrld.LoggerCtx(context.Background(), p.logger.Load())
for uid := range p.loop {
msg := loopTestMsg(uid)
uc := upstream[uid]
@@ -110,16 +109,16 @@ func (p *prog) checkDnsLoop() {
if uc == nil {
continue
}
resolver, err := ctrld.NewResolver(loggerCtx, uc)
resolver, err := ctrld.NewResolver(uc)
if err != nil {
p.Warn().Err(err).Msgf("Could not perform loop check for upstream: %q, endpoint: %q", uc.Name, uc.Endpoint)
mainLog.Load().Warn().Err(err).Msgf("could not perform loop check for upstream: %q, endpoint: %q", uc.Name, uc.Endpoint)
continue
}
if _, err := resolver.Resolve(context.Background(), msg); err != nil {
p.Warn().Err(err).Msgf("Could not send DNS loop check query for upstream: %q, endpoint: %q", uc.Name, uc.Endpoint)
mainLog.Load().Warn().Err(err).Msgf("could not send DNS loop check query for upstream: %q, endpoint: %q", uc.Name, uc.Endpoint)
}
}
p.Debug().Msg("End checking DNS loop")
mainLog.Load().Debug().Msg("end checking DNS loop")
}
// checkDnsLoopTicker performs p.checkDnsLoop every minute.
@@ -138,7 +137,7 @@ func (p *prog) checkDnsLoopTicker(ctx context.Context) {
}
}
// loopTestMsg creates a DNS test message for loop detection
// loopTestMsg generates DNS message for checking loop.
func loopTestMsg(uid string) *dns.Msg {
msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn(uid+loopTestDomain), loopTestQtype)
+84 -122
View File
@@ -10,91 +10,78 @@ import (
"time"
"github.com/kardianos/service"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"github.com/rs/zerolog"
"github.com/Control-D-Inc/ctrld"
)
// Global variables for CLI configuration and state management
// These are used across multiple commands and need to persist throughout the application lifecycle
var (
configPath string
configBase64 string
daemon bool
listenAddress string
primaryUpstream string
secondaryUpstream string
domains []string
logPath string
homedir string
cacheSize int
cfg ctrld.Config
verbose int
silent bool
cdUID string
cdOrg string
customHostname string
cdDev bool
iface string
ifaceStartStop string
nextdns string
cdUpstreamProto string
deactivationPin int64
skipSelfChecks bool
cleanup bool
startOnly bool
rfc1918 bool
interceptMode string // "", "dns", or "hard" — set via --intercept-mode flag or config
dnsIntercept bool // derived: interceptMode == "dns" || interceptMode == "hard"
hardIntercept bool // derived: interceptMode == "hard"
firewallMode string // "off" or "on" — set via --firewall-mode flag or config
firewallModeFlagChanged bool // true when --firewall-mode was explicitly provided
configPath string
configBase64 string
daemon bool
listenAddress string
primaryUpstream string
secondaryUpstream string
domains []string
logPath string
homedir string
cacheSize int
cfg ctrld.Config
verbose int
silent bool
cdUID string
cdOrg string
customHostname string
cdDev bool
iface string
ifaceStartStop string
nextdns string
cdUpstreamProto string
deactivationPin int64
skipSelfChecks bool
cleanup bool
startOnly bool
rfc1918 bool
interceptMode string // "", "dns", or "hard" — set via --intercept-mode flag or config
dnsIntercept bool // derived: interceptMode == "dns" || interceptMode == "hard"
hardIntercept bool // derived: interceptMode == "hard"
mainLog atomic.Pointer[ctrld.Logger]
consoleWriter zapcore.Core
consoleWriterLevel zapcore.Level
noConfigStart bool
mainLog atomic.Pointer[zerolog.Logger]
consoleWriter zerolog.ConsoleWriter
noConfigStart bool
)
// Flag name constants for consistent reference across the codebase
// Using constants prevents typos and makes refactoring easier
const (
cdUidFlagName = "cd"
cdOrgFlagName = "cd-org"
customHostnameFlagName = "custom-hostname"
nextdnsFlagName = "nextdns"
// autoIface is the sentinel --iface value meaning "use the default gateway interface".
autoIface = "auto"
)
// init initializes the default logger before any CLI commands are executed
// This ensures logging is available even during early initialization phases
func init() {
l := zap.NewNop()
mainLog.Store(&ctrld.Logger{Logger: l})
l := zerolog.New(io.Discard)
mainLog.Store(&l)
}
// Main is the entry point for the CLI application
// It initializes configuration, sets up the CLI structure, and executes the root command
func Main() {
// Fast path for pf interception probe subprocess.
// Fast path for pf interception probe subprocess. This runs before cobra
// initialization to minimize startup time. The parent process spawns us with
// "pf-probe-send <host> <hex-dns-packet>" and a non-_ctrld GID so pf
// intercepts the DNS query. If pf rdr is working, the query reaches ctrld's
// listener; if not, it goes to the real DNS server and ctrld detects the miss.
if len(os.Args) >= 4 && os.Args[1] == "pf-probe-send" {
pfProbeSend(os.Args[2], os.Args[3])
return
}
ctrld.InitConfig(v, "ctrld")
rootCmd := initCLI()
initCLI()
if err := rootCmd.Execute(); err != nil {
mainLog.Load().Error().Msg(err.Error())
os.Exit(1)
}
}
// normalizeLogFilePath converts relative log file paths to absolute paths
// This ensures log files are created in predictable locations regardless of working directory
func normalizeLogFilePath(logFilePath string) string {
if logFilePath == "" || filepath.IsAbs(logFilePath) || service.Interactive() {
return logFilePath
@@ -110,36 +97,40 @@ func normalizeLogFilePath(logFilePath string) string {
}
// initConsoleLogging initializes console logging, then storing to mainLog.
// This sets up human-readable logging output for interactive use
func initConsoleLogging() {
consoleWriterLevel = ctrld.NoticeLevel
consoleWriter = zerolog.NewConsoleWriter(func(w *zerolog.ConsoleWriter) {
w.TimeFormat = time.StampMilli
})
multi := zerolog.MultiLevelWriter(consoleWriter)
l := mainLog.Load().Output(multi).With().Timestamp().Logger()
mainLog.Store(&l)
switch {
case silent:
// For silent mode, use a no-op logger to suppress all output
l := zap.NewNop()
mainLog.Store(&ctrld.Logger{Logger: l})
zerolog.SetGlobalLevel(zerolog.NoLevel)
case verbose == 1:
// Info level provides basic operational information
consoleWriterLevel = zapcore.InfoLevel
ctrld.ProxyLogger.Store(&l)
zerolog.SetGlobalLevel(zerolog.InfoLevel)
case verbose > 1:
// Debug level provides detailed diagnostic information
consoleWriterLevel = zapcore.DebugLevel
ctrld.ProxyLogger.Store(&l)
zerolog.SetGlobalLevel(zerolog.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
// to be used for all interactive commands.
//
// Current log file config will also be ignored.
// This prevents log file conflicts during interactive command execution
func initInteractiveLogging() {
old := cfg.Service.LogPath
cfg.Service.LogPath = ""
zerolog.TimeFieldFormat = time.RFC3339 + ".000"
initLoggingWithBackup(false)
cfg.Service.LogPath = old
l := zerolog.New(io.Discard)
ctrld.ProxyLogger.Store(&l)
}
// initLoggingWithBackup initializes log setup base on current config.
@@ -148,107 +139,77 @@ func initInteractiveLogging() {
// This is only used in runCmd for special handling in case of logging config
// change in cd mode. Without special reason, the caller should use initLogging
// wrapper instead of calling this function directly.
func initLoggingWithBackup(doBackup bool) []zapcore.Core {
func initLoggingWithBackup(doBackup bool) []io.Writer {
var writers []io.Writer
if logFilePath := normalizeLogFilePath(cfg.Service.LogPath); logFilePath != "" {
// Create parent directory if necessary.
// This ensures log files can be created even if the directory doesn't exist
if err := os.MkdirAll(filepath.Dir(logFilePath), 0750); err != nil {
mainLog.Load().Error().Msgf("Failed to create log path: %v", err)
mainLog.Load().Error().Msgf("failed to create log path: %v", err)
os.Exit(1)
}
// Default open log file in append mode.
// This preserves existing log entries across restarts
flags := os.O_CREATE | os.O_RDWR | os.O_APPEND
if doBackup {
// Backup old log file with .1 suffix.
// This prevents log file corruption during rotation
if err := os.Rename(logFilePath, logFilePath+oldLogSuffix); err != nil && !os.IsNotExist(err) {
mainLog.Load().Error().Msgf("Could not backup old log file: %v", err)
mainLog.Load().Error().Msgf("could not backup old log file: %v", err)
} else {
// Backup was created, set flags for truncating old log file.
// This ensures a clean start for the new log file
flags = os.O_CREATE | os.O_RDWR
}
}
logFile, err := openLogFile(logFilePath, flags)
if err != nil {
mainLog.Load().Error().Msgf("Failed to create log file: %v", err)
mainLog.Load().Error().Msgf("failed to create log file: %v", err)
os.Exit(1)
}
writers = append(writers, logFile)
}
writers = append(writers, consoleWriter)
multi := zerolog.MultiLevelWriter(writers...)
l := mainLog.Load().Output(multi).With().Logger()
mainLog.Store(&l)
// TODO: find a better way.
ctrld.ProxyLogger.Store(&l)
// 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
zerolog.SetGlobalLevel(zerolog.NoticeLevel)
logLevel := cfg.Service.LogLevel
switch {
case silent:
// For silent mode, use a no-op logger to suppress all output
l := zap.NewNop()
mainLog.Store(&ctrld.Logger{Logger: l})
return cores
zerolog.SetGlobalLevel(zerolog.NoLevel)
return writers
case verbose == 1:
logLevel = "info"
case verbose > 1:
logLevel = "debug"
}
// Parse log level string to zapcore.Level
// This provides human-readable log level configuration
var level zapcore.Level
switch logLevel {
case "debug":
level = zapcore.DebugLevel
case "info":
level = zapcore.InfoLevel
case "notice":
level = ctrld.NoticeLevel
case "warn":
level = zapcore.WarnLevel
case "error":
level = zapcore.ErrorLevel
default:
level = zapcore.InfoLevel // default level
if logLevel == "" {
return writers
}
consoleWriter.Enabled(level)
// Add cores for all writers
// This enables multi-destination logging (console + file)
for _, writer := range writers {
core := newMachineFriendlyZapCore(writer, level)
cores = append(cores, core)
level, err := zerolog.ParseLevel(logLevel)
if err != nil {
mainLog.Load().Warn().Err(err).Msg("could not set log level")
return writers
}
// Create a multi-core logger
// This allows simultaneous logging to multiple destinations
multiCore := zapcore.NewTee(cores...)
logger := zap.New(multiCore)
mainLog.Store(&ctrld.Logger{Logger: logger})
return cores
zerolog.SetGlobalLevel(level)
return writers
}
// initCache initializes DNS cache configuration
// This improves performance by caching frequently requested DNS responses
func initCache() {
if !cfg.Service.CacheEnable {
return
}
if cfg.Service.CacheSize == 0 {
// Default cache size provides good balance between memory usage and performance
cfg.Service.CacheSize = 4096
}
}
// pfProbeSend is a minimal subprocess that sends a pre-built DNS query packet
// to the specified host on port 53.
// to the specified host on port 53. It's invoked by probePFIntercept() with a
// non-_ctrld GID so pf interception applies to the query.
//
// Usage: ctrld pf-probe-send <host> <hex-encoded-dns-packet>
func pfProbeSend(host, hexPacket string) {
packet, err := hex.DecodeString(hexPacket)
if err != nil {
@@ -261,6 +222,7 @@ func pfProbeSend(host, hexPacket string) {
defer conn.Close()
conn.SetDeadline(time.Now().Add(time.Second))
_, _ = conn.Write(packet)
// Read response (don't care about result, just need the send to happen)
buf := make([]byte, 512)
_, _ = conn.Read(buf)
}
+3 -18
View File
@@ -6,29 +6,14 @@ import (
"strings"
"testing"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"github.com/Control-D-Inc/ctrld"
"github.com/rs/zerolog"
)
var logOutput strings.Builder
func TestMain(m *testing.M) {
// Create a custom writer that writes to logOutput
writer := zapcore.AddSync(&logOutput)
// Create zap encoder
encoderConfig := zap.NewDevelopmentEncoderConfig()
encoder := zapcore.NewConsoleEncoder(encoderConfig)
// Create core that writes to our string builder
core := zapcore.NewCore(encoder, writer, zap.DebugLevel)
// Create logger
l := zap.New(core)
mainLog.Store(&ctrld.Logger{Logger: l})
l := zerolog.New(&logOutput)
mainLog.Store(&l)
// Stub the self-upgrade command builder for the whole test binary. The real
// builder execs os.Executable() — which under `go test` IS this test binary
+4 -11
View File
@@ -15,7 +15,6 @@ import (
)
// metricsServer represents a server to expose Prometheus metrics via HTTP.
// This provides monitoring and observability for the DNS proxy service
type metricsServer struct {
server *http.Server
mux *http.ServeMux
@@ -25,7 +24,6 @@ type metricsServer struct {
}
// newMetricsServer returns new metrics server.
// This initializes the HTTP server for exposing Prometheus metrics
func newMetricsServer(addr string, reg *prometheus.Registry) (*metricsServer, error) {
mux := http.NewServeMux()
ms := &metricsServer{
@@ -39,13 +37,11 @@ func newMetricsServer(addr string, reg *prometheus.Registry) (*metricsServer, er
}
// register adds handlers for given pattern.
// This provides a clean interface for adding HTTP endpoints to the metrics server
func (ms *metricsServer) register(pattern string, handler http.Handler) {
ms.mux.Handle(pattern, handler)
}
// registerMetricsServerHandler adds handlers for metrics server.
// This sets up both Prometheus format and JSON format endpoints for metrics
func (ms *metricsServer) registerMetricsServerHandler() {
ms.register("/metrics", promhttp.HandlerFor(
ms.reg,
@@ -78,7 +74,6 @@ func (ms *metricsServer) registerMetricsServerHandler() {
}
// start runs the metricsServer.
// This starts the HTTP server for metrics exposure
func (ms *metricsServer) start() error {
listener, err := net.Listen("tcp", ms.addr)
if err != nil {
@@ -90,7 +85,6 @@ func (ms *metricsServer) start() error {
}
// stop shutdowns the metricsServer within 2 seconds timeout.
// This ensures graceful shutdown of the metrics server
func (ms *metricsServer) stop() error {
if !ms.started {
return nil
@@ -101,7 +95,6 @@ func (ms *metricsServer) stop() error {
}
// runMetricsServer initializes metrics stats and runs the metrics server if enabled.
// This sets up the complete metrics infrastructure including Prometheus collectors
func (p *prog) runMetricsServer(ctx context.Context, reloadCh chan struct{}) {
if !p.metricsEnabled() {
return
@@ -122,7 +115,7 @@ func (p *prog) runMetricsServer(ctx context.Context, reloadCh chan struct{}) {
addr := p.cfg.Service.MetricsListener
ms, err := newMetricsServer(addr, reg)
if err != nil {
mainLog.Load().Warn().Err(err).Msg("Could not create new metrics server")
mainLog.Load().Warn().Err(err).Msg("could not create new metrics server")
return
}
// Only start listener address if defined.
@@ -137,9 +130,9 @@ func (p *prog) runMetricsServer(ctx context.Context, reloadCh chan struct{}) {
statsVersion.WithLabelValues(commit, runtime.Version(), curVersion()).Inc()
reg.MustRegister(statsTimeStart)
statsTimeStart.Set(float64(time.Now().Unix()))
mainLog.Load().Debug().Msgf("Starting metrics server on: %s", addr)
mainLog.Load().Debug().Msgf("starting metrics server on: %s", addr)
if err := ms.start(); err != nil {
mainLog.Load().Warn().Err(err).Msg("Could not start metrics server")
mainLog.Load().Warn().Err(err).Msg("could not start metrics server")
return
}
}
@@ -151,7 +144,7 @@ func (p *prog) runMetricsServer(ctx context.Context, reloadCh chan struct{}) {
}
if err := ms.stop(); err != nil {
mainLog.Load().Warn().Err(err).Msg("Could not stop metrics server")
mainLog.Load().Warn().Err(err).Msg("could not stop metrics server")
return
}
}
+25
View File
@@ -49,3 +49,28 @@ func validInterface(iface *net.Interface, validIfacesMap map[string]struct{}) bo
_, ok := validIfacesMap[iface.Name]
return ok
}
// validInterfacesMap returns a set of all valid hardware ports.
func validInterfacesMap() map[string]struct{} {
b, err := exec.Command("networksetup", "-listallhardwareports").Output()
if err != nil {
return nil
}
return parseListAllHardwarePorts(bytes.NewReader(b))
}
// parseListAllHardwarePorts parses output of "networksetup -listallhardwareports"
// and returns map presents all hardware ports.
func parseListAllHardwarePorts(r io.Reader) map[string]struct{} {
m := make(map[string]struct{})
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := scanner.Text()
after, ok := strings.CutPrefix(line, "Device: ")
if !ok {
continue
}
m[after] = struct{}{}
}
return m
}
+38 -3
View File
@@ -2,16 +2,51 @@ package cli
import (
"net"
"net/netip"
"os"
"strings"
"tailscale.com/net/netmon"
)
// patchNetIfaceName patches network interface names on Linux
// This is a no-op on Linux as interface names don't need special handling
func patchNetIfaceName(iface *net.Interface) (bool, error) { return true, nil }
// validInterface reports whether the *net.Interface is a valid one.
// Only non-virtual interfaces are considered valid.
// This prevents DNS configuration on virtual interfaces like docker, veth, etc.
func validInterface(iface *net.Interface, validIfacesMap map[string]struct{}) bool {
_, ok := validIfacesMap[iface.Name]
return ok
}
// validInterfacesMap returns a set containing non virtual interfaces.
func validInterfacesMap() map[string]struct{} {
m := make(map[string]struct{})
vis := virtualInterfaces()
netmon.ForeachInterface(func(i netmon.Interface, prefixes []netip.Prefix) {
if _, existed := vis[i.Name]; existed {
return
}
m[i.Name] = struct{}{}
})
// Fallback to default route interface if found nothing.
if len(m) == 0 {
defaultRoute, err := netmon.DefaultRoute()
if err != nil {
return m
}
m[defaultRoute.InterfaceName] = struct{}{}
}
return m
}
// virtualInterfaces returns a map of virtual interfaces on current machine.
func virtualInterfaces() map[string]struct{} {
s := make(map[string]struct{})
entries, _ := os.ReadDir("/sys/devices/virtual/net")
for _, entry := range entries {
if entry.IsDir() {
s[strings.TrimSpace(entry.Name())] = struct{}{}
}
}
return s
}
+11 -2
View File
@@ -4,10 +4,19 @@ package cli
import (
"net"
"tailscale.com/net/netmon"
)
// patchNetIfaceName patches network interface names on non-Linux/Darwin platforms
func patchNetIfaceName(iface *net.Interface) (bool, error) { return true, nil }
// validInterface checks if an interface is valid on non-Linux/Darwin platforms
func validInterface(iface *net.Interface, validIfacesMap map[string]struct{}) bool { return true }
// validInterfacesMap returns a set containing only default route interfaces.
func validInterfacesMap() map[string]struct{} {
defaultRoute, err := netmon.DefaultRoute()
if err != nil {
return nil
}
return map[string]struct{}{defaultRoute.InterfaceName: {}}
}
+77
View File
@@ -1,7 +1,16 @@
package cli
import (
"io"
"log"
"net"
"os"
"github.com/microsoft/wmi/pkg/base/host"
"github.com/microsoft/wmi/pkg/base/instance"
"github.com/microsoft/wmi/pkg/base/query"
"github.com/microsoft/wmi/pkg/constant"
"github.com/microsoft/wmi/pkg/hardware/network/netadapter"
)
func patchNetIfaceName(iface *net.Interface) (bool, error) {
@@ -14,3 +23,71 @@ func validInterface(iface *net.Interface, validIfacesMap map[string]struct{}) bo
_, ok := validIfacesMap[iface.Name]
return ok
}
// validInterfacesMap returns a set of all physical interfaces.
func validInterfacesMap() map[string]struct{} {
m := make(map[string]struct{})
for _, ifaceName := range validInterfaces() {
m[ifaceName] = struct{}{}
}
return m
}
// validInterfaces returns a list of all physical interfaces.
func validInterfaces() []string {
log.SetOutput(io.Discard)
defer log.SetOutput(os.Stderr)
whost := host.NewWmiLocalHost()
q := query.NewWmiQuery("MSFT_NetAdapter")
instances, err := instance.GetWmiInstancesFromHost(whost, string(constant.StadardCimV2), q)
if instances != nil {
defer instances.Close()
}
if err != nil {
mainLog.Load().Warn().Err(err).Msg("failed to get wmi network adapter")
return nil
}
var adapters []string
for _, i := range instances {
adapter, err := netadapter.NewNetworkAdapter(i)
if err != nil {
mainLog.Load().Warn().Err(err).Msg("failed to get network adapter")
continue
}
name, err := adapter.GetPropertyName()
if err != nil {
mainLog.Load().Warn().Err(err).Msg("failed to get interface name")
continue
}
// From: https://learn.microsoft.com/en-us/previous-versions/windows/desktop/legacy/hh968170(v=vs.85)
//
// "Indicates if a connector is present on the network adapter. This value is set to TRUE
// if this is a physical adapter or FALSE if this is not a physical adapter."
physical, err := adapter.GetPropertyConnectorPresent()
if err != nil {
mainLog.Load().Debug().Str("method", "validInterfaces").Str("interface", name).Msg("failed to get network adapter connector present property")
continue
}
if !physical {
mainLog.Load().Debug().Str("method", "validInterfaces").Str("interface", name).Msg("skipping non-physical adapter")
continue
}
// Check if it's a hardware interface. Checking only for connector present is not enough
// because some interfaces are not physical but have a connector.
hardware, err := adapter.GetPropertyHardwareInterface()
if err != nil {
mainLog.Load().Debug().Str("method", "validInterfaces").Str("interface", name).Msg("failed to get network adapter hardware interface property")
continue
}
if !hardware {
mainLog.Load().Debug().Str("method", "validInterfaces").Str("interface", name).Msg("skipping non-hardware interface")
continue
}
adapters = append(adapters, name)
}
return adapters
}
+1 -6
View File
@@ -3,23 +3,18 @@ package cli
import (
"bufio"
"bytes"
"context"
"maps"
"slices"
"strings"
"testing"
"time"
"github.com/Control-D-Inc/ctrld"
)
func Test_validInterfaces(t *testing.T) {
verbose = 3
initConsoleLogging()
start := time.Now()
im := ctrld.ValidInterfaces(ctrld.LoggerCtx(context.Background(), mainLog.Load()))
ifaces := validInterfaces()
t.Logf("Using Windows API takes: %d", time.Since(start).Milliseconds())
ifaces := slices.Collect(maps.Keys(im))
start = time.Now()
ifacesPowershell := validInterfacesPowershell()
+14 -15
View File
@@ -23,67 +23,66 @@ systemd-resolved=false
var networkManagerCtrldConfFile = filepath.Join(nmConfDir, nmCtrldConfFilename)
// hasNetworkManager reports whether NetworkManager executable found.
// hasNetworkManager checks if NetworkManager is available on the system
func hasNetworkManager() bool {
exe, _ := exec.LookPath("NetworkManager")
return exe != ""
}
func (p *prog) setupNetworkManager() error {
func setupNetworkManager() error {
if !hasNetworkManager() {
return nil
}
if content, _ := os.ReadFile(nmCtrldConfContent); string(content) == nmCtrldConfContent {
p.Debug().Msg("NetworkManager already setup, nothing to do")
mainLog.Load().Debug().Msg("NetworkManager already setup, nothing to do")
return nil
}
err := os.WriteFile(networkManagerCtrldConfFile, []byte(nmCtrldConfContent), os.FileMode(0644))
if os.IsNotExist(err) {
p.Debug().Msg("NetworkManager is not available")
mainLog.Load().Debug().Msg("NetworkManager is not available")
return nil
}
if err != nil {
p.Debug().Err(err).Msg("Could not write NetworkManager ctrld config file")
mainLog.Load().Debug().Err(err).Msg("could not write NetworkManager ctrld config file")
return err
}
p.reloadNetworkManager()
p.Debug().Msg("Setup NetworkManager done")
reloadNetworkManager()
mainLog.Load().Debug().Msg("setup NetworkManager done")
return nil
}
func (p *prog) restoreNetworkManager() error {
func restoreNetworkManager() error {
if !hasNetworkManager() {
return nil
}
err := os.Remove(networkManagerCtrldConfFile)
if os.IsNotExist(err) {
p.Debug().Msg("NetworkManager is not available")
mainLog.Load().Debug().Msg("NetworkManager is not available")
return nil
}
if err != nil {
p.Debug().Err(err).Msg("Could not remove NetworkManager ctrld config file")
mainLog.Load().Debug().Err(err).Msg("could not remove NetworkManager ctrld config file")
return err
}
p.reloadNetworkManager()
p.Debug().Msg("Restore NetworkManager done")
reloadNetworkManager()
mainLog.Load().Debug().Msg("restore NetworkManager done")
return nil
}
func (p *prog) reloadNetworkManager() {
func reloadNetworkManager() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
conn, err := dbus.NewSystemConnectionContext(ctx)
if err != nil {
p.Error().Err(err).Msg("Could not create new system connection")
mainLog.Load().Error().Err(err).Msg("could not create new system connection")
return
}
defer conn.Close()
waitCh := make(chan string)
if _, err := conn.ReloadUnitContext(ctx, nmSystemdUnitName, "ignore-dependencies", waitCh); err != nil {
p.Debug().Err(err).Msg("Could not reload NetworkManager")
mainLog.Load().Debug().Err(err).Msg("could not reload NetworkManager")
return
}
<-waitCh
+5 -5
View File
@@ -2,14 +2,14 @@
package cli
func (p *prog) setupNetworkManager() error {
p.reloadNetworkManager()
func setupNetworkManager() error {
reloadNetworkManager()
return nil
}
func (p *prog) restoreNetworkManager() error {
p.reloadNetworkManager()
func restoreNetworkManager() error {
reloadNetworkManager()
return nil
}
func (p *prog) reloadNetworkManager() {}
func reloadNetworkManager() {}
+1 -2
View File
@@ -8,12 +8,11 @@ import (
const nextdnsURL = "https://dns.nextdns.io"
// generateNextDNSConfig generates NextDNS configuration for the given UID
func generateNextDNSConfig(uid string) {
if uid == "" {
return
}
mainLog.Load().Info().Msg("Generating ctrld config for NextDNS resolver")
mainLog.Load().Info().Msg("generating ctrld config for NextDNS resolver")
cfg = ctrld.Config{
Listener: map[string]*ctrld.ListenerConfig{
"0": {
-101
View File
@@ -1,101 +0,0 @@
//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
}
@@ -1,74 +0,0 @@
//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)
}
}
+6 -20
View File
@@ -8,31 +8,26 @@ import (
"os/exec"
"strings"
"github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/resolvconffile"
)
// allocateIP allocates an IP address on the specified interface
// allocate loopback ip
// sudo ifconfig lo0 alias 127.0.0.2 up
func allocateIP(ip string) error {
mainLog.Load().Debug().Str("ip", ip).Msg("Allocating IP address")
cmd := exec.Command("ifconfig", "lo0", "alias", ip, "up")
if err := cmd.Run(); err != nil {
mainLog.Load().Error().Err(err).Msg("AllocateIP failed")
mainLog.Load().Error().Err(err).Msg("allocateIP failed")
return err
}
mainLog.Load().Debug().Str("ip", ip).Msg("IP address allocated successfully")
return nil
}
// deAllocateIP deallocates an IP address from the specified interface
func deAllocateIP(ip string) error {
mainLog.Load().Debug().Str("ip", ip).Msg("Deallocating IP address")
cmd := exec.Command("ifconfig", "lo0", "-alias", ip)
if err := cmd.Run(); err != nil {
mainLog.Load().Error().Err(err).Msg("DeAllocateIP failed")
mainLog.Load().Error().Err(err).Msg("deAllocateIP failed")
return err
}
mainLog.Load().Debug().Str("ip", ip).Msg("IP address deallocated successfully")
return nil
}
@@ -52,8 +47,6 @@ func setDnsIgnoreUnusableInterface(iface *net.Interface, nameservers []string) e
// networksetup -setdnsservers Wi-Fi 8.8.8.8 1.1.1.1
// TODO(cuonglm): use system API
func setDNS(iface *net.Interface, nameservers []string) error {
mainLog.Load().Debug().Str("interface", iface.Name).Strs("nameservers", nameservers).Msg("Setting DNS configuration")
// Note that networksetup won't modify search domains settings,
// This assignment is just a placeholder to silent linter.
_ = searchDomains
@@ -63,8 +56,6 @@ func setDNS(iface *net.Interface, nameservers []string) error {
if out, err := exec.Command(cmd, args...).CombinedOutput(); err != nil {
return fmt.Errorf("%v: %w", string(out), err)
}
mainLog.Load().Debug().Str("interface", iface.Name).Msg("DNS configuration set successfully")
return nil
}
@@ -82,30 +73,25 @@ func resetDnsIgnoreUnusableInterface(iface *net.Interface) error {
// TODO(cuonglm): use system API
func resetDNS(iface *net.Interface) error {
mainLog.Load().Debug().Str("interface", iface.Name).Msg("Resetting DNS configuration")
cmd := "networksetup"
args := []string{"-setdnsservers", iface.Name, "empty"}
if out, err := exec.Command(cmd, args...).CombinedOutput(); err != nil {
return fmt.Errorf("%v: %w", string(out), err)
}
mainLog.Load().Debug().Str("interface", iface.Name).Msg("DNS configuration reset successfully")
return nil
}
// restoreDNS restores the DNS settings of the given interface.
// this should only be executed upon turning off the ctrld service.
func restoreDNS(iface *net.Interface) (err error) {
if ns := ctrld.SavedStaticNameservers(iface); len(ns) > 0 {
if ns := savedStaticNameservers(iface); len(ns) > 0 {
err = setDNS(iface, ns)
}
return err
}
// currentDNS returns the current DNS servers for the specified interface
func currentDNS(_ *net.Interface) []string {
return ctrld.CurrentNameserversFromResolvconf()
return resolvconffile.NameServers()
}
// currentStaticDNS returns the current static DNS settings of given interface.
+8 -23
View File
@@ -9,32 +9,27 @@ import (
"tailscale.com/health"
"tailscale.com/util/dnsname"
"github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/dns"
"github.com/Control-D-Inc/ctrld/internal/resolvconffile"
)
// allocateIP allocates an IP address on the specified interface
// allocate loopback ip
// sudo ifconfig lo0 127.0.0.53 alias
func allocateIP(ip string) error {
mainLog.Load().Debug().Str("ip", ip).Msg("Allocating IP address")
cmd := exec.Command("ifconfig", "lo0", ip, "alias")
if err := cmd.Run(); err != nil {
mainLog.Load().Error().Err(err).Msg("allocateIP failed")
return err
}
mainLog.Load().Debug().Str("ip", ip).Msg("IP address allocated successfully")
return nil
}
// deAllocateIP deallocates an IP address from the specified interface
func deAllocateIP(ip string) error {
mainLog.Load().Debug().Str("ip", ip).Msg("Deallocating IP address")
cmd := exec.Command("ifconfig", "lo0", ip, "-alias")
if err := cmd.Run(); err != nil {
mainLog.Load().Error().Err(err).Msg("deAllocateIP failed")
return err
}
mainLog.Load().Debug().Str("ip", ip).Msg("IP address deallocated successfully")
return nil
}
@@ -45,11 +40,9 @@ func setDnsIgnoreUnusableInterface(iface *net.Interface, nameservers []string) e
// set the dns server for the provided network interface
func setDNS(iface *net.Interface, nameservers []string) error {
mainLog.Load().Debug().Str("interface", iface.Name).Strs("nameservers", nameservers).Msg("Setting DNS configuration")
r, err := dns.NewOSConfigurator(logf, &health.Tracker{}, &controlknobs.Knobs{}, iface.Name)
if err != nil {
mainLog.Load().Error().Err(err).Msg("Failed to create DNS OS configurator")
mainLog.Load().Error().Err(err).Msg("failed to create DNS OS configurator")
return err
}
@@ -65,15 +58,13 @@ func setDNS(iface *net.Interface, nameservers []string) error {
if sds, err := searchDomains(); err == nil {
osConfig.SearchDomains = sds
} else {
mainLog.Load().Debug().Err(err).Msg("Failed to get search domains list")
mainLog.Load().Debug().Err(err).Msg("failed to get search domains list")
}
if err := r.SetDNS(osConfig); err != nil {
mainLog.Load().Error().Err(err).Msg("Failed to set DNS")
mainLog.Load().Error().Err(err).Msg("failed to set DNS")
return err
}
mainLog.Load().Debug().Str("interface", iface.Name).Msg("DNS configuration set successfully")
return nil
}
@@ -82,22 +73,17 @@ func resetDnsIgnoreUnusableInterface(iface *net.Interface) error {
return resetDNS(iface)
}
// resetDNS resets DNS servers for the specified interface
func resetDNS(iface *net.Interface) error {
mainLog.Load().Debug().Str("interface", iface.Name).Msg("Resetting DNS configuration")
r, err := dns.NewOSConfigurator(logf, &health.Tracker{}, &controlknobs.Knobs{}, iface.Name)
if err != nil {
mainLog.Load().Error().Err(err).Msg("Failed to create DNS OS configurator")
mainLog.Load().Error().Err(err).Msg("failed to create DNS OS configurator")
return err
}
if err := r.Close(); err != nil {
mainLog.Load().Error().Err(err).Msg("Failed to rollback DNS setting")
mainLog.Load().Error().Err(err).Msg("failed to rollback DNS setting")
return err
}
mainLog.Load().Debug().Str("interface", iface.Name).Msg("DNS configuration reset successfully")
return nil
}
@@ -107,9 +93,8 @@ func restoreDNS(iface *net.Interface) (err error) {
return err
}
// currentDNS returns the current DNS servers for the specified interface
func currentDNS(_ *net.Interface) []string {
return ctrld.CurrentNameserversFromResolvconf()
return resolvconffile.NameServers()
}
// currentStaticDNS returns the current static DNS settings of given interface.
+19 -23
View File
@@ -21,36 +21,30 @@ import (
"tailscale.com/health"
"tailscale.com/util/dnsname"
"github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/dns"
ctrldnet "github.com/Control-D-Inc/ctrld/internal/net"
"github.com/Control-D-Inc/ctrld/internal/resolvconffile"
)
const resolvConfBackupFailedMsg = "open /etc/resolv.pre-ctrld-backup.conf: read-only file system"
type getDNS func(iface string) []string
// allocate loopback ip
// sudo ip a add 127.0.0.2/24 dev lo
func allocateIP(ip string) error {
mainLog.Load().Debug().Str("ip", ip).Msg("Allocating IP address")
cmd := exec.Command("ip", "a", "add", ip+"/24", "dev", "lo")
if out, err := cmd.CombinedOutput(); err != nil {
mainLog.Load().Error().Err(err).Msgf("AllocateIP failed: %s", string(out))
mainLog.Load().Error().Err(err).Msgf("allocateIP failed: %s", string(out))
return err
}
mainLog.Load().Debug().Str("ip", ip).Msg("IP address allocated successfully")
return nil
}
func deAllocateIP(ip string) error {
mainLog.Load().Debug().Str("ip", ip).Msg("Deallocating IP address")
cmd := exec.Command("ip", "a", "del", ip+"/24", "dev", "lo")
if err := cmd.Run(); err != nil {
mainLog.Load().Error().Err(err).Msg("DeAllocateIP failed")
mainLog.Load().Error().Err(err).Msg("deAllocateIP failed")
return err
}
mainLog.Load().Debug().Str("ip", ip).Msg("IP address deallocated successfully")
return nil
}
@@ -62,11 +56,9 @@ func setDnsIgnoreUnusableInterface(iface *net.Interface, nameservers []string) e
}
func setDNS(iface *net.Interface, nameservers []string) error {
mainLog.Load().Debug().Str("interface", iface.Name).Strs("nameservers", nameservers).Msg("Setting DNS configuration")
r, err := dns.NewOSConfigurator(logf, &health.Tracker{}, &controlknobs.Knobs{}, iface.Name)
if err != nil {
mainLog.Load().Error().Err(err).Msg("Failed to create dns os configurator")
mainLog.Load().Error().Err(err).Msg("failed to create DNS OS configurator")
return err
}
@@ -80,9 +72,17 @@ func setDNS(iface *net.Interface, nameservers []string) error {
SearchDomains: []dnsname.FQDN{},
}
if sds, err := searchDomains(); err == nil {
osConfig.SearchDomains = sds
// Filter the root domain, since it's not allowed by systemd.
// See https://github.com/systemd/systemd/issues/9515
filteredSds := slices.DeleteFunc(sds, func(s dnsname.FQDN) bool {
return s == "" || s == "."
})
if len(filteredSds) != len(sds) {
mainLog.Load().Debug().Msg(`Removed root domain "." from search domains list`)
}
osConfig.SearchDomains = filteredSds
} else {
mainLog.Load().Debug().Err(err).Msg("Failed to get search domains list")
mainLog.Load().Debug().Err(err).Msg("failed to get search domains list")
}
trySystemdResolve := false
if err := r.SetDNS(osConfig); err != nil {
@@ -125,8 +125,6 @@ systemdResolve:
}
mainLog.Load().Debug().Msg("DNS was not set for some reason")
}
mainLog.Load().Debug().Str("interface", iface.Name).Msg("DNS configuration set successfully")
return nil
}
@@ -136,8 +134,6 @@ func resetDnsIgnoreUnusableInterface(iface *net.Interface) error {
}
func resetDNS(iface *net.Interface) (err error) {
mainLog.Load().Debug().Str("interface", iface.Name).Msg("Resetting DNS configuration")
defer func() {
if err == nil {
return
@@ -149,7 +145,7 @@ func resetDNS(iface *net.Interface) (err error) {
if r, oerr := dns.NewOSConfigurator(logf, &health.Tracker{}, &controlknobs.Knobs{}, iface.Name); oerr == nil {
_ = r.SetDNS(dns.OSConfig{})
if err := r.Close(); err != nil {
mainLog.Load().Error().Err(err).Msg("Failed to rollback dns setting")
mainLog.Load().Error().Err(err).Msg("failed to rollback DNS setting")
return
}
err = nil
@@ -177,18 +173,18 @@ func resetDNS(iface *net.Interface) (err error) {
}
// TODO(cuonglm): handle DHCPv6 properly.
mainLog.Load().Debug().Msg("Checking for ipv6 availability")
mainLog.Load().Debug().Msg("checking for IPv6 availability")
if ctrldnet.IPv6Available(ctx) {
c := client6.NewClient()
conversation, err := c.Exchange(iface.Name)
if err != nil && !errAddrInUse(err) {
mainLog.Load().Debug().Err(err).Msg("Could not exchange dhcpv6")
mainLog.Load().Debug().Err(err).Msg("could not exchange DHCPv6")
}
for _, packet := range conversation {
if packet.Type() == dhcpv6.MessageTypeReply {
msg, err := packet.GetInnerMessage()
if err != nil {
mainLog.Load().Debug().Err(err).Msg("Could not get inner dhcpv6 message")
mainLog.Load().Debug().Err(err).Msg("could not get inner DHCPv6 message")
return nil
}
nameservers := msg.Options.DNS()
@@ -213,7 +209,7 @@ func restoreDNS(iface *net.Interface) (err error) {
}
func currentDNS(iface *net.Interface) []string {
resolvconfFunc := func(_ string) []string { return ctrld.CurrentNameserversFromResolvconf() }
resolvconfFunc := func(_ string) []string { return resolvconffile.NameServers() }
for _, fn := range []getDNS{getDNSByResolvectl, getDNSBySystemdResolved, getDNSByNmcli, resolvconfFunc} {
if ns := fn(iface.Name); len(ns) > 0 {
return ns
+2 -2
View File
@@ -2,12 +2,12 @@
package cli
// allocateIP allocates an IP address on the specified interface
// TODO(cuonglm): implement.
func allocateIP(ip string) error {
return nil
}
// deAllocateIP deallocates an IP address from the specified interface
// TODO(cuonglm): implement.
func deAllocateIP(ip string) error {
return nil
}
+126 -14
View File
@@ -1,18 +1,21 @@
package cli
import (
"bytes"
"errors"
"fmt"
"net"
"net/netip"
"os"
"os/exec"
"slices"
"strings"
"sync"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
"golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
"github.com/Control-D-Inc/ctrld"
ctrldnet "github.com/Control-D-Inc/ctrld/internal/net"
)
@@ -21,6 +24,11 @@ const (
v6InterfaceKeyPathFormat = `SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters\Interfaces\`
)
var (
setDNSOnce sync.Once
resetDNSOnce sync.Once
)
// setDnsIgnoreUnusableInterface likes setDNS, but return a nil error if the interface is not usable.
func setDnsIgnoreUnusableInterface(iface *net.Interface, nameservers []string) error {
return setDNS(iface, nameservers)
@@ -31,7 +39,49 @@ func setDNS(iface *net.Interface, nameservers []string) error {
if len(nameservers) == 0 {
return errors.New("empty DNS nameservers")
}
setDNSOnce.Do(func() {
// If there's a Dns server running, that means we are on AD with Dns feature enabled.
// Configuring the Dns server to forward queries to ctrld instead.
if hasLocalDnsServerRunning() {
mainLog.Load().Debug().Msg("Local DNS server detected, configuring forwarders")
file := absHomeDir(windowsForwardersFilename)
mainLog.Load().Debug().Msgf("Using forwarders file: %s", file)
oldForwardersContent, err := os.ReadFile(file)
if err != nil {
mainLog.Load().Debug().Err(err).Msg("Could not read existing forwarders file")
} else {
mainLog.Load().Debug().Msgf("Existing forwarders content: %s", string(oldForwardersContent))
}
hasLocalIPv6Listener := needLocalIPv6Listener(interceptMode)
mainLog.Load().Debug().Bool("has_ipv6_listener", hasLocalIPv6Listener).Msg("IPv6 listener status")
forwarders := slices.DeleteFunc(slices.Clone(nameservers), func(s string) bool {
if !hasLocalIPv6Listener {
return false
}
return s == "::1"
})
mainLog.Load().Debug().Strs("forwarders", forwarders).Msg("Filtered forwarders list")
if err := os.WriteFile(file, []byte(strings.Join(forwarders, ",")), 0600); err != nil {
mainLog.Load().Warn().Err(err).Msg("could not save forwarders settings")
} else {
mainLog.Load().Debug().Msg("Successfully wrote new forwarders file")
}
oldForwarders := strings.Split(string(oldForwardersContent), ",")
mainLog.Load().Debug().Strs("old_forwarders", oldForwarders).Msg("Previous forwarders")
if err := addDnsServerForwarders(forwarders, oldForwarders); err != nil {
mainLog.Load().Warn().Err(err).Msg("could not set forwarders settings")
} else {
mainLog.Load().Debug().Msg("Successfully configured DNS server forwarders")
}
}
})
luid, err := winipcfg.LUIDFromIndex(uint32(iface.Index))
if err != nil {
return fmt.Errorf("setDNS: %w", err)
@@ -75,8 +125,25 @@ func resetDnsIgnoreUnusableInterface(iface *net.Interface) error {
return resetDNS(iface)
}
// resetDNS resets DNS servers for the specified interface
// TODO(cuonglm): should we use system API?
func resetDNS(iface *net.Interface) error {
resetDNSOnce.Do(func() {
// See corresponding comment in setDNS.
if hasLocalDnsServerRunning() {
file := absHomeDir(windowsForwardersFilename)
content, err := os.ReadFile(file)
if err != nil {
mainLog.Load().Error().Err(err).Msg("could not read forwarders settings")
return
}
nameservers := strings.Split(string(content), ",")
if err := removeDnsServerForwarders(nameservers); err != nil {
mainLog.Load().Error().Err(err).Msg("could not remove forwarders settings")
return
}
}
})
luid, err := winipcfg.LUIDFromIndex(uint32(iface.Index))
if err != nil {
return fmt.Errorf("resetDNS: %w", err)
@@ -94,7 +161,7 @@ func resetDNS(iface *net.Interface) error {
// restoreDNS restores the DNS settings of the given interface.
// this should only be executed upon turning off the ctrld service.
func restoreDNS(iface *net.Interface) (err error) {
if nss := ctrld.SavedStaticNameservers(iface); len(nss) > 0 {
if nss := savedStaticNameservers(iface); len(nss) > 0 {
v4ns := make([]string, 0, 2)
v6ns := make([]string, 0, 2)
for _, ns := range nss {
@@ -111,24 +178,24 @@ func restoreDNS(iface *net.Interface) (err error) {
}
if len(v4ns) > 0 {
mainLog.Load().Debug().Msgf("Restoring IPv4 static DNS for interface %q: %v", iface.Name, v4ns)
mainLog.Load().Debug().Msgf("restoring IPv4 static DNS for interface %q: %v", iface.Name, v4ns)
if err := setDNS(iface, v4ns); err != nil {
return fmt.Errorf("restoreDNS (IPv4): %w", err)
}
} else {
mainLog.Load().Debug().Msgf("Restoring IPv4 DHCP for interface %q", iface.Name)
mainLog.Load().Debug().Msgf("restoring IPv4 DHCP for interface %q", iface.Name)
if err := luid.SetDNS(windows.AF_INET, nil, nil); err != nil {
return fmt.Errorf("restoreDNS (IPv4 clear): %w", err)
}
}
if len(v6ns) > 0 {
mainLog.Load().Debug().Msgf("Restoring IPv6 static DNS for interface %q: %v", iface.Name, v6ns)
mainLog.Load().Debug().Msgf("restoring IPv6 static DNS for interface %q: %v", iface.Name, v6ns)
if err := setDNS(iface, v6ns); err != nil {
return fmt.Errorf("restoreDNS (IPv6): %w", err)
}
} else {
mainLog.Load().Debug().Msgf("Restoring IPv6 DHCP for interface %q", iface.Name)
mainLog.Load().Debug().Msgf("restoring IPv6 DHCP for interface %q", iface.Name)
if err := luid.SetDNS(windows.AF_INET6, nil, nil); err != nil {
return fmt.Errorf("restoreDNS (IPv6 clear): %w", err)
}
@@ -137,16 +204,15 @@ func restoreDNS(iface *net.Interface) (err error) {
return err
}
// currentDNS returns the current DNS servers for the specified interface
func currentDNS(iface *net.Interface) []string {
luid, err := winipcfg.LUIDFromIndex(uint32(iface.Index))
if err != nil {
mainLog.Load().Error().Err(err).Msg("Failed to get interface LUID")
mainLog.Load().Error().Err(err).Msg("failed to get interface LUID")
return nil
}
nameservers, err := luid.DNS()
if err != nil {
mainLog.Load().Error().Err(err).Msg("Failed to get interface DNS")
mainLog.Load().Error().Err(err).Msg("failed to get interface DNS")
return nil
}
ns := make([]string, 0, len(nameservers))
@@ -174,7 +240,7 @@ func currentStaticDNS(iface *net.Interface) ([]string, error) {
interfaceKeyPath := path + guid.String()
k, err := registry.OpenKey(registry.LOCAL_MACHINE, interfaceKeyPath, registry.QUERY_VALUE)
if err != nil {
mainLog.Load().Debug().Err(err).Msgf("Failed to open registry key %q for interface %q; trying next key", interfaceKeyPath, iface.Name)
mainLog.Load().Debug().Err(err).Msgf("failed to open registry key %q for interface %q; trying next key", interfaceKeyPath, iface.Name)
continue
}
func() {
@@ -182,11 +248,11 @@ func currentStaticDNS(iface *net.Interface) ([]string, error) {
for _, keyName := range []string{"NameServer", "ProfileNameServer"} {
value, _, err := k.GetStringValue(keyName)
if err != nil && !errors.Is(err, registry.ErrNotExist) {
mainLog.Load().Debug().Err(err).Msgf("Error reading %s registry key", keyName)
mainLog.Load().Debug().Err(err).Msgf("error reading %s registry key", keyName)
continue
}
if len(value) > 0 {
mainLog.Load().Debug().Msgf("Found static DNS for interface %q: %s", iface.Name, value)
mainLog.Load().Debug().Msgf("found static DNS for interface %q: %s", iface.Name, value)
parsed := parseDNSServers(value)
for _, pns := range parsed {
if !slices.Contains(ns, pns) {
@@ -198,7 +264,7 @@ func currentStaticDNS(iface *net.Interface) ([]string, error) {
}()
}
if len(ns) == 0 {
mainLog.Load().Debug().Msgf("No static DNS values found for interface %q", iface.Name)
mainLog.Load().Debug().Msgf("no static DNS values found for interface %q", iface.Name)
}
return ns, nil
}
@@ -218,3 +284,49 @@ func parseDNSServers(val string) []string {
}
return servers
}
// addDnsServerForwarders adds given nameservers to DNS server forwarders list,
// and also removing old forwarders if provided.
func addDnsServerForwarders(nameservers, old []string) error {
newForwardersMap := make(map[string]struct{})
newForwarders := make([]string, len(nameservers))
for i := range nameservers {
newForwardersMap[nameservers[i]] = struct{}{}
newForwarders[i] = fmt.Sprintf("%q", nameservers[i])
}
oldForwarders := old[:0]
for _, fwd := range old {
if _, ok := newForwardersMap[fwd]; !ok {
oldForwarders = append(oldForwarders, fwd)
}
}
// NOTE: It is important to add new forwarder before removing old one.
// Testing on Windows Server 2022 shows that removing forwarder1
// then adding forwarder2 sometimes ends up adding both of them
// to the forwarders list.
cmd := fmt.Sprintf("Add-DnsServerForwarder -IPAddress %s", strings.Join(newForwarders, ","))
if len(oldForwarders) > 0 {
cmd = fmt.Sprintf("%s ; Remove-DnsServerForwarder -IPAddress %s -Force", cmd, strings.Join(oldForwarders, ","))
}
if out, err := powershell(cmd); err != nil {
return fmt.Errorf("%w: %s", err, string(out))
}
return nil
}
// removeDnsServerForwarders removes given nameservers from DNS server forwarders list.
func removeDnsServerForwarders(nameservers []string) error {
for _, ns := range nameservers {
cmd := fmt.Sprintf("Remove-DnsServerForwarder -IPAddress %s -Force", ns)
if out, err := powershell(cmd); err != nil {
return fmt.Errorf("%w: %s", err, string(out))
}
}
return nil
}
// powershell runs the given powershell command.
func powershell(cmd string) ([]byte, error) {
out, err := exec.Command("powershell", "-Command", cmd).CombinedOutput()
return bytes.TrimSpace(out), err
}
-8
View File
@@ -1,10 +1,8 @@
package cli
import (
"bytes"
"fmt"
"net"
"os/exec"
"slices"
"strings"
"testing"
@@ -68,9 +66,3 @@ func currentStaticDnsPowershell(iface *net.Interface) ([]string, error) {
}
return ns, nil
}
// powershell runs the given powershell command.
func powershell(cmd string) ([]byte, error) {
out, err := exec.Command("powershell", "-Command", cmd).CombinedOutput()
return bytes.TrimSpace(out), err
}
+294 -278
View File
File diff suppressed because it is too large Load Diff
-2
View File
@@ -4,10 +4,8 @@ import (
"github.com/kardianos/service"
)
// setDependencies sets service dependencies for Darwin
func setDependencies(svc *service.Config) {}
// setWorkingDirectory sets the working directory for the service
func setWorkingDirectory(svc *service.Config, dir string) {
svc.WorkingDirectory = dir
}
-2
View File
@@ -6,11 +6,9 @@ import (
"github.com/kardianos/service"
)
// setDependencies sets service dependencies for FreeBSD
func setDependencies(svc *service.Config) {
// TODO(cuonglm): remove once https://github.com/kardianos/service/issues/359 fixed.
_ = os.MkdirAll("/usr/local/etc/rc.d", 0755)
}
// setWorkingDirectory sets the working directory for the service
func setWorkingDirectory(svc *service.Config, dir string) {}
+5 -2
View File
@@ -9,6 +9,8 @@ import (
"strings"
"github.com/kardianos/service"
"github.com/Control-D-Inc/ctrld/internal/router"
)
func init() {
@@ -21,7 +23,6 @@ func init() {
}
}
// setDependencies sets service dependencies for Linux
func setDependencies(svc *service.Config) {
svc.Dependencies = []string{
"Wants=network-online.target",
@@ -36,9 +37,11 @@ func setDependencies(svc *service.Config) {
svc.Dependencies = append(svc.Dependencies, "Wants=systemd-networkd-wait-online.service")
}
}
if routerDeps := router.ServiceDependencies(); len(routerDeps) > 0 {
svc.Dependencies = append(svc.Dependencies, routerDeps...)
}
}
// setWorkingDirectory sets the working directory for the service
func setWorkingDirectory(svc *service.Config, dir string) {
svc.WorkingDirectory = dir
}
-33
View File
@@ -1,33 +0,0 @@
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()
}
-2
View File
@@ -4,10 +4,8 @@ package cli
import "github.com/kardianos/service"
// setDependencies sets service dependencies for other platforms
func setDependencies(svc *service.Config) {}
// setWorkingDirectory sets the working directory for the service
func setWorkingDirectory(svc *service.Config, dir string) {
// WorkingDirectory is not supported on Windows.
svc.WorkingDirectory = dir
+15 -38
View File
@@ -1,46 +1,17 @@
package cli
import (
"context"
"net"
"net/url"
"syscall"
"runtime"
"testing"
"time"
"github.com/Masterminds/semver/v3"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
"go.uber.org/zap"
"github.com/Control-D-Inc/ctrld"
)
func TestErrNetworkErrorTreatsNoRouteAsNetworkError(t *testing.T) {
err := &net.OpError{Op: "dial", Net: "tcp", Err: syscall.EHOSTUNREACH}
assert.True(t, errNetworkError(err))
assert.True(t, errUrlNetworkError(&url.Error{Op: "Get", URL: "https://dns.controld.com", Err: err}))
}
func TestSleepWithContext(t *testing.T) {
assert.True(t, sleepWithContext(context.Background(), time.Millisecond))
ctx, cancel := context.WithCancel(context.Background())
cancel()
start := time.Now()
assert.False(t, sleepWithContext(ctx, time.Minute))
assert.Less(t, time.Since(start), 100*time.Millisecond)
}
func TestUnreachableRecoveryBackoff(t *testing.T) {
// Streak starts at the base cadence and doubles each attempt, capped at the max.
assert.Equal(t, checkUpstreamBackoffSleep, unreachableRecoveryBackoff(0))
assert.Equal(t, checkUpstreamBackoffSleep, unreachableRecoveryBackoff(1))
assert.Equal(t, 2*checkUpstreamBackoffSleep, unreachableRecoveryBackoff(2))
assert.Equal(t, 4*checkUpstreamBackoffSleep, unreachableRecoveryBackoff(3))
assert.Equal(t, checkUpstreamUnreachableBackoffMax, unreachableRecoveryBackoff(100))
}
func Test_prog_dnsWatchdogEnabled(t *testing.T) {
p := &prog{cfg: &ctrld.Config{}}
@@ -203,10 +174,10 @@ func Test_shouldUpgrade(t *testing.T) {
tc := tc
t.Run(tc.name, func(t *testing.T) {
// Create test logger
testLogger := &ctrld.Logger{Logger: zap.NewNop()}
testLogger := zerolog.New(zerolog.NewTestWriter(t)).With().Logger()
// Call the function and capture the result
result := shouldUpgrade(tc.versionTarget, tc.currentVersion, testLogger)
result := shouldUpgrade(tc.versionTarget, tc.currentVersion, &testLogger)
// Assert the expected result
assert.Equal(t, tc.shouldUpgrade, result, tc.description)
@@ -215,6 +186,10 @@ func Test_shouldUpgrade(t *testing.T) {
}
func Test_selfUpgradeCheck(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("skipped due to Windows file locking issue on Github Action runners")
}
// Helper function to create a version
makeVersion := func(v string) *semver.Version {
ver, err := semver.NewVersion(v)
@@ -251,10 +226,10 @@ func Test_selfUpgradeCheck(t *testing.T) {
tc := tc
t.Run(tc.name, func(t *testing.T) {
// Create test logger
testLogger := &ctrld.Logger{Logger: zap.NewNop()}
testLogger := zerolog.New(zerolog.NewTestWriter(t)).With().Logger()
// Call the function and capture the result
result := selfUpgradeCheck(tc.versionTarget, tc.currentVersion, testLogger)
result := selfUpgradeCheck(tc.versionTarget, tc.currentVersion, &testLogger)
// Assert the expected result
assert.Equal(t, tc.shouldUpgrade, result, tc.description)
@@ -263,6 +238,10 @@ func Test_selfUpgradeCheck(t *testing.T) {
}
func Test_performUpgrade(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("skipped due to Windows file locking issue on Github Action runners")
}
tests := []struct {
name string
versionTarget string
@@ -288,10 +267,8 @@ func Test_performUpgrade(t *testing.T) {
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
// Create test logger
testLogger := &ctrld.Logger{Logger: zap.NewNop()}
// Call the function and capture the result
result := performUpgrade(tc.versionTarget, testLogger)
result := performUpgrade(tc.versionTarget)
assert.Equal(t, tc.expectedResult, result, tc.description)
})
}
+5 -3
View File
@@ -2,10 +2,12 @@ package cli
import "github.com/kardianos/service"
// setDependencies sets service dependencies for Windows
func setDependencies(svc *service.Config) {}
func setDependencies(svc *service.Config) {
if hasLocalDnsServerRunning() {
svc.Dependencies = []string{"DNS"}
}
}
// setWorkingDirectory sets the working directory for the service
func setWorkingDirectory(svc *service.Config, dir string) {
// WorkingDirectory is not supported on Windows.
svc.WorkingDirectory = dir
-9
View File
@@ -2,8 +2,6 @@ package cli
import "github.com/prometheus/client_golang/prometheus"
// Prometheus metrics label constants for consistent labeling across all metrics
// These ensure standardized metric labeling for monitoring and alerting
const (
metricsLabelListener = "listener"
metricsLabelClientSourceIP = "client_source_ip"
@@ -15,21 +13,17 @@ const (
)
// statsVersion represent ctrld version.
// This metric provides version information for monitoring and debugging
var statsVersion = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "ctrld_build_info",
Help: "Version of ctrld process.",
}, []string{"gitref", "goversion", "version"})
// statsTimeStart represents start time of ctrld service.
// This metric tracks service uptime and helps with monitoring service restarts
var statsTimeStart = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ctrld_time_seconds",
Help: "Start time of the ctrld process since unix epoch in seconds.",
})
// statsQueriesCountLabels defines the labels for query count metrics
// These labels provide detailed breakdown of DNS query statistics
var statsQueriesCountLabels = []string{
metricsLabelListener,
metricsLabelClientSourceIP,
@@ -41,7 +35,6 @@ var statsQueriesCountLabels = []string{
}
// statsQueriesCount counts total number of queries.
// This provides comprehensive DNS query statistics for monitoring and alerting
var statsQueriesCount = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "ctrld_queries_count",
Help: "Total number of queries.",
@@ -51,14 +44,12 @@ var statsQueriesCount = prometheus.NewCounterVec(prometheus.CounterOpts{
//
// The labels "client_source_ip", "client_mac", "client_hostname" are unbounded,
// thus this stat is highly inefficient if there are many devices.
// This metric should be used carefully in high-client environments
var statsClientQueriesCount = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "ctrld_client_queries_count",
Help: "Total number queries of a client.",
}, []string{metricsLabelClientSourceIP, metricsLabelClientMac, metricsLabelClientHostname})
// WithLabelValuesInc increases prometheus counter by 1 if query stats is enabled.
// This provides conditional metric collection to avoid performance impact when metrics are disabled
func (p *prog) WithLabelValuesInc(c *prometheus.CounterVec, lvs ...string) {
if p.metricsQueryStats.Load() {
c.WithLabelValues(lvs...).Inc()
-2
View File
@@ -8,12 +8,10 @@ import (
"syscall"
)
// notifyReloadSigCh sends reload signal to the channel
func notifyReloadSigCh(ch chan os.Signal) {
signal.Notify(ch, syscall.SIGUSR1)
}
// sendReloadSignal sends a reload signal to the current process
func (p *prog) sendReloadSignal() error {
return syscall.Kill(syscall.Getpid(), syscall.SIGUSR1)
}
-2
View File
@@ -6,10 +6,8 @@ import (
"time"
)
// notifyReloadSigCh is a no-op on Windows platforms
func notifyReloadSigCh(ch chan os.Signal) {}
// sendReloadSignal sends a reload signal to the program
func (p *prog) sendReloadSignal() error {
select {
case p.reloadCh <- struct{}{}:
+34 -23
View File
@@ -3,45 +3,59 @@ package cli
import (
"net"
"net/netip"
"os"
"path/filepath"
"strings"
"time"
"github.com/fsnotify/fsnotify"
"github.com/Control-D-Inc/ctrld/internal/resolvconffile"
)
// parseResolvConfNameservers reads the resolv.conf file and returns the nameservers found.
// Returns nil if no nameservers are found.
// This function parses the system DNS configuration to understand current nameserver settings
func (p *prog) parseResolvConfNameservers(path string) ([]string, error) {
return resolvconffile.NameserversFromFile(path)
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
}
// watchResolvConf watches any changes to /etc/resolv.conf file,
// and reverting to the original config set by ctrld.
// This ensures that DNS settings are not overridden by other applications or system processes
func (p *prog) watchResolvConf(iface *net.Interface, ns []netip.Addr, setDnsFn func(iface *net.Interface, ns []netip.Addr) error) {
resolvConfPath := "/etc/resolv.conf"
// Evaluating symbolics link to watch the target file that /etc/resolv.conf point to.
// This handles systems where resolv.conf is a symlink to another location
if rp, _ := filepath.EvalSymlinks(resolvConfPath); rp != "" {
resolvConfPath = rp
}
p.Debug().Msgf("Start watching %s file", resolvConfPath)
mainLog.Load().Debug().Msgf("start watching %s file", resolvConfPath)
watcher, err := fsnotify.NewWatcher()
if err != nil {
p.Warn().Err(err).Msg("Could not create watcher for /etc/resolv.conf")
mainLog.Load().Warn().Err(err).Msg("could not create watcher for /etc/resolv.conf")
return
}
defer watcher.Close()
// We watch /etc instead of /etc/resolv.conf directly,
// see: https://github.com/fsnotify/fsnotify#watching-a-file-doesnt-work-well
// This is necessary because some systems don't properly notify on file changes
watchDir := filepath.Dir(resolvConfPath)
if err := watcher.Add(watchDir); err != nil {
p.Warn().Err(err).Msgf("Could not add %s to watcher list", watchDir)
mainLog.Load().Warn().Err(err).Msgf("could not add %s to watcher list", watchDir)
return
}
@@ -50,7 +64,7 @@ func (p *prog) watchResolvConf(iface *net.Interface, ns []netip.Addr, setDnsFn f
case <-p.dnsWatcherStopCh:
return
case <-p.stopCh:
p.Debug().Msgf("Stopping watcher for %s", resolvConfPath)
mainLog.Load().Debug().Msgf("stopping watcher for %s", resolvConfPath)
return
case event, ok := <-watcher.Events:
if p.recoveryRunning.Load() {
@@ -63,10 +77,9 @@ func (p *prog) watchResolvConf(iface *net.Interface, ns []netip.Addr, setDnsFn f
continue
}
if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) {
p.Debug().Msgf("/etc/resolv.conf changes detected, reading changes...")
mainLog.Load().Debug().Msgf("/etc/resolv.conf changes detected, reading changes...")
// Convert expected nameservers to strings for comparison
// This allows us to detect when the resolv.conf has been modified
expectedNS := make([]string, len(ns))
for i, addr := range ns {
expectedNS[i] = addr.String()
@@ -79,20 +92,18 @@ func (p *prog) watchResolvConf(iface *net.Interface, ns []netip.Addr, setDnsFn f
for retry := 0; retry < maxRetries; retry++ {
foundNS, err = p.parseResolvConfNameservers(resolvConfPath)
if err != nil {
p.Error().Err(err).Msg("Failed to read resolv.conf content")
mainLog.Load().Error().Err(err).Msg("failed to read resolv.conf content")
break
}
// If we found nameservers, break out of retry loop
// This handles cases where the file is being written but not yet complete
if len(foundNS) > 0 {
break
}
// Only retry if we found no nameservers
// This handles temporary file states during updates
if retry < maxRetries-1 {
p.Debug().Msgf("resolv.conf has no nameserver entries, retry %d/%d in 2 seconds", retry+1, maxRetries)
mainLog.Load().Debug().Msgf("resolv.conf has no nameserver entries, retry %d/%d in 2 seconds", retry+1, maxRetries)
select {
case <-p.stopCh:
return
@@ -102,7 +113,7 @@ func (p *prog) watchResolvConf(iface *net.Interface, ns []netip.Addr, setDnsFn f
continue
}
} else {
p.Debug().Msg("resolv.conf remained empty after all retries")
mainLog.Load().Debug().Msg("resolv.conf remained empty after all retries")
}
}
@@ -119,7 +130,7 @@ func (p *prog) watchResolvConf(iface *net.Interface, ns []netip.Addr, setDnsFn f
}
}
p.Debug().
mainLog.Load().Debug().
Strs("found", foundNS).
Strs("expected", expectedNS).
Bool("matches", matches).
@@ -128,16 +139,16 @@ func (p *prog) watchResolvConf(iface *net.Interface, ns []netip.Addr, setDnsFn f
// Only revert if the nameservers don't match
if !matches {
if err := watcher.Remove(watchDir); err != nil {
p.Error().Err(err).Msg("Failed to pause watcher")
mainLog.Load().Error().Err(err).Msg("failed to pause watcher")
continue
}
if err := setDnsFn(iface, ns); err != nil {
p.Error().Err(err).Msg("Failed to revert /etc/resolv.conf changes")
mainLog.Load().Error().Err(err).Msg("failed to revert /etc/resolv.conf changes")
}
if err := watcher.Add(watchDir); err != nil {
p.Error().Err(err).Msg("Failed to continue running watcher")
mainLog.Load().Error().Err(err).Msg("failed to continue running watcher")
return
}
}
@@ -147,7 +158,7 @@ func (p *prog) watchResolvConf(iface *net.Interface, ns []netip.Addr, setDnsFn f
if !ok {
return
}
p.Error().Err(err).Msg("Could not get event for /etc/resolv.conf")
mainLog.Load().Err(err).Msg("could not get event for /etc/resolv.conf")
}
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ import (
const resolvConfPath = "/etc/resolv.conf"
// setResolvConf sets the content of resolv.conf file using the given nameservers list.
func (p *prog) setResolvConf(iface *net.Interface, ns []netip.Addr) error {
func setResolvConf(iface *net.Interface, ns []netip.Addr) error {
servers := make([]string, len(ns))
for i := range ns {
servers[i] = ns[i].String()
+2 -2
View File
@@ -14,7 +14,7 @@ import (
)
// setResolvConf sets the content of the resolv.conf file using the given nameservers list.
func (p *prog) setResolvConf(iface *net.Interface, ns []netip.Addr) error {
func setResolvConf(iface *net.Interface, ns []netip.Addr) error {
r, err := newLoopbackOSConfigurator()
if err != nil {
return err
@@ -27,7 +27,7 @@ func (p *prog) setResolvConf(iface *net.Interface, ns []netip.Addr) error {
if sds, err := searchDomains(); err == nil {
oc.SearchDomains = sds
} else {
p.Debug().Err(err).Msg("Failed to get search domains list when reverting resolv.conf file")
mainLog.Load().Debug().Err(err).Msg("failed to get search domains list when reverting resolv.conf file")
}
return r.SetDNS(oc)
}
-51
View File
@@ -1,51 +0,0 @@
//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)
}
+1 -1
View File
@@ -6,7 +6,7 @@ import (
)
// setResolvConf sets the content of resolv.conf file using the given nameservers list.
func (p *prog) setResolvConf(_ *net.Interface, _ []netip.Addr) error {
func setResolvConf(_ *net.Interface, _ []netip.Addr) error {
return nil
}
+1 -1
View File
@@ -33,7 +33,7 @@ func searchDomains() ([]dnsname.FQDN, error) {
for a := aa.FirstDNSSuffix; a != nil; a = a.Next {
d, err := dnsname.ToFQDN(a.String())
if err != nil {
mainLog.Load().Debug().Err(err).Msgf("Failed to parse domain: %s", a.String())
mainLog.Load().Debug().Err(err).Msgf("failed to parse domain: %s", a.String())
continue
}
sds = append(sds, d)
-1
View File
@@ -4,5 +4,4 @@ package cli
var supportedSelfDelete = true
// selfDeleteExe performs self-deletion on non-Windows platforms
func selfDeleteExe() error { return nil }
-4
View File
@@ -33,7 +33,6 @@ type FILE_DISPOSITION_INFO struct {
DeleteFile bool
}
// dsOpenHandle opens a handle to the specified file with DELETE access
func dsOpenHandle(pwPath *uint16) (windows.Handle, error) {
handle, err := windows.CreateFile(
pwPath,
@@ -52,7 +51,6 @@ func dsOpenHandle(pwPath *uint16) (windows.Handle, error) {
return handle, nil
}
// dsRenameHandle renames a file handle to a stream name
func dsRenameHandle(hHandle windows.Handle) error {
var fRename FILE_RENAME_INFO
DS_STREAM_RENAME, err := windows.UTF16FromString(":deadbeef")
@@ -84,7 +82,6 @@ func dsRenameHandle(hHandle windows.Handle) error {
return nil
}
// dsDepositeHandle marks a file handle for deletion
func dsDepositeHandle(hHandle windows.Handle) error {
var fDelete FILE_DISPOSITION_INFO
fDelete.DeleteFile = true
@@ -103,7 +100,6 @@ func dsDepositeHandle(hHandle windows.Handle) error {
return nil
}
// selfDeleteExe performs self-deletion on Windows platforms
func selfDeleteExe() error {
var wcPath [windows.MAX_PATH + 1]uint16
var hCurrent windows.Handle
+3 -4
View File
@@ -5,13 +5,12 @@ package cli
import (
"os"
"github.com/Control-D-Inc/ctrld"
"github.com/rs/zerolog"
)
// selfUninstall performs self-uninstallation on non-Unix platforms
func selfUninstall(p *prog, logger *ctrld.Logger) {
func selfUninstall(p *prog, logger zerolog.Logger) {
if uninstallInvalidCdUID(p, logger, false) {
logger.Warn().Msgf("Service was uninstalled because device %q does not exist", cdUID)
logger.Warn().Msgf("service was uninstalled because device %q does not exist", cdUID)
os.Exit(0)
}
}
+7 -9
View File
@@ -9,18 +9,17 @@ import (
"runtime"
"syscall"
"github.com/Control-D-Inc/ctrld"
"github.com/rs/zerolog"
)
// selfUninstall performs self-uninstallation on Unix platforms
func selfUninstall(p *prog, logger *ctrld.Logger) {
func selfUninstall(p *prog, logger zerolog.Logger) {
if runtime.GOOS == "linux" {
selfUninstallLinux(p, logger)
}
bin, err := os.Executable()
if err != nil {
logger.Fatal().Err(err).Msg("Could not determine executable")
logger.Fatal().Err(err).Msg("could not determine executable")
}
args := []string{"uninstall"}
if deactivationPinSet() {
@@ -29,19 +28,18 @@ func selfUninstall(p *prog, logger *ctrld.Logger) {
cmd := exec.Command(bin, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
if err := cmd.Start(); err != nil {
logger.Fatal().Err(err).Msg("Could not start self uninstall command")
logger.Fatal().Err(err).Msg("could not start self uninstall command")
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
logger.Warn().Msgf("Service was uninstalled because device %q does not exist", cdUID)
logger.Warn().Msgf("service was uninstalled because device %q does not exist", cdUID)
_ = cmd.Wait()
os.Exit(0)
}
// selfUninstallLinux performs self-uninstallation on Linux platforms
func selfUninstallLinux(p *prog, logger *ctrld.Logger) {
func selfUninstallLinux(p *prog, logger zerolog.Logger) {
if uninstallInvalidCdUID(p, logger, true) {
logger.Warn().Msgf("Service was uninstalled because device %q does not exist", cdUID)
logger.Warn().Msgf("service was uninstalled because device %q does not exist", cdUID)
os.Exit(0)
}
}
-7
View File
@@ -1,31 +1,24 @@
package cli
// semaphore provides a simple synchronization mechanism
type semaphore interface {
acquire()
release()
}
// noopSemaphore is a no-operation implementation of semaphore
type noopSemaphore struct{}
// acquire performs a no-operation for the noop semaphore
func (n noopSemaphore) acquire() {}
// release performs a no-operation for the noop semaphore
func (n noopSemaphore) release() {}
// chanSemaphore is a channel-based implementation of semaphore
type chanSemaphore struct {
ready chan struct{}
}
// acquire blocks until a slot is available in the semaphore
func (c *chanSemaphore) acquire() {
c.ready <- struct{}{}
}
// release signals that a slot has been freed in the semaphore
func (c *chanSemaphore) release() {
<-c.ready
}
+51 -10
View File
@@ -11,6 +11,9 @@ import (
"github.com/coreos/go-systemd/v22/unit"
"github.com/kardianos/service"
"github.com/Control-D-Inc/ctrld/internal/router"
"github.com/Control-D-Inc/ctrld/internal/router/openwrt"
)
// newService wraps service.New call to return service.Service
@@ -21,6 +24,10 @@ func newService(i service.Interface, c *service.Config) (service.Service, error)
return nil, err
}
switch {
case router.IsOldOpenwrt(), router.IsNetGearOrbi():
return &procd{sysV: &sysV{s}, svcConfig: c}, nil
case router.IsGLiNet():
return &sysV{s}, nil
case s.Platform() == "unix-systemv":
return &sysV{s}, nil
case s.Platform() == "linux-systemd":
@@ -35,7 +42,7 @@ func newService(i service.Interface, c *service.Config) (service.Service, error)
// sysV wraps a service.Service, and provide start/stop/status command
// base on "/etc/init.d/<service_name>".
//
// Use this on system where "service" command is not available.
// Use this on system where "service" command is not available, like GL.iNET router.
type sysV struct {
service.Service
}
@@ -82,6 +89,37 @@ func (s *sysV) Status() (service.Status, error) {
return unixSystemVServiceStatus()
}
// procd wraps a service.Service, and provide start/stop command
// base on "/etc/init.d/<service_name>", status command base on parsing "ps" command output.
//
// Use this on system where "/etc/init.d/<service_name> status" command is not available,
// like old GL.iNET Opal router.
type procd struct {
*sysV
svcConfig *service.Config
}
func (s *procd) Status() (service.Status, error) {
if !s.installed() {
return service.StatusUnknown, service.ErrNotInstalled
}
bin := s.svcConfig.Executable
if bin == "" {
exe, err := os.Executable()
if err != nil {
return service.StatusUnknown, nil
}
bin = exe
}
// Looking for something like "/sbin/ctrld run ".
shellCmd := fmt.Sprintf("ps | grep -q %q", bin+" [r]un ")
if err := exec.Command("sh", "-c", shellCmd).Run(); err != nil {
return service.StatusStopped, nil
}
return service.StatusRunning, nil
}
// systemd wraps a service.Service, and provide status command to
// report the status correctly.
type systemd struct {
@@ -115,7 +153,7 @@ func (s *systemd) Start() error {
if out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput(); err != nil {
return fmt.Errorf("systemctl daemon-reload failed: %w\n%s", err, string(out))
}
mainLog.Load().Debug().Msg("Set KillMode=process successfully")
mainLog.Load().Debug().Msg("set KillMode=process successfully")
}
return s.Service.Start()
}
@@ -125,7 +163,7 @@ func (s *systemd) Start() error {
func ensureSystemdKillMode(r io.Reader) (opts []*unit.UnitOption, change bool) {
opts, err := unit.DeserializeOptions(r)
if err != nil {
mainLog.Load().Error().Err(err).Msg("Failed to deserialize options")
mainLog.Load().Error().Err(err).Msg("failed to deserialize options")
return
}
change = true
@@ -149,7 +187,6 @@ func ensureSystemdKillMode(r io.Reader) (opts []*unit.UnitOption, change bool) {
return opts, change
}
// newLaunchd creates a new launchd service wrapper
func newLaunchd(s service.Service) *launchd {
return &launchd{
Service: s,
@@ -179,30 +216,28 @@ type task struct {
Name string
}
// doTasks executes a list of tasks and returns success status
func doTasks(tasks []task) bool {
for _, task := range tasks {
mainLog.Load().Debug().Msgf("Running task %s", task.Name)
if err := task.f(); err != nil {
if task.abortOnError {
mainLog.Load().Error().Msgf("Error running task %s: %v", task.Name, err)
mainLog.Load().Error().Msgf("error running task %s: %v", task.Name, err)
return false
}
// if this is darwin stop command, dont print debug
// since launchctl complains on every start
if runtime.GOOS != "darwin" || task.Name != "Stop" {
mainLog.Load().Debug().Msgf("Error running task %s: %v", task.Name, err)
mainLog.Load().Debug().Msgf("error running task %s: %v", task.Name, err)
}
}
}
return true
}
// checkHasElevatedPrivilege checks if the process has elevated privileges and exits if not
func checkHasElevatedPrivilege() {
ok, err := hasElevatedPrivilege()
if err != nil {
mainLog.Load().Error().Msgf("Could not detect user privilege: %v", err)
mainLog.Load().Error().Msgf("could not detect user privilege: %v", err)
return
}
if !ok {
@@ -211,10 +246,16 @@ func checkHasElevatedPrivilege() {
}
}
// unixSystemVServiceStatus checks the status of a Unix System V service
func unixSystemVServiceStatus() (service.Status, error) {
out, err := exec.Command("/etc/init.d/ctrld", "status").CombinedOutput()
if err != nil {
// Specific case for openwrt >= 24.10, it returns non-success code
// for above status command, which may not right.
if router.Name() == openwrt.Name {
if string(bytes.ToLower(bytes.TrimSpace(out))) == "inactive" {
return service.StatusStopped, nil
}
}
return service.StatusUnknown, nil
}
+5 -3
View File
@@ -6,15 +6,17 @@ import (
"os"
)
// hasElevatedPrivilege checks if the current process has elevated privileges
func hasElevatedPrivilege() (bool, error) {
return os.Geteuid() == 0, nil
}
// openLogFile opens a log file with the specified flags
func openLogFile(path string, flags int) (*os.File, error) {
return os.OpenFile(path, flags, os.FileMode(0o600))
}
// ConfigureWindowsServiceFailureActions is a no-op on non-Windows platforms
// hasLocalDnsServerRunning reports whether we are on Windows and having Dns server running.
func hasLocalDnsServerRunning() bool { return false }
func ConfigureWindowsServiceFailureActions(serviceName string) error { return nil }
func isRunningOnDomainControllerWindows() (bool, int) { return false, 0 }
+82 -2
View File
@@ -2,16 +2,22 @@ package cli
import (
"os"
"reflect"
"runtime"
"strconv"
"strings"
"syscall"
"time"
"unsafe"
"github.com/microsoft/wmi/pkg/base/host"
"github.com/microsoft/wmi/pkg/base/instance"
"github.com/microsoft/wmi/pkg/base/query"
"github.com/microsoft/wmi/pkg/constant"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/svc/mgr"
)
// hasElevatedPrivilege checks if the current process has elevated privileges on Windows
func hasElevatedPrivilege() (bool, error) {
var sid *windows.SID
if err := windows.AllocateAndInitializeSid(
@@ -94,7 +100,6 @@ func ConfigureWindowsServiceFailureActions(serviceName string) error {
return nil
}
// openLogFile opens a log file with the specified mode on Windows
func openLogFile(path string, mode int) (*os.File, error) {
if len(path) == 0 {
return nil, &os.PathError{Path: path, Op: "open", Err: syscall.ERROR_FILE_NOT_FOUND}
@@ -146,3 +151,78 @@ func openLogFile(path string, mode int) (*os.File, error) {
return os.NewFile(uintptr(handle), path), nil
}
const processEntrySize = uint32(unsafe.Sizeof(windows.ProcessEntry32{}))
// hasLocalDnsServerRunning reports whether we are on Windows and having Dns server running.
func hasLocalDnsServerRunning() bool {
h, e := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
if e != nil {
return false
}
defer windows.CloseHandle(h)
p := windows.ProcessEntry32{Size: processEntrySize}
for {
e := windows.Process32Next(h, &p)
if e != nil {
return false
}
if strings.ToLower(windows.UTF16ToString(p.ExeFile[:])) == "dns.exe" {
return true
}
}
}
func isRunningOnDomainControllerWindows() (bool, int) {
whost := host.NewWmiLocalHost()
q := query.NewWmiQuery("Win32_ComputerSystem")
instances, err := instance.GetWmiInstancesFromHost(whost, string(constant.CimV2), q)
if err != nil {
mainLog.Load().Debug().Err(err).Msg("WMI query failed")
return false, 0
}
if instances == nil {
mainLog.Load().Debug().Msg("WMI query returned nil instances")
return false, 0
}
defer instances.Close()
if len(instances) == 0 {
mainLog.Load().Debug().Msg("no rows returned from Win32_ComputerSystem")
return false, 0
}
val, err := instances[0].GetProperty("DomainRole")
if err != nil {
mainLog.Load().Debug().Err(err).Msg("failed to get DomainRole property")
return false, 0
}
if val == nil {
mainLog.Load().Debug().Msg("DomainRole property is nil")
return false, 0
}
// Safely handle varied types: string or integer
var roleInt int
switch v := val.(type) {
case string:
// "4", "5", etc.
parsed, parseErr := strconv.Atoi(v)
if parseErr != nil {
mainLog.Load().Debug().Err(parseErr).Msgf("failed to parse DomainRole value %q", v)
return false, 0
}
roleInt = parsed
case int8, int16, int32, int64:
roleInt = int(reflect.ValueOf(v).Int())
case uint8, uint16, uint32, uint64:
roleInt = int(reflect.ValueOf(v).Uint())
default:
mainLog.Load().Debug().Msgf("unexpected DomainRole type: %T value=%v", v, v)
return false, 0
}
// Check if role indicates a domain controller
isDC := roleInt == BackupDomainController || roleInt == PrimaryDomainController
return isDC, roleInt
}
+25
View File
@@ -0,0 +1,25 @@
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
}
-62
View File
@@ -1,62 +0,0 @@
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")
}
})
}
+6 -29
View File
@@ -2,7 +2,6 @@ package cli
import (
"sync"
"sync/atomic"
"time"
"github.com/Control-D-Inc/ctrld"
@@ -13,31 +12,11 @@ const (
maxFailureRequest = 50
// checkUpstreamBackoffSleep is the time interval between each upstream checks.
checkUpstreamBackoffSleep = 2 * time.Second
// checkUpstreamUnreachableBackoffMax caps the recovery retry interval for an
// endpoint that keeps failing with a network-unreachable error. It bounds
// the backoff so an unroutable endpoint is still re-probed periodically and
// recovers once the route returns.
checkUpstreamUnreachableBackoffMax = 60 * time.Second
)
// unreachableRecoveryBackoff returns the retry interval for the given streak of
// consecutive network-unreachable failures. It starts at checkUpstreamBackoffSleep
// and doubles each attempt, capped at checkUpstreamUnreachableBackoffMax.
func unreachableRecoveryBackoff(streak int) time.Duration {
d := checkUpstreamBackoffSleep
for i := 1; i < streak; i++ {
d *= 2
if d >= checkUpstreamUnreachableBackoffMax {
return checkUpstreamUnreachableBackoffMax
}
}
return d
}
// upstreamMonitor performs monitoring upstreams health.
type upstreamMonitor struct {
cfg *ctrld.Config
logger atomic.Pointer[ctrld.Logger]
cfg *ctrld.Config
mu sync.RWMutex
checking map[string]bool
@@ -49,8 +28,7 @@ type upstreamMonitor struct {
failureTimerActive map[string]bool
}
// newUpstreamMonitor creates a new upstream monitor instance
func newUpstreamMonitor(cfg *ctrld.Config, logger *ctrld.Logger) *upstreamMonitor {
func newUpstreamMonitor(cfg *ctrld.Config) *upstreamMonitor {
um := &upstreamMonitor{
cfg: cfg,
checking: make(map[string]bool),
@@ -59,7 +37,6 @@ func newUpstreamMonitor(cfg *ctrld.Config, logger *ctrld.Logger) *upstreamMonito
recovered: make(map[string]bool),
failureTimerActive: make(map[string]bool),
}
um.logger.Store(logger)
for n := range cfg.Upstream {
upstream := upstreamPrefix + n
um.reset(upstream)
@@ -76,7 +53,7 @@ func (um *upstreamMonitor) increaseFailureCount(upstream string) {
defer um.mu.Unlock()
if um.recovered[upstream] {
um.logger.Load().Debug().Msgf("Upstream %q is recovered, skipping failure count increase", upstream)
mainLog.Load().Debug().Msgf("upstream %q is recovered, skipping failure count increase", upstream)
return
}
@@ -84,7 +61,7 @@ func (um *upstreamMonitor) increaseFailureCount(upstream string) {
failedCount := um.failureReq[upstream]
// Log the updated failure count.
um.logger.Load().Debug().Msgf("Upstream %q failure count updated to %d", upstream, failedCount)
mainLog.Load().Debug().Msgf("upstream %q failure count updated to %d", upstream, failedCount)
// If this is the first failure and no timer is running, start a 10-second timer.
if failedCount == 1 && !um.failureTimerActive[upstream] {
@@ -97,7 +74,7 @@ func (um *upstreamMonitor) increaseFailureCount(upstream string) {
// and the upstream is not in a recovered state, mark it as down.
if um.failureReq[upstream] > 0 && !um.recovered[upstream] {
um.down[upstream] = true
um.logger.Load().Warn().Msgf("Upstream %q marked as down after 10 seconds (failure count: %d)", upstream, um.failureReq[upstream])
mainLog.Load().Warn().Msgf("upstream %q marked as down after 10 seconds (failure count: %d)", upstream, um.failureReq[upstream])
}
// Reset the timer flag so that a new timer can be spawned if needed.
um.failureTimerActive[upstream] = false
@@ -107,7 +84,7 @@ func (um *upstreamMonitor) increaseFailureCount(upstream string) {
// If the failure count quickly reaches the threshold, mark the upstream as down immediately.
if failedCount >= maxFailureRequest {
um.down[upstream] = true
um.logger.Load().Warn().Msgf("Upstream %q marked as down immediately (failure count: %d)", upstream, failedCount)
mainLog.Load().Warn().Msgf("upstream %q marked as down immediately (failure count: %d)", upstream, failedCount)
}
}
+34 -63
View File
@@ -6,8 +6,8 @@ import (
"runtime"
"strings"
"sync"
"sync/atomic"
"github.com/rs/zerolog"
"tailscale.com/net/netmon"
"github.com/Control-D-Inc/ctrld"
@@ -19,9 +19,7 @@ var vpnDNSSettlingEnabled = runtime.GOOS == "windows"
// including the interface it was discovered on. The interface is used on macOS
// to create interface-scoped pf exemptions that allow the VPN's local DNS
// handler (e.g., Tailscale's MagicDNS Network Extension) to receive queries
// from all processes — not just ctrld. Without the interface scope, VPN DNS
// handlers that operate at the packet level (Network Extensions) never see
// the queries because pf intercepts them first.
// from all processes — not just ctrld.
type vpnDNSExemption struct {
Server string // DNS server IP (e.g., "100.100.100.100")
Interface string // Interface name from scutil (e.g., "utun11"), may be empty
@@ -30,8 +28,6 @@ type vpnDNSExemption struct {
// vpnDNSExemptFunc is called when VPN DNS servers change, to update
// the intercept layer (WFP/pf) to permit VPN DNS traffic.
// On macOS, exemptions are interface-scoped to allow VPN local DNS handlers
// (e.g., Tailscale MagicDNS) to receive queries from all processes.
type vpnDNSExemptFunc func(exemptions []vpnDNSExemption) error
// vpnDNSManager tracks active VPN DNS configurations and provides
@@ -41,7 +37,6 @@ type vpnDNSManager struct {
configs []ctrld.VPNDNSConfig
// Map of domain suffix → DNS servers for fast lookup
routes map[string][]string
logger *atomic.Pointer[ctrld.Logger]
// DNS servers from VPN interfaces that have no domain/suffix config.
// These are NOT added to the global OS resolver. They're only used
// as additional nameservers for queries that match split-DNS rules
@@ -55,9 +50,6 @@ type vpnDNSManager struct {
// discoverVPNDNS is injected for tests so Refresh does not depend on the
// runner host's real VPN/virtual adapter state.
discoverVPNDNS func(context.Context) []ctrld.VPNDNSConfig
// refreshRunning keeps noisy network-change storms from running overlapping
// scutil/networksetup VPN DNS discovery work.
refreshRunning atomic.Bool
// Called when VPN DNS server list changes, to update intercept exemptions.
onServersChanged vpnDNSExemptFunc
}
@@ -65,10 +57,9 @@ type vpnDNSManager struct {
// newVPNDNSManager creates a new manager. Only call when dnsIntercept is active.
// exemptFunc is called whenever VPN DNS servers are discovered/changed, to update
// the OS-level intercept rules to permit ctrld's outbound queries to those IPs.
func newVPNDNSManager(logger *atomic.Pointer[ctrld.Logger], exemptFunc vpnDNSExemptFunc) *vpnDNSManager {
func newVPNDNSManager(exemptFunc vpnDNSExemptFunc) *vpnDNSManager {
return &vpnDNSManager{
routes: make(map[string][]string),
logger: logger,
discoverVPNDNS: ctrld.DiscoverVPNDNS,
onServersChanged: exemptFunc,
}
@@ -76,31 +67,25 @@ func newVPNDNSManager(logger *atomic.Pointer[ctrld.Logger], exemptFunc vpnDNSExe
// Refresh re-discovers VPN DNS configs from the OS.
// Called on network change events.
func (m *vpnDNSManager) Refresh(ctx context.Context, guardAgainstNoNameservers ...bool) {
logger := ctrld.LoggerFromCtx(ctx)
guardedRefresh := len(guardAgainstNoNameservers) > 0 && guardAgainstNoNameservers[0]
if !m.refreshRunning.CompareAndSwap(false, true) {
ctrld.Log(ctx, logger.Debug(), "VPN DNS refresh already running, skipping duplicate")
return
}
defer m.refreshRunning.Store(false)
func (m *vpnDNSManager) Refresh(guardAgainstNoNameservers bool) {
logger := mainLog.Load()
ctrld.Log(ctx, logger.Debug(), "Refreshing VPN DNS configurations")
logger.Debug().Msg("Refreshing VPN DNS configurations")
discoverVPNDNS := m.discoverVPNDNS
if discoverVPNDNS == nil {
discoverVPNDNS = ctrld.DiscoverVPNDNS
}
configs := discoverVPNDNS(ctx)
configs := discoverVPNDNS(context.Background())
// Detect exit mode: if the default route goes through a VPN DNS interface,
// the VPN is routing ALL traffic (exit node / full tunnel). This is more
// reliable than scutil flag parsing because the routing table is the ground
// truth for traffic flow.
// truth for traffic flow, regardless of how the VPN presents itself in scutil.
if dri, err := netmon.DefaultRouteInterface(); err == nil && dri != "" {
for i := range configs {
if configs[i].InterfaceName == dri {
if !configs[i].IsExitMode {
ctrld.Log(ctx, logger.Info(), "VPN DNS on %s: default route interface match — EXIT MODE (route-based detection)", dri)
logger.Info().Msgf("VPN DNS on %s: default route interface match — EXIT MODE (route-based detection)", dri)
}
configs[i].IsExitMode = true
}
@@ -112,21 +97,21 @@ func (m *vpnDNSManager) Refresh(ctx context.Context, guardAgainstNoNameservers .
previousExemptions := m.currentExemptionsLocked()
if vpnDNSSettlingEnabled && len(configs) == 0 && guardedRefresh && m.hasVPNDNSStateLocked() {
if vpnDNSSettlingEnabled && len(configs) == 0 && guardAgainstNoNameservers && m.hasVPNDNSStateLocked() {
if !m.retainedAfterEmptyDiscovery {
exemptions := m.currentExemptionsLocked()
m.retainedAfterEmptyDiscovery = true
ctrld.Log(ctx, logger.Debug(),
logger.Debug().Msgf(
"VPN DNS discovery empty; retaining last-known VPN DNS state for one guarded refresh (%d domainless servers, %d exemptions)",
len(m.domainlessServers), len(exemptions))
if m.onServersChanged != nil {
if err := m.onServersChanged(exemptions); err != nil {
ctrld.Log(ctx, logger.Error().Err(err), "Failed to re-apply retained VPN DNS exemptions")
logger.Error().Err(err).Msg("Failed to re-apply retained VPN DNS exemptions")
}
}
return
}
ctrld.Log(ctx, logger.Debug(),
logger.Debug().Msgf(
"VPN DNS discovery still empty on next guarded refresh; clearing retained VPN DNS state (%d domainless servers)",
len(m.domainlessServers))
}
@@ -140,26 +125,24 @@ func (m *vpnDNSManager) Refresh(ctx context.Context, guardAgainstNoNameservers .
// Build domain -> DNS servers mapping
for _, config := range configs {
ctrld.Log(ctx, logger.Debug(), "Processing VPN interface %s with %d domains and %d servers",
logger.Debug().Msgf("Processing VPN interface %s with %d domains and %d servers",
config.InterfaceName, len(config.Domains), len(config.Servers))
for _, domain := range config.Domains {
// Normalize domain: remove leading dot, Linux routing domain prefix (~),
// and convert to lowercase.
domain = strings.TrimPrefix(domain, "~") // Linux resolvectl routing domain prefix
domain = strings.TrimPrefix(domain, "~")
domain = strings.TrimPrefix(domain, ".")
domain = strings.ToLower(domain)
if domain != "" {
m.routes[domain] = append([]string{}, config.Servers...)
ctrld.Log(ctx, logger.Debug(), "Added VPN DNS route: %s -> %v", domain, config.Servers)
logger.Debug().Msgf("Added VPN DNS route: %s -> %v", domain, config.Servers)
}
}
}
// Collect unique VPN DNS exemptions (server + interface) for pf/WFP rules.
// We track server+interface pairs because the same server IP on different
// interfaces needs separate exemptions (interface-scoped on macOS).
type exemptionKey struct{ server, iface string }
seen := make(map[exemptionKey]bool)
var exemptions []vpnDNSExemption
@@ -182,22 +165,22 @@ func (m *vpnDNSManager) Refresh(ctx context.Context, guardAgainstNoNameservers .
// they're stored separately and only used for queries that match existing
// split-DNS rules (from ctrld config, AD domain, or VPN suffix config).
var domainlessServers []string
seenDomainless := make(map[string]bool)
seen2 := make(map[string]bool)
for _, config := range configs {
if len(config.Domains) == 0 && len(config.Servers) > 0 {
ctrld.Log(ctx, logger.Debug(), "VPN interface %s has DNS servers but no domains, storing as split-rule fallback: %v",
logger.Debug().Msgf("VPN interface %s has DNS servers but no domains, storing as split-rule fallback: %v",
config.InterfaceName, config.Servers)
for _, server := range config.Servers {
if !seenDomainless[server] {
seenDomainless[server] = true
domainlessServers = append(domainlessServers, server)
for _, s := range config.Servers {
if !seen2[s] {
seen2[s] = true
domainlessServers = append(domainlessServers, s)
}
}
}
}
m.domainlessServers = domainlessServers
ctrld.Log(ctx, logger.Debug(), "VPN DNS refresh completed: %d configs, %d routes, %d domainless servers, %d unique exemptions",
logger.Debug().Msgf("VPN DNS refresh completed: %d configs, %d routes, %d domainless servers, %d unique exemptions",
len(m.configs), len(m.routes), len(m.domainlessServers), len(exemptions))
// Update intercept rules to permit VPN DNS traffic only when the exemption set
@@ -206,19 +189,19 @@ func (m *vpnDNSManager) Refresh(ctx context.Context, guardAgainstNoNameservers .
// a self-triggering network-change loop. Empty exemptions are still applied
// when they differ from the previous set, so stale VPN exemptions are cleared
// on disconnect.
m.updateInterceptExemptionsIfChanged(ctx, logger, previousExemptions, exemptions, "VPN DNS")
m.updateInterceptExemptionsIfChanged(logger, previousExemptions, exemptions, "VPN DNS")
}
func (m *vpnDNSManager) updateInterceptExemptionsIfChanged(ctx context.Context, logger *ctrld.Logger, before, after []vpnDNSExemption, reason string) {
func (m *vpnDNSManager) updateInterceptExemptionsIfChanged(logger *zerolog.Logger, before, after []vpnDNSExemption, reason string) {
if m.onServersChanged == nil {
return
}
if vpnDNSExemptionsEqual(before, after) {
ctrld.Log(ctx, logger.Debug(), "VPN DNS exemptions unchanged after %s refresh; skipping intercept rule update", reason)
logger.Debug().Msgf("VPN DNS exemptions unchanged after %s refresh; skipping intercept rule update", reason)
return
}
if err := m.onServersChanged(after); err != nil {
ctrld.Log(ctx, logger.Error().Err(err), "Failed to update intercept exemptions for VPN DNS servers")
logger.Error().Err(err).Msg("Failed to update intercept exemptions for VPN DNS servers")
}
}
@@ -320,12 +303,9 @@ func (m *vpnDNSManager) ShouldFailClosedAfterVPNDNSTransportFailure(domain strin
return false
}
logger := m.logger.Load()
if logger != nil {
logger.Debug().Msgf(
"VPN DNS transport failed for %s while retained VPN DNS state is active; suppressing OS fallback for this query (servers=%v)",
domain, servers)
}
mainLog.Load().Debug().Msgf(
"VPN DNS transport failed for %s while retained VPN DNS state is active; suppressing OS fallback for this query (servers=%v)",
domain, servers)
return true
}
@@ -336,17 +316,13 @@ func (m *vpnDNSManager) VPNDNSReachable() {
m.mu.Lock()
defer m.mu.Unlock()
if m.retainedAfterEmptyDiscovery {
logger := m.logger.Load()
if logger != nil {
logger.Debug().Msg("VPN DNS transport recovered; clearing retained-empty-discovery state")
}
mainLog.Load().Debug().Msg("VPN DNS transport recovered; clearing retained-empty-discovery state")
}
m.retainedAfterEmptyDiscovery = false
}
// UpstreamForDomain checks if the domain matches any VPN search domain.
// Returns VPN DNS servers if matched, nil otherwise.
// Uses suffix matching: "foo.provisur.local" matches "provisur.local"
func (m *vpnDNSManager) UpstreamForDomain(domain string) []string {
if domain == "" {
return nil
@@ -355,19 +331,16 @@ func (m *vpnDNSManager) UpstreamForDomain(domain string) []string {
m.mu.RLock()
defer m.mu.RUnlock()
// Normalize domain (remove trailing dot, convert to lowercase)
domain = strings.TrimSuffix(domain, ".")
domain = strings.ToLower(domain)
// First try exact match
if servers, ok := m.routes[domain]; ok {
return append([]string{}, servers...) // Return copy to avoid race conditions
return append([]string{}, servers...)
}
// Try suffix matching - check if domain ends with any of our VPN domains
for vpnDomain, servers := range m.routes {
if strings.HasSuffix(domain, "."+vpnDomain) {
return append([]string{}, servers...) // Return copy
return append([]string{}, servers...)
}
}
@@ -384,7 +357,6 @@ func (m *vpnDNSManager) DomainlessServers() []string {
}
// CurrentServers returns the current set of unique VPN DNS server IPs.
// Used by pf anchor rebuild to include VPN DNS exemptions without a full Refresh().
func (m *vpnDNSManager) CurrentServers() []string {
m.mu.RLock()
defer m.mu.RUnlock()
@@ -403,7 +375,6 @@ func (m *vpnDNSManager) CurrentServers() []string {
}
// CurrentExemptions returns VPN DNS server + interface pairs for pf exemption rules.
// Used by pf anchor rebuild paths that need interface-scoped exemptions.
func (m *vpnDNSManager) CurrentExemptions() []vpnDNSExemption {
m.mu.RLock()
defer m.mu.RUnlock()
@@ -435,6 +406,6 @@ func (m *vpnDNSManager) upstreamConfigFor(server string) *ctrld.UpstreamConfig {
Name: "VPN DNS",
Type: ctrld.ResolverTypeLegacy,
Endpoint: endpoint,
Timeout: 2000, // 2 second timeout for VPN DNS queries
Timeout: 2000,
}
}
+8 -40
View File
@@ -2,8 +2,6 @@ package cli
import (
"context"
"sync"
"sync/atomic"
"testing"
"github.com/Control-D-Inc/ctrld"
@@ -16,40 +14,10 @@ func withVPNDNSSettlingEnabled(t *testing.T) {
t.Cleanup(func() { vpnDNSSettlingEnabled = old })
}
func TestVPNDNSRefreshSkipsConcurrentDuplicate(t *testing.T) {
m := newVPNDNSManager(&mainLog, nil)
started := make(chan struct{})
release := make(chan struct{})
done := make(chan struct{})
var once sync.Once
var calls atomic.Int32
m.discoverVPNDNS = func(context.Context) []ctrld.VPNDNSConfig {
calls.Add(1)
once.Do(func() { close(started) })
<-release
return nil
}
go func() {
defer close(done)
m.Refresh(context.Background(), true)
}()
<-started
m.Refresh(context.Background(), true)
close(release)
<-done
if calls.Load() != 1 {
t.Fatalf("expected overlapping refresh to be skipped, got %d discovery calls", calls.Load())
}
}
func TestVPNDNSRefreshRetainsStateForOneGuardedEmptyDiscovery(t *testing.T) {
withVPNDNSSettlingEnabled(t)
var gotExemptions []vpnDNSExemption
m := newVPNDNSManager(&mainLog, func(exemptions []vpnDNSExemption) error {
m := newVPNDNSManager(func(exemptions []vpnDNSExemption) error {
gotExemptions = exemptions
return nil
})
@@ -60,7 +28,7 @@ func TestVPNDNSRefreshRetainsStateForOneGuardedEmptyDiscovery(t *testing.T) {
}}
m.domainlessServers = []string{"10.25.37.21", "10.25.37.22"}
m.Refresh(context.Background(), true)
m.Refresh(true)
if got := m.DomainlessServers(); len(got) != 2 {
t.Fatalf("expected retained domainless servers, got %v", got)
@@ -76,7 +44,7 @@ func TestVPNDNSRefreshRetainsStateForOneGuardedEmptyDiscovery(t *testing.T) {
func TestVPNDNSRefreshClearsOnSecondGuardedEmptyDiscovery(t *testing.T) {
withVPNDNSSettlingEnabled(t)
var gotExemptions []vpnDNSExemption
m := newVPNDNSManager(&mainLog, func(exemptions []vpnDNSExemption) error {
m := newVPNDNSManager(func(exemptions []vpnDNSExemption) error {
gotExemptions = exemptions
return nil
})
@@ -88,7 +56,7 @@ func TestVPNDNSRefreshClearsOnSecondGuardedEmptyDiscovery(t *testing.T) {
m.domainlessServers = []string{"10.25.37.21"}
m.retainedAfterEmptyDiscovery = true
m.Refresh(context.Background(), true)
m.Refresh(true)
if got := m.DomainlessServers(); len(got) != 0 {
t.Fatalf("expected domainless servers to be cleared on second empty discovery, got %v", got)
@@ -103,7 +71,7 @@ func TestVPNDNSRefreshClearsOnSecondGuardedEmptyDiscovery(t *testing.T) {
func TestVPNDNSRefreshSkipsUnchangedInterceptExemptions(t *testing.T) {
var updates [][]vpnDNSExemption
m := newVPNDNSManager(&mainLog, func(exemptions []vpnDNSExemption) error {
m := newVPNDNSManager(func(exemptions []vpnDNSExemption) error {
updates = append(updates, append([]vpnDNSExemption{}, exemptions...))
return nil
})
@@ -115,8 +83,8 @@ func TestVPNDNSRefreshSkipsUnchangedInterceptExemptions(t *testing.T) {
}}
}
m.Refresh(context.Background(), true)
m.Refresh(context.Background(), true)
m.Refresh(true)
m.Refresh(true)
if len(updates) != 1 {
t.Fatalf("expected exactly one intercept exemption update for unchanged VPN DNS state, got %d", len(updates))
@@ -128,7 +96,7 @@ func TestVPNDNSRefreshSkipsUnchangedInterceptExemptions(t *testing.T) {
func TestVPNDNSTransportFailureSuppressesFallbackOnlyWhileRetainingState(t *testing.T) {
withVPNDNSSettlingEnabled(t)
m := newVPNDNSManager(&mainLog, nil)
m := newVPNDNSManager(nil)
m.domainlessServers = []string{"10.25.37.21"}
if m.ShouldFailClosedAfterVPNDNSTransportFailure("splunk.aws.arena.net.", []string{"10.25.37.21"}) {
+1 -1
View File
@@ -45,7 +45,7 @@ func (c *Controller) Start(CdUID string, ProvisionID string, CustomHostname stri
}
}
// mapCallback maps the AppCallback interface to cli.AppCallback to avoid circular dependency
// As workaround to avoid circular dependency between cli and ctrld_library module
func mapCallback(callback AppCallback) cli.AppCallback {
return cli.AppCallback{
HostName: func() string {
+124 -113
View File
@@ -117,10 +117,6 @@ func SetConfigNameWithPath(v *viper.Viper, name, configPath string) {
// InitConfig initializes default config values for given *viper.Viper instance.
func InitConfig(v *viper.Viper, name string) {
ctx := context.Background()
logger := LoggerFromCtx(ctx)
Log(ctx, logger.Debug(), "Config initialization started")
v.SetDefault("listener", map[string]*ListenerConfig{
"0": {
IP: "",
@@ -159,8 +155,6 @@ func InitConfig(v *viper.Viper, name string) {
Timeout: 3000,
},
})
Log(ctx, logger.Debug(), "Config initialization completed")
}
// Config represents ctrld supported configuration.
@@ -247,16 +241,8 @@ type ServiceConfig struct {
ForceRefetchWaitTime *int `mapstructure:"force_refetch_wait_time" toml:"force_refetch_wait_time,omitempty"`
LeakOnUpstreamFailure *bool `mapstructure:"leak_on_upstream_failure" toml:"leak_on_upstream_failure,omitempty"`
InterceptMode string `mapstructure:"intercept_mode" toml:"intercept_mode,omitempty" validate:"omitempty,oneof=off dns hard"`
NRPTRecoveryMaxAttempts *int `mapstructure:"nrpt_recovery_max_attempts" toml:"nrpt_recovery_max_attempts,omitempty" validate:"omitempty,gte=0"`
NRPTRecoveryCooldown *time.Duration `mapstructure:"nrpt_recovery_cooldown" toml:"nrpt_recovery_cooldown,omitempty"`
// FirewallMode controls the DNS-resolved IP allowlist. When "on", only IPs
// that were successfully resolved by ctrld are allowed for outbound connections.
// This closes the "DNS gap" where apps bypass DNS policy using hardcoded IPs.
// Requires intercept mode to be active for enforcement on desktop platforms.
// On mobile, the netstack layer uses the allowlist directly.
FirewallMode string `mapstructure:"firewall_mode" toml:"firewall_mode,omitempty" validate:"omitempty,oneof=off on"`
Daemon bool `mapstructure:"-" toml:"-"`
AllocateIP bool `mapstructure:"-" toml:"-"`
Daemon bool `mapstructure:"-" toml:"-"`
AllocateIP bool `mapstructure:"-" toml:"-"`
}
// NetworkConfig specifies configuration for networks where ctrld will handle requests.
@@ -333,20 +319,14 @@ func (lc *ListenerConfig) IsDirectDnsListener() bool {
}
}
// MatchingConfig defines the configuration for rule matching behavior
type MatchingConfig struct {
Order []string `mapstructure:"order" toml:"order,omitempty"`
}
// ListenerPolicyConfig specifies the policy rules for ctrld to filter incoming requests.
type ListenerPolicyConfig struct {
Name string `mapstructure:"name" toml:"name,omitempty"`
Networks []Rule `mapstructure:"networks" toml:"networks,omitempty,inline,multiline" validate:"dive,len=1"`
Rules []Rule `mapstructure:"rules" toml:"rules,omitempty,inline,multiline" validate:"dive,len=1"`
Macs []Rule `mapstructure:"macs" toml:"macs,omitempty,inline,multiline" validate:"dive,len=1"`
FailoverRcodes []string `mapstructure:"failover_rcodes" toml:"failover_rcodes,omitempty" validate:"dive,dnsrcode"`
FailoverRcodeNumbers []int `mapstructure:"-" toml:"-"`
Matching *MatchingConfig `mapstructure:"-" toml:"-"`
Name string `mapstructure:"name" toml:"name,omitempty"`
Networks []Rule `mapstructure:"networks" toml:"networks,omitempty,inline,multiline" validate:"dive,len=1"`
Rules []Rule `mapstructure:"rules" toml:"rules,omitempty,inline,multiline" validate:"dive,len=1"`
Macs []Rule `mapstructure:"macs" toml:"macs,omitempty,inline,multiline" validate:"dive,len=1"`
FailoverRcodes []string `mapstructure:"failover_rcodes" toml:"failover_rcodes,omitempty" validate:"dive,dnsrcode"`
FailoverRcodeNumbers []int `mapstructure:"-" toml:"-"`
}
// Rule is a map from source to list of upstreams.
@@ -355,13 +335,12 @@ type ListenerPolicyConfig struct {
type Rule map[string][]string
// Init initialized necessary values for an UpstreamConfig.
func (uc *UpstreamConfig) Init(ctx context.Context) {
logger := LoggerFromCtx(ctx)
func (uc *UpstreamConfig) Init() {
if err := uc.initDnsStamps(); err != nil {
logger.Fatal().Err(err).Msg("Invalid dns stamps")
ProxyLogger.Load().Fatal().Err(err).Msg("invalid DNS Stamps")
}
uc.initDoHScheme()
uc.uid = upstreamUID(ctx)
uc.uid = upstreamUID()
if u, err := url.Parse(uc.Endpoint); err == nil {
uc.Domain = u.Hostname()
switch uc.Type {
@@ -381,9 +360,6 @@ func (uc *UpstreamConfig) Init(ctx context.Context) {
}
}
if uc.IPStack == "" {
// Set default IP stack based on upstream type
// Control-D upstreams use split stack for better IPv4/IPv6 handling,
// while other upstreams use both stacks for maximum compatibility
if uc.IsControlD() {
uc.IPStack = IpStackSplit
} else {
@@ -445,7 +421,7 @@ func (uc *UpstreamConfig) IsDiscoverable() bool {
return *uc.Discoverable
}
switch uc.Type {
case ResolverTypeOS, ResolverTypeLegacy, ResolverTypePrivate:
case ResolverTypeOS, ResolverTypeLegacy, ResolverTypePrivate, ResolverTypeLocal:
if ip, err := netip.ParseAddr(uc.Domain); err == nil {
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || tsaddr.CGNATRange().Contains(ip)
}
@@ -468,18 +444,21 @@ func (uc *UpstreamConfig) UID() string {
return uc.uid
}
// SetupBootstrapIP sets up bootstrap IPs for the upstream config.
// SetupBootstrapIP manually find all available IPs of the upstream.
// The first usable IP will be used as bootstrap IP of the upstream.
// The upstream domain will be looked up using following orders:
//
// - Current system DNS settings.
// - Direct IPs table for ControlD upstreams.
// - ControlD Bootstrap DNS 76.76.2.22
//
// The setup process will block until there's usable IPs found.
func (uc *UpstreamConfig) SetupBootstrapIP(ctx context.Context) {
logger := LoggerFromCtx(ctx)
Log(ctx, logger.Debug(), "Setting up bootstrap IPs for upstream: %s", uc.Name)
func (uc *UpstreamConfig) SetupBootstrapIP() {
b := backoff.NewBackoff("setupBootstrapIP", func(format string, args ...any) {}, 10*time.Second)
isControlD := uc.IsControlD()
nss := initDefaultOsResolver(ctx)
nss := initDefaultOsResolver()
for {
Log(ctx, logger.Debug(), "Looking up bootstrap IPs for domain: %s", uc.Domain)
uc.bootstrapIPs = lookupIP(ctx, uc.Domain, uc.Timeout, nss)
uc.bootstrapIPs = lookupIP(uc.Domain, uc.Timeout, nss)
// For ControlD upstream, the bootstrap IPs could not be RFC 1918 addresses,
// filtering them out here to prevent weird behavior.
if isControlD {
@@ -494,18 +473,18 @@ func (uc *UpstreamConfig) SetupBootstrapIP(ctx context.Context) {
uc.bootstrapIPs = uc.bootstrapIPs[:n]
if len(uc.bootstrapIPs) == 0 {
uc.bootstrapIPs = bootstrapIPsFromControlDDomain(uc.Domain)
logger.Warn().Msgf("No record found for %q, lookup from direct ip table", uc.Domain)
ProxyLogger.Load().Warn().Msgf("no record found for %q, lookup from direct IP table", uc.Domain)
}
}
if len(uc.bootstrapIPs) == 0 {
logger.Warn().Msgf("No record found for %q, using bootstrap server: %s", uc.Domain, PremiumDNSBoostrapIP)
uc.bootstrapIPs = lookupIP(ctx, uc.Domain, uc.Timeout, []string{net.JoinHostPort(PremiumDNSBoostrapIP, "53")})
ProxyLogger.Load().Warn().Msgf("no record found for %q, using bootstrap server: %s", uc.Domain, PremiumDNSBoostrapIP)
uc.bootstrapIPs = lookupIP(uc.Domain, uc.Timeout, []string{net.JoinHostPort(PremiumDNSBoostrapIP, "53")})
}
if len(uc.bootstrapIPs) > 0 {
break
}
logger.Warn().Msg("Could not resolve bootstrap ips, retrying...")
ProxyLogger.Load().Warn().Msg("could not resolve bootstrap IPs, retrying...")
b.BackOff(context.Background(), errors.New("no bootstrap IPs"))
}
for _, ip := range uc.bootstrapIPs {
@@ -515,12 +494,11 @@ func (uc *UpstreamConfig) SetupBootstrapIP(ctx context.Context) {
uc.bootstrapIPs4 = append(uc.bootstrapIPs4, ip)
}
}
logger.Debug().Msgf("Bootstrap ips: %v", uc.bootstrapIPs)
Log(ctx, logger.Debug(), "Bootstrap IP setup completed for upstream: %s", uc.Name)
ProxyLogger.Load().Debug().Msgf("bootstrap IPs: %v", uc.bootstrapIPs)
}
// ReBootstrap re-setup the bootstrap IP and the transport.
func (uc *UpstreamConfig) ReBootstrap(ctx context.Context) {
func (uc *UpstreamConfig) ReBootstrap() {
switch uc.Type {
case ResolverTypeDOH, ResolverTypeDOH3, ResolverTypeDOQ, ResolverTypeDOT:
default:
@@ -528,47 +506,90 @@ func (uc *UpstreamConfig) ReBootstrap(ctx context.Context) {
}
_, _, _ = uc.g.Do("ReBootstrap", func() (any, error) {
if uc.rebootstrap.CompareAndSwap(rebootstrapNotStarted, rebootstrapStarted) {
logger := LoggerFromCtx(ctx)
Log(ctx, logger.Debug(), "Re-bootstrapping upstream: %s", uc.Name)
ProxyLogger.Load().Debug().Msgf("re-bootstrapping upstream ip for %v", uc)
}
return true, nil
})
}
// ForceReBootstrap immediately creates a new transport (closing old idle
// connections first) without waiting for the lazy re-bootstrap mechanism.
// Used after pf state table flushes where existing TCP/QUIC connections
// are dead and we need fresh connections immediately.
func (uc *UpstreamConfig) ForceReBootstrap(ctx context.Context) {
// ForceReBootstrap immediately replaces the upstream transport, closing old
// connections and creating new ones synchronously. Unlike ReBootstrap() which
// sets a lazy flag (new transport created on next query), this ensures the
// transport is ready before any queries arrive. Use when external events
// (e.g. firewall state flush) are known to have killed existing connections.
func (uc *UpstreamConfig) ForceReBootstrap() {
switch uc.Type {
case ResolverTypeDOH, ResolverTypeDOH3, ResolverTypeDOQ, ResolverTypeDOT:
default:
return
}
logger := LoggerFromCtx(ctx)
Log(ctx, logger.Debug(), "force re-bootstrapping upstream transport for %v", uc)
uc.closeTransports()
uc.SetupTransport(ctx)
ProxyLogger.Load().Debug().Msgf("force re-bootstrapping upstream transport for %v", uc)
uc.SetupTransport()
// Clear any pending lazy re-bootstrap flag so ensureSetupTransport()
// doesn't redundantly recreate the transport we just built.
uc.rebootstrap.Store(rebootstrapNotStarted)
}
// closeTransports closes idle connections on all existing transports.
// This is called before creating new transports during re-bootstrap to
// force in-flight requests on stale connections to fail quickly, rather
// than waiting for the full context deadline (e.g. 5s) after a firewall
// state table flush kills the underlying TCP/QUIC connections.
func (uc *UpstreamConfig) closeTransports() {
if t := uc.transport; t != nil {
t.CloseIdleConnections()
}
if t := uc.transport4; t != nil {
t.CloseIdleConnections()
}
if t := uc.transport6; t != nil {
t.CloseIdleConnections()
}
if p := uc.doqConnPool; p != nil {
p.CloseIdleConnections()
}
if p := uc.doqConnPool4; p != nil {
p.CloseIdleConnections()
}
if p := uc.doqConnPool6; p != nil {
p.CloseIdleConnections()
}
if p := uc.dotClientPool; p != nil {
p.CloseIdleConnections()
}
if p := uc.dotClientPool4; p != nil {
p.CloseIdleConnections()
}
if p := uc.dotClientPool6; p != nil {
p.CloseIdleConnections()
}
// http3RoundTripper is stored as http.RoundTripper but the concrete type
// (*http3.Transport) exposes CloseIdleConnections via this interface.
type idleCloser interface {
CloseIdleConnections()
}
for _, rt := range []http.RoundTripper{uc.http3RoundTripper, uc.http3RoundTripper4, uc.http3RoundTripper6} {
if c, ok := rt.(idleCloser); ok {
c.CloseIdleConnections()
}
}
}
// SetupTransport initializes the network transport used to connect to upstream servers.
// For now, DoH/DoH3/DoQ/DoT upstreams are supported.
func (uc *UpstreamConfig) SetupTransport(ctx context.Context) {
func (uc *UpstreamConfig) SetupTransport() {
switch uc.Type {
case ResolverTypeDOH, ResolverTypeDOH3, ResolverTypeDOQ, ResolverTypeDOT:
default:
return
}
// Close existing transport connections before creating new ones.
// This forces in-flight requests on stale connections (e.g. after a
// firewall state table flush) to fail fast instead of waiting for
// the full context deadline timeout.
uc.closeTransports()
ips := uc.bootstrapIPs
switch uc.IPStack {
case IpStackV4:
@@ -576,20 +597,21 @@ func (uc *UpstreamConfig) SetupTransport(ctx context.Context) {
case IpStackV6:
ips = uc.bootstrapIPs6
}
uc.transport = uc.newDOHTransport(ctx, ips)
uc.http3RoundTripper = uc.newDOH3Transport(ctx, ips)
uc.doqConnPool = uc.newDOQConnPool(ctx, ips)
uc.dotClientPool = uc.newDOTClientPool(ctx, ips)
uc.transport = uc.newDOHTransport(ips)
uc.http3RoundTripper = uc.newDOH3Transport(ips)
uc.doqConnPool = uc.newDOQConnPool(ips)
uc.dotClientPool = uc.newDOTClientPool(ips)
if uc.IPStack == IpStackSplit {
uc.transport4 = uc.newDOHTransport(ctx, uc.bootstrapIPs4)
uc.http3RoundTripper4 = uc.newDOH3Transport(ctx, uc.bootstrapIPs4)
uc.doqConnPool4 = uc.newDOQConnPool(ctx, uc.bootstrapIPs4)
uc.dotClientPool4 = uc.newDOTClientPool(ctx, uc.bootstrapIPs4)
if HasIPv6(ctx) {
uc.transport6 = uc.newDOHTransport(ctx, uc.bootstrapIPs6)
uc.http3RoundTripper6 = uc.newDOH3Transport(ctx, uc.bootstrapIPs6)
uc.doqConnPool6 = uc.newDOQConnPool(ctx, uc.bootstrapIPs6)
uc.dotClientPool6 = uc.newDOTClientPool(ctx, uc.bootstrapIPs6)
uc.transport4 = uc.newDOHTransport(uc.bootstrapIPs4)
uc.http3RoundTripper4 = uc.newDOH3Transport(uc.bootstrapIPs4)
uc.doqConnPool4 = uc.newDOQConnPool(uc.bootstrapIPs4)
uc.dotClientPool4 = uc.newDOTClientPool(uc.bootstrapIPs4)
if HasIPv6() {
uc.transport6 = uc.newDOHTransport(uc.bootstrapIPs6)
uc.http3RoundTripper6 = uc.newDOH3Transport(uc.bootstrapIPs6)
uc.doqConnPool6 = uc.newDOQConnPool(uc.bootstrapIPs6)
uc.dotClientPool6 = uc.newDOTClientPool(uc.bootstrapIPs6)
} else {
uc.transport6 = uc.transport4
uc.http3RoundTripper6 = uc.http3RoundTripper4
@@ -599,18 +621,17 @@ func (uc *UpstreamConfig) SetupTransport(ctx context.Context) {
}
}
func (uc *UpstreamConfig) ensureSetupTransport(ctx context.Context) {
func (uc *UpstreamConfig) ensureSetupTransport() {
uc.transportOnce.Do(func() {
uc.SetupTransport(ctx)
uc.SetupTransport()
})
if uc.rebootstrap.CompareAndSwap(rebootstrapStarted, rebootstrapInProgress) {
uc.SetupTransport(ctx)
uc.SetupTransport()
uc.rebootstrap.Store(rebootstrapNotStarted)
}
}
func (uc *UpstreamConfig) newDOHTransport(ctx context.Context, addrs []string) *http.Transport {
func (uc *UpstreamConfig) newDOHTransport(addrs []string) *http.Transport {
if uc.Type != ResolverTypeDOH {
return nil
}
@@ -634,13 +655,12 @@ func (uc *UpstreamConfig) newDOHTransport(ctx context.Context, addrs []string) *
dialerTimeoutMs = uc.Timeout
}
dialerTimeout := time.Duration(dialerTimeoutMs) * time.Millisecond
logger := LoggerFromCtx(ctx)
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
_, port, _ := net.SplitHostPort(addr)
if uc.BootstrapIP != "" {
dialer := net.Dialer{Timeout: dialerTimeout, KeepAlive: dialerTimeout}
addr := net.JoinHostPort(uc.BootstrapIP, port)
Log(ctx, logger.Debug(), "Sending doh request to: %s", addr)
Log(ctx, ProxyLogger.Load().Debug(), "sending doh request to: %s", addr)
return dialer.DialContext(ctx, network, addr)
}
pd := &ctrldnet.ParallelDialer{}
@@ -650,11 +670,11 @@ func (uc *UpstreamConfig) newDOHTransport(ctx context.Context, addrs []string) *
for i := range addrs {
dialAddrs[i] = net.JoinHostPort(addrs[i], port)
}
conn, err := pd.DialContext(ctx, network, dialAddrs, logger.Logger)
conn, err := pd.DialContext(ctx, network, dialAddrs, ProxyLogger.Load())
if err != nil {
return nil, err
}
Log(ctx, logger.Debug(), "Sending doh request to: %s", conn.RemoteAddr())
Log(ctx, ProxyLogger.Load().Debug(), "sending doh request to: %s", conn.RemoteAddr())
return conn, nil
}
runtime.SetFinalizer(transport, func(transport *http.Transport) {
@@ -664,20 +684,19 @@ func (uc *UpstreamConfig) newDOHTransport(ctx context.Context, addrs []string) *
}
// Ping warms up the connection to DoH/DoH3 upstream.
func (uc *UpstreamConfig) Ping(ctx context.Context) {
if err := uc.ping(ctx); err != nil {
logger := LoggerFromCtx(ctx)
logger.Debug().Err(err).Msgf("Upstream ping failed: %s", uc.Endpoint)
_ = uc.FallbackToDirectIP(ctx)
func (uc *UpstreamConfig) Ping() {
if err := uc.ping(); err != nil {
ProxyLogger.Load().Debug().Err(err).Msgf("upstream ping failed: %s", uc.Endpoint)
_ = uc.FallbackToDirectIP()
}
}
// ErrorPing is like Ping, but return an error if any.
func (uc *UpstreamConfig) ErrorPing(ctx context.Context) error {
return uc.ping(ctx)
func (uc *UpstreamConfig) ErrorPing() error {
return uc.ping()
}
func (uc *UpstreamConfig) ping(ctx context.Context) error {
func (uc *UpstreamConfig) ping() error {
switch uc.Type {
case ResolverTypeDOH, ResolverTypeDOH3, ResolverTypeDOQ:
default:
@@ -706,21 +725,21 @@ func (uc *UpstreamConfig) ping(ctx context.Context) error {
for _, typ := range []uint16{dns.TypeA, dns.TypeAAAA} {
switch uc.Type {
case ResolverTypeDOH:
if err := ping(uc.dohTransport(ctx, typ)); err != nil {
if err := ping(uc.dohTransport(typ)); err != nil {
return err
}
case ResolverTypeDOH3:
if err := ping(uc.doh3Transport(ctx, typ)); err != nil {
if err := ping(uc.doh3Transport(typ)); err != nil {
return err
}
case ResolverTypeDOQ:
// For DoQ, we just ensure transport is set up by calling doqTransport
// DoQ doesn't use HTTP, so we can't ping it the same way
_ = uc.doqTransport(ctx, typ)
_ = uc.doqTransport(typ)
case ResolverTypeDOT:
// For DoT, we just ensure transport is set up by calling dotTransport
// DoT doesn't use HTTP, so we can't ping it the same way
_ = uc.dotTransport(ctx, typ)
_ = uc.dotTransport(typ)
}
}
@@ -753,12 +772,12 @@ func (uc *UpstreamConfig) isNextDNS() bool {
return domain == "dns.nextdns.io"
}
func (uc *UpstreamConfig) dohTransport(ctx context.Context, dnsType uint16) http.RoundTripper {
uc.ensureSetupTransport(ctx)
func (uc *UpstreamConfig) dohTransport(dnsType uint16) http.RoundTripper {
uc.ensureSetupTransport()
return transportByIpStack(uc.IPStack, dnsType, uc.transport, uc.transport4, uc.transport6)
}
func (uc *UpstreamConfig) netForDNSType(ctx context.Context, dnsType uint16) (string, string) {
func (uc *UpstreamConfig) netForDNSType(dnsType uint16) (string, string) {
switch uc.IPStack {
case IpStackBoth:
return "tcp-tls", "udp"
@@ -771,7 +790,7 @@ func (uc *UpstreamConfig) netForDNSType(ctx context.Context, dnsType uint16) (st
case dns.TypeA:
return "tcp4-tls", "udp4"
default:
if HasIPv6(ctx) {
if HasIPv6() {
return "tcp6-tls", "udp6"
}
return "tcp4-tls", "udp4"
@@ -852,7 +871,7 @@ func (uc *UpstreamConfig) Context(ctx context.Context) (context.Context, context
}
// FallbackToDirectIP changes ControlD upstream endpoint to use direct IP instead of domain.
func (uc *UpstreamConfig) FallbackToDirectIP(ctx context.Context) bool {
func (uc *UpstreamConfig) FallbackToDirectIP() bool {
if !uc.IsControlD() {
return false
}
@@ -871,8 +890,7 @@ func (uc *UpstreamConfig) FallbackToDirectIP(ctx context.Context) bool {
default:
return
}
logger := LoggerFromCtx(ctx)
Log(ctx, logger.Warn(), "Using direct IP for %q: %s", uc.Endpoint, ip)
ProxyLogger.Load().Warn().Msgf("using direct IP for %q: %s", uc.Endpoint, ip)
uc.u.Host = ip
done = true
})
@@ -881,18 +899,12 @@ func (uc *UpstreamConfig) FallbackToDirectIP(ctx context.Context) bool {
// Init initialized necessary values for an ListenerConfig.
func (lc *ListenerConfig) Init() {
logger := LoggerFromCtx(context.Background())
Log(context.Background(), logger.Debug(), "Initializing listener config")
if lc.Policy != nil {
lc.Policy.FailoverRcodeNumbers = make([]int, len(lc.Policy.FailoverRcodes))
for i, rcode := range lc.Policy.FailoverRcodes {
lc.Policy.FailoverRcodeNumbers[i] = dnsrcode.FromString(rcode)
}
Log(context.Background(), logger.Debug(), "Listener policy initialized with %d failover rcodes", len(lc.Policy.FailoverRcodes))
}
Log(context.Background(), logger.Debug(), "Listener config initialization completed")
}
// ValidateConfig validates the given config.
@@ -1008,12 +1020,11 @@ func ResolverTypeFromEndpoint(endpoint string) string {
}
// upstreamUID generates an unique identifier for an upstream.
func upstreamUID(ctx context.Context) string {
logger := LoggerFromCtx(ctx)
func upstreamUID() string {
b := make([]byte, 4)
for {
if _, err := crand.Read(b); err != nil {
logger.Warn().Err(err).Msg("Could not generate uid for upstream, retrying...")
ProxyLogger.Load().Warn().Err(err).Msg("could not generate uid for upstream, retrying...")
continue
}
return hex.EncodeToString(b)
+13 -14
View File
@@ -1,7 +1,6 @@
package ctrld
import (
"context"
"net/url"
"sync"
"testing"
@@ -38,10 +37,10 @@ func TestUpstreamConfig_SetupBootstrapIP(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
// Enable parallel tests once https://github.com/microsoft/wmi/issues/165 fixed.
// t.Parallel()
tc.uc.Init(context.Background())
tc.uc.SetupBootstrapIP(context.Background())
tc.uc.Init()
tc.uc.SetupBootstrapIP()
if len(tc.uc.bootstrapIPs) == 0 {
t.Log(defaultNameservers(context.Background()))
t.Log(defaultNameservers())
t.Fatalf("could not bootstrap ip: %s", tc.uc.String())
}
})
@@ -357,7 +356,7 @@ func TestUpstreamConfig_Init(t *testing.T) {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
tc.uc.Init(context.Background())
tc.uc.Init()
tc.uc.uid = "" // we don't care about the uid.
assert.Equal(t, tc.expected, tc.uc)
})
@@ -499,7 +498,7 @@ func TestUpstreamConfig_IsDiscoverable(t *testing.T) {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
tc.uc.Init(context.Background())
tc.uc.Init()
if got := tc.uc.IsDiscoverable(); got != tc.discoverable {
t.Errorf("unexpected result, want: %v, got: %v", tc.discoverable, got)
}
@@ -516,9 +515,7 @@ func TestRebootstrapRace(t *testing.T) {
bootstrapIPs: []string{"1.1.1.1", "1.0.0.1"},
}
ctx := LoggerCtx(context.Background(), NopLogger)
uc.SetupTransport(ctx)
uc.SetupTransport()
if uc.transport == nil {
t.Fatal("initial transport should be set")
@@ -526,7 +523,7 @@ func TestRebootstrapRace(t *testing.T) {
const goroutines = 100
uc.ReBootstrap(ctx)
uc.ReBootstrap()
started := make(chan struct{})
go func() {
@@ -534,7 +531,7 @@ func TestRebootstrapRace(t *testing.T) {
for {
switch uc.rebootstrap.Load() {
case rebootstrapStarted, rebootstrapInProgress:
uc.ReBootstrap(ctx)
uc.ReBootstrap()
default:
return
}
@@ -544,10 +541,12 @@ func TestRebootstrapRace(t *testing.T) {
<-started
var wg sync.WaitGroup
wg.Add(goroutines)
for range goroutines {
wg.Go(func() {
uc.ensureSetupTransport(ctx)
})
go func() {
defer wg.Done()
uc.ensureSetupTransport()
}()
}
wg.Wait()
+13 -14
View File
@@ -13,19 +13,18 @@ import (
"github.com/quic-go/quic-go/http3"
)
func (uc *UpstreamConfig) newDOH3Transport(ctx context.Context, addrs []string) http.RoundTripper {
func (uc *UpstreamConfig) newDOH3Transport(addrs []string) http.RoundTripper {
if uc.Type != ResolverTypeDOH3 {
return nil
}
rt := &http3.Transport{}
rt.TLSClientConfig = &tls.Config{RootCAs: uc.certPool, MinVersion: tls.VersionTLS12}
logger := LoggerFromCtx(ctx)
rt.Dial = func(ctx context.Context, addr string, tlsCfg *tls.Config, cfg *quic.Config) (*quic.Conn, error) {
_, port, _ := net.SplitHostPort(addr)
// if we have a bootstrap ip set, use it to avoid DNS lookup
if uc.BootstrapIP != "" {
addr = net.JoinHostPort(uc.BootstrapIP, port)
Log(ctx, logger.Debug(), "Sending doh3 request to: %s", addr)
ProxyLogger.Load().Debug().Msgf("sending doh3 request to: %s", addr)
udpConn, err := net.ListenUDP("udp", nil)
if err != nil {
return nil, err
@@ -45,7 +44,7 @@ func (uc *UpstreamConfig) newDOH3Transport(ctx context.Context, addrs []string)
if err != nil {
return nil, err
}
Log(ctx, logger.Debug(), "Sending doh3 request to: %s", conn.RemoteAddr())
ProxyLogger.Load().Debug().Msgf("sending doh3 request to: %s", conn.RemoteAddr())
return conn, err
}
runtime.SetFinalizer(rt, func(rt *http3.Transport) {
@@ -54,18 +53,18 @@ func (uc *UpstreamConfig) newDOH3Transport(ctx context.Context, addrs []string)
return rt
}
func (uc *UpstreamConfig) doh3Transport(ctx context.Context, dnsType uint16) http.RoundTripper {
uc.ensureSetupTransport(ctx)
func (uc *UpstreamConfig) doh3Transport(dnsType uint16) http.RoundTripper {
uc.ensureSetupTransport()
return transportByIpStack(uc.IPStack, dnsType, uc.http3RoundTripper, uc.http3RoundTripper4, uc.http3RoundTripper6)
}
func (uc *UpstreamConfig) doqTransport(ctx context.Context, dnsType uint16) *doqConnPool {
uc.ensureSetupTransport(ctx)
func (uc *UpstreamConfig) doqTransport(dnsType uint16) *doqConnPool {
uc.ensureSetupTransport()
return transportByIpStack(uc.IPStack, dnsType, uc.doqConnPool, uc.doqConnPool4, uc.doqConnPool6)
}
func (uc *UpstreamConfig) dotTransport(ctx context.Context, dnsType uint16) *dotConnPool {
uc.ensureSetupTransport(ctx)
func (uc *UpstreamConfig) dotTransport(dnsType uint16) *dotConnPool {
uc.ensureSetupTransport()
return transportByIpStack(uc.IPStack, dnsType, uc.dotClientPool, uc.dotClientPool4, uc.dotClientPool6)
}
@@ -159,16 +158,16 @@ func (d *quicParallelDialer) Dial(ctx context.Context, addrs []string, tlsCfg *t
return nil, errors.Join(errs...)
}
func (uc *UpstreamConfig) newDOQConnPool(ctx context.Context, addrs []string) *doqConnPool {
func (uc *UpstreamConfig) newDOQConnPool(addrs []string) *doqConnPool {
if uc.Type != ResolverTypeDOQ {
return nil
}
return newDOQConnPool(ctx, uc, addrs)
return newDOQConnPool(uc, addrs)
}
func (uc *UpstreamConfig) newDOTClientPool(ctx context.Context, addrs []string) *dotConnPool {
func (uc *UpstreamConfig) newDOTClientPool(addrs []string) *dotConnPool {
if uc.Type != ResolverTypeDOT {
return nil
}
return newDOTClientPool(ctx, uc, addrs)
return newDOTClientPool(uc, addrs)
}

Some files were not shown because too many files have changed in this diff Show More