internal/agent/netenv/netenv.go
Ref: Size: 18.4 KiB History
// Package netenv manages the host side of VM networking: bridge eitri0 with
// the host as .1 gateway, per-VM taps, and NAT for outbound internet. The
// bridge is a pure masqueraded underlay; inbound admin reachability is via the
// server's SSH-CA jump gate (internal/server/sshgate), not any guest overlay.
//
// A VM whose spec names one of the host's configured networks gets a SECOND
// tap on top of all that, attached to a bridge the operator already owns (see
// named.go). None of the machinery above touches it — the site's own DHCP
// server addresses that NIC and its own gateway routes it — and none of it is
// withheld from the guest either: every VM keeps its NAT attachment, its
// reservation and its place on eitri0 beside its siblings.
package netenv
import (
"context"
"errors"
"fmt"
"net"
"net/netip"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/a73x/eitri/internal/agent/dhcp"
"github.com/a73x/eitri/internal/agent/exec"
"github.com/a73x/eitri/internal/agent/permanent"
"github.com/a73x/eitri/internal/agent/state"
)
// Bridge is the name of the Linux bridge device created by EnsureBridge.
const Bridge = "eitri0"
// Net holds the runner and the bridge CIDR for this agent instance.
type Net struct {
run exec.Runner
cidr netip.Prefix
// isTap reports whether an existing link is a tun/tap device. Injectable
// for tests; production checks /sys/class/net/<name>/tun_flags, which
// exists only for tun/tap links — no error-string parsing.
isTap func(name string) bool
// ifaces enumerates this host's interfaces for the uplink-collision check.
// Injectable for the same reason isTap is: the production reader touches
// the kernel, and the logic above it should be testable without one.
ifaces func() ([]Iface, error)
dhcp *dhcp.Server
// networks maps a configured network name to its operator-owned bridge.
// Fixed at construction: it is the agent's command line, and changing it
// is a restart.
networks map[string]string
// isBridge reports whether a link is a bridge. Injectable like isTap;
// production reads /sys/class/net/<name>/bridge, a directory that exists
// exactly for bridge devices.
isBridge func(name string) bool
// listen watches a tap for the DHCP ACK addressed to a guest's MAC.
// Injectable like isTap, and for a second reason: the production snoop is
// a Linux packet socket, so the default is chosen per platform.
listen func(ctx context.Context, ifname string, mac net.HardwareAddr, found func(ip string)) error
mu sync.Mutex
// attached holds one entry per VM that has a second NIC on a named network,
// and holding an entry is what being attached means: it is created when the
// NIC is (CreateTap, or AdoptNetwork replaying one that survived a restart)
// and deleted once, whole, when the NIC goes (DeleteTap). Everything the
// attachment accumulates — the address the site granted it, the goroutine
// watching for the next one — lives and dies with that single entry, so
// there is no state left over to speak for a VM this map has forgotten. In
// memory, rebuilt at startup by the replay, exactly like the DHCP
// reservation table.
attached map[string]*attachment
}
// attachment is one VM's named-network NIC as this agent holds it. Which
// network that is does not appear here: the record the control plane placed is
// what says so, and the agent re-reads it on every replay, so a copy in memory
// would only be a second answer to a question already settled. What is here is
// what nothing else can answer — the address the site handed this NIC, and the
// goroutine listening for the next one. Both arrive after the attachment does,
// and both end with it, because the entry does.
type attachment struct {
// discovered is the last address the site's DHCP server granted this NIC.
// Never the NAT address: that one this host allocates, and it lives in the
// reservation table. Empty until an ACK is snooped or a restart replays one.
discovered string
// stopSnoop ends the goroutine watching this NIC's tap, and its presence is
// what says one is running: nil for a VM adopted with no tap to watch, and
// nil again after a snoop that would not start (so a retry re-arms).
stopSnoop context.CancelFunc
}
// guestDNS is handed to guests as their DHCP DNS servers (option 6). Public
// resolvers reachable via the eitri0 masquerade — matches the pre-DHCP static
// netplan, and avoids handing guests a host-loopback stub resolver.
var guestDNS = []net.IP{net.IPv4(1, 1, 1, 1), net.IPv4(9, 9, 9, 9)}
// New constructs a Net. cidr must be a valid IPv4 prefix (e.g. "10.77.1.0/24").
// networks maps a named guest network to the operator-owned bridge backing it;
// nil or empty means this host serves only the NAT underlay. The names are not
// checked against the host's links here — VerifyNetworks does that, so a
// caller decides whether a missing bridge stops the agent.
func New(run exec.Runner, cidr string, networks map[string]string) (*Net, error) {
p, err := netip.ParsePrefix(cidr)
if err != nil {
return nil, err
}
if !p.Addr().Is4() {
return nil, fmt.Errorf("bridge CIDR must be IPv4, got %s", cidr)
}
n := &Net{
run: run, cidr: p, isTap: sysfsIsTap, ifaces: hostIfaces,
networks: networks, isBridge: sysfsIsBridge, listen: defaultListen,
attached: map[string]*attachment{},
}
gw := net.ParseIP(n.Gateway())
mask := net.CIDRMask(p.Bits(), 32)
n.dhcp = dhcp.NewServer(Bridge, n.cidr.String(), gw, mask, guestDNS, 12*time.Hour)
return n, nil
}
// sysfsIsTap reports whether name is an L2 TAP link: /sys/class/net/<name>/
// tun_flags exists exactly for tun-driver links, and bit 0x0002 (IFF_TAP)
// distinguishes a TAP from an L3 TUN (which cannot join a bridge and would
// fail later with a raw RTNETLINK error).
func sysfsIsTap(name string) bool { return sysfsIsTapAt("/sys/class/net", name) }
// sysfsIsTapAt is sysfsIsTap with an injectable sysfs root (for tests).
func sysfsIsTapAt(root, name string) bool {
raw, err := os.ReadFile(filepath.Join(root, name, "tun_flags"))
if err != nil {
return false
}
flags, err := strconv.ParseUint(strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(string(raw)), "0x")), 16, 64)
if err != nil {
return false
}
const iffTap = 0x0002
return flags&iffTap != 0
}
// Gateway returns the host-side IP (.1) on the bridge, as a bare address string.
func (n *Net) Gateway() string { return n.cidr.Masked().Addr().Next().String() }
// TapName returns the TAP device name for vmID: "eit-" plus the first 8
// characters of the id, giving 12 characters, safely below the 15-char
// IFNAMSIZ limit. It is a method rather than a package function because it is
// part of what a host networking backend answers for — a backend with no tap
// device has no such name, and nothing above the platform line may assume one.
func (n *Net) TapName(vmID string) string { return tapName("eit-", vmID) }
// netTapPrefix begins the name of every named-network tap, and is the one place
// that shape is written down. The nftables rule in named.go matches devices by
// it, so a rename that missed the rule would leave the bridge judging a name no
// tap has any more — hence one home, and both readers derived from it.
const netTapPrefix = "eil-"
// NetTapName returns the device name of the VM's SECOND tap, the one on a
// named host network: "eil-" plus the same 8 characters, so the two taps of
// one guest sort together and neither can be mistaken for the other in
// `ip link`. The "l" is just that marker — the second-NIC tap, distinct from
// "eit-" — not short for anything the console says; live devices already wear
// the prefix, so it stays even though the word it once stood for did not.
// Same 12-character budget as TapName.
func (n *Net) NetTapName(vmID string) string { return tapName(netTapPrefix, vmID) }
// tapName is the shared truncation both device names owe their IFNAMSIZ safety
// to: one rule, so a second NIC can never be the one that overruns it.
func tapName(prefix, vmID string) string {
if len(vmID) > 8 {
vmID = vmID[:8]
}
return prefix + vmID
}
// ReserveIP returns a sticky IP for vmID, allocated from the bridge CIDR and
// recorded as the guest's DHCP reservation (keyed by the VM's deterministic
// MAC). Idempotent: the same VM gets the same address across reboots and agent
// restarts. The table lives in memory, so an address survives an agent restart
// only by way of the startup replay that rebuilds it from durable records.
//
// Every VM gets one, whatever else its spec asks for: the NAT NIC is the
// management fabric — the gate's splice target, the guests' way to each other,
// the egress — and a guest that also sits on the operator's LAN is a guest with
// two NICs, not a guest that gave this one up.
func (n *Net) ReserveIP(vmID string) (string, error) {
mac, err := net.ParseMAC(state.MAC(vmID))
if err != nil {
return "", err
}
ip, err := n.dhcp.Reserve(mac)
if err != nil {
return "", err
}
return ip.String(), nil
}
// Address returns the address currently reserved for vmID on the NAT underlay,
// or "" when the VM holds no reservation. Unlike ReserveIP it never allocates:
// it is the polled read of what this host believes the guest's address is.
//
// It is unaffected by a named network. A guest's second NIC has its own address
// and its own question (NetworkAddress); this one is the address everything
// eitri does with a VM goes through, and it is known before the guest boots.
func (n *Net) Address(vmID string) string {
mac, err := net.ParseMAC(state.MAC(vmID))
if err != nil {
return ""
}
ip, ok := n.dhcp.Lookup(mac)
if !ok {
return ""
}
return ip.String()
}
// tolerated reports whether err carries one of the given substrings in either
// the command stdout or the error message. iproute2 puts the same condition in
// different streams across versions, so both are checked.
func tolerated(out string, err error, substrs ...string) bool {
msg := err.Error()
for _, s := range substrs {
if strings.Contains(out, s) || strings.Contains(msg, s) {
return true
}
}
return false
}
// best runs a command, tolerating "already exists" / "File exists" errors so
// that EnsureBridge is idempotent (called on every agent start).
func (n *Net) best(ctx context.Context, name string, args ...string) (string, error) {
out, err := n.run(ctx, name, args...)
if err != nil {
if tolerated(out, err, "File exists", "already exists") {
return out, nil
}
return out, fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err)
}
return out, nil
}
// EnsureBridge creates and configures the eitri0 Linux bridge, enables IP
// forwarding, and installs an nftables NAT rule that masquerades VM outbound
// traffic on every interface.
//
// On a host that serves named networks it also programs the bridge-family rule
// those networks owe the operator (blockGuestDHCP). A host with none issues no
// bridge-family command at all: its ruleset is what it always was.
//
// The function is safe to call on every agent restart: the nft chain is
// flushed before the masquerade rule is added, so rules never accumulate
// across restarts. The same holds for the bridge-family chain.
func (n *Net) EnsureBridge(ctx context.Context) error {
gw := n.Gateway()
bits := n.cidr.Bits()
cidrStr := n.cidr.Masked().String()
// Idempotency by genuinely-idempotent operations, NOT by parsing kernel
// error strings: iproute2 message wording varies across versions (a
// duplicate address says "Address already assigned." on some, "File exists"
// on others), so a string allow-list is fragile and silently strands a
// restarting agent. Instead: existence-check the link, and use `ip addr
// replace` (add-or-update, exit 0 whether or not the address is present).
// Bridge link — create only if absent.
if _, err := n.run(ctx, "ip", "link", "show", "dev", Bridge); err != nil {
if _, err := n.run(ctx, "ip", "link", "add", Bridge, "type", "bridge"); err != nil {
return fmt.Errorf("ip link add %s: %w", Bridge, err)
}
}
// Gateway address — `replace` is idempotent.
if _, err := n.run(ctx, "ip", "addr", "replace",
fmt.Sprintf("%s/%d", gw, bits), "dev", Bridge); err != nil {
return fmt.Errorf("ip addr replace %s/%d dev %s: %w", gw, bits, Bridge, err)
}
// Post-condition: the gateway must actually be on the bridge now. Catches a
// genuine failure (e.g. the address ended up elsewhere) with an actionable
// error instead of mysterious VM-networking failures later.
if out, err := n.run(ctx, "ip", "-o", "addr", "show", "dev", Bridge); err != nil ||
!strings.Contains(out, gw+"/") {
return fmt.Errorf("gateway IP %s not present on %s after setup", gw, Bridge)
}
// Remaining steps are idempotent by nature: `ip link set up` is a no-op if
// already up; `sysctl -w` just sets the value; nft `add table`/`add chain`
// are no-ops if the object already exists (only `create` errors).
idempotentSteps := [][]string{
{"ip", "link", "set", Bridge, "up"},
{"sysctl", "-w", "net.ipv4.ip_forward=1"},
{"nft", "add", "table", "ip", "eitri"},
{"nft", "add", "chain", "ip", "eitri", "postrouting",
"{ type nat hook postrouting priority srcnat ; }"},
}
for _, s := range idempotentSteps {
if _, err := n.run(ctx, s[0], s[1:]...); err != nil {
return fmt.Errorf("%s %s: %w", s[0], strings.Join(s[1:], " "), err)
}
}
// Flush the chain before adding the rule so that repeated agent restarts do
// not accumulate duplicate masquerade rules in the kernel ruleset.
if _, err := n.run(ctx, "nft", "flush", "chain", "ip", "eitri", "postrouting"); err != nil {
return fmt.Errorf("nft flush chain ip eitri postrouting: %w", err)
}
// Plain masquerade rule: no interface exclusions.
ruleArgs := []string{"add", "rule", "ip", "eitri", "postrouting",
"ip", "saddr", cidrStr, "masquerade"}
if _, err := n.run(ctx, "nft", ruleArgs...); err != nil {
return fmt.Errorf("nft add rule: %w", err)
}
return n.blockGuestDHCP(ctx)
}
// StartDHCP begins serving reserved leases on the bridge. Call after
// EnsureBridge (the interface must exist) and after reservations are preloaded.
func (n *Net) StartDHCP(ctx context.Context) error { return n.dhcp.Start(ctx) }
// AddReservation pins vmID's deterministic MAC to ip in the DHCP table. Used at
// startup to rebuild the (in-memory) table from durable records, and by
// CreateTap. A bad ip is ignored (the record is malformed; nothing to serve).
func (n *Net) AddReservation(vmID, ip string) {
mac, err := net.ParseMAC(state.MAC(vmID))
if err != nil {
return
}
parsed := net.ParseIP(ip)
if parsed == nil {
return
}
n.dhcp.SetReservation(mac, parsed)
}
// CreateTap creates the VM's TAP device, attaches it to eitri0, and adds the
// guest's DHCP reservation (MAC(vmID) -> ip). vmID is the reconcile identity;
// ip is the agent-allocated address.
//
// Idempotent by existence check, not error-string parsing (same rationale as
// EnsureBridge): re-running `ip tuntap add` on a live tap fails with
// "ioctl(TUNSETIFF): Device or resource busy" — a message the tolerated()
// allow-list can never chase across iproute2 versions. A create-retry hitting
// the previous attempt's tap must not mask the retry's real error. Pinned by
// TestCreateTapIdempotentWhenTapExists.
//
// network, when non-empty, adds a SECOND tap on the operator's own bridge (see
// createNamedTap) after this one — the guest keeps everything above and gains a
// NIC on the named network. It is validated before anything is created, so a
// network this agent does not serve leaves the host exactly as it found it.
func (n *Net) CreateTap(ctx context.Context, vmID, ip, network string) error {
if network != "" {
if _, ok := n.networks[network]; !ok {
// The control plane only places a network on a host that advertised
// it, so getting here means this agent restarted without the flag.
// Permanent, and refused before the NAT tap exists: converge must
// fail the VM legibly, never boot it with the NIC it asked for
// missing — a guest silently reachable on the private underlay
// alone is a guest nothing that was talking to it can find.
return permanent.Errorf("VM wants network %q but this agent is not configured with it; "+
"restart the agent with --host-network %s=<bridge> (its NIC is never silently dropped)",
network, network)
}
}
tap := n.TapName(vmID)
if _, err := n.run(ctx, "ip", "link", "show", "dev", tap); err != nil {
if _, err := n.best(ctx, "ip", "tuntap", "add", "dev", tap, "mode", "tap"); err != nil {
return err
}
} else if !n.isTap(tap) {
// Name collision or stale device: silently enslaving a non-tap would
// surface later as an illegible cloud-hypervisor failure. Fail loudly
// here instead (pinned by TestCreateTapRejectsNonTapDevice).
return permanent.Errorf("link %s exists but is not a TAP device — name collision or stale interface; remove it or rename the VM", tap)
}
if _, err := n.best(ctx, "ip", "link", "set", tap, "master", Bridge); err != nil {
return err
}
if _, err := n.best(ctx, "ip", "link", "set", tap, "up"); err != nil {
return err
}
n.AddReservation(vmID, ip)
if network == "" {
return nil
}
return n.createNamedTap(ctx, vmID, network)
}
// DeleteTap removes the VM's DHCP reservation and BOTH of its TAP devices, plus
// the snoop and discovered address a named NIC leaves behind. Idempotent: a
// missing device is tolerated.
//
// The named tap is deleted whether or not this agent remembers the VM having
// one. The memory of it is in-memory state rebuilt from records at startup, and
// a tap outliving its guest holds a port on the operator's bridge — so the
// device, not the bookkeeping, is what gets asked.
//
// Both deletes are attempted and their errors joined, for the same reason: an
// early return on the NAT tap would leave the named one on the operator's
// bridge, alive, with nothing left that remembers to ask again.
func (n *Net) DeleteTap(ctx context.Context, vmID string) error {
n.mu.Lock()
var cancel context.CancelFunc
if a, live := n.attached[vmID]; live {
cancel = a.stopSnoop
}
delete(n.attached, vmID)
n.mu.Unlock()
if cancel != nil {
cancel()
}
if mac, err := net.ParseMAC(state.MAC(vmID)); err == nil {
n.dhcp.RemoveReservation(mac)
}
return errors.Join(
n.deleteLink(ctx, n.TapName(vmID)),
n.deleteLink(ctx, n.NetTapName(vmID)),
)
}
// deleteLink removes one device, tolerating its absence — the shape both taps
// are torn down in.
func (n *Net) deleteLink(ctx context.Context, dev string) error {
out, err := n.run(ctx, "ip", "link", "del", dev)
if err != nil {
if tolerated(out, err, "Cannot find device") {
return nil
}
return fmt.Errorf("ip link del %s: %w", dev, err)
}
return nil
}