mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-09-04 13:36:35 +02:00
macOS Firewall Mode blocked forwarded/NATed VM/container egress: a guest
resolves DNS through a path host ctrld does not observe, so the guest-resolved
public IP never enters <ctrld_allowed> and the blanket outbound block drops the
guest's TCP/443.
Make VM/container guests first-class Firewall Mode clients by forcing their DNS
through ctrld. The trusted source subnets are the UNION of:
- Auto-detected VM/NAT networks (default, no config): interfaces that are up,
carry an RFC1918 IPv4 address, and whose VM ownership can be proven either
by a vendor-specific name (vnic/vboxnet/vmnet) or by being a bridge* whose
member list contains a vendor VM interface (typically vmenet*). Each keeps
its ingress interface, and its pf rules are scoped "on <iface>" so an
unrelated interface on the same private range is never affected.
- service.firewall_forwarded_sources (opt-in): explicit IPv4 CIDRs, matched on
the source CIDR alone, for stacks whose ownership cannot be proven. Config
adds only; an invalid or non-IPv4 entry is dropped with a warning.
Checking bridge membership is what makes the common case work without config.
Every vmnet.framework stack - UTM and other Virtualization.framework guests,
Docker Desktop, Multipass, Fusion 12.1+ NAT - puts the RFC1918 gateway address
on a bridge10x interface and attaches the vendor-named vmenet* interface as an
address-less member, so matching on interface name alone never sees them.
Membership is the ownership proof a bridge name lacks: macOS shares that
namespace with Thunderbolt/aggregated links (ctrld's own tunnel-change code
treats bridge0 as physical), and such a bridge has en* members, so it stays
untrusted however private its address. Members are read with ifconfig, and only
for a bridge that already carries an RFC1918 IPv4 address, so a host with no VM
running executes no subprocess.
Per source subnet, plaintext DNS (53) is force-routed through ctrld (route-to
lo0 -> existing rdr-on-lo0) so guest resolutions are policy-enforced and
populate the allowlist; guest egress to allowed IPs is then permitted by the
existing <ctrld_allowed> rule. DoT (853) is blocked so guests cannot swap in an
alternate resolver. DoH/443 is a documented limitation.
Rules are emitted strictly per address family. Sources are IPv4 (interception
targets ctrld's IPv4 listener), so only inet rules are generated: pf rejects an
entire anchor over a single "inet6 ... from 192.168.x.0/24" mismatch, which
would take DNS interception down with it. Guest IPv6 DoT is covered by the
blanket IPv6 block instead, since such a resolver never enters <ctrld_allowed>.
firewall_forwarded_sources is deliberately not validated with `cidr`. Entries
are checked where they are parsed and a bad one is dropped while the rest of the
set still applies. A hard validator would make one typo in an MDM-pushed subnet
fatal at startup - validateConfig exits the process - taking DNS service down
for the whole host over a line that only ever widened a firewall allowance. The
warning fires when the set of unusable entries changes rather than on every
parse, since config is re-read on every anchor build and every watchdog tick.
Reconcile the trust set at runtime, since VM interfaces appear and disappear
while ctrld runs and no existing path rebuilds an intact anchor for that
(ensurePFAnchorActive returns early, checkTunnelInterfaceChanges tracks only
tunnels, pfInterceptMonitor rebuilds only after a failed host probe). On a
change, rebuild the anchor and drop the pf states of the affected subnets
(targeted pfctl -k, not a global state flush), because rules govern only new
states: a stopped guest would otherwise keep using states created while it was
trusted, and a newly trusted one would keep bypassing interception until its
states expired. Reconciliation runs on interface appear/disappear, on network
changes, on the delayed post-change re-checks (a VM network often gets its
address after its interface appears), and on the pf watchdog tick, which bounds
guest start/stop convergence to one interval even with no network event.
Convergence is not latched on failure: the applied set advances, and states are
killed, only after pf has accepted the new anchor. reloadForwardedSourceAnchor
reports write/pfctl failures to the caller, which then keeps the previous set
recorded and logs a warning, so the next reconcile retries the same transition
instead of going quiet with the old anchor still installed. The whole
compare-reload-record sequence is serialized so a watchdog tick and a network
change cannot both rebuild or interleave snapshots.
Every anchor rebuild records the forwarded-source set it installed, and takes
that set as a parameter rather than re-detecting internally. Otherwise a rebuild
triggered by something else (tunnel change, watchdog restore, VPN DNS
exemptions, forced reload, startup) leaves the snapshot at the older set and the
next reconcile "discovers" the same change again: another rebuild, another round
of killed guest states, and a transition logged for something already in effect.
Passing the set in also means what pf loaded is exactly what gets recorded, so
an interface appearing mid-reconcile cannot leave the snapshot describing a set
that was never installed.
The anchor file is replaced atomically (temp file plus rename). Seven paths
rebuild it from timers and network-change callbacks in their own goroutines with
no lock between them, and os.WriteFile truncates before writing, so a pfctl -f
racing that window could read a partial ruleset and reject the anchor - taking
DNS interception down until the next watchdog restore.
Report the effective trust set at startup and on every change, naming each
subnet's origin ("192.168.64.0/24 (configured)" vs "(auto-detected on
bridge100)"), and say so explicitly when the set is empty, including what
auto-detection requires. Configured entries previously produced no log output at
all, so an admin who set firewall_forwarded_sources could not confirm it had
taken effect without reading pf rules. Per-source detection logging is at debug,
since detection re-runs on every anchor build.
Explicit, per-subnet trust boundary (RFC1918-only auto-detect, proven VM
ownership, interface-scoped rules, config adds only), not an interface-wide
permit: direct public IPs the guest never resolved through ctrld stay blocked.
Tests cover rule generation and its interface scoping, the no-blanket-permit
boundary, single-family emission (no inet6 rule for an IPv4 source, IPv6 sources
skipped), a real pfctl -n -f parse of both the forwarded-source rules alone and
the full anchor (group-scoped rules stripped, since _ctrld exists only where the
service is installed), invalid/duplicate/non-IPv4 config entries, that a bad
entry does not fail config validation, the union set and its signature, a
deterministic guest start/stop lifecycle asserting both the rebuild points and
which subnets' states must be dropped, anchor-reload failure followed by a
successful retry, that reconcile is inert outside firewall mode, ifconfig member
parsing against real bridge output, and the trust decision per interface -
including everything that must NOT qualify: a Thunderbolt bridge, a
public-range VM bridge, an IPv6-only bridge, an address-less vendor interface, a
physical uplink and a VPN tunnel. docs/firewall-mode.md documents the boundary,
what auto-detection can and cannot prove, the address-family constraint, the
lifecycle/retry behavior, how to confirm the trust set from the log, and that a
resolver running inside the guest is not supported (its encrypted upstream
leaves the host nothing to allowlist).
1007 lines
41 KiB
Go
1007 lines
41 KiB
Go
//go:build darwin
|
|
|
|
package cli
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"net/netip"
|
|
"os/exec"
|
|
"sort"
|
|
"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
|
|
|
|
// lastForwardedSources is the forwarded-source set (auto-detected VM networks +
|
|
// configured) that pf has actually accepted, and lastForwardedKey its
|
|
// order-independent signature. reconcileForwardedSources compares against these
|
|
// to rebuild the anchor when guests appear/disappear, and to know which subnets
|
|
// gained or lost trust so their stale pf states can be dropped. Both advance only
|
|
// after a successful anchor load, so a failed reload is retried, never latched.
|
|
lastForwardedSources []forwardedSource
|
|
lastForwardedKey string
|
|
|
|
// applyForwardedMu serializes the compare-reload-record sequence in
|
|
// applyForwardedSourceChange, so a watchdog tick and a network change cannot both
|
|
// rebuild the anchor for the same transition or interleave their snapshot updates.
|
|
// Separate from mu, which must not be held across pfctl execution.
|
|
applyForwardedMu sync.Mutex
|
|
}
|
|
|
|
// forwardedSourceWarnTracker dedupes the "unusable configured entry" warning by
|
|
// signature. Config is re-parsed on every anchor build and every watchdog tick, so
|
|
// warning unconditionally would repeat the same line for the life of the process;
|
|
// comparing signatures still reports an entry a config reload has just introduced.
|
|
//
|
|
// Package-level rather than a pfFirewallState field because it has to work before
|
|
// that state exists: initPlatformFirewall defers pf enforcement until intercept mode
|
|
// starts, so Firewall Mode can be on with no pfFirewallState yet.
|
|
var forwardedSourceWarnTracker struct {
|
|
mu sync.Mutex
|
|
key string
|
|
}
|
|
|
|
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
|
|
)
|
|
|
|
// hypervisorVMNetPrefixes are interface-name prefixes that are specific to a
|
|
// virtualization vendor's private VM/NAT network, and therefore reliable proof of
|
|
// VM ownership by name alone.
|
|
//
|
|
// Two vendors still put the subnet directly on such an interface: Parallels (vnic0)
|
|
// and VirtualBox host-only (vboxnet0). vmnet* covers legacy kext-based VMware Fusion
|
|
// on Intel. They are also what proves ownership of a bridge they are a member of -
|
|
// see bridgeHasVMMember.
|
|
//
|
|
// On current macOS this is not sufficient on its own. vmnet.framework - which backs
|
|
// Virtualization.framework guests (UTM, Docker Desktop, Multipass, Fusion 12.1+ NAT)
|
|
// - puts the RFC1918 gateway address on a bridge10x interface and attaches the
|
|
// vendor-named vmenet* interface as an address-less member of it. Matching on name
|
|
// alone therefore never fires for those stacks, which is why bridge membership is
|
|
// checked too.
|
|
var hypervisorVMNetPrefixes = []string{
|
|
"vmnet", // VMware Fusion (legacy kext-based, Intel)
|
|
"vmenet", // vmnet.framework member interface (UTM, Docker, Multipass, Fusion 12.1+)
|
|
"vnic", // Parallels Desktop
|
|
"vboxnet", // VirtualBox host-only
|
|
}
|
|
|
|
// isHypervisorVMNetIface reports whether an interface name is a known
|
|
// vendor-specific VM/NAT network (see hypervisorVMNetPrefixes).
|
|
func isHypervisorVMNetIface(name string) bool {
|
|
for _, prefix := range hypervisorVMNetPrefixes {
|
|
if strings.HasPrefix(name, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// isBridgeIface reports whether name is a macOS bridge interface.
|
|
//
|
|
// A bridge name is deliberately NOT treated as proof of anything: macOS uses this
|
|
// namespace for Thunderbolt/aggregated links too (this repo's own tunnel-change code
|
|
// lists bridge0 among physical interfaces), and auto-trusting those could force-route
|
|
// unrelated same-subnet traffic. Ownership comes from the member list instead.
|
|
func isBridgeIface(name string) bool {
|
|
return strings.HasPrefix(name, "bridge")
|
|
}
|
|
|
|
// bridgeMembersFn returns a bridge's member interfaces. Indirected so detection can
|
|
// be tested without a hypervisor or ifconfig.
|
|
var bridgeMembersFn = bridgeMembers
|
|
|
|
// bridgeMembers returns the member interfaces of a macOS bridge, via ifconfig.
|
|
// There is no address-family-independent syscall for this that does not mean
|
|
// hand-rolling SIOCGDRVSPEC/BRDGGIFS structs, and this runs at most once per
|
|
// candidate bridge per anchor build.
|
|
func bridgeMembers(name string) []string {
|
|
out, err := exec.Command("ifconfig", name).CombinedOutput()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return parseBridgeMembers(string(out))
|
|
}
|
|
|
|
// parseBridgeMembers extracts member interface names from ifconfig output, which
|
|
// lists each as a line of the form "\tmember: vmenet0 flags=3<LEARNING,DISCOVER>".
|
|
func parseBridgeMembers(ifconfigOut string) []string {
|
|
var members []string
|
|
for _, line := range strings.Split(ifconfigOut, "\n") {
|
|
fields := strings.Fields(line)
|
|
if len(fields) < 2 || fields[0] != "member:" {
|
|
continue
|
|
}
|
|
members = append(members, fields[1])
|
|
}
|
|
return members
|
|
}
|
|
|
|
// bridgeHasVMMember reports whether a bridge's member list proves VM/NAT ownership,
|
|
// i.e. whether a vendor-specific VM interface is bridged into it.
|
|
//
|
|
// This is the ownership evidence a bridge name lacks. A vmnet.framework bridge has a
|
|
// vmenet* member; a Thunderbolt bridge has en* members and so is never trusted.
|
|
func bridgeHasVMMember(members []string) bool {
|
|
for _, m := range members {
|
|
if isHypervisorVMNetIface(m) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// 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 := writePFAnchorFile(rulesStr); 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()
|
|
|
|
// Seed the forwarded-source snapshot with what the anchor was just built with,
|
|
// so the first reconcile only fires on a real subsequent change.
|
|
sources := p.forwardedSources()
|
|
state.lastForwardedSources = sources
|
|
state.lastForwardedKey = forwardedSourceSetKey(sources)
|
|
|
|
// Report the effective trust set, including the configured entries. Without this
|
|
// an admin who sets firewall_forwarded_sources has no way to confirm it took
|
|
// effect short of reading pf rules.
|
|
p.logForwardedSources(sources)
|
|
|
|
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()
|
|
}
|
|
|
|
// forwardedSource is a trusted VM/container source subnet. iface is the ingress
|
|
// interface for an auto-detected source, used to scope its pf rules to that
|
|
// interface so unrelated same-subnet traffic on other interfaces is unaffected.
|
|
// iface is empty for an operator-configured source, which matches on the source
|
|
// CIDR alone — an explicit opt-in the admin is responsible for.
|
|
type forwardedSource struct {
|
|
prefix netip.Prefix
|
|
iface string
|
|
}
|
|
|
|
// firewallForwardedSources parses the operator-configured forwarded-workload
|
|
// source subnets (service.firewall_forwarded_sources), dropping - with a warning -
|
|
// any entry that is not a valid CIDR, or is not IPv4, so one bad line never voids the
|
|
// whole set. These augment auto-detection (see forwardedSources) and are the supported
|
|
// way to trust generic bridge* stacks (Multipass, Docker Desktop).
|
|
//
|
|
// The IPv4 restriction is not cosmetic: forwarded DNS can only be redirected to
|
|
// ctrld's IPv4 intercept listener, and an IPv6 source would otherwise produce pf rules
|
|
// whose address family contradicts their source literal, which makes pfctl reject the
|
|
// entire anchor and take DNS interception down with it.
|
|
func (p *prog) firewallForwardedSources() []forwardedSource {
|
|
sources, rejected := parseForwardedSourceConfig(p.cfg.Service.FirewallForwardedSources)
|
|
p.warnRejectedForwardedSources(rejected)
|
|
return sources
|
|
}
|
|
|
|
// rejectedForwardedSource is a configured entry that could not be used, with the
|
|
// reason to report to the operator.
|
|
type rejectedForwardedSource struct {
|
|
value string
|
|
reason string
|
|
}
|
|
|
|
// parseForwardedSourceConfig parses configured entries, returning the usable sources
|
|
// and the rejected ones. It is pure and silent: callers decide when a rejection is
|
|
// worth logging, because this runs on every anchor build and every watchdog tick.
|
|
func parseForwardedSourceConfig(raw []string) ([]forwardedSource, []rejectedForwardedSource) {
|
|
if len(raw) == 0 {
|
|
return nil, nil
|
|
}
|
|
out := make([]forwardedSource, 0, len(raw))
|
|
var rejected []rejectedForwardedSource
|
|
for _, s := range raw {
|
|
pfx, err := netip.ParsePrefix(strings.TrimSpace(s))
|
|
if err != nil {
|
|
rejected = append(rejected, rejectedForwardedSource{
|
|
value: s,
|
|
reason: "not a valid CIDR (want e.g. 192.168.64.0/24): " + err.Error(),
|
|
})
|
|
continue
|
|
}
|
|
if !pfx.Addr().Is4() {
|
|
rejected = append(rejected, rejectedForwardedSource{
|
|
value: s,
|
|
reason: "not IPv4 - forwarded-workload DNS interception is IPv4-only (ctrld's intercept listener is IPv4)",
|
|
})
|
|
continue
|
|
}
|
|
out = append(out, forwardedSource{prefix: pfx.Masked()})
|
|
}
|
|
return out, rejected
|
|
}
|
|
|
|
// warnRejectedForwardedSources reports unusable configured entries, but only when the
|
|
// set of rejections changes (see forwardedSourceWarnTracker).
|
|
func (p *prog) warnRejectedForwardedSources(rejected []rejectedForwardedSource) {
|
|
key := rejectedForwardedSourcesKey(rejected)
|
|
|
|
forwardedSourceWarnTracker.mu.Lock()
|
|
unchanged := forwardedSourceWarnTracker.key == key
|
|
forwardedSourceWarnTracker.key = key
|
|
forwardedSourceWarnTracker.mu.Unlock()
|
|
|
|
if unchanged {
|
|
return
|
|
}
|
|
for _, r := range rejected {
|
|
p.Warn().Str("value", r.value).
|
|
Msgf("Firewall: ignoring firewall_forwarded_sources entry - %s", r.reason)
|
|
}
|
|
}
|
|
|
|
// rejectedForwardedSourcesKey returns an order-independent signature of a rejection
|
|
// set, so re-parsing an unchanged config is recognised as nothing new to report.
|
|
func rejectedForwardedSourcesKey(rejected []rejectedForwardedSource) string {
|
|
if len(rejected) == 0 {
|
|
return ""
|
|
}
|
|
parts := make([]string, 0, len(rejected))
|
|
for _, r := range rejected {
|
|
parts = append(parts, r.value+"|"+r.reason)
|
|
}
|
|
sort.Strings(parts)
|
|
return strings.Join(parts, ",")
|
|
}
|
|
|
|
// detectForwardedSources auto-discovers VM/NAT networks on the host so the common
|
|
// case works with no configuration.
|
|
//
|
|
// An interface qualifies only if it is up, carries an RFC1918 IPv4 network, AND its
|
|
// VM ownership can be proven one of two ways:
|
|
//
|
|
// - its own name is vendor-specific (hypervisorVMNetPrefixes): Parallels, legacy
|
|
// Fusion, VirtualBox host-only;
|
|
// - it is a bridge whose member list contains a vendor-specific interface
|
|
// (bridgeHasVMMember): every vmnet.framework stack, where the address lives on
|
|
// bridge10x and the vendor-named vmenet* member has none.
|
|
//
|
|
// Ownership proof is the security boundary: a bridge is trusted for what is bridged
|
|
// into it, never for its name. The RFC1918 filter is the second boundary - a stack
|
|
// presenting a public range is never auto-trusted. Each detected source keeps its
|
|
// ingress interface so its pf rules stay scoped to it.
|
|
//
|
|
// Addresses are checked before membership so the ifconfig call is only made for a
|
|
// bridge that could actually qualify; a Thunderbolt bridge with no RFC1918 address
|
|
// costs nothing.
|
|
func (p *prog) detectForwardedSources() []forwardedSource {
|
|
ifaces, err := net.Interfaces()
|
|
if err != nil {
|
|
p.Warn().Err(err).Msg("Firewall: could not enumerate interfaces for forwarded-source detection")
|
|
return nil
|
|
}
|
|
var out []forwardedSource
|
|
for _, ifi := range ifaces {
|
|
if ifi.Flags&net.FlagUp == 0 {
|
|
continue
|
|
}
|
|
addrs, err := ifi.Addrs()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
sources, reason := forwardedSourcesForIface(ifi.Name, addrs, bridgeMembersFn)
|
|
for _, src := range sources {
|
|
out = append(out, src)
|
|
// Debug, not Info: detection re-runs on every anchor build. The effective
|
|
// set is reported once by logForwardedSources at init and on every change.
|
|
p.Debug().Str("iface", src.iface).Str("subnet", src.prefix.String()).Str("reason", reason).
|
|
Msg("Firewall: auto-detected VM/container network for forwarded DNS")
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// forwardedSourcesForIface decides whether one up interface is an auto-trusted
|
|
// forwarded-workload source, and returns its subnets plus why it qualified.
|
|
//
|
|
// Split out from interface enumeration so the trust decision - which is a security
|
|
// boundary - is testable against synthetic interfaces, including the cases that must
|
|
// NOT qualify: a Thunderbolt bridge, a public-range VM network, an address-less
|
|
// vendor interface.
|
|
//
|
|
// members is only consulted for a bridge that already has an RFC1918 IPv4 address, so
|
|
// no subprocess runs for the address-less or public bridges on a typical host.
|
|
func forwardedSourcesForIface(name string, addrs []net.Addr, members func(string) []string) ([]forwardedSource, string) {
|
|
vendorNamed := isHypervisorVMNetIface(name)
|
|
if !vendorNamed && !isBridgeIface(name) {
|
|
return nil, ""
|
|
}
|
|
prefixes := privateIPv4Prefixes(addrs)
|
|
if len(prefixes) == 0 {
|
|
return nil, ""
|
|
}
|
|
reason := "vendor VM interface"
|
|
if !vendorNamed {
|
|
mem := members(name)
|
|
if !bridgeHasVMMember(mem) {
|
|
return nil, ""
|
|
}
|
|
reason = "bridge with VM member " + strings.Join(vmMembers(mem), ",")
|
|
}
|
|
out := make([]forwardedSource, 0, len(prefixes))
|
|
for _, pfx := range prefixes {
|
|
out = append(out, forwardedSource{prefix: pfx, iface: name})
|
|
}
|
|
return out, reason
|
|
}
|
|
|
|
// privateIPv4Prefixes returns the masked RFC1918 IPv4 networks among addrs, skipping
|
|
// public and IPv6 addresses.
|
|
func privateIPv4Prefixes(addrs []net.Addr) []netip.Prefix {
|
|
var out []netip.Prefix
|
|
for _, a := range addrs {
|
|
ipnet, ok := a.(*net.IPNet)
|
|
if !ok {
|
|
continue
|
|
}
|
|
pfx, err := netip.ParsePrefix(ipnet.String())
|
|
if err != nil {
|
|
continue
|
|
}
|
|
pfx = pfx.Masked()
|
|
if !pfx.Addr().Is4() || !pfx.Addr().IsPrivate() {
|
|
continue
|
|
}
|
|
out = append(out, pfx)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// vmMembers returns the vendor VM interfaces among a bridge's members, for logging
|
|
// which member made the bridge trusted.
|
|
func vmMembers(members []string) []string {
|
|
var out []string
|
|
for _, m := range members {
|
|
if isHypervisorVMNetIface(m) {
|
|
out = append(out, m)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// forwardedSources returns the effective trusted set: auto-detected vendor VM
|
|
// networks UNION operator-configured subnets, de-duplicated by prefix (a subnet
|
|
// that is both auto-detected and configured keeps the interface-scoped
|
|
// auto-detected form). Config augments auto-detection; it never disables it.
|
|
func (p *prog) forwardedSources() []forwardedSource {
|
|
var out []forwardedSource
|
|
seen := make(map[netip.Prefix]struct{})
|
|
for _, group := range [][]forwardedSource{
|
|
p.detectForwardedSources(),
|
|
p.firewallForwardedSources(),
|
|
} {
|
|
for _, src := range group {
|
|
if _, dup := seen[src.prefix]; dup {
|
|
continue
|
|
}
|
|
seen[src.prefix] = struct{}{}
|
|
out = append(out, src)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// forwardedSourceSetKey returns a deterministic, order-independent signature of a
|
|
// forwarded-source set. reconcileForwardedSources compares it across time to detect
|
|
// when VM/container interfaces appear or disappear.
|
|
func forwardedSourceSetKey(sources []forwardedSource) string {
|
|
parts := make([]string, 0, len(sources))
|
|
for _, s := range sources {
|
|
parts = append(parts, s.iface+"|"+s.prefix.String())
|
|
}
|
|
sort.Strings(parts)
|
|
return strings.Join(parts, ",")
|
|
}
|
|
|
|
// currentForwardedSources returns the effective forwarded-source set, or nil when
|
|
// Firewall Mode is off.
|
|
//
|
|
// The gate matters because detection is not free: it enumerates interfaces, execs
|
|
// ifconfig for each candidate bridge, and re-parses config. Those rules are only ever
|
|
// emitted in firewall mode, so doing any of it with firewall mode off is pure waste -
|
|
// and it would report unusable config entries on a path where nothing wants them.
|
|
// Anchor rebuilds happen on every tunnel change, watchdog restore and VPN DNS update
|
|
// regardless of firewall mode, so this is the difference between zero work and work on
|
|
// every one of them.
|
|
func (p *prog) currentForwardedSources() []forwardedSource {
|
|
if !p.firewallModeEnabled() {
|
|
return nil
|
|
}
|
|
return p.forwardedSources()
|
|
}
|
|
|
|
// buildPFForwardedSourceRulesFor generates the pf rules that make the given
|
|
// VM/container (forwarded/NATed) source subnets first-class Firewall Mode clients
|
|
// WITHOUT an interface-wide bypass. Returns "" for an empty input so anchor
|
|
// behavior is unchanged when there are no sources.
|
|
//
|
|
// For each source subnet:
|
|
//
|
|
// - Plaintext DNS (port 53) is force-routed through ctrld's loopback listener
|
|
// (route-to lo0, which then hits the existing rdr-on-lo0 redirect). ctrld
|
|
// therefore observes and policy-enforces every guest resolution, and the
|
|
// resolved IP lands in <ctrld_allowed>. The guest's subsequent egress to that
|
|
// IP is permitted by the existing "pass out ... to <ctrld_allowed>" rule: NAT
|
|
// rewrites the guest source to the host, which that rule's "from any" covers.
|
|
// - DoT (port 853) to any resolver is blocked so the guest cannot swap in an
|
|
// alternate encrypted resolver to escape policy. DoH over 443 is
|
|
// indistinguishable from ordinary HTTPS and is a documented limitation.
|
|
//
|
|
// Auto-detected sources carry their ingress interface and are scoped with
|
|
// "on <iface>", so an unrelated interface on the same private range is never
|
|
// affected. Configured sources (admin opt-in) match on the source CIDR alone.
|
|
// All matches are on the pre-NAT guest source (inbound): after NAT the source is
|
|
// the host and could no longer be told apart. This is an explicit, per-subnet
|
|
// trust boundary - a direct public IP the guest never resolved through ctrld stays
|
|
// blocked, so the guest cannot bypass Control D policy.
|
|
//
|
|
// IPv4 only, and strictly per address family: ctrld's intercept listener is IPv4, so
|
|
// IPv6 guest DNS cannot be redirected here - the anchor's existing
|
|
// "block out ... inet6 ... port 53" rule forces guests to fall back to interceptable
|
|
// IPv4 DNS, and guest IPv6 egress to an alternate resolver is covered by the blanket
|
|
// IPv6 block plus the fact that such a resolver never enters <ctrld_allowed>.
|
|
// Consequently only IPv4 sources produce rules, and each rule's address family
|
|
// matches its source literal: pf rejects a whole anchor over a single
|
|
// "inet6 ... from 192.168.x.0/24" mismatch, which would take DNS interception down
|
|
// with it. Non-IPv4 sources are skipped here as a backstop; firewallForwardedSources
|
|
// already drops them at parse time with a warning.
|
|
func buildPFForwardedSourceRulesFor(sources []forwardedSource, listenerIP string) string {
|
|
sources = ipv4ForwardedSources(sources)
|
|
if len(sources) == 0 {
|
|
return ""
|
|
}
|
|
|
|
var rules strings.Builder
|
|
rules.WriteString("\n# --- Firewall Mode: forwarded workload (VM/container) DNS interception ---\n")
|
|
rules.WriteString("# VM/container source subnets (auto-detected vendor VM networks + configured).\n")
|
|
rules.WriteString("# Their DNS is forced through ctrld so guest resolutions are policy-enforced and\n")
|
|
fmt.Fprintf(&rules, "# populate <%s>; egress to allowed IPs is then permitted by the allowlist rule\n", pfFirewallTable)
|
|
rules.WriteString("# below. Auto-detected sources are scoped to their ingress interface; this is an\n")
|
|
rules.WriteString("# explicit, per-subnet trust boundary - NOT an interface-wide permit.\n\n")
|
|
|
|
for _, src := range sources {
|
|
cidr := src.prefix.String()
|
|
on := ""
|
|
label := "configured (source-CIDR scope)"
|
|
if src.iface != "" {
|
|
on = "on " + src.iface + " "
|
|
label = "auto-detected on " + src.iface
|
|
}
|
|
fmt.Fprintf(&rules, "# %s - %s\n", cidr, label)
|
|
// Force guest plaintext DNS (port 53) onto loopback, where the existing
|
|
// rdr-on-lo0 rule redirects it to ctrld. Matched inbound (pre-NAT); "quick"
|
|
// so it wins over the blanket block that buildPFFirewallRules appends after.
|
|
fmt.Fprintf(&rules, "pass in quick %sroute-to lo0 inet proto udp from %s to ! %s port 53\n", on, cidr, listenerIP)
|
|
fmt.Fprintf(&rules, "pass in quick %sroute-to lo0 inet proto tcp from %s to ! %s port 53\n", on, cidr, listenerIP)
|
|
// Block DoT so the guest cannot escape ctrld via an alternate encrypted
|
|
// resolver. DoH over 443 is indistinguishable from HTTPS - documented limitation.
|
|
// inet only: the source literal is IPv4, and pf refuses to load an anchor
|
|
// containing an inet6 rule with an IPv4 source.
|
|
fmt.Fprintf(&rules, "block return in quick %sinet proto { tcp, udp } from %s to any port 853\n\n", on, cidr)
|
|
}
|
|
|
|
return rules.String()
|
|
}
|
|
|
|
// ipv4ForwardedSources returns the IPv4 subset of sources. Forwarded-workload DNS
|
|
// interception is IPv4-only (the intercept listener is IPv4), and every emitted rule
|
|
// must match its source's address family or pfctl rejects the entire anchor.
|
|
func ipv4ForwardedSources(sources []forwardedSource) []forwardedSource {
|
|
out := make([]forwardedSource, 0, len(sources))
|
|
for _, src := range sources {
|
|
if src.prefix.Addr().Is4() {
|
|
out = append(out, src)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// reconcileForwardedSources rebuilds the pf anchor when the effective forwarded-
|
|
// source set (auto-detected VM networks + configured) has changed since the last
|
|
// build, and drops the pf states of every subnet whose trust changed.
|
|
//
|
|
// VM/container interfaces appear and disappear at runtime (guest start/stop) and no
|
|
// existing path rebuilds an otherwise-intact anchor for that: ensurePFAnchorActive()
|
|
// returns early while the rules still exist, checkTunnelInterfaceChanges() tracks
|
|
// only tunnel interfaces, and pfInterceptMonitor() rebuilds only after the host
|
|
// interception probe fails. Without this, a guest started after ctrld would never be
|
|
// trusted, and a stopped guest's subnet would stay trusted until an unrelated
|
|
// rebuild. Invoked from the network-change paths and, as a time bound when no event
|
|
// fires, from the pf watchdog tick.
|
|
//
|
|
// A reload failure is not latched: the applied snapshot only advances once pf has
|
|
// actually accepted the new anchor, so the next reconcile (at the latest the next
|
|
// watchdog tick) retries the same change instead of treating it as done.
|
|
func (p *prog) reconcileForwardedSources() {
|
|
if !p.firewallModeEnabled() || p.dnsInterceptState == nil {
|
|
return
|
|
}
|
|
state, ok := p.platformFirewallState.(*pfFirewallState)
|
|
if !ok || state == nil {
|
|
return
|
|
}
|
|
sources := p.forwardedSources()
|
|
reload := func() error { return p.reloadForwardedSourceAnchor(sources) }
|
|
gained, lost, changed, err := state.applyForwardedSourceChange(sources, reload)
|
|
if !changed {
|
|
return
|
|
}
|
|
if err != nil {
|
|
p.Warn().Err(err).Strs("gained_trust", prefixStrings(gained)).Strs("lost_trust", prefixStrings(lost)).
|
|
Msg("Firewall: forwarded-source set changed but the pf anchor reload failed — keeping the previously applied set so the next reconcile retries")
|
|
return
|
|
}
|
|
p.Info().Strs("gained_trust", prefixStrings(gained)).Strs("lost_trust", prefixStrings(lost)).
|
|
Msg("Firewall: forwarded-source set changed (VM/container start/stop), pf anchor rebuilt")
|
|
// Restate the whole effective set, so one log line always answers "what is trusted
|
|
// right now" without replaying every earlier transition.
|
|
p.logForwardedSources(sources)
|
|
// Rules only govern new states, so kill the states of the affected subnets:
|
|
// a subnet that lost trust must stop using states created while it was trusted,
|
|
// and one that just gained it must have its pre-existing (un-intercepted) DNS
|
|
// states re-evaluated instead of running until they expire. The guest simply
|
|
// re-establishes the connections under the new rules. Only reached after a
|
|
// successful load - killing states against the old anchor would just have them
|
|
// recreated under the very rules the change was meant to replace.
|
|
p.killForwardedSourceStates(append(gained, lost...))
|
|
}
|
|
|
|
// applyForwardedSourceChange performs one reconcile step: compare cur against the
|
|
// last applied forwarded-source set and, when it differs, install it via reload.
|
|
// It reports which subnets gained and lost trust (trust identity is (subnet,
|
|
// interface scope), so a subnet that stays but changes scope appears in both lists),
|
|
// whether there was anything to do at all, and reload's error.
|
|
//
|
|
// The applied snapshot advances ONLY after reload returns nil. A failed write or
|
|
// pfctl load therefore leaves the previous set recorded, so the running anchor and
|
|
// the snapshot cannot diverge and the change is retried on the next reconcile rather
|
|
// than silently dropped. The whole check-reload-record sequence is serialized so
|
|
// concurrent callers (watchdog tick vs. network change) cannot both rebuild or
|
|
// interleave their snapshot updates.
|
|
//
|
|
// Split out from reconcileForwardedSources so the guest start/stop lifecycle and the
|
|
// failure-then-retry path are deterministically testable without pf or a hypervisor.
|
|
func (s *pfFirewallState) applyForwardedSourceChange(cur []forwardedSource, reload func() error) (gained, lost []netip.Prefix, changed bool, err error) {
|
|
s.applyForwardedMu.Lock()
|
|
defer s.applyForwardedMu.Unlock()
|
|
|
|
key := forwardedSourceSetKey(cur)
|
|
s.mu.Lock()
|
|
applied, appliedKey := s.lastForwardedSources, s.lastForwardedKey
|
|
s.mu.Unlock()
|
|
|
|
if key == appliedKey {
|
|
return nil, nil, false, nil
|
|
}
|
|
gained = forwardedSubnetsNotIn(cur, applied)
|
|
lost = forwardedSubnetsNotIn(applied, cur)
|
|
|
|
if err := reload(); err != nil {
|
|
return gained, lost, true, err
|
|
}
|
|
|
|
s.mu.Lock()
|
|
s.lastForwardedSources = cur
|
|
s.lastForwardedKey = key
|
|
s.mu.Unlock()
|
|
return gained, lost, true, nil
|
|
}
|
|
|
|
// forwardedSubnetsNotIn returns the subnets of a whose exact trust entry (subnet +
|
|
// interface scope) is absent from b, de-duplicated.
|
|
func forwardedSubnetsNotIn(a, b []forwardedSource) []netip.Prefix {
|
|
inB := make(map[forwardedSource]struct{}, len(b))
|
|
for _, src := range b {
|
|
inB[src] = struct{}{}
|
|
}
|
|
var out []netip.Prefix
|
|
seen := make(map[netip.Prefix]struct{}, len(a))
|
|
for _, src := range a {
|
|
if _, ok := inB[src]; ok {
|
|
continue
|
|
}
|
|
if _, dup := seen[src.prefix]; dup {
|
|
continue
|
|
}
|
|
seen[src.prefix] = struct{}{}
|
|
out = append(out, src.prefix)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// prefixStrings renders prefixes for logging.
|
|
func prefixStrings(prefixes []netip.Prefix) []string {
|
|
out := make([]string, 0, len(prefixes))
|
|
for _, pfx := range prefixes {
|
|
out = append(out, pfx.String())
|
|
}
|
|
return out
|
|
}
|
|
|
|
// forwardedSourceDescriptions renders the effective trust set for logging, naming each
|
|
// subnet's origin so an admin can tell an auto-detected VM network (and the interface
|
|
// its rules are scoped to) from an entry they configured.
|
|
func forwardedSourceDescriptions(sources []forwardedSource) []string {
|
|
out := make([]string, 0, len(sources))
|
|
for _, src := range sources {
|
|
if src.iface != "" {
|
|
out = append(out, src.prefix.String()+" (auto-detected on "+src.iface+")")
|
|
continue
|
|
}
|
|
out = append(out, src.prefix.String()+" (configured)")
|
|
}
|
|
return out
|
|
}
|
|
|
|
// logForwardedSources reports the effective forwarded-workload trust set. Configured
|
|
// entries were previously invisible in the log - only auto-detection said anything -
|
|
// so an admin had no way to confirm firewall_forwarded_sources took effect. The empty
|
|
// case is logged too, with what to do about it, because "no guest DNS interception"
|
|
// looks identical to "feature silently did nothing".
|
|
func (p *prog) logForwardedSources(sources []forwardedSource) {
|
|
if len(sources) == 0 {
|
|
p.Info().Msg("Firewall: no forwarded-workload (VM/container) sources — guest DNS is not intercepted. " +
|
|
"Auto-detection needs an up interface with an RFC1918 IPv4 address that is either vendor-named " +
|
|
"(vnic*, vboxnet*, vmnet*) or a bridge with a VM member (vmenet*); anything else must be listed " +
|
|
"in service.firewall_forwarded_sources")
|
|
return
|
|
}
|
|
p.Info().Int("count", len(sources)).Strs("sources", forwardedSourceDescriptions(sources)).
|
|
Msg("Firewall: forwarded-workload (VM/container) DNS interception active for these source subnets")
|
|
}
|
|
|
|
// killForwardedSourceStates drops the pf state entries sourced from the given
|
|
// subnets. Targeted (pfctl -k <network>) rather than a global state flush, so a
|
|
// guest starting or stopping never resets unrelated host connections.
|
|
func (p *prog) killForwardedSourceStates(prefixes []netip.Prefix) {
|
|
for _, pfx := range prefixes {
|
|
out, err := exec.Command("pfctl", "-k", pfx.String()).CombinedOutput()
|
|
if err != nil {
|
|
// Not a hard error - most often there simply are no matching states.
|
|
p.Debug().Err(err).Str("subnet", pfx.String()).Str("output", strings.TrimSpace(string(out))).
|
|
Msg("Firewall: could not kill pf states for changed forwarded source")
|
|
continue
|
|
}
|
|
p.Info().Str("subnet", pfx.String()).
|
|
Msg("Firewall: killed pf states for changed forwarded source")
|
|
}
|
|
}
|
|
|
|
// recordAppliedForwardedSources records sources as the set pf is now enforcing.
|
|
//
|
|
// Called by the rebuild paths that are not the forwarded-source reconcile itself
|
|
// (tunnel change, watchdog restore, VPN DNS exemptions, forced reload, startup): each
|
|
// of those installs a full anchor that already contains the current set, so without
|
|
// this the next reconcile would compare against a stale snapshot and redo the work.
|
|
//
|
|
// Takes applyForwardedMu so a record cannot land in the middle of a reconcile's
|
|
// compare-reload-record sequence and be overwritten by it, or overwrite it.
|
|
func (p *prog) recordAppliedForwardedSources(sources []forwardedSource) {
|
|
state, ok := p.platformFirewallState.(*pfFirewallState)
|
|
if !ok || state == nil {
|
|
return
|
|
}
|
|
state.applyForwardedMu.Lock()
|
|
defer state.applyForwardedMu.Unlock()
|
|
|
|
state.mu.Lock()
|
|
state.lastForwardedSources = sources
|
|
state.lastForwardedKey = forwardedSourceSetKey(sources)
|
|
state.mu.Unlock()
|
|
}
|
|
|
|
// reloadForwardedSourceAnchor rebuilds and reloads the ctrld pf anchor so the given
|
|
// forwarded-source rules take effect. Mirrors the anchor reload used by the intercept
|
|
// watchdog, but reports failure to the caller: whether pf actually accepted the anchor
|
|
// decides whether the new source set may be recorded as applied.
|
|
//
|
|
// The set is passed in rather than re-detected, so what gets loaded is exactly what
|
|
// the caller compared and will record. Re-detecting here could install a set that
|
|
// differs from the recorded snapshot if an interface appeared in between.
|
|
func (p *prog) reloadForwardedSourceAnchor(sources []forwardedSource) error {
|
|
var vpnExemptions []vpnDNSExemption
|
|
if p.vpnDNS != nil {
|
|
vpnExemptions = p.vpnDNS.CurrentExemptions()
|
|
}
|
|
rulesStr := p.buildPFAnchorRulesWith(vpnExemptions, sources)
|
|
if err := writePFAnchorFile(rulesStr); err != nil {
|
|
return fmt.Errorf("write pf anchor %s: %w", pfAnchorFile, err)
|
|
}
|
|
if out, err := exec.Command("pfctl", "-a", pfAnchorName, "-f", pfAnchorFile).CombinedOutput(); err != nil {
|
|
return fmt.Errorf("load pf anchor %s: %w (output: %s)", pfAnchorName, err, strings.TrimSpace(string(out)))
|
|
}
|
|
return nil
|
|
}
|