Commit Graph
684 Commits
Author SHA1 Message Date
Dev Scribe bfc46ef265 feat(cli): add opt-in macOS unprivileged log-send socket 2026-10-07 14:18:22 +07:00
Dev Scribe cee5bbe7ae fix(cli): journal that allowed destinations fail closed on a maintenance start
A maintenance fallback start holds no resolver config, and Allowed
Destinations (a master-only Firewall Mode feature) are not persisted with
the configuration on disk. Under Firewall Mode the exception set is
therefore empty until the API answers, so raw-IP destinations the
organization allowed stay blocked for the window, and a persisted pf
table from the previous run is replaced by the empty set.

Keep that fail-closed on purpose: the list is the organization's network
topology and is kept out of persisted state. Journal a Warn when a
fallback start applies the empty set under Firewall Mode so the blocked
destinations are explained, and pin that the first answer after recovery
re-applies the list.

Also capitalize the two managed-config marker log messages to match the
rest of master.
2026-10-07 14:18:04 +07:00
Cuong Manh Le c7b2134977 feat(cli): keep serving DNS through Control D API maintenance
A hard maintenance window took DNS down on endpoints that already had a
working configuration: the bootstrap preflight read the maintenance answer
as a failed fetch and exited, turning a temporary API outage into an
endpoint outage.

Startup now falls back to the configuration on disk when a content- and
identity-bound marker records that the API produced it. Without one, ctrld
stops with API_MAINTENANCE (exit 37). Maintenance never self-uninstalls or
counts as a permanent rejection, a fallback run retries every 5-7 minutes
until a recovered configuration is applied, and deactivation is refused
with 503 while the PIN cannot be verified.

Master adaptation: pending-recovery and PIN-known handling live in
applyFetchedResolverConfig; logging uses *ctrld.Logger; the reload fetch
seam is fetchResolverConfigFn; the control-server and self-uninstall
probes use the existing fetchResolverConfig seam with loggerCtx.
2026-10-07 14:17:46 +07:00
Cuong Manh Le f914105aec fix(cli): mark AD lookups on the bypass and OS retry paths
The previous change marked a rule-matched AD query as a LAN query, but
two paths still reach the OS resolver without that mark, so osResolver
raced the domain controller against 76.76.2.0 in plaintext:

  - the DNS-intercept recovery bypass, which forwards queries before
    rule handling runs
  - the OS-resolver retry catch-all after an explicit upstream fails
    with leak_on_upstream_failure enabled

Mark a query under the detected AD domain on both paths. Other names
keep the public fallback, and an explicit upstream is still tried first.

Build resolvers in queryUpstream() through newResolverFn, so tests drive proxy()
and assert the LAN mark that reaches each resolver, instead of testing
the predicate alone.
2026-10-07 14:16:47 +07:00
Anthony Wong 3b4d96ee50 fix(cli): port the log journal fixes and Windows race tests 2026-10-07 14:16:23 +07:00
Alex Paguis 6edce5f94f fix(cli): keep Active Directory lookups off Control D's public resolver
The auto-added AD split rules route corp names to upstream.os, but a rule
match returned before the LAN-query marking, so osResolver raced the domain
controller against 76.76.2.0 and sent every AD lookup (DC hostnames,
DC-locator SRV names carrying the machine name, WPAD) there in plaintext.
Mark a matched query under the machine's AD domain whose upstreams are the
OS resolver alone as a LAN query, which drops 76.76.2.0 for it.
2026-10-07 14:15:54 +07:00
Cuong Manh Le 36d4167388 cmd/cli: add missing function from cherry-pick 2026-09-28 15:07:03 +07:00
Dev Scribe c7b3881e13 Port DNS64, VPN routing and DNS recovery ownership to master
Retain master's context-aware VPN and resolver APIs, per-program logger,
shutdown fencing, native CLAT service identity and firewall behavior.
Keep saved-DNS paths owned by the root ctrld package; isolate the added
ownership/sweep tests with a shared path seam and temporary backups.

