a73x

internal/agent/netenv/named.go

Ref:   Size: 8.7 KiB   History

package netenv

import (
	"context"
	"fmt"
	"log/slog"
	"net"
	"os"
	"path/filepath"
	"strings"

	"github.com/a73x/eitri/internal/agent/permanent"
	"github.com/a73x/eitri/internal/agent/state"
)

// Bridge-ness is checked rather than assumed because attaching a tap to a link
// that is not a bridge fails later with a raw RTNETLINK error naming neither
// the network nor the flag that asked for it.
func sysfsIsBridge(name string) bool { return sysfsIsBridgeAt("/sys/class/net", name) }

// sysfsIsBridgeAt is sysfsIsBridge with an injectable sysfs root (for tests).
func sysfsIsBridgeAt(root, name string) bool {
	fi, err := os.Stat(filepath.Join(root, name, "bridge"))
	return err == nil && fi.IsDir()
}

// VerifyNetworks refuses to start when a configured network's bridge is absent.
// eitri never creates operator bridges — a missing one is a netplan/networkd
// change not yet made. Refusing at startup beats coming up and advertising a
// network this host cannot honor, which fails every VM placed on it instead.
func (n *Net) VerifyNetworks() error {
	for name, br := range n.networks {
		if !n.isBridge(br) {
			return fmt.Errorf("--host-network %s=%s: %s is not an existing bridge on this host; "+
				"declare it in the host's own network config first (eitri attaches to bridges, it does not create them)",
				name, br, br)
		}
	}
	return nil
}

// blockGuestDHCP drops DHCPv4 server talk entering an operator bridge from a
// guest tap. One rule serves any number of named networks: it keys on the
// eil- tap prefix, not on the bridge.
//
// It buys two things. The first is the half of the address snoop no socket can
// settle: netsnoop accepts only frames the kernel marks outgoing, which proves
// a guest cannot mint its own address — but an ACK one guest unicasts at a
// neighbour's MAC is forwarded by the bridge and leaves the neighbour tap
// outgoing too, indistinguishable there from the site answering. Prerouting is
// upstream of the bridge's port choice, so the frame never reaches a sibling
// guest or the physical uplink and the question cannot be asked. The second is
// plain LAN protection: a guest handing out leases would address the operator's
// real machines, and eitri is what put it on their network.
//
// DHCPv6 and IPv6 RAs are deliberately unfiltered: a bridged guest is meant to
// be a full peer, an operator may run a router guest on purpose, and the snoop
// reads IPv4 ACKs only.
func (n *Net) blockGuestDHCP(ctx context.Context) error {
	if len(n.networks) == 0 {
		return nil
	}
	// Two invocations are two transactions, so a restart on a host with live
	// taps leaves the chain empty between them. The fix is one nft -f
	// transaction, which the NAT chain needs identically.
	steps := [][]string{
		{"nft", "add", "table", "bridge", "eitri"},
		{"nft", "add", "chain", "bridge", "eitri", "prerouting",
			"{ type filter hook prerouting priority -200 ; }"},
		{"nft", "flush", "chain", "bridge", "eitri", "prerouting"},
		{"nft", "add", "rule", "bridge", "eitri", "prerouting",
			"iifname", fmt.Sprintf("%q", netTapPrefix+"*"), "udp", "sport", "67", "drop"},
	}
	for _, s := range steps {
		if _, err := n.run(ctx, s[0], s[1:]...); err != nil {
			return fmt.Errorf("%s %s: %w", s[0], strings.Join(s[1:], " "), err)
		}
	}
	return nil
}

