a73x

a449db80

feat(agent): a host refuses a guest subnet that contains its own uplink

a73x   2026-08-06 09:12

Commit message
feat(agent): a host refuses a guest subnet that contains its own uplink

Before the bridge is created, the Linux platform checks whether the resolved
guest subnet already contains an address this host is configured with, on any
interface but the bridge itself, and refuses to start naming both sides and the
flag that fixes it.

The failure it prevents was reproduced in network namespaces rather than
argued, and the obvious prediction about it is wrong in a way worth recording.
An agent whose subnet overlaps its own uplink assigns that subnet's .1 to the
bridge. Outbound survives — the DHCP-installed default route names the uplink
device explicitly, so nexthop resolution stays pinned there even once the
bridge's connected route wins the FIB tie — which means a check that only asked
"can this host still reach the control plane" would call it healthy. Inbound is
what dies. The gateway's address is now a local address on this box, so every
reply it sends there resolves to lo and never leaves; the host stops answering
anything that dials it, including the tunnel its own fleet uses to reach its
guests. The one situation this arises in is a fresh agent inside a guest, which
is exactly where losing the network also loses the way in to fix it.

The bridge's own interface is skipped, because every agent that has run once
finds its bridge on its own subnet and that is the normal case.

An enumeration that fails outright allows and warns. This is a diagnostic, and
refusing to start because a diagnostic could not be run would turn a guard
against unreachability into a cause of it.

It lives in netenv with its interface reading injected, not inline in the
composition root, so the logic is exercised on any machine: net.Interface's own
Addrs method reaches the kernel, so a fabricated one answers nothing, and the
check takes a name-and-addresses pair a test can describe freely. One of those
tests puts a bare-metal host, a guest host and the nested wedge side by side,
because the risk in adding a refusal is refusing something that already works.