Preserve the source DNS64 DNSSEC/cache gates, longest-suffix VPN routing,
DNS-journal convergence, scoped IPv6 handling, target ownership checks,
custom-listener regressions and documented native QA limits.
2026-09-28 14:44:47 +07:00
Dev Scribe a9382c3a50 macOS: share network service-order parsing
Decode networksetup entries once for interface lookup and DNS-target
selection. Preserve first-enabled lookup and fail-closed DNS uniqueness
as separate policies. Treat only the (*) index as disabled, preserving
asterisks and whitespace in enabled service names.
2026-09-25 13:21:03 +07:00
Dev Scribe f5a4cb69ed macOS: use the networksetup service name for USB CLAT targets 2026-09-25 13:20:49 +07:00
Dev Scribe 55190faaee macOS: port native CLAT DNS-target fallback to master 2026-09-25 13:20:27 +07:00
Dev Scribe 6e66ea82b4 Port scope OS recovery and retain DNS-target diagnostics 2026-09-24 14:19:43 +07:00
Cuong Manh Le c9c829a379 test(cli): scope probe journal assertions to their own log lines
captureDebugMainLog swaps the process-wide mainLog, so every
mainLog.Load().Warn()/.Error() site in the package writes into the
buffer while it is installed, including goroutines left running by
other tests. retainedProbeLines returned every retained line and the
tests asserted field values over all of them, so one unrelated warn
failed the run.

Test_checkDnsLoopKeepsACustomKeyOutOfTheJournal hit this on Windows,
where the loop probe held the buffer open for two seconds:

    upstream_probe_journal_test.go: field "upstream": got , want upstream.custom

The sibling tests were exposed the same way; they index retained[0]
after a strict length check, so a stray line fails them as got 2, want 1.

Let retainedProbeLines take the messages a caller owns and return only
those lines. wantNoProbeSecret keeps scanning every retained line,
which is what that leak check is for.
2026-09-23 15:17:38 +07:00
Cuong Manh Le 7f768e62ca fix(cli): bound the DNS loop check probe by upstream timeout
checkDnsLoop resolved with context.Background() and no dns.Client
timeout, so the DNS client fell back to its own 2s default and the
configured upstream timeout was ignored. The probes run serially on a
one-minute ticker, so each unreachable local upstream stalled the loop
for a fixed 2s no matter what the config asked for.

This is most visible on Windows, where UDP to a closed loopback port
does not surface an immediate refusal the way it does on Linux, so the
probe waits out the full default.

Apply the upstream timeout the way checkUpstreamOnce already does, and
keep 2s for an upstream that configures none so the unconfigured case
is unchanged.

The probe context deliberately carries no logger. A resolver logs its
own failure at error level with the endpoint in the message, which
would place an Internal Domain resolver address in the retained
journal; logUpstreamProbeFailure remains the reporter and bounds what
the line may hold. TestInternalDomainsLoopCheckHidesResolverAddress
covers this.
2026-09-23 15:17:26 +07:00
Dev Scribe 14d8e20cd5 fix(mobile): port synchronized controller teardown 2026-09-23 14:47:45 +07:00
Dev Scribe 4fb8e506e2 fix(cli): port runtime lifecycle cleanup 2026-09-23 14:46:38 +07:00
Cuong Manh Le 163c8c75c6 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.
2026-09-22 21:18:41 +07:00
Cuong Manh Le 4879574605 fix(cli): skip DNS restoration quietly when the interface is gone
Keep missing interfaces as debug skips while preserving enumeration
causes and error-level failures restoring DNS on existing interfaces.
Adapt the logger and NetworkManager method to master. Stub saved DNS
lookup in tests because master stores it through ctrld rather than the
CLI homedir; tests must not touch the service's saved DNS files.
2026-09-22 21:08:04 +07:00
Anthony Wong 37b835c791 feat(cli): port the log journal and network journal events to master 2026-09-22 21:07:51 +07:00
Cuong Manh Le 488065f6e8 fix: skip internal logging in silent mode
Running with --silent still created and grew log files in cd mode.
needInternalLogging() only checked cdUID and Service.LogPath, so a
--silent flag enabled internal logging, persisted it to disk, and
reset the global log level back to debug, overriding the NoLevel that
--silent had set.

