v0.96.0 — R-50 island NIC: provision attaches the guest island net1

- LocalAPIConfig.island_bridge + island_guest_addr (+ IslandEnabled, Validate
  all-or-nothing + CIDR guard)
- buildBringUpConfig attaches static net1 (island) on provision + DR when set;
  absent otherwise (pre-R-50 byte-for-byte). Plumbed from cfg.LocalAPI at both
  RunBringUp sites. Endpoint already follows listen_addr (A0: no template change).
- healer stays eth0-only (A3 verify-only) — red-proof test locks the scoping
- example config + firewall example rewritten for the island; REUSE updated
- 3 non-hollow tests; full green. MinAgent unchanged.

Coupling: host-install island config requires agent >= 0.96.0 (vouch first).
This commit is contained in:
2026-07-25 14:16:23 +02:00
parent 36ed6594d4
commit dfd5d731ee
11 changed files with 223 additions and 68 deletions
+27
View File
@@ -235,6 +235,16 @@ type LocalAPIConfig struct {
// TokenStore is the durable, hashed token→guest map (only a HASH of each token is
// persisted; the plaintext exists transiently at mint→write-to-mount, then is discarded).
TokenStore string `json:"token_store"` // default /var/lib/felhom-agent/local-tokens.log
// IslandBridge + IslandGuestAddr configure the R-50 host-internal control-plane bridge. When
// BOTH are set, the provisioner attaches each guest a static net1 on IslandBridge with
// IslandGuestAddr, so the controller reaches the agent over a fixed private address that no
// LAN/DHCP/site move can invalidate (the F1 fix — AUDIT-vacation-remote-ops-2026-07-20). Empty
// (the default) = LAN-only, byte-for-byte the pre-R-50 behaviour. On an island install ListenAddr
// is the host side (169.254.253.1:8443); IslandGuestAddr is the guest side (169.254.253.2/30 — a
// /30 is exactly host + one guest). Additive-only: it never removes a NIC, so a guest restored on
// a non-island host (both empty) is unaffected.
IslandBridge string `json:"island_bridge"` // e.g. "vmbr9" (portless host-internal bridge)
IslandGuestAddr string `json:"island_guest_addr"` // guest net1 CIDR, e.g. "169.254.253.2/30"
}
// Default local-API file locations (under the agent's state dir).
@@ -249,6 +259,12 @@ func (l LocalAPIConfig) Enabled() bool {
return l.Enable && strings.TrimSpace(l.ListenAddr) != ""
}
// IslandEnabled reports whether the provisioner should attach a guest island NIC (net1). True only
// when BOTH the bridge and the guest CIDR are set (R-50); empty = pre-R-50 LAN-only behaviour.
func (l LocalAPIConfig) IslandEnabled() bool {
return strings.TrimSpace(l.IslandBridge) != "" && strings.TrimSpace(l.IslandGuestAddr) != ""
}
// TokenStorePath returns the configured token-store path (default applied).
func (l LocalAPIConfig) TokenStorePath() string {
if l.TokenStore != "" {
@@ -283,6 +299,17 @@ func (l LocalAPIConfig) Validate() error {
if _, _, err := net.SplitHostPort(l.ListenAddr); err != nil {
return fmt.Errorf("config: local_api.listen_addr %q is not host:port: %w", l.ListenAddr, err)
}
// R-50: island fields are all-or-nothing, and the guest addr must be a CIDR (the net1 ip= value).
// A half-set island (bridge without guest addr, or vice versa) is a provisioning mistake, not a
// silent LAN fallback — fail loudly so a botched install config is caught at load, not at day-0.
if (strings.TrimSpace(l.IslandBridge) != "") != (strings.TrimSpace(l.IslandGuestAddr) != "") {
return fmt.Errorf("config: local_api.island_bridge and local_api.island_guest_addr must be set together (got bridge=%q guest_addr=%q)", l.IslandBridge, l.IslandGuestAddr)
}
if l.IslandEnabled() {
if _, _, err := net.ParseCIDR(strings.TrimSpace(l.IslandGuestAddr)); err != nil {
return fmt.Errorf("config: local_api.island_guest_addr %q is not a CIDR (want e.g. 169.254.253.2/30): %w", l.IslandGuestAddr, err)
}
}
return nil
}
+43
View File
@@ -171,3 +171,46 @@ func TestDeploymentModeEnvOverlay(t *testing.T) {
t.Errorf("env overlay did not set deployment_mode: %q", cfg.DeploymentMode)
}
}
// R-50: the island NIC fields are all-or-nothing and the guest addr must be a CIDR. A half-set or
// malformed island must fail at config load (a botched install) rather than silently fall back to
// LAN-only, which would leave a guest with an island bind and no island NIC — the exact silent break
// R-50 exists to kill. Covers LocalAPIConfig.Validate + IslandEnabled.
func TestLocalAPIConfig_IslandValidation(t *testing.T) {
base := LocalAPIConfig{Enable: true, ListenAddr: "169.254.253.1:8443"}
// both empty → fine (pre-R-50 default), IslandEnabled false
if err := base.Validate(); err != nil {
t.Errorf("no island config must validate: %v", err)
}
if base.IslandEnabled() {
t.Errorf("IslandEnabled must be false when unset")
}
// both set, valid CIDR → fine, IslandEnabled true
ok := base
ok.IslandBridge, ok.IslandGuestAddr = "vmbr9", "169.254.253.2/30"
if err := ok.Validate(); err != nil {
t.Errorf("valid island config must validate: %v", err)
}
if !ok.IslandEnabled() {
t.Errorf("IslandEnabled must be true when both set")
}
// bridge only → rejected (all-or-nothing)
half := base
half.IslandBridge = "vmbr9"
if err := half.Validate(); err == nil {
t.Errorf("half-set island (bridge only) must be rejected")
}
// guest addr only → rejected
half2 := base
half2.IslandGuestAddr = "169.254.253.2/30"
if err := half2.Validate(); err == nil {
t.Errorf("half-set island (guest addr only) must be rejected")
}
// both set but guest addr is not a CIDR → rejected
bad := base
bad.IslandBridge, bad.IslandGuestAddr = "vmbr9", "169.254.253.2" // missing /30
if err := bad.Validate(); err == nil {
t.Errorf("island guest addr without a CIDR mask must be rejected")
}
}
+18
View File
@@ -548,3 +548,21 @@ func TestClassify_Table(t *testing.T) {
}
var _ io.Writer = (*bytes.Buffer)(nil)
// A3 (R-50): the guestnet healer is eth0-only and MUST stay blind to the island NIC. A guest on an
// island host presents eth0 DHCP (the LAN leg the healer owns) PLUS eth1 static (the island). Because
// parseMode is interface-scoped, adding eth1 static cannot flip eth0's detected mode — so the healer
// keeps treating eth0 as DHCP and never runs dhclient against the static island NIC (which would
// sabotage it). This is the verify-only guarantee that let R-50 ship the island NIC without a healer
// change. Red-proof: make parseMode scan globally instead of per-dev and the eth0 assertion fails.
func TestParseMode_IslandStaticNICDoesNotConfuseEth0(t *testing.T) {
interfaces := "auto lo\niface lo inet loopback\n\n" +
"auto eth0\niface eth0 inet dhcp\n\n" +
"auto eth1\niface eth1 inet static\n address 169.254.253.2/30\n"
if got := parseMode(interfaces, "eth0"); got != ModeDHCP {
t.Errorf("eth0 must classify DHCP even with an island eth1 static present, got %q", got)
}
if got := parseMode(interfaces, "eth1"); got != ModeStatic {
t.Errorf("eth1 (island) must classify static when asked directly (dev-scoped), got %q", got)
}
}
+15
View File
@@ -193,6 +193,12 @@ type BringUpSpec struct {
Mounts []GuestMount // additive mpN mounts (slice 7 may pass empty/test)
KeepMAC bool // DR knob: keep the archived MAC (true) unless a source may be live
BootTimeout time.Duration // 0 → DefaultBootTimeout; bounds the link-up liveness wait
// IslandBridge + IslandGuestAddr (R-50): when BOTH are set, the guest gets a static net1 on the
// host-internal island bridge, so the controller reaches the agent over a fixed private address
// that survives any LAN/DHCP/site move (the F1 fix). Empty (default) = no net1, byte-for-byte the
// pre-R-50 config. Set from cfg.LocalAPI (island_bridge/island_guest_addr) at both call sites.
IslandBridge string // e.g. "vmbr9"
IslandGuestAddr string // guest net1 CIDR, e.g. "169.254.253.2/30"
}
// BringUpResult is the outcome. It reuses the restore-test's WARNINGS surface
@@ -606,6 +612,15 @@ func buildBringUpConfig(spec BringUpSpec, cfg proxmox.GuestConfig) map[string]st
params["net0"] = withoutHwaddr(net0) // omit hwaddr → PVE generates a fresh MAC (F1)
}
}
// R-50 island NIC: attach a static net1 on the host-internal bridge so the control plane
// (controller→agent local API) rides a fixed private address, immune to any LAN/DHCP/site move.
// Both modes: a provisioned guest AND a DR-restored guest need to reach the island-bound agent on
// the target host. No hwaddr → PVE mints a fresh per-guest MAC (the /30 is one guest per host, so
// a MAC would not collide either way, but a fresh one keeps net1 symmetric with net0). Additive:
// omitted entirely when the island is not configured, keeping non-island hosts unchanged.
if strings.TrimSpace(spec.IslandBridge) != "" && strings.TrimSpace(spec.IslandGuestAddr) != "" {
params["net1"] = fmt.Sprintf("name=eth1,bridge=%s,ip=%s", spec.IslandBridge, spec.IslandGuestAddr)
}
if spec.Mode == ModeProvision && spec.Hostname != "" {
params["hostname"] = spec.Hostname
}
+30
View File
@@ -165,6 +165,36 @@ func TestBuildBringUpConfig_ResourceCaps(t *testing.T) {
}
}
// R-50: with the island configured, bring-up attaches a static net1 on the island bridge; with it
// unset (or half-set), NO net1 is emitted — byte-for-byte the pre-R-50 config on non-island hosts.
// Pure-function check on buildBringUpConfig (the derivation that makes fresh installs F1-immune).
func TestBuildBringUpConfig_IslandNIC(t *testing.T) {
// island set → net1 present, exact shape, no hwaddr (PVE mints a fresh per-guest MAC)
island := buildBringUpConfig(BringUpSpec{
Mode: ModeProvision, IslandBridge: "vmbr9", IslandGuestAddr: "169.254.253.2/30",
}, scratchCfg())
if got, want := island["net1"], "name=eth1,bridge=vmbr9,ip=169.254.253.2/30"; got != want {
t.Errorf("island net1 mismatch:\n got %q\nwant %q", got, want)
}
// DR mode too — a restored customer guest must also reach the island-bound agent on the host.
dr := buildBringUpConfig(BringUpSpec{
Mode: ModeDRGuestLoss, KeepMAC: true, IslandBridge: "vmbr9", IslandGuestAddr: "169.254.253.2/30",
}, scratchCfg())
if _, ok := dr["net1"]; !ok {
t.Errorf("DR bring-up must also attach the island net1, got none")
}
// island unset → NO net1 key (non-island hosts unchanged; the pre-R-50 default)
none := buildBringUpConfig(BringUpSpec{Mode: ModeProvision}, scratchCfg())
if v, ok := none["net1"]; ok {
t.Errorf("net1 must be ABSENT when the island is not configured, got %q", v)
}
// half-configured (bridge only) → still no net1 (all-or-nothing; config.Validate rejects the config too)
half := buildBringUpConfig(BringUpSpec{Mode: ModeProvision, IslandBridge: "vmbr9"}, scratchCfg())
if v, ok := half["net1"]; ok {
t.Errorf("net1 must be ABSENT when only the bridge is set, got %q", v)
}
}
// Both restore sites allocate the guest INTO the felhom pool (SPIKE 3b): the provision bring-up
// threads spec.Pool, and the restore-test hardcodes DefaultPool — else a pool-scoped token 403s on
// the created guest's config/start/destroy. Asserts via the fakeAPI's captured RestoreLXCOptions.