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:
@@ -1,3 +1,29 @@
|
|||||||
|
## v0.96.0 — R-50 island NIC: provision attaches the guest's island net1 (2026-07-25)
|
||||||
|
|
||||||
|
Additive; **MinAgent unchanged** (no controller coupling — the controller dials whatever `bootstrap.json`
|
||||||
|
says, and the pin is address-independent). Implements the provisioning half of the R-50 island control
|
||||||
|
plane, spiked GO in `felhom.eu/documentation/audits/SPIKE-island-bridge-2026-07-25.md`. A fresh install
|
||||||
|
is now born immune to F1 (`AUDIT-vacation-remote-ops-2026-07-20`: a LAN/DHCP move made the agent fail to
|
||||||
|
bind → storage/PBS/quiesce/restore-test/DR down, silently).
|
||||||
|
|
||||||
|
- **`LocalAPIConfig`** gains `island_bridge` + `island_guest_addr` (config.go). `IslandEnabled()` = both
|
||||||
|
set. `Validate()` enforces all-or-nothing + a CIDR guest addr — a half-set/malformed island fails at
|
||||||
|
load (a botched install), never a silent LAN fallback that would leave an island bind with no island NIC.
|
||||||
|
- **`buildBringUpConfig`** (reconcile/bringup.go): when both island fields are set, attaches a static
|
||||||
|
`net1=name=eth1,bridge=<vmbr9>,ip=<169.254.253.2/30>` (no hwaddr → fresh per-guest MAC) on BOTH
|
||||||
|
provision and DR bring-up. Empty = pre-R-50, no net1 (byte-for-byte the old config on non-island hosts).
|
||||||
|
Plumbed from `cfg.LocalAPI` at both `RunBringUp` call sites (cmd/main.go). The bootstrap `endpoint`
|
||||||
|
already derives from `listen_addr` (main.go), so moving the agent bind to the island moves the guest
|
||||||
|
dial for free — **no template change** (A0 determination).
|
||||||
|
- **Design note (A0/A3):** endpoint is config-derived, so no code was needed there; the guestnet healer
|
||||||
|
is eth0-only (`parseMode` is dev-scoped) so the static island `eth1` is outside its scope — a red-proof
|
||||||
|
test locks that in (`TestParseMode_IslandStaticNICDoesNotConfuseEth0`) rather than changing the healer.
|
||||||
|
- Tests (all non-hollow, red-proofed): `TestBuildBringUpConfig_IslandNIC`,
|
||||||
|
`TestLocalAPIConfig_IslandValidation`, plus the healer scoping test above.
|
||||||
|
- **Coupling (deploy order):** a host-install that writes the island config REQUIRES agent ≥ 0.96.0 to
|
||||||
|
read `island_guest_addr` and attach net1 — vouch 0.96.0 before island installs go live. Migration of
|
||||||
|
existing boxes is the Phase-B runbook (`felhom.eu/documentation/runbooks/RUNBOOK-island-migration.md`).
|
||||||
|
|
||||||
## v0.95.0 — SMART coverage: union-path drives + LVM/dm root + device model (2026-07-25)
|
## v0.95.0 — SMART coverage: union-path drives + LVM/dm root + device model (2026-07-25)
|
||||||
|
|
||||||
Additive; **MinAgent unchanged**; hub untouched (unknown JSON fields ignored). Implements the graded
|
Additive; **MinAgent unchanged**; hub untouched (unknown JSON fields ignored). Implements the graded
|
||||||
|
|||||||
@@ -1,53 +1,31 @@
|
|||||||
# REPORT — v0.95.0: SMART coverage (spike fixes B+A) + device model (2026-07-25)
|
# REPORT — v0.96.0: R-50 island NIC (provisioning half) (2026-07-25)
|
||||||
|
|
||||||
**Overwritten** per the standing rule. Part of the SMART-coverage task (agent v0.95.0 + controller
|
Implements the agent half of the R-50 island control plane (spike GO,
|
||||||
v0.171.0); the R-50 island-bridge spike lives in felhom.eu.
|
`felhom.eu/documentation/audits/SPIKE-island-bridge-2026-07-25.md`). A fresh install is born immune to
|
||||||
|
F1 (a LAN/DHCP/site move can no longer take the control plane down).
|
||||||
|
|
||||||
## Baseline → target
|
## What changed
|
||||||
felhom-agent `643899c` (v0.94.0) → committed **`ed97232`** (v0.95.0). Deployed to felhom-pve.
|
- `internal/config/config.go` — `LocalAPIConfig.IslandBridge` + `IslandGuestAddr`; `IslandEnabled()`;
|
||||||
|
`Validate()` all-or-nothing + CIDR guard.
|
||||||
|
- `internal/reconcile/bringup.go` — `BringUpSpec` island fields; `buildBringUpConfig` attaches a static
|
||||||
|
`net1` (island NIC) on provision AND DR when configured; absent otherwise.
|
||||||
|
- `cmd/felhom-agent/main.go` — both `RunBringUp` call sites plumb `cfg.LocalAPI.Island*`.
|
||||||
|
- `configs/agent.example.json` — island shape (island bind + island_bridge/guest_addr + lan_resolver.host_ip).
|
||||||
|
- `configs/felhom-localapi-firewall.example` — rewritten for the island (the portless bind IS the LAN close).
|
||||||
|
- `REUSE.md` — island-NIC provisioning pattern + the "endpoint follows listen_addr" note.
|
||||||
|
|
||||||
## What shipped (additive; MinAgent unchanged; hub untouched)
|
## A0/A3 determination
|
||||||
Implements `SPIKE-smart-coverage-2026-07-25.md` (both demo disks answer `smartctl -a -j` PASSED, but
|
- **A0 = config-only for the endpoint:** `main.go` sets the bootstrap `Endpoint: cfg.LocalAPI.ListenAddr`;
|
||||||
the agent never asked):
|
moving the bind to the island moves the guest dial with no template change. The version bump is for the
|
||||||
- **Fix B — union-path SMART:** `storage.SmartReader.SMARTForBacking` (reuses `smartDeviceFor`) wired
|
net1-attach (the operator chose agent-bringup over golden-bake to eliminate the golden-coupling risk).
|
||||||
into the `/disks` union path via a localapi `Smart` seam (+ a `resolveStorageDevice` seam for
|
- **A3 = healer verify-only:** the guestnet healer is eth0-only (`parseMode` dev-scoped); the static island
|
||||||
testability). Registry/USB drives now get a real read. The watchdog `Known` path stays enrich-free
|
eth1 is outside its scope. Locked with a red-proof test — no healer change.
|
||||||
(asserted: zero smartctl calls).
|
|
||||||
- **Fix A — LVM/dm resolution:** `smartDeviceFor` resolves `/dev/dm-N` / `/dev/mapper/X` to the single
|
|
||||||
backing whole disk via `/sys/block/<dm>/slaves` (recursive; **skips** on >1 physical disk). The
|
|
||||||
builtin `local` dir on the LVM root gets a **SMART-only** device from its containing filesystem
|
|
||||||
(`containingMountDevice`), gated to `catDir && backing=="" && reachable` — never touching
|
|
||||||
`backing_device`/`durable_id` (the removable-safety guard stays intact).
|
|
||||||
- **Model:** `SmartSummary.ModelName` captured from smartctl's `model_name`.
|
|
||||||
- Fix C (`-d sat`) stays rejected — no sudoers/manifest change.
|
|
||||||
|
|
||||||
## Files
|
## Tests (green, non-hollow)
|
||||||
`internal/hub/report.go` (ModelName), `internal/storage/smart.go` (parse model), `internal/storage/smartdev.go`
|
`TestBuildBringUpConfig_IslandNIC`, `TestLocalAPIConfig_IslandValidation`,
|
||||||
(NEW: dm resolution + SmartReader + containingMountDevice), `internal/storage/observe.go` (smartDeviceFor dm
|
`TestParseMode_IslandStaticNICDoesNotConfuseEth0`. Full `go build/vet/test ./...` green.
|
||||||
branch, enrich smartHint, build hint), `internal/localapi/{server.go,disks.go}` (Smart seam + union read +
|
|
||||||
resolve seam), `cmd/felhom-agent/main.go` (wire), CHANGELOG/CONTEXT/REUSE. New tests:
|
|
||||||
`smartdev_test.go`, `observe_smart_test.go`, union-path test in `disks_smart_test.go`.
|
|
||||||
|
|
||||||
## Tests + red-proofs (all recorded)
|
## Coupling / deploy order
|
||||||
`go build/vet/test ./...` — **29 packages pass, 0 fail.** Red-proofs verified then restored:
|
A host-install that writes the island config REQUIRES agent ≥ 0.96.0 (to read `island_guest_addr` and
|
||||||
- dm multi-disk guard (`len(disks)!=1`) → mirror-over-two-disks resolves instead of skipping → FAIL.
|
attach net1). **Vouch 0.96.0 before island installs go live.** MinAgent unchanged (no controller coupling).
|
||||||
- enrich `smartHint` fallback → system disk stays UNKNOWN → FAIL.
|
Fleet migration of existing boxes = `felhom.eu/documentation/runbooks/RUNBOOK-island-migration.md` (Phase B).
|
||||||
- Fix-B union routing removed → union drive carries no SMART → FAIL.
|
|
||||||
- Known-path-never-SMARTs asserted (zero calls; the "route Known through enrich" mutation would break it).
|
|
||||||
|
|
||||||
## Deploy + LIVE acceptance (felhom-pve — REAL N100 hardware, not virtualized)
|
|
||||||
Built `0.95.0` (ldflags), backed up `.bak-0.94.0`, installed, restarted → **active, 68/68 capabilities,
|
|
||||||
listening**. `/disks` payload now carries real SMART + model for BOTH physical disks:
|
|
||||||
- `local` (system SSD): `backing=''` yet **smart=PASSED, model="AirDisk 512GB SSD"** — Fix A resolved
|
|
||||||
`/`→`pve-root`(LVM)→`sda`.
|
|
||||||
- `47a3361a` (USB): `backing=/dev/sdb`, **smart=PASSED, model="TOSHIBA MQ04ABF100"** — Fix B.
|
|
||||||
- `felhom-pbs`/`local-lvm`: UNKNOWN (logical; correctly excluded by the controller).
|
|
||||||
|
|
||||||
The controller v0.171.0 card on 9201 shows both with **real verdicts + human labels**:
|
|
||||||
**"AirDisk 512GB SSD" → Rendben (34°C)** and **"TOSHIBA MQ04ABF100" → Rendben (30°C)** — the operator's
|
|
||||||
"Nincs adat on a raw UUID" complaint is reversed. (A live FAILING/Figyelmeztetés verdict still needs a
|
|
||||||
genuinely degrading disk — unit+red-proof covered.)
|
|
||||||
|
|
||||||
## Observations
|
|
||||||
- The earlier assumption that demo-felhom is "virtualized (no real SMART)" was WRONG — it is a real N100
|
|
||||||
(SATA SSD `sda` + USB HDD `sdb`); the capability-map row is corrected accordingly.
|
|
||||||
|
|||||||
@@ -88,7 +88,8 @@
|
|||||||
| `pinnedTLS` | internal/pbs/pin.go | `pinnedTLS(fingerprint) (*tls.Config, error)` | PBS leaf pinning | Same model as PVE; 64-hex fingerprint normalized |
|
| `pinnedTLS` | internal/pbs/pin.go | `pinnedTLS(fingerprint) (*tls.Config, error)` | PBS leaf pinning | Same model as PVE; 64-hex fingerprint normalized |
|
||||||
| `hub.Client.Report` | internal/hub/client.go | `Report(ctx, *HostReport) (*ControlEnvelope, error)` | the heartbeat | Typed `TransportError`/`HTTPError`, never contain the bearer token |
|
| `hub.Client.Report` | internal/hub/client.go | `Report(ctx, *HostReport) (*ControlEnvelope, error)` | the heartbeat | Typed `TransportError`/`HTTPError`, never contain the bearer token |
|
||||||
| `hub.Loop` + `MultiObserver` | internal/hub/loop.go | `NewLoop(...)`; `MultiObserver(obs...)` | resilient report loop + envelope fan-out | Errors logged, loop continues; interval clamped 60–3600 s |
|
| `hub.Loop` + `MultiObserver` | internal/hub/loop.go | `NewLoop(...)`; `MultiObserver(obs...)` | resilient report loop + envelope fan-out | Errors logged, loop continues; interval clamped 60–3600 s |
|
||||||
| `provision.BackHalf.Provision` | internal/provision/backhalf.go | `Provision(ctx, Input) (Result, error)` | guest bootstrap back-half | mint→render→0600 write→chown 100000:100000→`pct set` ro bind→onboot; token NEVER logged/returned |
|
| `provision.BackHalf.Provision` | internal/provision/backhalf.go | `Provision(ctx, Input) (Result, error)` | guest bootstrap back-half | mint→render→0600 write→chown 100000:100000→`pct set` ro bind→onboot; token NEVER logged/returned. Bootstrap `local_api.endpoint` = the caller's `cfg.LocalAPI.ListenAddr` (main.go) — moving the agent bind to the island moves the guest dial for free (R-50, no template) |
|
||||||
|
| `buildBringUpConfig` island NIC | internal/reconcile/bringup.go | (pure) `BringUpSpec{IslandBridge,IslandGuestAddr}` → `params["net1"]` | R-50 island control plane | When BOTH island fields are set (from `cfg.LocalAPI`), attaches a static `net1=name=eth1,bridge=<vmbr9>,ip=<.2/30>` (no hwaddr → fresh MAC), so the controller reaches the agent over a fixed private address immune to LAN/DHCP/site moves. Empty = pre-R-50, no net1. All-or-nothing + CIDR enforced in `LocalAPIConfig.Validate`. The guestnet healer is eth0-only (`parseMode` is dev-scoped) so it never touches the static island NIC |
|
||||||
| `reconcile.Queue.Submit` | internal/reconcile/queue.go | `Submit(vmid, fn) <-chan error` | per-guest serialization of ALL mutations | Same vmid strictly FIFO; lanes parallel across guests |
|
| `reconcile.Queue.Submit` | internal/reconcile/queue.go | `Submit(vmid, fn) <-chan error` | per-guest serialization of ALL mutations | Same vmid strictly FIFO; lanes parallel across guests |
|
||||||
| `Engine.RunSignedJob` | internal/reconcile/job.go | `RunSignedJob(ctx, intent, signed, exec) JobResult` | executing a gated destructive job | Idempotency by nonce; journaled |
|
| `Engine.RunSignedJob` | internal/reconcile/job.go | `RunSignedJob(ctx, intent, signed, exec) JobResult` | executing a gated destructive job | Idempotency by nonce; journaled |
|
||||||
| `escrow.Create` | internal/escrow/escrow.go | `Create(ctx, CreateOptions) (CreateResult, R, error)` | PBS-key escrow (zero-knowledge) | Recovery code returned SEPARATELY from the result (anti-log); self-verifies recoverability |
|
| `escrow.Create` | internal/escrow/escrow.go | `Create(ctx, CreateOptions) (CreateResult, R, error)` | PBS-key escrow (zero-knowledge) | Recovery code returned SEPARATELY from the result (anti-log); self-verifies recoverability |
|
||||||
|
|||||||
@@ -1772,6 +1772,7 @@ func runSelftestBringUp(ctx context.Context, cfg config.Config, logger *slog.Log
|
|||||||
Cores: sizing.Cores, MemoryMB: sizing.MemoryMB,
|
Cores: sizing.Cores, MemoryMB: sizing.MemoryMB,
|
||||||
RootfsGrowGB: sizing.RootfsGrowGB, DataVolGrowGB: sizing.DataVolGrowGB, DataVolMount: sizing.DataVolMount,
|
RootfsGrowGB: sizing.RootfsGrowGB, DataVolGrowGB: sizing.DataVolGrowGB, DataVolMount: sizing.DataVolMount,
|
||||||
SysDataGrowGB: sizing.SysDataGrowGB, SysDataMount: sizing.SysDataMount,
|
SysDataGrowGB: sizing.SysDataGrowGB, SysDataMount: sizing.SysDataMount,
|
||||||
|
IslandBridge: cfg.LocalAPI.IslandBridge, IslandGuestAddr: cfg.LocalAPI.IslandGuestAddr, // R-50 island NIC (both empty = pre-R-50)
|
||||||
}
|
}
|
||||||
fmt.Printf(" bringing up %s → vmid %d on %s …\n", archive, vmid, cfg.Backup.RestoreStorage)
|
fmt.Printf(" bringing up %s → vmid %d on %s …\n", archive, vmid, cfg.Backup.RestoreStorage)
|
||||||
res := engine.RunBringUp(ctx, spec)
|
res := engine.RunBringUp(ctx, spec)
|
||||||
@@ -1935,6 +1936,7 @@ func runSelftestProvision(ctx context.Context, cfg config.Config, logger *slog.L
|
|||||||
Cores: a.sizing.Cores, MemoryMB: a.sizing.MemoryMB,
|
Cores: a.sizing.Cores, MemoryMB: a.sizing.MemoryMB,
|
||||||
RootfsGrowGB: a.sizing.RootfsGrowGB, DataVolGrowGB: a.sizing.DataVolGrowGB, DataVolMount: a.sizing.DataVolMount,
|
RootfsGrowGB: a.sizing.RootfsGrowGB, DataVolGrowGB: a.sizing.DataVolGrowGB, DataVolMount: a.sizing.DataVolMount,
|
||||||
SysDataGrowGB: a.sizing.SysDataGrowGB, SysDataMount: a.sizing.SysDataMount,
|
SysDataGrowGB: a.sizing.SysDataGrowGB, SysDataMount: a.sizing.SysDataMount,
|
||||||
|
IslandBridge: cfg.LocalAPI.IslandBridge, IslandGuestAddr: cfg.LocalAPI.IslandGuestAddr, // R-50 island NIC (both empty = pre-R-50)
|
||||||
})
|
})
|
||||||
if res.Err != nil || !res.Pass {
|
if res.Err != nil || !res.Pass {
|
||||||
fmt.Fprintf(os.Stderr, " [FAIL] front-half bring-up (vmid %d): %v\n", a.vmid, res.Err)
|
fmt.Fprintf(os.Stderr, " [FAIL] front-half bring-up (vmid %d): %v\n", a.vmid, res.Err)
|
||||||
|
|||||||
@@ -48,10 +48,16 @@
|
|||||||
},
|
},
|
||||||
"local_api": {
|
"local_api": {
|
||||||
"enable": true,
|
"enable": true,
|
||||||
"listen_addr": "192.168.0.162:8443",
|
"listen_addr": "169.254.253.1:8443",
|
||||||
"cert_file": "/var/lib/felhom-agent/local-api.crt",
|
"cert_file": "/var/lib/felhom-agent/local-api.crt",
|
||||||
"key_file": "/var/lib/felhom-agent/local-api.key",
|
"key_file": "/var/lib/felhom-agent/local-api.key",
|
||||||
"token_store": "/var/lib/felhom-agent/local-tokens.log"
|
"token_store": "/var/lib/felhom-agent/local-tokens.log",
|
||||||
|
"island_bridge": "vmbr9",
|
||||||
|
"island_guest_addr": "169.254.253.2/30"
|
||||||
|
},
|
||||||
|
"lan_resolver": {
|
||||||
|
"enable": true,
|
||||||
|
"host_ip": "192.168.0.162"
|
||||||
},
|
},
|
||||||
"log_level": "info"
|
"log_level": "info"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,30 @@
|
|||||||
# felhom-agent local API — host firewall narrowing (doc 03 §6, slice 8A)
|
# felhom-agent local API — host firewall narrowing (doc 03 §6; R-50 island update 2026-07-25)
|
||||||
#
|
#
|
||||||
# Defense-in-depth for the per-guest local API (the controller→agent channel on the host
|
# Defense-in-depth for the per-guest local API (the controller→agent channel). The PER-GUEST BEARER
|
||||||
# bridge). The PER-GUEST BEARER TOKEN is the authorization gate; this firewall rule is an
|
# TOKEN + the served-leaf pin are the authorization gate; a firewall rule is only an ADDITIONAL layer
|
||||||
# ADDITIONAL layer that limits who can even reach the port. The slice-8A spike found no rule
|
# limiting who can even open the port.
|
||||||
# was needed for reachability on the demo (PVE firewall off) — this narrows exposure so that
|
|
||||||
# only guests on the bridge subnet (not arbitrary LAN hosts) can open a connection.
|
|
||||||
#
|
#
|
||||||
# The agent already binds the listener to the host BRIDGE IP (local_api.listen_addr), not
|
# === R-50 ISLAND INSTALL (the default on a fresh appliance) =================================
|
||||||
# 0.0.0.0. This file adds the subnet restriction. Apply it at HOST SETUP (it is a host-level
|
# The agent binds local_api.listen_addr on the HOST-INTERNAL island bridge — 169.254.253.1:8443 on
|
||||||
# packet-filter change, intentionally OUTSIDE the agent's 3-exception privileged fence — the
|
# vmbr9, a bridge with NO physical port (bridge-ports none). That bind is the security win:
|
||||||
# agent never mutates the host firewall at runtime).
|
# * Nothing listens on the LAN IP at all, so no LAN host (or off-site attacker on the LAN) can
|
||||||
|
# reach the local API — the LAN:8443 surface is CLOSED by the bind, not by a rule.
|
||||||
|
# * vmbr9 has no uplink, so 169.254.253.1:8443 is reachable ONLY from the one guest wired to the
|
||||||
|
# /30 (169.254.253.2) — the controller. The portless bridge is the isolation.
|
||||||
|
# So on an island install NO firewall rule is required for exposure; the topology provides it. If you
|
||||||
|
# want belt-and-suspenders, restrict the port to the island bridge (it changes nothing, since nothing
|
||||||
|
# off-bridge can route to a portless bridge anyway):
|
||||||
#
|
#
|
||||||
# Replace the bridge IP (192.168.0.162), port (8443), and the guest bridge subnet
|
# nft add rule inet filter input iifname != "vmbr9" ip daddr 169.254.253.1 tcp dport 8443 drop
|
||||||
# (192.168.0.0/24) with this host's values.
|
#
|
||||||
|
# Verify: from the guest, a TLS connect to 169.254.253.1:8443 succeeds; there is no LAN listener to
|
||||||
|
# probe (`ss -lnt 'sport = :8443'` shows only the island IP).
|
||||||
|
#
|
||||||
|
# === LEGACY LAN BIND (byo, --no-island, or an explicit --bridge-ip) =========================
|
||||||
|
# When the agent still binds a LAN bridge IP (e.g. 192.168.0.162:8443), the port is exposed to the
|
||||||
|
# whole LAN and the subnet-narrowing rule below is worth applying. Replace the bridge IP, port, and
|
||||||
|
# the guest bridge subnet with this host's values.
|
||||||
#
|
#
|
||||||
# ---------------------------------------------------------------------------------------------
|
|
||||||
# Option A — nftables (recommended on PVE 8/9; inet filter table). Insert ABOVE any accept:
|
# Option A — nftables (recommended on PVE 8/9; inet filter table). Insert ABOVE any accept:
|
||||||
#
|
#
|
||||||
# nft add rule inet filter input ip daddr 192.168.0.162 tcp dport 8443 \
|
# nft add rule inet filter input ip daddr 192.168.0.162 tcp dport 8443 \
|
||||||
@@ -22,13 +32,11 @@
|
|||||||
# nft add rule inet filter input ip daddr 192.168.0.162 tcp dport 8443 \
|
# nft add rule inet filter input ip daddr 192.168.0.162 tcp dport 8443 \
|
||||||
# ip saddr 192.168.0.0/24 accept
|
# ip saddr 192.168.0.0/24 accept
|
||||||
#
|
#
|
||||||
# ---------------------------------------------------------------------------------------------
|
|
||||||
# Option B — iptables:
|
# Option B — iptables:
|
||||||
#
|
#
|
||||||
# iptables -A INPUT -d 192.168.0.162 -p tcp --dport 8443 -s 192.168.0.0/24 -j ACCEPT
|
# iptables -A INPUT -d 192.168.0.162 -p tcp --dport 8443 -s 192.168.0.0/24 -j ACCEPT
|
||||||
# iptables -A INPUT -d 192.168.0.162 -p tcp --dport 8443 -j DROP
|
# iptables -A INPUT -d 192.168.0.162 -p tcp --dport 8443 -j DROP
|
||||||
#
|
#
|
||||||
# ---------------------------------------------------------------------------------------------
|
|
||||||
# Option C — PVE host firewall (/etc/pve/nodes/<node>/host.fw), if the PVE firewall is enabled.
|
# Option C — PVE host firewall (/etc/pve/nodes/<node>/host.fw), if the PVE firewall is enabled.
|
||||||
# Add under [RULES] (and ensure the firewall is enabled in cluster.fw / host.fw):
|
# Add under [RULES] (and ensure the firewall is enabled in cluster.fw / host.fw):
|
||||||
#
|
#
|
||||||
@@ -36,5 +44,6 @@
|
|||||||
# IN ACCEPT -source 192.168.0.0/24 -dport 8443 -proto tcp -log nolog
|
# IN ACCEPT -source 192.168.0.0/24 -dport 8443 -proto tcp -log nolog
|
||||||
# IN DROP -dport 8443 -proto tcp -log nolog
|
# IN DROP -dport 8443 -proto tcp -log nolog
|
||||||
#
|
#
|
||||||
# Verify after applying: from a guest ON the bridge, a TLS connect to <bridge-ip>:8443 succeeds;
|
# Apply at HOST SETUP — a host-level packet-filter change, intentionally OUTSIDE the agent's
|
||||||
# from an OFF-bridge host it is refused/dropped. (The token + leaf-pin still gate the request.)
|
# 3-exception privileged fence (the agent never mutates the host firewall at runtime). The token +
|
||||||
|
# leaf-pin still gate the request regardless of which bind is in force.
|
||||||
|
|||||||
@@ -235,6 +235,16 @@ type LocalAPIConfig struct {
|
|||||||
// TokenStore is the durable, hashed token→guest map (only a HASH of each token is
|
// 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).
|
// 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
|
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).
|
// 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) != ""
|
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).
|
// TokenStorePath returns the configured token-store path (default applied).
|
||||||
func (l LocalAPIConfig) TokenStorePath() string {
|
func (l LocalAPIConfig) TokenStorePath() string {
|
||||||
if l.TokenStore != "" {
|
if l.TokenStore != "" {
|
||||||
@@ -283,6 +299,17 @@ func (l LocalAPIConfig) Validate() error {
|
|||||||
if _, _, err := net.SplitHostPort(l.ListenAddr); err != nil {
|
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)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -171,3 +171,46 @@ func TestDeploymentModeEnvOverlay(t *testing.T) {
|
|||||||
t.Errorf("env overlay did not set deployment_mode: %q", cfg.DeploymentMode)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -548,3 +548,21 @@ func TestClassify_Table(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var _ io.Writer = (*bytes.Buffer)(nil)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -193,6 +193,12 @@ type BringUpSpec struct {
|
|||||||
Mounts []GuestMount // additive mpN mounts (slice 7 may pass empty/test)
|
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
|
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
|
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
|
// 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)
|
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 != "" {
|
if spec.Mode == ModeProvision && spec.Hostname != "" {
|
||||||
params["hostname"] = spec.Hostname
|
params["hostname"] = spec.Hostname
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// 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
|
// 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.
|
// the created guest's config/start/destroy. Asserts via the fakeAPI's captured RestoreLXCOptions.
|
||||||
|
|||||||
Reference in New Issue
Block a user