Return false from needInternalLogging() when silent is set, so ctrld
neither creates the internal log file nor writes debug logs. Add
regression tests asserting needInternalLogging() is false in silent mode
and that initInternalLogging() creates no log file.
2026-09-22 21:06:32 +07:00
Cuong Manh Le 2ff70ac961 dns_intercept: don't write a GP sibling on failed NRPT handback
A failed handback restored ctrld's rule via addNRPTCatchAllRule, which
also writes the GP-path rule whenever another GP rule exists - leaving
CtrldCatchAll beside the administrator's catch-all. Restore only the
local store while that child is still present.
2026-09-21 19:06:13 +07:00
Cuong Manh Le 25b79d5e73 cli: route organization Internal Domains from managed config
Port the final merged change from !1009 to master, retaining master's
context-aware logger/resolver APIs, proxy decomposition and firewall
refresh handling. Adapt source regression tests and cover master's
refresh handler and transport-level private-address diagnostics.
2026-09-18 13:37:27 +07:00
Dev Scribe 13471ba128 macOS: repair network recovery and improve diagnostics 2026-09-18 13:36:47 +07:00
Cuong Manh Le 246a54020d fix(darwin): probe interception after the host wakes
Rule text survives a suspend unchanged, so the watchdog reports the pf
anchor intact while nothing translates, and the probe monitor is only
armed by interface changes with a schedule frozen through the sleep.
Detect the resume from the wall-clock gap and probe from there, with a
bounded repair: at most two forced reloads across the retry schedule.

Refresh the OS resolver before every probe - the probe aims at the first
OS nameserver and passes when it has none, so a pre-sleep list makes it
meaningless. Stand down for stabilization, which verifies on completion.
2026-09-18 13:35:00 +07:00
Cuong Manh Le 02d4e8e425 fix(darwin): probe interception when stabilization finishes
The post-stabilization reconcile verifies rule text, which cannot tell a
live redirect from an anchor pf has stopped evaluating. Sleep/wake QA
caught exactly that split: references intact, anchor rules intact,
post-load verification passed, and every query through the system
resolver timing out while the direct listener answered.

Nothing else probed. The interception probe monitor stands down while
stabilization owns pf and is never re-armed afterwards, so functional
recovery waited for the periodic watchdog - 11 seconds in the captured
run, up to a full 30-second interval - on a host whose link and default
route were already back. The watchdog's probe then failed once, forced a
reload, and public and VPN split-DNS both recovered immediately.

Probe once at the end of stabilization and, if it fails, force exactly
one reload and confirm with one more probe. Not the probe monitor: that
keeps probing for ~7.5s and can force a reload per failed probe, where
this path needs a single bounded repair before handing back to the
watchdog. Skipped when a monitor already owns probing, when intercept
state is gone, or during exec backoff.

Hand ownership over deterministically rather than skipping on sight. A
probe monitor started by an ignored network change claimed
functional-probe ownership before checking whether it could work, then
stood down because stabilization still owned pf; the verifier read that
claimed flag as "somebody is probing" and skipped, so neither path
probed and recovery fell back to the watchdog anyway. The monitor now
checks eligibility before claiming, and the verifier waits out a holder
that releases, yielding only to one that keeps probing.

