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)
}
}