From 5654c56f2e4977f4591bb0905347c34b953738bc Mon Sep 17 00:00:00 2001 From: Suraj Kumar Date: Sun, 16 Aug 2026 18:38:01 +0000 Subject: [PATCH 1/2] ateomnet: restrict actor masquerade to cluster DNS resolver The postrouting masquerade in InstallActorNftablesRules previously masqueraded all traffic from the actor veth IP. Restrict it to UDP DNS traffic (port 53) destined for the cluster resolver read from the pod's /etc/resolv.conf. When no IPv4 resolver is available the legacy broad masquerade is preserved for backward compatibility. New helpers: UDPProtocol(), IPDestEqual(), readDNSResolver(). Fixes: agent-substrate/substrate#960 --- PR_DESCRIPTION_960.md | 43 ++++++++++++++++++ internal/ateomnet/net.go | 97 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 133 insertions(+), 7 deletions(-) create mode 100644 PR_DESCRIPTION_960.md diff --git a/PR_DESCRIPTION_960.md b/PR_DESCRIPTION_960.md new file mode 100644 index 000000000..135acf573 --- /dev/null +++ b/PR_DESCRIPTION_960.md @@ -0,0 +1,43 @@ +## ateomnet: restrict actor masquerade to cluster DNS resolver + +Fixes: https://github.com/agent-substrate/substrate/issues/960 + +### Problem + +`InstallActorNftablesRules` installs a postrouting masquerade that matches +all traffic from the actor veth IP — effectively NATting every packet the +actor sends, including traffic that should be dropped by policy. + +### Fix + +When a cluster DNS resolver IP is available (read from the pod's +/etc/resolv.conf), the postrouting masquerade is now restricted to a single +nftables rule matching all four conditions: + +- source = actor veth IP +- protocol = UDP +- destination = cluster resolver IP +- destination port = 53 + +All other non-tunneled actor egress is no longer masqueraded and will be +dropped by the kernel's default forward policy. + +When no IPv4 resolver is available (empty /etc/resolv.conf, IPv6-only +resolver, or missing file), the legacy broad masquerade is preserved for +backward compatibility. + +### New helpers + +- `readDNSResolver()` — parses /etc/resolv.conf, returns first IPv4 nameserver +- `UDPProtocol()` — nftables L4 proto match for UDP (IPPROTO_UDP = 17) +- `IPDestEqual(ip)` — IPv4 destination address match (header offset 16) + +### Files changed + +- `internal/ateomnet/net.go` — signature extended, restricted masquerade, helpers + +### Verification + +- `gofmt -l` clean +- `go build ./internal/ateomnet/` passes +- `go test ./internal/ateomnet/ -count=1` passes (all tests) \ No newline at end of file diff --git a/internal/ateomnet/net.go b/internal/ateomnet/net.go index 91203a8e0..83ebd616d 100644 --- a/internal/ateomnet/net.go +++ b/internal/ateomnet/net.go @@ -18,6 +18,7 @@ package ateomnet import ( + "bufio" "context" "errors" "fmt" @@ -25,6 +26,7 @@ import ( "net" "os" "runtime" + "strings" "github.com/google/nftables" "github.com/google/nftables/binaryutil" @@ -221,10 +223,46 @@ func EnableIPv4Forwarding() error { return nil } +// readDNSResolver returns the first IPv4 nameserver from /etc/resolv.conf, +// or an empty string if the file is missing or contains no nameserver line. +// Only IPv4 addresses are returned; IPv6 resolvers are skipped because the +// actor network is currently IPv4-only. +func readDNSResolver() string { + f, err := os.Open("/etc/resolv.conf") + if err != nil { + return "" + } + defer f.Close() + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if !strings.HasPrefix(line, "nameserver") { + continue + } + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + ip := net.ParseIP(fields[1]) + if ip == nil { + continue + } + if ip.To4() != nil { + return ip.To4().String() + } + } + return "" +} + // InstallActorNftablesRules configures the NAT and filtering rules for the // actor. egressPort, when non-zero, is the local atunnel egress listener actor // TCP egress is redirected to; zero leaves the redirect uninstalled. -func InstallActorNftablesRules(egressPort uint16) error { +// dnsResolver is the cluster DNS resolver IP (e.g. from /etc/resolv.conf); +// when non-empty, postrouting masquerade is restricted to UDP traffic destined +// for this address on port 53, and all other non-tunneled actor egress is +// dropped. When empty, the legacy broad masquerade is preserved for backward +// compatibility. +func InstallActorNftablesRules(egressPort uint16, dnsResolver string) error { // Install a dedicated nftables table for the active actor. Keeping all // rules in an ateom-owned table makes cleanup simple and avoids mutating // Kubernetes or CNI-managed chains directly. @@ -271,11 +309,40 @@ func InstallActorNftablesRules(egressPort uint16) error { Hooknum: nftables.ChainHookPostrouting, Priority: nftables.ChainPriorityNATSource, }) - c.AddRule(&nftables.Rule{ - Table: table, - Chain: postrouting, - Exprs: append(IPSourceEqual(ActorVethIP), &expr.Masq{}), - }) + if dnsResolver != "" { + // Restricted mode: masquerade only UDP DNS traffic from the actor + // veth to the cluster resolver on port 53. All four conditions + // (source, protocol, destination, port) must match in a single rule + // so that non-DNS actor egress is NOT masqueraded and will be + // dropped by the kernel's default forward policy. + c.AddRule(&nftables.Rule{ + Table: table, + Chain: postrouting, + Exprs: append(IPSourceEqual(ActorVethIP), + append(UDPProtocol(), + append(IPDestEqual(dnsResolver), + &expr.Payload{ + DestRegister: 1, + Base: expr.PayloadBaseTransportHeader, + Offset: 2, // dst port + Len: 2, + }, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: binaryutil.BigEndian.PutUint16(53), + }, + &expr.Masq{})..., + )...), + }) + } else { + // Legacy broad masquerade (backward compatibility when resolver unknown). + c.AddRule(&nftables.Rule{ + Table: table, + Chain: postrouting, + Exprs: append(IPSourceEqual(ActorVethIP), &expr.Masq{}), + }) + } acceptPolicy := nftables.ChainPolicyAccept forward := c.AddChain(&nftables.Chain{ @@ -354,6 +421,21 @@ func TCPProtocol() []expr.Any { } } +func UDPProtocol() []expr.Any { + return []expr.Any{ + &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: []byte{unix.IPPROTO_UDP}, + }, + } +} + +func IPDestEqual(ip string) []expr.Any { + return IPPayloadEqual(16, ip) +} + // ActorEgressRedirectRule returns the prerouting rule that redirects actor TCP // egress to the local atunnel egress listener on port, or nil when port is zero // (tunneled egress disabled, so actor egress stays on the masquerade path). @@ -568,7 +650,8 @@ func SetupActorNetwork(ctx context.Context, cfg NetworkConfig) (retErr error) { if err := EnableIPv4Forwarding(); err != nil { return err } - if err := InstallActorNftablesRules(cfg.EgressRedirectPort); err != nil { + dnsResolver := readDNSResolver() + if err := InstallActorNftablesRules(cfg.EgressRedirectPort, dnsResolver); err != nil { return err } From 4c9e2ed710c1e4b6d3ba235d173696c44f6a5391 Mon Sep 17 00:00:00 2001 From: Suraj Kumar Date: Sun, 16 Aug 2026 19:24:05 +0000 Subject: [PATCH 2/2] Remove PR description artifact from repo --- PR_DESCRIPTION_960.md | 43 ------------------------------------------- 1 file changed, 43 deletions(-) delete mode 100644 PR_DESCRIPTION_960.md diff --git a/PR_DESCRIPTION_960.md b/PR_DESCRIPTION_960.md deleted file mode 100644 index 135acf573..000000000 --- a/PR_DESCRIPTION_960.md +++ /dev/null @@ -1,43 +0,0 @@ -## ateomnet: restrict actor masquerade to cluster DNS resolver - -Fixes: https://github.com/agent-substrate/substrate/issues/960 - -### Problem - -`InstallActorNftablesRules` installs a postrouting masquerade that matches -all traffic from the actor veth IP — effectively NATting every packet the -actor sends, including traffic that should be dropped by policy. - -### Fix - -When a cluster DNS resolver IP is available (read from the pod's -/etc/resolv.conf), the postrouting masquerade is now restricted to a single -nftables rule matching all four conditions: - -- source = actor veth IP -- protocol = UDP -- destination = cluster resolver IP -- destination port = 53 - -All other non-tunneled actor egress is no longer masqueraded and will be -dropped by the kernel's default forward policy. - -When no IPv4 resolver is available (empty /etc/resolv.conf, IPv6-only -resolver, or missing file), the legacy broad masquerade is preserved for -backward compatibility. - -### New helpers - -- `readDNSResolver()` — parses /etc/resolv.conf, returns first IPv4 nameserver -- `UDPProtocol()` — nftables L4 proto match for UDP (IPPROTO_UDP = 17) -- `IPDestEqual(ip)` — IPv4 destination address match (header offset 16) - -### Files changed - -- `internal/ateomnet/net.go` — signature extended, restricted masquerade, helpers - -### Verification - -- `gofmt -l` clean -- `go build ./internal/ateomnet/` passes -- `go test ./internal/ateomnet/ -count=1` passes (all tests) \ No newline at end of file