Extract the completion block into finishPFStabilization so the wiring is
testable, and cover the bounded repair, the healthy path that must not
reload, a prober that claims and stands down, a prober that keeps
working, and a monitor that must not claim ownership while stabilizing.
2026-09-18 13:34:48 +07:00
Local Scribe 5e1ebfa3d7 darwin: route Zscaler's DNS echo name through the OS resolver 2026-09-18 13:34:43 +07:00
Anthony Wong dc39073a55 feat(pkg): classify postinstall failures and test them in ci 2026-09-04 16:47:58 +07:00
Anthony Wong e8e04ae094 feat(cli): add ctrld diag for provisioning support 2026-09-04 16:47:29 +07:00
Anthony Wong 0f821a7907 feat(cli): classify every provisioning failure with stable codes 2026-09-04 16:46:55 +07:00
Dev Scribe dd77d865b6 dns proxy: port DNS64 synthesis to master
Port the reviewed DNS64 behavior while preserving master's context-aware
resolver, cache, logging, and Firewall Mode flow.
2026-09-03 13:58:24 +07:00
Cuong Manh Le d8994cc7d8 Rename ctrld v2.0.0 to ctrld-client
Updates #565
2026-09-03 13:57:38 +07:00
Dev Scribe 80d1acdfd5 dns intercept: port DNS-less network recovery to master
Port !997 from v1.0 onto the context-aware master recovery lifecycle. Preserve master logging and resolver APIs while adding macOS default-route DHCP detection, temporary DNS-target cleanup, and atomic recovery ownership.

Includes parser, lifecycle, failure, and concurrency regressions plus the corrected macOS QA helper. Relates to #533 and #597.
2026-09-02 16:54:35 +07:00
Cuong Manh Le f96868c266 all: permit ctrld's own endpoints in Firewall Mode
Firewall Mode permits only what ctrld resolved through its own listener.
The API transport resolves api.controld.com through the OS nameservers and
falls back to hardcoded addresses, so nothing ever teaches the allowlist
about it and ctrld's own block-all filters deny its control-plane socket.
The upgrade download server has the same shape: performUpgrade spawns a
detached child process, and the WFP filters carry no process condition, so
the service blocks its own upgrade.

Permit both permanently, at startup and on reload. For the API that means
the resolved addresses and the transport's direct fallbacks - the fallbacks
are what it dials when DNS is unusable, which is the state a blocked ctrld
is in. For the download server only the fallback IP is needed, since its
hostname lookup does go through the listener and is learned.
APIDomain/APIEndpointIPs are exported so the permitted set and the dialed
set cannot drift apart.

Call initPlatformFirewall on reload even when enforcement is already up.
AddPermanent fires no change callback, so an address permitted by a reload
reached memory only while the platform never heard about it. Each
platform's re-entry is a refresh: Windows reinstalls the permanent filters
it is missing, macOS returns early.

Also keep every dial attempt in the transport's error. It returned only the
last stage, an unroutable IPv6 address reporting "no route to host", hiding
the IPv4 WSAEACCES that named the real cause. The direct IPs are still
always dialed, so the API stays reachable without DNS; only a duplicate
dial of an address the resolver already returned is dropped.
2026-08-28 14:02:24 +07:00
Cuong Manh Le 4113064680 all: apply the organization's allowed destination IPs in Firewall Mode
Firewall Mode only permits what ctrld resolved, so an approved service addressed
by literal IP - with no DNS lookup to observe - is unreachable, and the only
workaround was turning the mode off. The API now sends the effective per-org list
in destination_ips of every resolver-config response.

Apply it as a set rather than as additions: each refresh replaces the previous
snapshot, so an entry added upstream takes effect and one removed upstream stops
bypassing enforcement. This happens inside the refresh handler before its early
returns, so scheduled and forced refreshes both carry it, and without a ctrld
reload. Entries carry no TTL and survive the allowlist flushes that follow a
profile or network change.

Track what the API asked for separately from what pf/WFP accepted, because
mirroring can fail and the next refresh - carrying an identical list - would
compute no delta to retry. The applied snapshot advances only on success, and the
difference is retried by the next refresh and by a reconcile every 5 minutes,
reported meanwhile as allowed_destinations_pending. Enforcement coming up
replaces the whole set rather than adding to it: the macOS table is a persist
table that can still hold what a previous run put there. Enforcement is versioned
by a generation advanced under the same lock the mirror is called with, so a
maintenance worker outliving its run cannot reinstall permits into enforcement
that is gone.

macOS keeps the set in a second pf table, <ctrld_allowed_dst>; Windows in
per-entry WFP permit filters in their own map - apart from the DNS-resolved
entries so a flush of those leaves them installed. Linux is unchanged, the mode
already fails open there, and devices with Firewall Mode off are unaffected.