internal/agent/netenv/netenv.go
Old New
@@ -31,7 +31,11 @@ type Net struct {
31 // for tests; production checks /sys/class/net/<name>/tun_flags, which 31 // for tests; production checks /sys/class/net/<name>/tun_flags, which
32 // exists only for tun/tap links — no error-string parsing. 32 // exists only for tun/tap links — no error-string parsing.
33 isTap func(name string) bool 33 isTap func(name string) bool
34 dhcp *dhcp.Server 34 // ifaces enumerates this host's interfaces for the uplink-collision check.
35 // Injectable for the same reason isTap is: the production reader touches
36 // the kernel, and the logic above it should be testable without one.
37 ifaces func() ([]Iface, error)
38 dhcp *dhcp.Server
35 } 39 }
36 40
37 // guestDNS is handed to guests as their DHCP DNS servers (option 6). Public 41 // guestDNS is handed to guests as their DHCP DNS servers (option 6). Public
@@ -48,7 +52,7 @@ func New(run exec.Runner, cidr string) (*Net, error) {
48 if !p.Addr().Is4() { 52 if !p.Addr().Is4() {
49 return nil, fmt.Errorf("bridge CIDR must be IPv4, got %s", cidr) 53 return nil, fmt.Errorf("bridge CIDR must be IPv4, got %s", cidr)
50 } 54 }
51 n := &Net{run: run, cidr: p, isTap: sysfsIsTap} 55 n := &Net{run: run, cidr: p, isTap: sysfsIsTap, ifaces: hostIfaces}
52 gw := net.ParseIP(n.Gateway()) 56 gw := net.ParseIP(n.Gateway())
53 mask := net.CIDRMask(p.Bits(), 32) 57 mask := net.CIDRMask(p.Bits(), 32)
54 n.dhcp = dhcp.NewServer(Bridge, n.cidr.String(), gw, mask, guestDNS, 12*time.Hour) 58 n.dhcp = dhcp.NewServer(Bridge, n.cidr.String(), gw, mask, guestDNS, 12*time.Hour)
internal/agent/netenv/uplink.go
Old New
@@ -0,0 +1,108 @@
1 package netenv
2
3 import (
4 "fmt"
5 "log/slog"
6 "net"
7 "net/netip"
8 )
9
10 // Iface is one host interface as the uplink check sees it: a name, and the
11 // addresses configured on it.
12 //
13 // It exists because net.Interface's own Addrs method reaches the kernel, so a
14 // fabricated net.Interface answers nothing — the check takes this instead, and
15 // a test can describe any host it likes.
16 type Iface struct {
17 Name string
18 Addrs []net.Addr
19 }
20
21 // hostIfaces enumerates this host's interfaces and the addresses on each. An
22 // interface whose addresses cannot be read is reported without them rather than
23 // failing the enumeration: one unreadable interface should not blind the check
24 // to every other.
25 func hostIfaces() ([]Iface, error) {
26 ifaces, err := net.Interfaces()
27 if err != nil {
28 return nil, err
29 }
30 out := make([]Iface, 0, len(ifaces))
31 for _, iface := range ifaces {
32 addrs, err := iface.Addrs()
33 if err != nil {
34 addrs = nil
35 }
36 out = append(out, Iface{Name: iface.Name, Addrs: addrs})
37 }
38 return out, nil
39 }
40
41 // CheckUplinkCollision refuses a guest subnet that already contains an address
42 // this host is configured with, on any interface but the bridge itself. It is
43 // called before EnsureBridge, because by the time the bridge exists the damage
44 // is done and the way in to undo it is gone.
45 //
46 // The failure it prevents was reproduced in network namespaces rather than
47 // argued, and the obvious prediction about it is wrong in an instructive way.
48 // An agent whose resolved subnet overlaps its own uplink assigns the gateway
49 // address — .1 of that subnet — to the bridge. Outbound survives, because the
50 // DHCP-installed default route names the uplink device explicitly, so a check
51 // that only asked "can this host still reach the control plane" would call it
52 // healthy. Inbound is what dies: the gateway's address is now a LOCAL address
53 // on this box, so every reply it sends there resolves to lo and never leaves.
54 // The host stops answering anything that dials it, including the tunnel its
55 // own fleet uses to reach its guests — and the machine best placed to fix it is
56 // the one that just lost the route.
57 //
58 // The one situation this arises in is the one where it hurts most: a fresh
59 // agent inside a guest, where losing the network also loses the way in.
60 //
61 // The bridge's own interface is skipped. Finding the bridge on the bridge's
62 // subnet is the normal case on every restart, not a collision.
63 //
64 // An enumeration that fails outright ALLOWS and warns. This is a diagnostic;
65 // refusing to start because a diagnostic could not be run would turn a check
66 // against unreachability into a cause of it.
67 func (n *Net) CheckUplinkCollision() error {
68 ifaces, err := n.ifaces()
69 if err != nil {
70 slog.Warn("cannot read this host's interfaces; starting without the uplink-collision check",
71 "guest_cidr", n.cidr.Masked(), "err", err)
72 return nil
73 }
74 for _, iface := range ifaces {
75 if iface.Name == Bridge {
76 continue
77 }
78 for _, a := range iface.Addrs {
79 addr, ok := ifaceAddr(a)
80 if !ok || !n.cidr.Contains(addr) {
81 continue
82 }
83 return fmt.Errorf(
84 "guest subnet %s already contains %s, configured on %s: "+
85 "putting the bridge there would make this host's own address a local one, "+
86 "and it would stop answering anything that dials it — "+
87 "give this host a subnet it is not already on (--bridge-cidr)",
88 n.cidr.Masked(), addr, iface.Name)
89 }
90 }
91 return nil
92 }
93
94 // ifaceAddr reduces one interface address to the IPv4 address it carries.
95 // Anything else — an IPv6 address, or an address shape that is not an IPNet —
96 // cannot collide with a guest subnet, which New has already established is
97 // IPv4.
98 func ifaceAddr(a net.Addr) (netip.Addr, bool) {
99 ipnet, ok := a.(*net.IPNet)
100 if !ok {
101 return netip.Addr{}, false
102 }
103 v4 := ipnet.IP.To4()
104 if v4 == nil {
105 return netip.Addr{}, false
106 }
107 return netip.AddrFromSlice(v4)
108 }
internal/agent/netenv/uplink_test.go
Old New
@@ -0,0 +1,220 @@
1 package netenv
2
3 import (
4 "errors"
5 "net"
6 "strings"
7 "testing"
8 )
9
10 // ifaceFrom describes one interface the way a host would present it. A CIDR
11 // per address, because a bare address tells the check nothing it needs.
12 func ifaceFrom(t *testing.T, name string, cidrs ...string) Iface {
13 t.Helper()
14 iface := Iface{Name: name}
15 for _, c := range cidrs {
16 ip, ipnet, err := net.ParseCIDR(c)
17 if err != nil {
18 t.Fatalf("bad test address %q: %v", c, err)
19 }
20 iface.Addrs = append(iface.Addrs, &net.IPNet{IP: ip, Mask: ipnet.Mask})
21 }
22 return iface
23 }
24
25 // newNetFor builds a Net on cidr whose interface enumeration answers ifaces.
26 // The command runner is nil: this check reads interfaces and runs nothing.
27 func newNetFor(t *testing.T, cidr string, ifaces ...Iface) *Net {
28 t.Helper()
29 n, err := New(nil, cidr)
30 if err != nil {
31 t.Fatalf("New(%q): %v", cidr, err)
32 }
33 n.ifaces = func() ([]Iface, error) { return ifaces, nil }
34 return n
35 }
36
37 // TestCheckUplinkCollisionRefusesASubnetContainingThisHost pins the refusal
38 // that keeps a nested agent reachable. An agent whose guest subnet overlaps its
39 // own uplink assigns that subnet's .1 to the bridge, its own address becomes
40 // local, and it stops answering anything that dials it — including the fleet
41 // that would have to fix it.
42 func TestCheckUplinkCollisionRefusesASubnetContainingThisHost(t *testing.T) {
43 n := newNetFor(t, "10.78.1.0/24",
44 ifaceFrom(t, "lo", "127.0.0.1/8"),
45 ifaceFrom(t, "eth0", "10.78.1.3/24"),
46 )
47 err := n.CheckUplinkCollision()
48 if err == nil {
49 t.Fatal("a subnet containing this host's own address must be refused")
50 }
51 // Actionable without further digging: both sides named, and the way out.
52 for _, want := range []string{"10.78.1.0/24", "10.78.1.3", "eth0", "--bridge-cidr"} {
53 if !strings.Contains(err.Error(), want) {
54 t.Errorf("error must name %q, got: %v", want, err)
55 }
56 }
57 }
58
59 // TestCheckUplinkCollisionAllowsADisjointSubnet pins the ordinary case: a host
60 // whose guests live somewhere it does not.
61 func TestCheckUplinkCollisionAllowsADisjointSubnet(t *testing.T) {
62 n := newNetFor(t, "10.77.1.0/24",
63 ifaceFrom(t, "lo", "127.0.0.1/8"),
64 ifaceFrom(t, "eth0", "192.168.1.50/24"),
65 ifaceFrom(t, "docker0", "172.17.0.1/16"),
66 )
67 if err := n.CheckUplinkCollision(); err != nil {
68 t.Errorf("a disjoint subnet must be allowed, got: %v", err)
69 }
70 }
71
72 // TestCheckUplinkCollisionIgnoresTheBridgeItself pins the exemption that makes
73 // the check survive a restart. Every agent that has run once finds its own
74 // bridge sitting on its own subnet; that is the normal case, not a collision.
75 func TestCheckUplinkCollisionIgnoresTheBridgeItself(t *testing.T) {
76 n := newNetFor(t, "10.77.1.0/24",
77 ifaceFrom(t, "eth0", "192.168.1.50/24"),
78 ifaceFrom(t, Bridge, "10.77.1.1/24"),
79 )
80 if err := n.CheckUplinkCollision(); err != nil {
81 t.Errorf("the bridge's own address must not read as a collision, got: %v", err)
82 }
83 }
84
85 // TestCheckUplinkCollisionSkipsAddressesItCannotCompare pins that nothing a
86 // guest subnet could not contain is treated as a collision. The subnet is IPv4
87 // by construction (New enforces it), so an IPv6 address or an address shape
88 // that is not a network is simply not a candidate.
89 func TestCheckUplinkCollisionSkipsAddressesItCannotCompare(t *testing.T) {
90 v6 := ifaceFrom(t, "eth0", "192.168.1.50/24")
91 _, v6net, _ := net.ParseCIDR("fd00::/64")
92 v6.Addrs = append(v6.Addrs, &net.IPNet{IP: net.ParseIP("fd00::1"), Mask: v6net.Mask})
93 // A non-IPNet address: interfaces do not normally present these, but the
94 // type assertion has to have an answer.
95 v6.Addrs = append(v6.Addrs, &net.TCPAddr{IP: net.ParseIP("10.77.1.9")})
96
97 n := newNetFor(t, "10.77.1.0/24", v6)
98 if err := n.CheckUplinkCollision(); err != nil {
99 t.Errorf("uncomparable addresses must be skipped, got: %v", err)
100 }
101 }
102
103 // TestCheckUplinkCollisionAllowsWhenItCannotLook pins the deliberate
104 // asymmetry: this is a diagnostic, and refusing to start because a diagnostic
105 // could not be run would turn a guard against unreachability into a cause of
106 // it.
107 func TestCheckUplinkCollisionAllowsWhenItCannotLook(t *testing.T) {
108 n, err := New(nil, "10.77.1.0/24")
109 if err != nil {
110 t.Fatal(err)
111 }
112 n.ifaces = func() ([]Iface, error) { return nil, errors.New("netlink is not available") }
113 if err := n.CheckUplinkCollision(); err != nil {
114 t.Errorf("an unreadable interface list must allow, got: %v", err)
115 }
116 }
117
118 // TestHostIfacesReadsThisMachine is a smoke test over the real enumeration. It
119 // cannot assert an interface list — it runs on whatever CI is — but it does
120 // prove the reading works unprivileged and returns something usable, which is
121 // the half a fabricated Iface can never cover.
122 func TestHostIfacesReadsThisMachine(t *testing.T) {
123 ifaces, err := hostIfaces()
124 if err != nil {
125 t.Skipf("interfaces unreadable here: %v", err)
126 }
127 if len(ifaces) == 0 {
128 t.Skip("no interfaces on this machine")
129 }
130 var named int
131 for _, iface := range ifaces {
132 if iface.Name != "" {
133 named++
134 }
135 }
136 if named == 0 {
137 t.Error("hostIfaces returned only unnamed interfaces")
138 }
139 }
140
141 // TestCheckUplinkCollisionAgainstRealHostShapes puts the three arrangements
142 // side by side, because the risk in adding a refusal is refusing something that
143 // works. A bare-metal host finds only its own bridge on the guest subnet; a
144 // guest host with a disjoint subnet finds nothing; and the nested agent handed
145 // the subnet its own uplink sits in is the one that has to be stopped.
146 func TestCheckUplinkCollisionAgainstRealHostShapes(t *testing.T) {
147 for _, tc := range []struct {
148 name, guestCIDR string
149 ifaces []Iface
150 wantRefused bool
151 }{
152 {
153 name: "bare metal, bridge already up", guestCIDR: "10.78.3.0/24",
154 ifaces: []Iface{
155 ifaceFrom(t, "lo", "127.0.0.1/8"),
156 ifaceFrom(t, "enp4s0", "192.168.0.251/24"),
157 ifaceFrom(t, Bridge, "10.78.3.1/24"),
158 },
159 },
160 {
161 name: "a guest host whose guests live elsewhere", guestCIDR: "10.101.1.0/24",
162 ifaces: []Iface{
163 ifaceFrom(t, "lo", "127.0.0.1/8"),
164 ifaceFrom(t, "ens3", "10.78.3.4/24"),
165 },
166 },
167 {
168 name: "nested, and handed the subnet it is already on", guestCIDR: "10.78.3.0/24",
169 ifaces: []Iface{
170 ifaceFrom(t, "lo", "127.0.0.1/8"),
171 ifaceFrom(t, "ens3", "10.78.3.4/24"),
172 },
173 wantRefused: true,
174 },
175 } {
176 t.Run(tc.name, func(t *testing.T) {
177 err := newNetFor(t, tc.guestCIDR, tc.ifaces...).CheckUplinkCollision()
178 switch {
179 case tc.wantRefused && err == nil:
180 t.Error("this host would make itself unreachable and must be refused")
181 case !tc.wantRefused && err != nil:
182 t.Errorf("a working host must not be refused: %v", err)
183 }
184 })
185 }
186 }
187
188 // TestIfaceAddr pins the reduction of one interface address to a comparable
189 // IPv4 address.
190 func TestIfaceAddr(t *testing.T) {
191 ipnet := func(ip, mask string) net.Addr {
192 _, n, err := net.ParseCIDR(ip + "/" + mask)
193 if err != nil {
194 t.Fatal(err)
195 }
196 return &net.IPNet{IP: net.ParseIP(ip), Mask: n.Mask}
197 }
198 for _, tc := range []struct {
199 name string
200 addr net.Addr
201 want string // "" = not comparable
202 }{
203 {"an IPv4 address", ipnet("10.0.0.5", "24"), "10.0.0.5"},
204 {"IPv6", &net.IPNet{IP: net.ParseIP("fd00::1"), Mask: net.CIDRMask(64, 128)}, ""},
205 {"not an IPNet", &net.TCPAddr{IP: net.ParseIP("10.0.0.5")}, ""},
206 } {
207 t.Run(tc.name, func(t *testing.T) {
208 got, ok := ifaceAddr(tc.addr)
209 if tc.want == "" {
210 if ok {
211 t.Errorf("ifaceAddr accepted %v as %s", tc.addr, got)
212 }
213 return
214 }
215 if !ok || got.String() != tc.want {
216 t.Errorf("ifaceAddr() = %s, %v; want %s", got, ok, tc.want)
217 }
218 })
219 }
220 }
internal/agent/run/wire_linux.go
Old New
@@ -91,6 +91,13 @@ func newPlatform(ctx context.Context, cfg Config, st *state.Store) (platform, er
91 if err != nil { 91 if err != nil {
92 return platform{}, fmt.Errorf("netenv init: %w", err) 92 return platform{}, fmt.Errorf("netenv init: %w", err)
93 } 93 }
94 // Before anything is created: a subnet overlapping this host's own uplink
95 // would make its address local and stop it answering, and the only machine
96 // positioned to fix that is the one that just lost the route. Refusing here
97 // costs a startup error; not refusing costs the host.
98 if err := net.CheckUplinkCollision(); err != nil {
99 return platform{}, err
100 }
94 if err := net.EnsureBridge(ctx); err != nil { 101 if err := net.EnsureBridge(ctx); err != nil {
95 return platform{}, fmt.Errorf("ensure bridge: %w", err) 102 return platform{}, fmt.Errorf("ensure bridge: %w", err)
96 } 103 }