a449db80
feat(agent): a host refuses a guest subnet that contains its own uplink
a73x 2026-08-06 09:12
Commit message
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 | } |