Lookups binary search sorted per-family address ranges, so the per-connection hot
path stays flat at ~40ns rather than growing with the list. Addresses are logged
at debug level only: the list is organization network topology, and Info-level
logs are persisted and travel in support bundles.

Indirect the refresh's fetch and split its handler out of the fetch loop so both
refresh paths are driven end to end in tests without an API server.
2026-08-28 14:01:23 +07:00
Dev Scribe f0b60f3efa pkg: address managed DNS mode review feedback 2026-08-24 15:18:01 +07:00
Anthony Wong 47dd64b901 fix(cli): treat explicit intercept-mode off as final in listener setup
tryUpdateListenerConfig treated an explicit --intercept-mode off the same
as an empty flag and fell back to the persisted config value. run() selects
the listener strategy before it clears the persisted mode, so the first
start after a revert to standard mode selected the intercept strategy from
a stale dns/hard value while setDNS kept interception off.

Extract the resolution into listenerInterceptMode and make an explicit off
final, the same contract as setDNS. Add a regression test that fails
without the fix.
2026-08-24 15:17:25 +07:00
Dev Scribe 5eb2db549b pkg: apply managed DNS mode in the macOS package 2026-08-24 15:17:03 +07:00
Anthony Wong a40eb70ce8 feat(cli): add stable provisioning failure codes 2026-08-24 15:16:12 +07:00
Codescribe fffa621797 security: default metrics server to loopback
Fixes unauthenticated metrics exposure by defaulting to 127.0.0.1 when
no host is provided. Logs a warning when bound to non-loopback addresses.
2026-08-24 15:14:59 +07:00
Codescribe 0f310594b0 Redact provision token in logs 2026-08-24 15:14:59 +07:00
Cuong Manh Le 0db5ee44be cmd/cli: log the config error that rejected a custom config
The warning reported err, the resolver-config fetch error, which is nil on
every path that reaches it - so a rejected custom config was logged with no
reason attached. cfgErr holds the validation failure.
2026-08-14 15:30:19 +07:00
Cuong Manh Le 062dcbfb12 cmd/cli: report whether ctrld finished starting up, not just what SCM thinks
"ctrld status" reported the service manager's view and nothing else, so it
printed "Service is running" and exited 0 for the incident's process: alive,
registered as started, stuck in API preflight, with no control socket, no DNS
listener and no policy applied. The one command an operator reaches for first
confirmed the service was fine while the host had no working DNS.

Probe the control server's /started endpoint before reporting success. That
endpoint only answers once the onStarted hooks have completed, which is after the
listeners are up, so a successful probe means the process is serving rather than
merely alive. A service that is registered as running but cannot confirm startup
is now reported as such, with a pointer to the log, and exits 3 - distinct from
stopped (1) and unknown (2), because it needs a different response.

A probe blocked by permissions is not evidence of a broken service: an
unprivileged caller still gets "Service is running", with a note that startup
was not verified. The probe is bounded by a short timeout so status stays fast.

Document the exit codes in the command's help, and cover the probe (ready, not
finished starting, no socket, timed out) and the classification, including that
an unreadable socket is not reported as a failure.

The not-ready verdict is only reported when the probe could have found the
daemon's socket. socketDir() is caller-relative on unix - the system directory
when writable, the caller's home otherwise - so an unprivileged "ctrld status"
looks somewhere the root-owned daemon never listened and gets ENOENT, which is
"wrong path", not "not ready". Since only darwin has an elevation PreRun and the
root-level alias has none, that is the normal invocation; reporting exit 3 there
would have told a monitoring check to restart healthy daemons. Such a caller now
gets the service manager's view with startup reported as unverified. Windows and
mobile resolve the same directory for every caller, so the verdict stays fully
available on the platform the hung start was seen on. A successful probe is still
conclusive whoever ran it.
2026-08-14 15:30:05 +07:00
Cuong Manh Le 88e076abe7 cmd/cli: stop the replacement before rolling its binary back
Rollback ran os.Remove(bin) while the replacement service was still running from
that image. Windows locks a running executable, so the remove failed with
"Access is denied" - and it was fatal, so the os.Rename that restores the
previous binary never ran. The upgrade ended with the broken replacement still
installed and the working binary stranded at its _previous name, which is what
the Firewall Mode incident produced.

