mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-09-04 13:36:35 +02:00
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.
215 lines
6.6 KiB
Go
215 lines
6.6 KiB
Go
package firewall
|
|
|
|
import (
|
|
"fmt"
|
|
"net/netip"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func prefixes(t *testing.T, ss ...string) []netip.Prefix {
|
|
t.Helper()
|
|
out := make([]netip.Prefix, 0, len(ss))
|
|
for _, s := range ss {
|
|
out = append(out, netip.MustParsePrefix(s))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestSetExceptionsNormalizesAndReplaces(t *testing.T) {
|
|
al := New()
|
|
|
|
al.SetExceptions(prefixes(t, "203.0.113.10/32", "198.51.100.0/24"))
|
|
first := al.Exceptions()
|
|
if got := prefixStrings(first); len(got) != 2 {
|
|
t.Fatalf("Exceptions() = %v, want 2 entries", got)
|
|
}
|
|
|
|
// Same set, different order and an unmasked spelling of the same network. The
|
|
// index must not be rebuilt: the list is re-applied on every configuration
|
|
// refresh, and a rebuild would republish it to the Contains() hot path hourly
|
|
// for a set that did not change.
|
|
al.SetExceptions(prefixes(t, "198.51.100.77/24", "203.0.113.10/32"))
|
|
same := al.Exceptions()
|
|
if len(same) != len(first) || &same[0] != &first[0] {
|
|
t.Fatalf("an equivalent set replaced the index: %v -> %v", prefixStrings(first), prefixStrings(same))
|
|
}
|
|
|
|
al.SetExceptions(prefixes(t, "203.0.113.10/32", "2001:db8::/48"))
|
|
changed := al.Exceptions()
|
|
if got := prefixStrings(changed); len(got) != 2 {
|
|
t.Fatalf("Exceptions() = %v, want 2 entries", got)
|
|
}
|
|
if &changed[0] == &first[0] {
|
|
t.Fatal("a changed set left the previous index installed")
|
|
}
|
|
|
|
// An empty list clears the set: an organization can withdraw everything.
|
|
al.SetExceptions(nil)
|
|
if got := al.Exceptions(); len(got) != 0 {
|
|
t.Fatalf("Exceptions() after clear = %v, want empty", got)
|
|
}
|
|
al.SetExceptions(nil)
|
|
if got := al.Exceptions(); len(got) != 0 {
|
|
t.Fatalf("Exceptions() after clearing twice = %v, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestExceptionsAllowWithoutDNS(t *testing.T) {
|
|
al := New()
|
|
inRange := netip.MustParseAddr("198.51.100.7")
|
|
host := netip.MustParseAddr("203.0.113.10")
|
|
other := netip.MustParseAddr("203.0.113.11")
|
|
v6 := netip.MustParseAddr("2001:db8::1")
|
|
|
|
for _, ip := range []netip.Addr{inRange, host, other, v6} {
|
|
if al.Contains(ip) {
|
|
t.Fatalf("%s allowed before any exception was applied", ip)
|
|
}
|
|
}
|
|
|
|
al.SetExceptions(prefixes(t, "198.51.100.0/24", "203.0.113.10/32", "2001:db8::/48"))
|
|
|
|
// Allowed with no prior DNS resolution — the point of the list.
|
|
for _, ip := range []netip.Addr{inRange, host, v6} {
|
|
if !al.Contains(ip) {
|
|
t.Fatalf("%s not allowed by the exception set", ip)
|
|
}
|
|
}
|
|
// An unresolved public destination outside the set stays blocked.
|
|
if al.Contains(other) {
|
|
t.Fatalf("%s allowed although it is outside the exception set", other)
|
|
}
|
|
|
|
// Removing an entry stops it bypassing, and leaves the rest allowed.
|
|
al.SetExceptions(prefixes(t, "203.0.113.10/32"))
|
|
if al.Contains(inRange) {
|
|
t.Fatalf("%s still allowed after its prefix was removed", inRange)
|
|
}
|
|
if !al.Contains(host) {
|
|
t.Fatalf("%s should still be allowed", host)
|
|
}
|
|
}
|
|
|
|
// TestExceptionsBoundaries pins the range arithmetic the binary-search index
|
|
// rests on: the first and last address of a prefix are inside it, the addresses
|
|
// on either side are not, and a family never matches the other family's ranges.
|
|
func TestExceptionsBoundaries(t *testing.T) {
|
|
al := New()
|
|
al.SetExceptions(prefixes(t, "198.51.100.0/24", "2001:db8:1::/48"))
|
|
|
|
in := []string{
|
|
"198.51.100.0", "198.51.100.255",
|
|
"2001:db8:1::", "2001:db8:1:ffff:ffff:ffff:ffff:ffff",
|
|
}
|
|
out := []string{
|
|
"198.51.99.255", "198.51.101.0",
|
|
"2001:db8:0:ffff:ffff:ffff:ffff:ffff", "2001:db8:2::",
|
|
}
|
|
for _, s := range in {
|
|
if !al.Contains(netip.MustParseAddr(s)) {
|
|
t.Errorf("%s should be inside the exception set", s)
|
|
}
|
|
}
|
|
for _, s := range out {
|
|
if al.Contains(netip.MustParseAddr(s)) {
|
|
t.Errorf("%s should be outside the exception set", s)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestExceptionsOverlappingEntries covers an organization listing a network and
|
|
// an address within it, plus two adjacent networks - both of which the index
|
|
// merges, and neither of which may change what is allowed.
|
|
func TestExceptionsOverlappingEntries(t *testing.T) {
|
|
al := New()
|
|
al.SetExceptions(prefixes(t,
|
|
"198.51.100.0/24", "198.51.100.7/32", // contained
|
|
"203.0.113.0/25", "203.0.113.128/25", // adjacent halves
|
|
))
|
|
|
|
for _, s := range []string{"198.51.100.7", "198.51.100.200", "203.0.113.1", "203.0.113.200"} {
|
|
if !al.Contains(netip.MustParseAddr(s)) {
|
|
t.Errorf("%s should be allowed", s)
|
|
}
|
|
}
|
|
for _, s := range []string{"198.51.101.7", "203.0.114.1"} {
|
|
if al.Contains(netip.MustParseAddr(s)) {
|
|
t.Errorf("%s should not be allowed", s)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExceptionsIndependentOfDNSAllowlist(t *testing.T) {
|
|
al := New()
|
|
al.SetExceptions(prefixes(t, "203.0.113.0/24"))
|
|
|
|
resolved := netip.MustParseAddr("192.0.2.5")
|
|
al.Add(resolved, "example.com", time.Minute)
|
|
|
|
// Flush discards DNS-resolved IPs; exceptions are administrative and survive.
|
|
al.Flush()
|
|
if al.Contains(resolved) {
|
|
t.Fatalf("%s survived a flush", resolved)
|
|
}
|
|
if !al.Contains(netip.MustParseAddr("203.0.113.9")) {
|
|
t.Fatal("exception did not survive a flush of the DNS-resolved allowlist")
|
|
}
|
|
|
|
// A removed exception is not resurrected by an unrelated DNS resolution.
|
|
al.SetExceptions(nil)
|
|
if al.Contains(netip.MustParseAddr("203.0.113.9")) {
|
|
t.Fatal("cleared exception still allowed")
|
|
}
|
|
}
|
|
|
|
func TestExceptionsInStats(t *testing.T) {
|
|
al := New()
|
|
if got := al.Stats().ExceptionPrefixes; got != 0 {
|
|
t.Fatalf("ExceptionPrefixes = %d, want 0", got)
|
|
}
|
|
al.SetExceptions(prefixes(t, "203.0.113.0/24", "2001:db8::/48"))
|
|
if got := al.Stats().ExceptionPrefixes; got != 2 {
|
|
t.Fatalf("ExceptionPrefixes = %d, want 2", got)
|
|
}
|
|
}
|
|
|
|
func prefixStrings(prefixes []netip.Prefix) []string {
|
|
out := make([]string, 0, len(prefixes))
|
|
for _, prefix := range prefixes {
|
|
out = append(out, prefix.String())
|
|
}
|
|
return out
|
|
}
|
|
|
|
// benchExceptions builds a set of n distinct /24s plus one /32 the benchmark
|
|
// looks up, so lookups traverse the whole index rather than hitting an early
|
|
// entry.
|
|
func benchExceptions(n int) []netip.Prefix {
|
|
out := make([]netip.Prefix, 0, n)
|
|
for i := 0; i < n; i++ {
|
|
out = append(out, netip.MustParsePrefix(fmt.Sprintf("10.%d.%d.0/24", i/256, i%256)))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// BenchmarkContainsWithExceptions guards the hot path: Contains() is called per
|
|
// connection (per packet for embedders), so the cost of a large organization
|
|
// list must not scale with its length.
|
|
func BenchmarkContainsWithExceptions(b *testing.B) {
|
|
for _, n := range []int{0, 100, 2000} {
|
|
b.Run(fmt.Sprintf("prefixes=%d", n), func(b *testing.B) {
|
|
al := New()
|
|
if n > 0 {
|
|
al.SetExceptions(benchExceptions(n))
|
|
}
|
|
ip := netip.MustParseAddr("203.0.113.10") // never in the set: worst case
|
|
b.ReportAllocs()
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
al.Contains(ip)
|
|
}
|
|
})
|
|
}
|
|
}
|