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.
This commit is contained in:
Cuong Manh Le
2026-08-28 14:01:23 +07:00
parent f0b60f3efa
commit 4113064680
15 changed files with 2434 additions and 103 deletions
+240
View File
@@ -0,0 +1,240 @@
package cli
import (
"context"
"net/netip"
"sync"
"testing"
"time"
"github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/controld"
)
// progForRefresh builds a prog that can run a configuration refresh: Firewall
// Mode on, and a buffered reload channel so a refresh that decides ctrld must
// reload does not block on a listener that does not exist in a test.
func progForRefresh() *prog {
p := progWithAllowList()
p.rc = &controld.ResolverConfig{}
p.apiReloadCh = make(chan *ctrld.Config, 1)
return p
}
// refresh runs one configuration refresh through the handler apiConfigReload
// uses, which is the point: a test that called applyAllowedDestinations directly
// would still pass if the refresh path stopped calling it.
func refresh(t *testing.T, p *prog, forced bool, rc *controld.ResolverConfig) {
t.Helper()
p.applyFetchedResolverConfig(context.Background(), discardLogger(), rc, forced, time.Now().Unix())
}
// TestRefreshAppliesAllowedDestinations drives the real refresh handler for both
// the scheduled and the forced path, in the case where nothing else about the
// configuration changed - no custom config, unchanged exclusions - so the
// refresh takes its early return. That is where an allowed-destination update is
// easiest to lose, because the refresh has no other work to do.
func TestRefreshAppliesAllowedDestinations(t *testing.T) {
direct := netip.MustParseAddr("203.0.113.10")
inRange := netip.MustParseAddr("198.51.100.7")
for _, forced := range []bool{false, true} {
name := "scheduled refresh"
if forced {
name = "forced refresh"
}
t.Run(name, func(t *testing.T) {
p := progForRefresh()
refresh(t, p, forced, &controld.ResolverConfig{
DestinationIPs: []string{"203.0.113.10", "198.51.100.0/24"},
})
if !p.allowList.Contains(direct) || !p.allowList.Contains(inRange) {
t.Fatalf("refresh did not apply the organization list: %s=%v %s=%v",
direct, p.allowList.Contains(direct), inRange, p.allowList.Contains(inRange))
}
select {
case cfg := <-p.apiReloadCh:
t.Fatalf("unchanged configuration signaled a reload (%v)", cfg)
default:
}
// A later refresh withdraws one entry and keeps the other.
refresh(t, p, forced, &controld.ResolverConfig{
DestinationIPs: []string{"203.0.113.10"},
})
if p.allowList.Contains(inRange) {
t.Fatalf("%s still allowed after the refresh that withdrew it", inRange)
}
if !p.allowList.Contains(direct) {
t.Fatalf("%s should still be allowed", direct)
}
// And one clears the list entirely.
refresh(t, p, forced, &controld.ResolverConfig{})
if p.allowList.Contains(direct) {
t.Fatalf("%s still allowed after the refresh that cleared the list", direct)
}
})
}
}
// TestRefreshAppliesAllowedDestinationsWhenReloading covers the other branch of
// the same handler: a refresh that also changes the exclusion list, and so
// signals a ctrld reload, must still apply the destinations - and they must
// survive the allowlist flush that the reload performs.
func TestRefreshAppliesAllowedDestinationsWhenReloading(t *testing.T) {
p := progForRefresh()
p.rc = &controld.ResolverConfig{Exclude: []string{"example.com"}}
direct := netip.MustParseAddr("203.0.113.10")
refresh(t, p, false, &controld.ResolverConfig{
Exclude: []string{"example.com", "example.net"},
DestinationIPs: []string{"203.0.113.10"},
})
select {
case <-p.apiReloadCh:
default:
t.Fatal("exclusion list change did not signal a reload")
}
if !p.allowList.Contains(direct) {
t.Fatalf("%s not allowed after a refresh that reloaded ctrld", direct)
}
}
// TestRefreshKeepsAllowedDestinationsPendingUntilMirrored pins that the refresh
// path reports honestly: while platform enforcement is rejecting the change, the
// refresh leaves it pending and retries it, instead of recording it as applied.
func TestRefreshKeepsAllowedDestinationsPendingUntilMirrored(t *testing.T) {
p := progForRefresh()
var calls []mirrorCall
failing := true
stubMirror(t, &calls, &failing)
rc := &controld.ResolverConfig{DestinationIPs: []string{"203.0.113.10"}}
refresh(t, p, false, rc)
if got := p.pendingDestinations(p.allowList); got != 1 {
t.Fatalf("pendingDestinations = %d after a rejected mirror, want 1", got)
}
refresh(t, p, false, rc)
if len(calls) != 2 {
t.Fatalf("refresh did not retry the rejected change: calls = %v", calls)
}
failing = false
refresh(t, p, false, rc)
if got := p.pendingDestinations(p.allowList); got != 0 {
t.Fatalf("pendingDestinations = %d after the mirror succeeded, want 0", got)
}
}
// stubResolverConfigFetch replaces the refresh loop's API call for the duration
// of a test, and points cdUID at a device so apiConfigReload does not return
// immediately. Each fetch returns the config the test currently wants and
// reports on fetched, which is how a test knows a refresh cycle has run.
func stubResolverConfigFetch(t *testing.T, config func() *controld.ResolverConfig, fetched chan<- struct{}) {
t.Helper()
origFetch, origUID := fetchResolverConfigFn, cdUID
t.Cleanup(func() { fetchResolverConfigFn, cdUID = origFetch, origUID })
cdUID = "test-uid"
fetchResolverConfigFn = func(context.Context, *controld.ResolverConfigRequest, bool) (*controld.ResolverConfig, error) {
rc := config()
select {
case fetched <- struct{}{}:
default:
}
return rc, nil
}
}
// startRefreshLoop runs apiConfigReload in the background and joins it before the
// test finishes. The loop must not outlive the test: it calls through the same
// package-level stubs the next test replaces, so a leaked one would both race
// those globals and act on another test's prog.
func startRefreshLoop(t *testing.T, p *prog) {
t.Helper()
done := make(chan struct{})
go func() {
defer close(done)
p.apiConfigReload()
}()
t.Cleanup(func() {
close(p.stopCh)
select {
case <-done:
case <-time.After(5 * time.Second):
t.Error("apiConfigReload did not stop")
}
})
}
// waitForCondition polls until cond holds, failing the test if it never does. The
// refresh loop runs in its own goroutine, so its effects land asynchronously.
func waitForCondition(t *testing.T, what string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("timed out waiting for %s", what)
}
// TestApiConfigReloadAppliesAllowedDestinations drives apiConfigReload itself -
// the loop that owns the refresh ticker and the forced-reload channel - rather
// than the handler it calls, so the wiring between them is covered too: removing
// the handler call from the loop must fail a test, not just removing the work
// inside the handler.
func TestApiConfigReloadAppliesAllowedDestinations(t *testing.T) {
direct := netip.MustParseAddr("203.0.113.10")
for _, forced := range []bool{true, false} {
name := "forced reload"
if !forced {
name = "refresh ticker"
}
t.Run(name, func(t *testing.T) {
p := progForRefresh()
p.cfg = &ctrld.Config{}
refetch := 1 // seconds; only the ticker path waits for it
p.cfg.Service.RefetchTime = &refetch
p.stopCh = make(chan struct{})
p.apiForceReloadCh = make(chan struct{})
var mu sync.Mutex
destinations := []string{"203.0.113.10"}
fetched := make(chan struct{}, 1)
stubResolverConfigFetch(t, func() *controld.ResolverConfig {
mu.Lock()
defer mu.Unlock()
return &controld.ResolverConfig{DestinationIPs: append([]string(nil), destinations...)}
}, fetched)
startRefreshLoop(t, p)
if forced {
p.apiForceReloadCh <- struct{}{}
}
waitForCondition(t, "the destination to be applied", func() bool {
return p.allowList.Contains(direct)
})
// The organization withdraws it; the next cycle must take it away.
mu.Lock()
destinations = nil
mu.Unlock()
if forced {
p.apiForceReloadCh <- struct{}{}
}
waitForCondition(t, "the withdrawn destination to stop being allowed", func() bool {
return !p.allowList.Contains(direct)
})
})
}
}