Readiness failing is not evidence the process exited: the service manager can
report a started service whose process never became operational. So rollback now
stops the service and waits until the manager reports it stopped before touching
the executable, then resets DNS the way the restart path's Cleanup task does.
That also leaves the host in a known state - a stopped ctrld holds no WFP or
NRPT enforcement, so a replacement that was blocking traffic stops blocking it
at rollback instead of at the next reboot.

Restoring is now conditional on the previous binary reporting a version. The
incident's _previous file existed but produced no version output; renaming that
over the current binary would have traded a service that starts and hangs for
one that cannot start at all. When it is unusable, rollback keeps it for
inspection, leaves the installed binary alone, and says so instead of pressing
on. The --version probe is bounded by a timeout so a binary that hangs cannot
hang the upgrade.

Remaining failures return errors rather than calling Fatal, so each one reports
what state the host was left in. os.Remove is retried while the path stays
locked, since Windows releases an image lock asynchronously after the process
exits.

Extract the rollback into rollbackToPreviousBinary() and cover it: the stop
happens while the executable is still present, an unusable previous binary is
kept without swapping or restarting, and a failed stop aborts before anything is
modified. Reversing the stop and the remove fails these tests.

The version probe is called through a variable so those tests do not have to
stage a runnable executable. Staging one is not portable: oldBin is
bin+"_previous", so a fixture named "ctrld" yields the extension-less
"ctrld_previous", which Windows refuses to execute, and a symlink to the test
binary needs a privilege Windows does not grant by default. The probe itself is
still covered against the real test binary. Production is unaffected: ctrld.exe
_previous does have an extension, and os/exec only appends PATHEXT entries when
a path has none at all - noted at binaryVersion so the suffix is not renamed
into something extension-less by accident.
2026-08-14 15:29:49 +07:00
Cuong Manh Le 052b057756 all: keep the first-attempt error when the direct-ip fallback also fails
Both API requests and binary downloads retry against a hard-coded IP when the
attempt via hostname fails. Both then overwrote the first error with the
fallback's, so only the last failure was reported.

That discarded the diagnosis. During the Firewall Mode incident the hostname
attempt was denied locally - WSAEACCES, "An attempt was made to access a socket
in a way forbidden by its access permissions", which means the host is blocking
ctrld - while the direct-ip fallback failed with an unreachable IPv6 route. What
surfaced to the operator was "dial tcp6: no route to host", pointing at a
routing problem that did not exist, while the WSAEACCES that named the real
cause was visible only in debug logs.

Report both failures instead, keeping the error chain intact so errors.Is still
matches either one. Also switch the final wrap in doWithRetry from %v to %w,
which had been flattening the chain even when a single error was reported.

Add coverage for both paths, including that the fallback is still attempted and
that a successful fallback returns no error.

This also changes retry classification, which is worth stating explicitly because
it is not obvious from "report both errors". processCDFlags decides whether to
keep backing off with errUrlNetworkError, which uses errors.As - and errors.As
returns the first match in the tree. Wrapping the hostname attempt first
therefore hands the predicate that attempt's failure, where previously only the
fallback's error survived to be classified.

The effect is intended. A locally denied socket (WSAEACCES) is not a transient
network error, so preflight now fails fast and reports instead of retrying
against a firewall that is not going to clear on its own - the incident logged
256 retry cycles doing exactly that. The case that justifies retrying forever, a
network unreachable on both attempts at boot, is unchanged. Both classifications
are pinned by tests, along with the wrap order they depend on at each composition
site, so reversing it fails loudly rather than silently restoring the old
behaviour.
2026-08-14 15:29:32 +07:00
Cuong Manh Le 891d5d9821 cmd/cli: drop stale intercept enforcement at startup, before API bootstrap
A host carrying orphaned WFP filters from a ctrld build that predates
session-scoped ownership cannot recover on its own. In Firewall Mode those
filters block all non-allowlisted outbound traffic machine-wide, which denies
the replacement ctrld's own API bootstrap. API-managed startup then sits in the
resolver-config retry loop forever and never reaches startWFPFilters, where the
only stale-sublayer cleanup lived. Cleanup needs startup, startup needs the
network, the network needs cleanup - the host stays locked out until a reboot.

