mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-09-04 13:36:35 +02:00
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:
@@ -569,3 +569,85 @@ func TestPFBuildAnchorRules_ForwardedSourcesGating(t *testing.T) {
|
||||
t.Errorf("forwarded-source redirect (%d) must come before the blanket block (%d)", fwdIdx, blockIdx)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildPFFirewallRulesDeclaresExceptionTable pins the pf side of the
|
||||
// organization's Allowed Destination IP list.
|
||||
//
|
||||
// Every cmd/cli test of the allowed-destination paths stubs the platform mirror,
|
||||
// so nothing else reaches this generator: the table the mirror populates could
|
||||
// stop being declared, or lose its pass rules, and the mirror would keep
|
||||
// reporting success while every approved destination stayed blocked. Both
|
||||
// families are asserted - a list is not usable if only one of them passes.
|
||||
func TestBuildPFFirewallRulesDeclaresExceptionTable(t *testing.T) {
|
||||
rules := buildPFFirewallRules()
|
||||
|
||||
wants := []string{
|
||||
// Declared persist, like the dynamic table: pfctl -T add/delete/replace
|
||||
// against an undeclared table fails, and persist is what keeps the table
|
||||
// alive while it holds no addresses.
|
||||
"table <" + pfFirewallExceptionTable + "> persist",
|
||||
"pass out quick inet proto { tcp, udp } from any to <" + pfFirewallExceptionTable + ">",
|
||||
"pass out quick inet6 proto { tcp, udp } from any to <" + pfFirewallExceptionTable + ">",
|
||||
}
|
||||
for _, want := range wants {
|
||||
if !strings.Contains(rules, want) {
|
||||
t.Errorf("missing rule:\n %s\nin:\n%s", want, rules)
|
||||
}
|
||||
}
|
||||
|
||||
// The exception table is separate from the dynamic one on purpose: the flushes
|
||||
// that discard DNS-resolved IPs must leave administratively allowed
|
||||
// destinations in place.
|
||||
if pfFirewallExceptionTable == pfFirewallTable {
|
||||
t.Fatal("the exception table and the dynamic table are the same table; a flush would drop the organization's list")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPFExceptionTableChunks covers the argv-length split. The organization's
|
||||
// list is API-supplied and unbounded, and every entry becomes an argv element, so
|
||||
// a long enough list would blow past ARG_MAX and fail as a whole.
|
||||
func TestPFExceptionTableChunks(t *testing.T) {
|
||||
entries := make([]string, pfExceptionTableOpChunk*2+1)
|
||||
for i := range entries {
|
||||
entries[i] = "203.0.113.10/32"
|
||||
}
|
||||
|
||||
if got := pfExceptionTableChunks("replace", nil); len(got) != 0 {
|
||||
t.Errorf("chunks for an empty list = %d, want 0", len(got))
|
||||
}
|
||||
|
||||
short := pfExceptionTableChunks("replace", entries[:2])
|
||||
if len(short) != 1 || short[0].op != "replace" || len(short[0].entries) != 2 {
|
||||
t.Fatalf("a list that fits was split: %+v", short)
|
||||
}
|
||||
|
||||
// A split replace must replace once and add the rest. Splitting it into three
|
||||
// replaces would leave pf holding only the final chunk, with the organization's
|
||||
// other destinations silently dropped while the mirror reported success.
|
||||
split := pfExceptionTableChunks("replace", entries)
|
||||
if len(split) != 3 {
|
||||
t.Fatalf("chunks = %d, want 3 for %d entries at %d per call", len(split), len(entries), pfExceptionTableOpChunk)
|
||||
}
|
||||
if split[0].op != "replace" {
|
||||
t.Errorf("first chunk op = %q, want replace", split[0].op)
|
||||
}
|
||||
for _, chunk := range split[1:] {
|
||||
if chunk.op != "add" {
|
||||
t.Errorf("chunk after the first has op %q, want add: a second replace discards the first", chunk.op)
|
||||
}
|
||||
}
|
||||
var total int
|
||||
for _, chunk := range split {
|
||||
total += len(chunk.entries)
|
||||
}
|
||||
if total != len(entries) {
|
||||
t.Errorf("chunked entries = %d, want %d: the split dropped entries", total, len(entries))
|
||||
}
|
||||
|
||||
// delete is per-entry, so every chunk keeps the operation.
|
||||
for _, chunk := range pfExceptionTableChunks("delete", entries) {
|
||||
if chunk.op != "delete" {
|
||||
t.Errorf("delete chunk op = %q, want delete", chunk.op)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user