// createNamedTap is CreateTap's second half for a guest that asked for a named
// network. No reservation, no masquerade, no forced DNS — the site's DHCP
// server owns all three on this NIC.
func (n *Net) createNamedTap(ctx context.Context, vmID, network string) error {
	bridge := n.networks[network]
	tap := n.NetTapName(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) {
		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.mu.Lock()
	n.attachLocked(vmID)
	n.mu.Unlock()
	// The named NIC's address arrives only via the snoop, so a guest booted
	// without one is a guest whose LAN address nothing above the host can ever
	// learn. Failing the create beats reporting a silently half-reported VM.
	//nolint:contextcheck // the snoop's lifetime is the tap's, not this call's: inheriting ctx would end discovery the moment CreateTap returned
	return n.startSnoop(vmID, tap)
}

// attachLocked returns vmID's attachment, creating it if the VM has none.
// Called with n.mu held. It keeps any existing entry because a Boot retry
// re-runs createNamedTap on a guest whose snoop is live, and neither that
// snoop nor the address it found may be dropped by the re-attach.
func (n *Net) attachLocked(vmID string) *attachment {
	a, live := n.attached[vmID]
	if !live {
		a = &attachment{}
		n.attached[vmID] = a
	}
	return a
}

// startSnoop begins DHCP-ACK discovery for vmID's named-network tap. It keys on
// state.NetMAC — the second NIC's address — so the VM's NAT lease, which this
// host granted itself over a different tap, can never be mistaken for what the
// site's server said.
func (n *Net) startSnoop(vmID, tap string) error {
	n.mu.Lock()
	a, live := n.attached[vmID]
	if !live || a.stopSnoop != nil {
		n.mu.Unlock()
		return nil
	}
	mac, err := net.ParseMAC(state.NetMAC(vmID))
	if err != nil {
		n.mu.Unlock()
		return err
	}
	ctx, cancel := context.WithCancel(context.Background())
	a.stopSnoop = cancel
	n.mu.Unlock()

	if err := n.listen(ctx, tap, mac, n.noteDiscovered(vmID)); err != nil {
		// Leave nothing armed: the cancel above would make a retry believe a
		// snoop is running. Only this attachment is disarmed — a VM deleted and
		// re-created while the listener was refusing has a new entry, and that
		// one's snoop is not this failure's to un-arm.
		n.mu.Lock()
		if cur, live := n.attached[vmID]; live && cur == a {
			cur.stopSnoop = nil
		}
		n.mu.Unlock()
		cancel()
		return err
	}
	return nil
}

// NetworkAddress returns the address the site's DHCP server granted this VM's
// named-network NIC, or "". Empty is "not yet known", never "unreachable": the
// VM's NAT address (Address) is what eitri reaches it by, and that exists from
// boot.
func (n *Net) NetworkAddress(vmID string) string {
	n.mu.Lock()
	defer n.mu.Unlock()
	if a, live := n.attached[vmID]; live {
		return a.discovered
	}
	return ""
}

// noteDiscovered records what an ACK granted, but only while the VM is still
// attached: the listener checks its context between reads, so a frame from the
// last poll window (~1s) can call back after DeleteTap cancelled it, and a
// deleted VM must not come back holding an address. It looks the attachment up
// on every ACK rather than closing over the entry — an entry DeleteTap dropped
// is a place nothing may still be writing to.
func (n *Net) noteDiscovered(vmID string) func(ip string) {
	return func(ip string) {
		n.mu.Lock()
		defer n.mu.Unlock()
		if a, live := n.attached[vmID]; live {
			a.discovered = ip
		}
	}
}

// AdoptNetwork rebuilds a networked VM's discovery state after an agent
// restart. It says nothing about the VM's NAT attachment — that half is
// AddReservation's, and the replay calls both for the same guest.
//
// It adopts the VM even when this agent no longer serves the named network.
// That guest is still running, still holding the tap, still answering on the
// site's address; forgetting it would take that address off the VM's report and
// disarm the snoop, punishing the guest for a change made on the host. It
// survives on borrowed configuration — CreateTap refuses the network now, so
// the next boot fails — and this is the only moment to say so.
func (n *Net) AdoptNetwork(vmID, network, ip string) {
	n.mu.Lock()
	a := n.attachLocked(vmID)
	if ip != "" {
		a.discovered = ip
	}
	n.mu.Unlock()
	// networks is the agent's command line, fixed at construction, so it is read
	// without the lock exactly as CreateTap reads it.
	if _, served := n.networks[network]; !served {
		slog.Warn("adopting a guest onto a named network this host no longer serves; it keeps its NIC and address, but its next reboot fails until --host-network names this network again",
			"vm_id", vmID, "network", network)
	}
	tap := n.NetTapName(vmID)
	if n.isTap(tap) {
		// A snoop that refuses to start is not worth failing agent startup
		// over: the address above is already reported, and the VM's own
		// converge is where a broken tap becomes a story. The cost is real
		// though — an already-running VM keeps no re-arm path until it reboots,
		// and reports the replayed address until then.
		_ = n.startSnoop(vmID, tap)
	}
}