Add cleanupStaleDNSInterceptState() and call it early in run(), before the
network-up wait and before the API preflight. On Windows it deletes ctrld's WFP
sublayer, which takes its child filters with it, so a previous process's
enforcement is gone before this process makes its first connection. Objects
owned by a live session cannot be deleted, so a running ctrld is unaffected and
"nothing to clean up" stays at debug level; an actual removal logs a warning,
since it means a previous ctrld left machine-wide enforcement installed.

macOS and the other platforms get no-op implementations: pf enforcement does not
outlive the process, and startDNSIntercept already flushes the anchor and
removes a stale anchor file before loading rules.

The cleanup inside startWFPFilters stays as a second line of defense.

The cleanup session is deliberately NOT dynamic. FwpmSubLayerDeleteByKey0 is
documented to fail with FWP_E_DYNAMIC_SESSION_IN_PROGRESS when called from a
dynamic session for an object that was not added in one, and the only orphans
that can exist are exactly those: a ctrld predating session-scoped ownership
added its sublayer statically. Anything a newer ctrld leaves behind is removed by
the OS when its session ends. Opening this session dynamically would have made
the cleanup a no-op in the one case it exists for, while still logging "no stale
WFP state".

A concurrently running ctrld is protected by documented ownership rather than by
the child-filter question below: a session-scoped ctrld's sublayer belongs to a
different dynamic session, so the delete fails with FWP_E_WRONG_SESSION. That
matters because this call is made unconditionally at startup, in every intercept
mode, so an interactive "ctrld run" alongside a healthy service reaches it.

A ctrld predating session scoping has no such protection: it holds a non-dynamic
sublayer, which is exactly what this targets and is indistinguishable from an
orphan. Two guards cover that instead. Elevation, because opening a WFP engine
and deleting ctrld's sublayer must not be reachable from an unprivileged local
process - FwpmEngineOpen0 is expected to fail without elevation, but that is a
property of the API rather than something this code checked, and it was the only
barrier. And interactive invocation, since a service start is not interactive: the
deadlock case still gets cleaned, while a hand-run "ctrld run" beside a live
service does not strip its enforcement. That second guard requires positive
evidence of absence - ctrldServiceLiveness answers unknown for an unreachable SCM
or a service mid-stop, and unknown skips the cleanup exactly as running does,
because "could not be observed" is not "not there".

Both are asserted against the deletion itself, not only against the predicate: a
caller-level test substitutes the WFP delete and the guard inputs, so a future
change that stops consulting the guard, or consults it and deletes anyway, fails
rather than staying green.

Delete failures are no longer collapsed into "nothing to clean up". Only
FWP_E_SUBLAYER_NOT_FOUND means that; any other code means state exists under our
GUID that we could not remove, which is the lockout condition itself, so it is
logged as a warning naming the code.

Whether deleting the sublayer is sufficient is left explicitly UNRESOLVED rather
than asserted. It is sufficient only if the delete also removes the filters
inside it. FwpmSubLayerDeleteByKey0's Remarks say nothing about child filters
either way, while object management states that an object cannot be deleted until
everything referencing it has been - and FWP_E_IN_USE exists for that. Whether a
filter's subLayerKey counts as such a reference is not documented, and this code
cannot be exercised off-Windows, so the earlier claim that the delete "takes its
child filters with it" is removed from the comment here and from
docs/wfp-dns-intercept.md, which carried it from before this branch.

The behaviour is safe under both readings: the delete is attempted, and
FWP_E_IN_USE is reported rather than counted as success, so a support log
distinguishes "cleared it" from "could not clear it". If a live Windows check
shows FWP_E_IN_USE against orphaned filters, the cleanup must enumerate and
delete those filters first. That is deliberately not written blind:
FWPM_FILTER_ENUM_TEMPLATE0 has no sublayer field, so selecting ctrld's own
filters means reading subLayerKey at a computed offset in FWPM_FILTER0, and
getting that offset wrong would delete other software's filters - a worse failure
than not cleaning up.

Refs: https://learn.microsoft.com/en-us/windows/win32/api/fwpmu/nf-fwpmu-fwpmsublayerdeletebykey0
Refs: https://learn.microsoft.com/en-us/windows/win32/fwp/object-management
2026-08-14 15:29:06 +07:00
Cuong Manh Le 7de6298fa4 cmd/cli: scope ctrld's WFP objects to a dynamic session on Windows
ctrld opened the WFP engine with a plain FWPM_SESSION0, so every filter and
sublayer it installed was persistent for the engine's boot lifetime: the kernel
kept enforcing them after the installing process was gone. Any exit that did
not run the shutdown path - kill, crash, or a service stop during upgrade -
left them behind.

In hard intercept mode that orphaned the DNS block filters. With Firewall Mode
enabled it orphaned machine-wide block-all filters that carry no process or SID
condition, so the entire host lost outbound traffic: browsers, other users, and
a replacement ctrld's own API bootstrap alike, with no way back short of a
reboot.

Set FWPM_SESSION_FLAG_DYNAMIC on both engine sessions (hard intercept and
loopback protect). Windows then deletes everything the session owns when the
handle closes, including on abnormal termination, so ctrld's enforcement can no
longer outlive the process that installed it.

This removes the cause. The next commit adds startup self-heal for hosts
already carrying orphaned filters from a build that predates this change.
2026-08-14 15:28:16 +07:00
Cuong Manh Le 52b7aaab87 cmd/cli, internal/controld: bound API preflight by service lifetime
processCDFlags retries the resolver-config fetch indefinitely by design: a
device that has no working network at boot must eventually come up. The loop
had no cancellation, so a stop request arriving while the API is unreachable
was ignored - the process kept retrying long after "Service stopped" was
logged, doing work on behalf of a service the OS considers stopped. The
Windows Firewall Mode incident showed this concretely: API retries continued
15 seconds after the stop completed, so stopping the service could not release
what the process was still holding.

Thread a context through processCDFlags and derive it from p.stopCh, in both
the startup preflight and the config-reload path. The loop now returns as soon
as the context is cancelled, checked both before a retry and after backoff
returns (backoff can wake up on cancellation).

A stop during preflight now exits the way a normal stop does, without Fatal,
so the service manager does not treat it as a failed start and apply its
restart policy to a service the operator just asked to stop.

Also bind the two API requests in internal/controld to the caller's context.
They were built with http.NewRequest, so an in-flight request ignored
cancellation and waited out its own timeout instead.

Covered by tests that assert what the incident needed: a stop request during
preflight ends the retry/backoff loop and reports cancellation rather than
continuing to retry after the service reports itself stopped, an
already-cancelled context makes at most one attempt, and contextFromStopCh
handles its three cases (cancelled by stopCh, released by cancel, usable with
no stop channel). Removing either cancellation check makes these tests hang
until the test timeout. The tests are also what exercise fetchResolverConfig,
the seam this commit introduces.

Sampling the stop state is the whole point of runAPIPreflight rather than doing
this inline. A stop and a failure need opposite handling - one exits quietly, the
other self-uninstalls a deleted device, surfaces the error to a mobile app, and
reports a failed start - so the two must not be confused. Reading it from the
context after cancelling would report stopped for every failure, since
CancelFunc sets ctx.Err() regardless of whether anyone asked to stop; the stop
channel is read directly instead, which also does not depend on the context
watcher goroutine having been scheduled.
2026-08-14 15:27:54 +07:00
Cuong Manh Le e5f2506199 cmd/cli: fix linter 2026-08-12 14:47:04 +07:00
Cuong Manh Le 0c8281c879 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.
2026-08-12 14:46:48 +07:00
Dev Scribe e4e1c0abb9 windows: adopt GP-managed NRPT catch-all 2026-08-12 14:32:13 +07:00