From 912f7c127e757e858a392053834d32f7d248ee5b Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Fri, 16 Oct 2026 03:55:47 +0200 Subject: [PATCH] =?UTF-8?q?The=20internal=20data=20place,=20partitions=20a?= =?UTF-8?q?s=20places,=20add=5Finternal=5Fplace=20(felhom.eu=20architectur?= =?UTF-8?q?e/16=20=C2=A75-=C2=A77,=20=C2=A79;=20build=20B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - internal place: the record /var/lib/felhom-agent/internal-place.json -> mounted at /mnt/belso by fs-UUID like an enrolled drive (startup + 20 s tick), kind "internal" in GET /disks; never takes a household drive's /mnt/belso; eject/decommission/format refused (dm on the system disk). - configs/felhom-internal-place: the ruled size rule and the only lvcreate/mkfs of a system-disk volume (installer + the new signed op add_internal_place, operator key, no parameters). - partitions listed per free disk (empty / filesystem / in_use); a data-bearing part's wipe id binds disk id + part number + fs-UUID. - felhom-mkfs-guarded: Felhom's own sibling drive no longer blocks formatting the free part; an unlistable disk refuses. Tests red-proved (script mutations, Go mutations, the old wrapper measured against the new tests). Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb --- REUSE.md | 4 + cmd/felhom-agent/main.go | 35 ++- cmd/felhom-opsign/main.go | 2 +- configs/felhom-agent.sudoers | 10 +- configs/felhom-internal-place | 103 ++++++++ configs/felhom-mkfs-guarded.sh | 36 ++- configs/felhom-os-apply | 2 + configs/test_felhom_internal_place.py | 217 +++++++++++++++++ configs/test_felhom_mkfs_guarded.py | 140 +++++++++++ internal/capability/manifest.go | 2 + internal/capability/r861_injection_test.go | 5 + internal/localapi/internalplace.go | 207 ++++++++++++++++ internal/localapi/internalplace_test.go | 249 ++++++++++++++++++++ internal/localapi/samedisk.go | 2 + internal/localapi/server.go | 92 ++++---- internal/reconcile/classify.go | 7 +- internal/signedjobs/internalplace.go | 91 +++++++ internal/signedjobs/internalplace_test.go | 94 ++++++++ internal/storage/candidates.go | 14 +- internal/storage/durable_device.go | 35 ++- internal/storage/mkfs_guarded_suite_test.go | 24 ++ internal/storage/partition_durable_test.go | 112 +++++++++ internal/storage/partitions.go | 104 ++++++++ internal/storage/partitions_test.go | 73 ++++++ 24 files changed, 1591 insertions(+), 69 deletions(-) create mode 100755 configs/felhom-internal-place create mode 100644 configs/test_felhom_internal_place.py create mode 100644 configs/test_felhom_mkfs_guarded.py create mode 100644 internal/localapi/internalplace.go create mode 100644 internal/localapi/internalplace_test.go create mode 100644 internal/signedjobs/internalplace.go create mode 100644 internal/signedjobs/internalplace_test.go create mode 100644 internal/storage/mkfs_guarded_suite_test.go create mode 100644 internal/storage/partition_durable_test.go create mode 100644 internal/storage/partitions.go create mode 100644 internal/storage/partitions_test.go diff --git a/REUSE.md b/REUSE.md index 56944bd..c8afa7d 100644 --- a/REUSE.md +++ b/REUSE.md @@ -42,6 +42,10 @@ | `deriveDurableID` | internal/storage/durableid.go | `deriveDurableID(typ, s, backingDevice, uuid) string` | storage-target durable id (DR re-attach key) | Deterministic per type; `uuid:` for usb/local-dir; PBS id includes `#` | | `SystemDisks` / `isSystemBacked` / `RoleForStorage` / `RoleForRawDevice` | internal/storage/role.go | `RoleForRawDevice(device, sysDisks, sysKnown) DeviceRole` | protection-tier classification | Fails safe to `system` (most protected) on any ambiguity; role is AGENT-derived, never caller-supplied | | `storage.IdentityResolver` / `Relation` / `MountFor` | internal/storage/identity.go | `HostPath(path) PlaceIdentity`, `Device(dev) PlaceIdentity`, `Relation(a, b) (different, proven, reason)` | physical-disk identity of a storage place (felhom.eu `16-storage-physical-identity.md` §3, v0.157.0) | Reads /sys + /run/udev/data + /proc/mounts only (no block I/O). Judge a drive ONLY on its own mount (`localapi.ownMountIdentity`) — never the containing fs, or an absent drive reads as the root disk. A shared disk key is "same, proven" even when unknown. Pinned by identity_test.go (fixture trees) + samedisk_test.go | +| `localapi.Server.EnsureInternalPlace` / `InternalPlaceRecord` | internal/localapi/internalplace.go | `EnsureInternalPlace(ctx)` (startup + 20 s tick); `ReadInternalPlace` / `WriteInternalPlace` | the one-disk box's internal data place (felhom.eu `16` §5, v0.158.0): mounts it at /mnt/belso by fs-UUID like an enrolled drive, records it `enrolled`; the controller binds + registers it | Never takes /mnt/belso from a household drive (a foreign mount, or a unit naming another UUID, waits). Never formats/ejects (dm on the system disk → role system). The ONLY maker is the root helper `configs/felhom-internal-place` (installer + signed `add_internal_place`). Pinned by internalplace_test.go | +| `configs/felhom-internal-place` | configs/ (bundle → /usr/local/sbin) | `plan\|create ` | the ruled size rule (reserve max(16, 10 %) ≤ 48 GiB; ≥ 20 GiB or nothing) + the only lvcreate/mkfs of a system-disk volume | Formats only a felhom-belso with NO signature; refuses a foreign one; never resizes/removes. Sudoers: `^(plan\|create) 0$` only. Pinned by configs/test_felhom_internal_place.py | +| `storage.candidatePartitions` / `CandidatePartition` | internal/storage/partitions.go | `CandidateDisk.Partitions` | partitions as places (`16` §6): empty / filesystem / in_use per part of a free disk | A part with children or a member type is never listed; a raw /mnt mount counts as Felhom's only when corroborated (felhomOwnedMounts). Pinned by partitions_test.go | +| partition wipe id `byid:<…-partN>+fsuuid:` | internal/storage/durable_device.go | `DeviceDurableID` / `ResolveDurableDevice` | a data-bearing PART binds disk id + part number + filesystem (`16` §6) | Both halves must resolve to the SAME device or the wipe refuses. Whole disks keep the plain id. Pinned by partition_durable_test.go | | `ValidateUUID/MountPath/BlockDevice/FSType/SMARTDevice/LVMName`, `UnitNameForMount` | internal/storage/validate.go | `Validate*(v) error` | EVERY arg that reaches a root shell-out | The security boundary; strict whitelists (no by-* symlinks, no dm, no traversal); `systemdEscapePath` computed in-process | | `ValidateNetworkMountSpec` | internal/storage/netmount.go | `ValidateNetworkMountSpec(spec) error` | NAS mount input boundary | Same discipline as validate.go; SMB requires a creds ref; mountpoint confined under `NetworkMountRoot` | diff --git a/cmd/felhom-agent/main.go b/cmd/felhom-agent/main.go index e037617..6e84aee 100644 --- a/cmd/felhom-agent/main.go +++ b/cmd/felhom-agent/main.go @@ -959,10 +959,7 @@ func runDaemon(cfg config.Config, logger *slog.Logger, logRing *applog.Ring) int // state. Gates the watchdog's self-heal re-mount to ENROLLED drives, and the local API records // enroll/eject into it. Open failure → ungated legacy remount (logged) so the daemon still runs. var intentReader storage.IntentReader - intentStateDir := "/var/lib/felhom-agent" - if cfg.Authz.NonceStorePath != "" { - intentStateDir = filepath.Dir(cfg.Authz.NonceStorePath) // share the agent's durable state dir - } + intentStateDir := agentStateDir(cfg) intentStore, ierr := storage.OpenIntentStore(filepath.Join(intentStateDir, "drive-intents.json")) if ierr != nil { logger.Warn("storage: intent store unavailable — self-heal runs UNGATED (legacy remount-any)", "err", ierr) @@ -1168,7 +1165,11 @@ func runDaemon(cfg config.Config, logger *slog.Logger, logRing *applog.Ring) int } logger.Warn("osupdate: capability probe after the config bundle", "ok", ok, "total", total, "degraded", strings.Join(names, ",")) }} - jobsRunner := signedjobs.NewRunner(client, gate, signedjobs.ExecutorChain{wipeExec, decommExec, updateExec, dockerExec, pveExec, kernelExec, bundleExec}, cfg.Hub.HostID, logger) + // v0.158.0 (felhom.eu `16` §9): the internal data place on an old box — the root helper, then the mount (late-bound + // to the local API server built below). + internalPlaceExec := signedjobs.NewInternalPlaceExecutor(suRunner, + localapi.InternalPlaceFileIn(intentStateDir), logger) + jobsRunner := signedjobs.NewRunner(client, gate, signedjobs.ExecutorChain{wipeExec, decommExec, updateExec, dockerExec, pveExec, kernelExec, bundleExec, internalPlaceExec}, cfg.Hub.HostID, logger) loop.SetEnvelopeObserver(hub.MultiObserver(desiredSyncer, jobsRunner)) // Controller-driven escrow ceremony (v0.88.0): static config facts + the LATE-BOUND DR gate — @@ -1201,6 +1202,7 @@ func runDaemon(cfg config.Config, logger *slog.Logger, logRing *applog.Ring) int } localSrv := buildLocalAPIServer(cfg, px, backupStore, heavyOps, observer, driveKnown, hostOps, gate, collector, client, intentRec, guestBindStore, formatJobStore, logRing, escrowCeremonyCfg, logger, &localTokens) if localSrv != nil { + internalPlaceExec.SetEnsure(localSrv.EnsureInternalPlace) // R-998 (doc 14): POST /host/poweroff — guest shutdown, then host power-off; guards in hostpoweroff.go. pwMode := proxmox.RunnerMode(cfg.Privileged.Mode) if pwMode == "" { @@ -1419,6 +1421,9 @@ func runDaemon(cfg config.Config, logger *slog.Logger, logRing *applog.Ring) int // filesystem UUID and re-mounts (idempotent `enable --now`) so a letter reshuffle is a no-op. // Type-asserted (the concrete op exposes it; the interface stays lean). mountReasserter := hostOps // concrete *storage.SudoHostOps — exposes ReassertEnrolledMounts + // v0.158.0 (felhom.eu `16` §5): the internal data place is mounted like an enrolled drive — first, so the + // re-assert below sees its unit. + localSrv.EnsureInternalPlace(ctx) if mountReasserter != nil { mountReasserter.ReassertEnrolledMounts(ctx) } @@ -1456,6 +1461,7 @@ func runDaemon(cfg config.Config, logger *slog.Logger, logRing *applog.Ring) int case <-t.C: // Re-assert mounts first (handles a USB that enumerated late after a host reboot, or a // unit re-disabled at runtime), then re-assert the guest binds against the now-live mounts. + localSrv.EnsureInternalPlace(ctx) // `16` §5: a record written since the last tick (the signed job) if mountReasserter != nil { mountReasserter.ReassertEnrolledMounts(ctx) } @@ -2023,10 +2029,12 @@ func buildLocalAPIServer(cfg config.Config, px *proxmox.Client, store *backup.St // F2-b: recover a guest left with a stale vzdump lock by a reboot-during-backup. Reads + start // go through the API client; the `pct unlock` is the one fenced root-CLI op (no API equivalent). // A1 (v0.62.0): the scan is restricted to felhom-pool members (ownership proven, not assumed). - StaleLock: localapi.NewStaleLockController(px, &proxmox.ExecRunner{Mode: gaMode, SudoPath: cfg.Privileged.SudoPath}, reconcile.DefaultPool, logger), - Intent: intent, // slice 10 P3: record enroll/eject intent for self-heal - GuestBinds: guestBinds, // F9: per-guest bind record for the startup re-assert - FormatJobs: formatJobs, // F20-BUG3: detached-format job record + restart recovery + StaleLock: localapi.NewStaleLockController(px, &proxmox.ExecRunner{Mode: gaMode, SudoPath: cfg.Privileged.SudoPath}, reconcile.DefaultPool, logger), + Intent: intent, // slice 10 P3: record enroll/eject intent for self-heal + // v0.158.0 (felhom.eu `16` §5): the internal data place record, beside the intent store. + InternalPlaceFile: localapi.InternalPlaceFileIn(agentStateDir(cfg)), + GuestBinds: guestBinds, // F9: per-guest bind record for the startup re-assert + FormatJobs: formatJobs, // F20-BUG3: detached-format job record + restart recovery // Host metrics (slice 9): the shared collector serves GET /host/metrics — a fresh host + // per-storage view to the customer's monitoring page (reuses the slice-4 collector). @@ -3905,3 +3913,12 @@ func (f *factsReporter) SystemFacts(ctx context.Context) (int, json.RawMessage, f.facts, f.err = f.leg.Facts(ctx, f.vmid) return f.vmid, f.facts, f.err } + +// agentStateDir is the agent's durable state dir: beside the nonce store when one is configured, else the default. +// The intent store, the guest-bind record and the internal place record (v0.158.0) all live here. +func agentStateDir(cfg config.Config) string { + if cfg.Authz.NonceStorePath != "" { + return filepath.Dir(cfg.Authz.NonceStorePath) + } + return "/var/lib/felhom-agent" +} diff --git a/cmd/felhom-opsign/main.go b/cmd/felhom-opsign/main.go index 0eafb63..b6f1303 100644 --- a/cmd/felhom-opsign/main.go +++ b/cmd/felhom-opsign/main.go @@ -43,7 +43,7 @@ func main() { func run() error { var ( - op = flag.String("op", "", "op class to sign, e.g. storage_wipe | guest_destroy | decommission | agent_update | os_docker_step | os_pve_step | os_kernel_step | agent_config_update") + op = flag.String("op", "", "op class to sign, e.g. storage_wipe | guest_destroy | decommission | agent_update | os_docker_step | os_pve_step | os_kernel_step | agent_config_update | add_internal_place") host = flag.String("host", "", "target host_id (anti-retarget — the op runs ONLY on this host)") guest = flag.String("guest", "", "target guest_id (\"\" = host-scoped op)") keyID = flag.String("key-id", "", "key id of the signing key (must match a pinned agent signer)") diff --git a/configs/felhom-agent.sudoers b/configs/felhom-agent.sudoers index c3fa523..4b0c6ce 100644 --- a/configs/felhom-agent.sudoers +++ b/configs/felhom-agent.sudoers @@ -96,6 +96,14 @@ Cmnd_Alias FELHOM_POWEROFF = \ Cmnd_Alias FELHOM_VZPARTIAL = \ /usr/local/sbin/felhom-vzdump-partial-clean ^[A-Za-z][A-Za-z0-9_-]{0\,63} [1-9][0-9]{2\,8} vzdump-(lxc|qemu)-[1-9][0-9]{2\,8}-[0-9]{4}_[0-9]{2}_[0-9]{2}-[0-9]{2}_[0-9]{2}_[0-9]{2}\.(tar\.dat|vma\.dat|tmp)$ +# Internal data place („Belső tárhely", felhom.eu architecture/16 §5, §7; agent v0.158.0). ONE root helper +# (configs/felhom-internal-place, from the config bundle) creates /felhom-belso in the local-lvm thin pool by the +# ruled size rule and formats ONLY that volume, ONLY when it carries no signature. The agent calls it from the signed +# `add_internal_place` job (an old box), always with 0 GiB planned: its guest's volumes already exist. `plan` changes +# nothing. The installer runs the helper as root itself (a new box). +Cmnd_Alias FELHOM_INTERNALPLACE = \ + /usr/local/sbin/felhom-internal-place ^(plan|create) 0$ + # Intermediary mount model (the drive hot-swap re-architecture). The agent keeps a SHARED host parent # /mnt/felhom-drives (self-bind + make-shared) and binds/unbinds each drive's felhom-data namespace UNDERNEATH it so the # change propagates into the running guest live. The boot-persistence script + unit are FIXED files from the config @@ -328,4 +336,4 @@ Cmnd_Alias FELHOM_GUESTNET = \ /usr/sbin/pct ^exec [0-9]+ -- pgrep -x dhclient$, \ /usr/sbin/pct ^exec [0-9]+ -- dhclient -pf /run/dhclient\.eth0\.pid -lf /var/lib/dhcp/dhclient\.eth0\.leases eth0$ -felhom-agent ALL=(root) NOPASSWD: FELHOM_MOUNT, FELHOM_DISK, FELHOM_PROVISION, FELHOM_FORMAT, FELHOM_DNSMASQ, FELHOM_GUESTHOOK, FELHOM_INTERMEDIARY, FELHOM_CONTROLLERSWAP, FELHOM_STALELOCK, FELHOM_FSTRIM, FELHOM_NETMOUNT, FELHOM_WG, FELHOM_SELFUPDATE, FELHOM_SSHD, FELHOM_OOB, FELHOM_PBSDR, FELHOM_BACKUPTARGET, FELHOM_SELFHEAL, FELHOM_ESCROW, FELHOM_GUESTNET, FELHOM_SCRATCH_TEARDOWN, FELHOM_OSAPPLY, FELHOM_POWEROFF, FELHOM_VZPARTIAL +felhom-agent ALL=(root) NOPASSWD: FELHOM_MOUNT, FELHOM_DISK, FELHOM_PROVISION, FELHOM_FORMAT, FELHOM_DNSMASQ, FELHOM_GUESTHOOK, FELHOM_INTERMEDIARY, FELHOM_CONTROLLERSWAP, FELHOM_STALELOCK, FELHOM_FSTRIM, FELHOM_NETMOUNT, FELHOM_WG, FELHOM_SELFUPDATE, FELHOM_SSHD, FELHOM_OOB, FELHOM_PBSDR, FELHOM_BACKUPTARGET, FELHOM_SELFHEAL, FELHOM_ESCROW, FELHOM_GUESTNET, FELHOM_SCRATCH_TEARDOWN, FELHOM_OSAPPLY, FELHOM_POWEROFF, FELHOM_VZPARTIAL, FELHOM_INTERNALPLACE diff --git a/configs/felhom-internal-place b/configs/felhom-internal-place new file mode 100755 index 0000000..f4a7389 --- /dev/null +++ b/configs/felhom-internal-place @@ -0,0 +1,103 @@ +#!/bin/bash +# felhom-internal-place — make the one-disk box's internal data place („Belső tárhely"), felhom.eu +# architecture/16-storage-physical-identity.md §5, §7 (sizes ruled by the operator 2026-10-16). Installed as +# /usr/local/sbin/felhom-internal-place (config bundle). Called by felhom-host-install.sh (a new box) and by the agent's +# signed `add_internal_place` job (an old box) — ONE implementation of the size rule and of the only mkfs it does. +# +# felhom-internal-place plan → prints the numbers, changes nothing +# felhom-internal-place create → makes the place (or finds it), prints the result +# +# is thin space still to be promised to the guest (rootfs + data volume) that does not exist yet: +# the installer passes 32 + the data volume; an old box passes 0 (its guest's volumes are already counted). +# +# The rule (16 §7, ruled): reserve = max(16 GiB, 10 % of the pool), at most 48 GiB; internal = pool − reserve − +# every thin volume already promised in the pool − , made only when ≥ 20 GiB. So nothing in the pool is ever +# promised beyond pool − reserve: a pool at 100 % stops every volume on the box. +# +# It trusts nothing it is told. Every rule is checked here, as root, and any doubt REFUSES (exit 1): +# - the pool is the `lvmthin: local-lvm` section of /etc/pve/storage.cfg (its vgname/thinpool), nothing else; +# - the only volume it creates, formats or reads is /felhom-belso; +# - it formats ONLY a felhom-belso that carries no signature at all (blkid -p finds nothing) — the one it just made, +# or one a crash left between lvcreate and mkfs; any other content REFUSES; it never resizes or removes anything. +# Output, one line: „created uuid= gib=", „formatted uuid=… gib=…" (a crash left it empty), „existing uuid= gib=", „skipped " (exit 0), +# or „plan pool=… reserve=… promised=… planned=… internal=…". Pinned by configs/test_felhom_internal_place.py. +set -u +STORAGE_CFG=${FELHOM_STORAGE_CFG:-/etc/pve/storage.cfg} +LV_NAME=felhom-belso +LABEL=felhom-belso +MIN_GIB=20 + +die() { echo "felhom-internal-place: REFUSED: $*" >&2; exit 1; } + +pool_of_local_lvm() { # prints " " of the lvmthin storage local-lvm, or nothing + awk ' + /^[a-z]+:[ \t]/ { split($0, h, /:[ \t]+/); type = h[1]; id = h[2]; sub(/[ \t]+$/, "", id); in_s = (type == "lvmthin" && id == "local-lvm"); next } + in_s && /^[ \t]+vgname[ \t]/ { vg = $2 } + in_s && /^[ \t]+thinpool[ \t]/ { tp = $2 } + END { if (vg != "" && tp != "") print vg, tp } + ' "$STORAGE_CFG" +} + +gib_floor() { echo $(( $1 / 1073741824 )); } + +[[ $# -eq 2 ]] || die "usage: felhom-internal-place plan|create " +MODE=$1; PLANNED=$2 +[[ "$MODE" == plan || "$MODE" == create ]] || die "mode must be plan or create" +[[ "$PLANNED" =~ ^(0|[1-9][0-9]{0,5})$ ]] || die "planned GiB must be a whole number" + +read -r VG POOL < <(pool_of_local_lvm) || true +[[ -n "${VG:-}" && -n "${POOL:-}" ]] || die "no lvmthin storage local-lvm in $STORAGE_CFG" +[[ "$VG" =~ ^[A-Za-z0-9_.+-]+$ && "$POOL" =~ ^[A-Za-z0-9_.+-]+$ ]] || die "unexpected vg/pool name" + +pool_b=$(lvs --noheadings --nosuffix --units b -o lv_size "$VG/$POOL" 2>/dev/null | tr -d ' ') +[[ "$pool_b" =~ ^[0-9]+$ ]] || die "cannot read the size of $VG/$POOL" +pool_gib=$(gib_floor "$pool_b") + +# Every thin volume promised in the pool (its virtual size) — except the place itself and vzdump's short-lived +# snapshots (snap_*_vzdump), which exist only during a whole-box copy and are what the reserve is for. +promised_b=0; have_lv=false +while read -r name pl size; do + [[ -n "${name:-}" ]] || continue + [[ "$pl" == "$POOL" ]] || continue + if [[ "$name" == "$LV_NAME" ]]; then have_lv=true; continue; fi + [[ "$name" =~ ^snap_.*_vzdump$ ]] && continue + [[ "$size" =~ ^[0-9]+$ ]] || die "cannot read the size of $VG/$name" + promised_b=$(( promised_b + size )) +done < <(lvs --noheadings --nosuffix --units b -o lv_name,pool_lv,lv_size "$VG" 2>/dev/null) +promised_gib=$(( (promised_b + 1073741823) / 1073741824 )) # rounded UP: never promise a partial GiB twice + +reserve=$(( pool_gib / 10 )); (( reserve < 16 )) && reserve=16; (( reserve > 48 )) && reserve=48 +internal=$(( pool_gib - reserve - promised_gib - PLANNED )) + +if [[ "$MODE" == plan ]]; then + echo "plan pool=$pool_gib reserve=$reserve promised=$promised_gib planned=$PLANNED internal=$internal exists=$have_lv" + exit 0 +fi + +DEV=/dev/$VG/$LV_NAME +is_block() { [[ "$(stat -L -c %F "$1" 2>/dev/null)" == "block special file" ]]; } +fs_uuid() { blkid -p -s UUID -o value "$DEV" 2>/dev/null; } +fs_type() { blkid -p -s TYPE -o value "$DEV" 2>/dev/null; } +fs_label() { blkid -p -s LABEL -o value "$DEV" 2>/dev/null; } +lv_gib() { gib_floor "$(lvs --noheadings --nosuffix --units b -o lv_size "$VG/$LV_NAME" 2>/dev/null | tr -d ' ')"; } + +if $have_lv; then + is_block "$DEV" || die "$DEV is not an active block device" + t=$(fs_type) + if [[ -n "$t" ]]; then + [[ "$t" == ext4 && "$(fs_label)" == "$LABEL" ]] || die "$DEV holds something that is not Felhom's ($t) — left alone" + echo "existing uuid=$(fs_uuid) gib=$(lv_gib)"; exit 0 + fi + # A felhom-belso with NO signature: a crash between lvcreate and mkfs. Nothing can be on it — format it. + [[ -z "$(blkid -p -o value "$DEV" 2>/dev/null)" ]] || die "$DEV carries a signature blkid cannot name — left alone" +else + (( internal >= MIN_GIB )) || { echo "skipped only $internal GiB free for it (pool $pool_gib, reserve $reserve, promised $promised_gib, planned $PLANNED; needs $MIN_GIB)"; exit 0; } + lvcreate -q -y -V "${internal}G" -T "$VG/$POOL" -n "$LV_NAME" >/dev/null || die "lvcreate failed" + is_block "$DEV" || die "$DEV did not appear after lvcreate" + [[ -z "$(blkid -p -o value "$DEV" 2>/dev/null)" ]] || die "the new $DEV already carries a signature — left alone" + made=created +fi +mkfs.ext4 -q -L "$LABEL" "$DEV" >/dev/null || die "mkfs.ext4 failed on $DEV" +u=$(fs_uuid) +[[ "$u" =~ ^[0-9a-fA-F-]{36}$ ]] || die "no filesystem UUID after mkfs on $DEV" +echo "${made:-formatted} uuid=$u gib=$(lv_gib)" diff --git a/configs/felhom-mkfs-guarded.sh b/configs/felhom-mkfs-guarded.sh index ed4d747..cf8003b 100644 --- a/configs/felhom-mkfs-guarded.sh +++ b/configs/felhom-mkfs-guarded.sh @@ -58,14 +58,34 @@ fi # 3) mounted OUTSIDE Felhom's own drive area = a live foreign filesystem → catastrophic. Mounts under # /mnt/felhom-drives are our own drives (the agent detaches before a re-init) → allowed. -while read -r mp; do - [[ -z "$mp" ]] && continue - case "$mp" in - /mnt/felhom-drives|/mnt/felhom-drives/*) : ;; - *) die "device (or a partition) is mounted at $mp ($dev)" ;; - esac -done < <(lsblk -nro MOUNTPOINT "$whole" 2>/dev/null || true) - +# Agent v0.158.0 (felhom.eu architecture/16 §6, partitions as places): a SIBLING partition that is Felhom's own +# enrolled drive is mounted TWICE — raw at /mnt/ and under /mnt/felhom-drives (R-220). That raw mount is +# allowed only when the same device is ALSO mounted under /mnt/felhom-drives, a state only Felhom's enrolment +# produces. The TARGET itself mounted anywhere outside /mnt/felhom-drives, or any mount elsewhere (/srv, /media, +# /mnt/a/b), still REFUSES. Read from the kernel's mount table, every node of the disk (partitions and children). +PROC_MOUNTS=/proc/mounts +node_mounts() { awk -v d="$1" '$1 == d { print $2 }' "$PROC_MOUNTS"; } +disk_nodes="$(lsblk -nrpo NAME "$whole" 2>/dev/null || true)" +[[ -n "$disk_nodes" ]] || die "cannot list the nodes of $whole — refusing" +while read -r node; do + [[ -z "$node" ]] && continue + own=false + while read -r mp; do + case "$mp" in /mnt/felhom-drives/*) own=true ;; esac + done < <(node_mounts "$node") + while read -r mp; do + [[ -z "$mp" ]] && continue + case "$mp" in + /mnt/felhom-drives|/mnt/felhom-drives/*) : ;; + *) + if [[ "$node" != "$dev" && "$own" == true && "$mp" =~ ^/mnt/[A-Za-z0-9_][A-Za-z0-9_.-]*$ ]]; then + : # a sibling that is Felhom's own enrolled drive (raw mount + managed mount) + else + die "device (or a partition) is mounted at $mp ($node)" + fi ;; + esac + done < <(node_mounts "$node") +done <<< "$disk_nodes" # 4) read-only device (audit D1): a device the kernel marks RO is never a formattable data disk. wbase="${whole#/dev/}" rof="/sys/block/$wbase/ro" diff --git a/configs/felhom-os-apply b/configs/felhom-os-apply index 566df09..8fd8fe9 100755 --- a/configs/felhom-os-apply +++ b/configs/felhom-os-apply @@ -191,6 +191,8 @@ BUNDLE_FILES = [ ("/usr/local/sbin/felhom-backup-target-apply", "felhom-backup-target-apply", 0o755, "bash", "replace"), # R-1006 (agent v0.156.0): the only delete of backup files — a dead half-written vzdump, re-checked as root. ("/usr/local/sbin/felhom-vzdump-partial-clean", "felhom-vzdump-partial-clean", 0o755, "bash", "replace"), + # felhom.eu architecture/16 §5 (agent v0.158.0): the internal data place — the only lvcreate/mkfs of a system-disk volume. + ("/usr/local/sbin/felhom-internal-place", "felhom-internal-place", 0o755, "bash", "replace"), ("/usr/local/sbin/felhom-os-apply", "felhom-os-apply", 0o755, "python", "replace"), ("/usr/local/sbin/felhom-crash-guard", "felhom-crash-guard", 0o755, "python", "replace"), # R-861 (agent v0.146.0): the content checker for every agent-staged file a root program reads, and the two FIXED diff --git a/configs/test_felhom_internal_place.py b/configs/test_felhom_internal_place.py new file mode 100644 index 0000000..ad1aee2 --- /dev/null +++ b/configs/test_felhom_internal_place.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +"""Tests for configs/felhom-internal-place (felhom.eu architecture/16 §5, §7 — sizes ruled 2026-10-16). + +The helper is run for real under bash; `lvs`, `lvcreate`, `blkid`, `mkfs.ext4` and `stat` are PATH stubs that read and +write one JSON state file, so nothing on this machine is touched. Every refusal has a positive control: the same setup +without the one condition goes through — so a test cannot pass because the helper refuses everything. +Run: python3 configs/test_felhom_internal_place.py (also run by the Go suite, internal/internalplace) +""" +import json, os, pathlib, stat, subprocess, tempfile, unittest + +HELPER = pathlib.Path(__file__).resolve().parent / "felhom-internal-place" +GIB = 1 << 30 + +STUB_LVS = r'''#!/usr/bin/env python3 +import json, os, sys +st = json.load(open(os.environ["IP_STATE"])) +args = [a for a in sys.argv[1:] if not a.startswith("-") and a not in ("b",)] +fields = sys.argv[sys.argv.index("-o") + 1].split(",") +target = args[-1] +for lv in st["lvs"]: + full = st["vg"] + "/" + lv["name"] + if target == st["vg"] or target == full: + row = {"lv_name": lv["name"], "pool_lv": lv.get("pool", ""), "lv_size": str(lv["size"])} + print(" " + " ".join(row[f] for f in fields)) +''' +STUB_LVCREATE = r'''#!/usr/bin/env python3 +import json, os, sys +p = os.environ["IP_STATE"]; st = json.load(open(p)) +a = sys.argv[1:] +size = int(a[a.index("-V") + 1].rstrip("G")) * (1 << 30) +vg, pool = a[a.index("-T") + 1].split("/") +st["lvs"].append({"name": a[a.index("-n") + 1], "pool": pool, "size": size, "active": True}) +st.setdefault("calls", []).append("lvcreate " + " ".join(a)) +if st.get("dirty_new"): st["fs"]["felhom-belso"] = {"TYPE": "", "RAW": "junk"} +json.dump(st, open(p, "w")) +''' +STUB_BLKID = r'''#!/usr/bin/env python3 +import json, os, sys +st = json.load(open(os.environ["IP_STATE"])) +fs = st["fs"].get(os.path.basename(sys.argv[-1]), {}) +if "-s" in sys.argv: + v = fs.get(sys.argv[sys.argv.index("-s") + 1], "") + if v: print(v) +else: + vals = [fs.get(k, "") for k in ("TYPE", "LABEL", "UUID", "RAW") if fs.get(k)] + if vals: print("\n".join(vals)) +''' +STUB_MKFS = r'''#!/usr/bin/env python3 +import json, os, sys +p = os.environ["IP_STATE"]; st = json.load(open(p)) +dev = os.path.basename(sys.argv[-1]) +st["fs"][dev] = {"TYPE": "ext4", "LABEL": sys.argv[sys.argv.index("-L") + 1], "UUID": "0b6e4c5a-1d2e-4f3a-9b8c-7d6e5f4a3b2c"} +st.setdefault("calls", []).append("mkfs.ext4 " + " ".join(sys.argv[1:])) +json.dump(st, open(p, "w")) +''' +STUB_STAT = r'''#!/usr/bin/env python3 +import json, os, sys +st = json.load(open(os.environ["IP_STATE"])) +name = os.path.basename(sys.argv[-1]) +if any(lv["name"] == name and lv.get("active", True) for lv in st["lvs"]): print("block special file") +else: sys.exit(1) +''' + +CFG = "dir: local\n\tpath /var/lib/vz\n\tcontent iso,backup\n\nlvmthin: local-lvm\n\tthinpool data\n\tvgname pve\n\tcontent rootdir,images\n" + + +class Box: + def __init__(self, pool_gib, lvs=(), fs=None, cfg=CFG, **extra): + self.t = tempfile.TemporaryDirectory() + r = pathlib.Path(self.t.name) + self.bin = r / "bin"; self.bin.mkdir() + for name, body in (("lvs", STUB_LVS), ("lvcreate", STUB_LVCREATE), ("blkid", STUB_BLKID), + ("mkfs.ext4", STUB_MKFS), ("stat", STUB_STAT)): + f = self.bin / name; f.write_text(body); f.chmod(0o755) + self.cfg = r / "storage.cfg"; self.cfg.write_text(cfg) + self.state = r / "state.json" + st = {"vg": "pve", "lvs": [{"name": "data", "pool": "", "size": pool_gib * GIB}] + [dict(l) for l in lvs], + "fs": fs or {}} + st.update(extra) + self.state.write_text(json.dumps(st)) + + def run(self, *args): + env = {"PATH": f"{self.bin}:/usr/bin:/bin", "IP_STATE": str(self.state), "FELHOM_STORAGE_CFG": str(self.cfg)} + p = subprocess.run(["bash", str(HELPER), *args], env=env, capture_output=True, text=True) + return p.returncode, p.stdout.strip(), p.stderr.strip() + + def st(self): + return json.loads(self.state.read_text()) + + def calls(self): + return self.st().get("calls", []) + + def close(self): + self.t.cleanup() + + +def thin(name, gib, pool="data"): + return {"name": name, "pool": pool, "size": gib * GIB} + + +class Sizes(unittest.TestCase): + """The operator's table (SPACE-CHECK §4, ruled 2026-10-16), as the helper computes it on a fresh box.""" + + def plan(self, pool, planned, lvs=()): + b = Box(pool, lvs) + try: + rc, out, err = b.run("plan", str(planned)) + self.assertEqual(rc, 0, err) + self.assertEqual(b.calls(), [], "plan must change nothing") + return dict(kv.split("=") for kv in out.split()[1:]) + finally: + b.close() + + def test_the_ruled_table(self): + # disk → pool (GiB), data volume (planned = 32 + mp0), expected internal + for pool, mp0, reserve, internal in ((140, 50, 16, 42), (349, 100, 34, 183), (800, 130, 48, 590), (1720, 130, 48, 1510)): + got = self.plan(pool, 32 + mp0) + self.assertEqual((int(got["reserve"]), int(got["internal"])), (reserve, internal), (pool, got)) + + def test_promised_volumes_count_but_vzdump_snapshots_and_other_pools_do_not(self): + got = self.plan(349, 0, [thin("vm-9201-disk-0", 32), thin("vm-9201-disk-1", 250), + thin("snap_vm-9201-disk-1_vzdump", 250), thin("vm-500-disk-0", 40, pool="other")]) + self.assertEqual((int(got["promised"]), int(got["internal"])), (282, 33), got) + + +class Create(unittest.TestCase): + def test_a_fresh_box_gets_the_place(self): + b = Box(349) + try: + rc, out, err = b.run("create", "132") + self.assertEqual(rc, 0, err) + self.assertRegex(out, r"^created uuid=[0-9a-f-]{36} gib=183$") + self.assertEqual(len([c for c in b.calls() if c.startswith("lvcreate")]), 1) + self.assertIn("-V 183G -T pve/data -n felhom-belso", b.calls()[0]) + self.assertIn("-L felhom-belso", b.calls()[1]) + finally: + b.close() + + def test_too_little_space_makes_nothing(self): + b = Box(53) # demo-hp's 128 GB disk + try: + rc, out, err = b.run("create", str(32 + 50)) + self.assertEqual(rc, 0, err) + self.assertTrue(out.startswith("skipped "), out) + self.assertEqual(b.calls(), []) + finally: + b.close() + + def test_an_existing_place_is_found_never_formatted(self): + fs = {"felhom-belso": {"TYPE": "ext4", "LABEL": "felhom-belso", "UUID": "11111111-2222-3333-4444-555555555555"}} + b = Box(349, [thin("felhom-belso", 183)], fs) + try: + rc, out, err = b.run("create", "0") + self.assertEqual(rc, 0, err) + self.assertEqual(out, "existing uuid=11111111-2222-3333-4444-555555555555 gib=183") + self.assertEqual(b.calls(), [], "an existing place must never be created or formatted again") + finally: + b.close() + + def test_a_foreign_filesystem_on_the_name_is_refused(self): + # positive control: test_an_existing_place_is_found_never_formatted (same LV, Felhom's label) goes through + for fs in ({"TYPE": "xfs", "LABEL": "felhom-belso", "UUID": "x"}, {"TYPE": "ext4", "LABEL": "films", "UUID": "y"}): + b = Box(349, [thin("felhom-belso", 183)], {"felhom-belso": fs}) + try: + rc, out, err = b.run("create", "0") + self.assertEqual(rc, 1, (fs, out)) + self.assertIn("REFUSED", err) + self.assertEqual(b.calls(), [], fs) + finally: + b.close() + + def test_a_crash_between_lvcreate_and_mkfs_is_finished(self): + b = Box(349, [thin("felhom-belso", 183)], {}) + try: + rc, out, err = b.run("create", "0") + self.assertEqual(rc, 0, err) + self.assertRegex(out, r"^formatted uuid=") + self.assertEqual([c.split()[0] for c in b.calls()], ["mkfs.ext4"]) + finally: + b.close() + + def test_an_unnamed_signature_is_refused(self): + b = Box(349, [thin("felhom-belso", 183)], {"felhom-belso": {"RAW": "something"}}) + try: + rc, out, err = b.run("create", "0") + self.assertEqual(rc, 1, out) + self.assertEqual(b.calls(), []) + finally: + b.close() + + def test_a_new_volume_that_is_not_empty_is_not_formatted(self): + b = Box(349, dirty_new=True) + try: + rc, out, err = b.run("create", "132") + self.assertEqual(rc, 1, out) + self.assertNotIn("mkfs", " ".join(b.calls())) + finally: + b.close() + + def test_bad_arguments_and_no_local_lvm_are_refused(self): + b = Box(349, cfg="dir: local\n\tpath /var/lib/vz\n") + try: + self.assertEqual(b.run("create", "0")[0], 1) + self.assertEqual(b.calls(), []) + finally: + b.close() + b = Box(349) + try: + for args in (("create", "-1"), ("create", "1x"), ("wipe", "0"), ("create",)): + self.assertEqual(b.run(*args)[0], 1, args) + self.assertEqual(b.calls(), []) + finally: + b.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/configs/test_felhom_mkfs_guarded.py b/configs/test_felhom_mkfs_guarded.py new file mode 100644 index 0000000..d60566c --- /dev/null +++ b/configs/test_felhom_mkfs_guarded.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Tests for configs/felhom-mkfs-guarded.sh — the ONLY mkfs path the agent's sudoers permits. + +First tests of this wrapper (agent v0.158.0), written with its change for partitions as places (felhom.eu +architecture/16 §6): a SIBLING partition that is Felhom's own enrolled drive (mounted raw at /mnt/ AND under +/mnt/felhom-drives) no longer blocks formatting the empty partition next to it; every other mount still refuses. + +HOW IT RUNS ANYWHERE: the wrapper is copied with five literal host paths replaced (each replacement is asserted, so a +drifted wrapper fails here instead of silently testing nothing): /proc/mounts → a fake mount table, the block-device +test → true (no real /dev nodes here), the pvs candidates → a stub, /sys/block → a fake tree, the mkfs binaries → a +recorder. `lsblk` is a PATH stub answering the three calls the wrapper makes from one JSON disk description. +Every refusal has a positive control (the same disk without the one condition is formatted). +Run: python3 configs/test_felhom_mkfs_guarded.py (also run by the Go suite, internal/storage) +""" +import json, os, pathlib, subprocess, tempfile, unittest + +WRAPPER = pathlib.Path(__file__).resolve().parent / "felhom-mkfs-guarded.sh" + +STUB_LSBLK = r'''#!/usr/bin/env python3 +import json, os, sys +st = json.load(open(os.environ["MG_STATE"])) +a = sys.argv[1:]; dev = a[-1] +nodes = st["nodes"] # [{"name": "/dev/sdb", "pk": "", "fstype": ""}, ...] +if a[0] == "-ndo" and a[1] == "PKNAME": + for n in nodes: + if n["name"] == dev and n.get("pk"): print(n["pk"]) +elif a[0] == "-nrpo" and a[1] == "NAME": + if not st.get("lsblk_empty"): + for n in nodes: print(n["name"]) +elif a[0] == "-nro" and a[1] == "FSTYPE": + for n in nodes: print(n.get("fstype", "")) +elif a[0] == "-nro" and a[1] == "MOUNTPOINT": # the pre-v0.158.0 wrapper's call: one mountpoint per node, like lsblk + mt = [l.split()[:2] for l in open(os.environ["MG_MOUNTS"]) if l.strip()] + for n in nodes: + mps = [m for d, m in mt if d == n["name"]] + print(mps[0] if mps else "") +''' +STUB_MKFS = '#!/bin/sh\necho "$0 $*" >> "$MG_CALLS"\n' +STUB_PVS = '#!/bin/sh\nprintf "%s\\n" $MG_PVS\n' + + +class Disk: + def __init__(self, nodes, mounts, ro="0", pvs="", lsblk_empty=False): + self.t = tempfile.TemporaryDirectory() + r = pathlib.Path(self.t.name) + self.bin = r / "bin"; self.bin.mkdir() + (self.bin / "lsblk").write_text(STUB_LSBLK); (self.bin / "lsblk").chmod(0o755) + for n in ("mkfs.ext4", "mkfs.xfs"): + (self.bin / n).write_text(STUB_MKFS); (self.bin / n).chmod(0o755) + (self.bin / "pvs").write_text(STUB_PVS); (self.bin / "pvs").chmod(0o755) + self.state = r / "state.json" + self.state.write_text(json.dumps({"nodes": nodes, "lsblk_empty": lsblk_empty})) + self.mounts = r / "mounts" + self.mounts.write_text("".join(f"{d} {m} ext4 rw 0 0\n" for d, m in mounts)) + self.sys = r / "sys" + for n in nodes: + if not n.get("pk"): + d = self.sys / os.path.basename(n["name"]); d.mkdir(parents=True); (d / "ro").write_text(ro + "\n") + self.calls = r / "calls" + self.pvs = pvs + src = WRAPPER.read_text() + subs = [ + ("/proc/mounts", str(self.mounts)), + ('[[ -b "$dev" ]]', "true"), + ("for c in /usr/sbin/pvs /sbin/pvs; do", f"for c in {self.bin}/pvs; do"), + ('rof="/sys/block/', f'rof="{self.sys}/'), + ("exec /usr/sbin/mkfs.ext4", f"exec {self.bin}/mkfs.ext4"), + ("exec /usr/sbin/mkfs.xfs ", f"exec {self.bin}/mkfs.xfs "), + ] + for a, b in subs: + assert a in src, f"the wrapper no longer contains {a!r} — update this test's substitutions" + src = src.replace(a, b) + self.copy = r / "mkfs-guarded"; self.copy.write_text(src) + + def run(self, dev, fstype="ext4"): + env = {"PATH": f"{self.bin}:/usr/bin:/bin", "MG_STATE": str(self.state), "MG_CALLS": str(self.calls), + "MG_MOUNTS": str(self.mounts), + "MG_PVS": self.pvs} + p = subprocess.run(["bash", str(self.copy), dev, fstype], env=env, capture_output=True, text=True) + made = self.calls.read_text().split("\n")[0] if self.calls.exists() else "" + return p.returncode, p.stderr.strip(), made + + def close(self): + self.t.cleanup() + + +def disk_sdb(sdb1_fstype="ext4", sdb2_fstype=""): + return [{"name": "/dev/sdb", "pk": "", "fstype": ""}, + {"name": "/dev/sdb1", "pk": "sdb", "fstype": sdb1_fstype}, + {"name": "/dev/sdb2", "pk": "sdb", "fstype": sdb2_fstype}] + + +ROOT = ("/dev/sda2", "/") +OWN_SDB1 = [("/dev/sdb1", "/mnt/hdd_1"), ("/dev/sdb1", "/mnt/felhom-drives/hdd_1")] + + +class Partitions(unittest.TestCase): + def check(self, mounts, dev="/dev/sdb2", nodes=None, want_ok=True, **kw): + d = Disk(nodes or disk_sdb(), [ROOT] + mounts, **kw) + try: + rc, err, made = d.run(dev) + if want_ok: + self.assertEqual(rc, 0, err) + self.assertTrue(made.endswith(f"mkfs.ext4 -F {dev}"), made) + else: + self.assertEqual(rc, 1, (mounts, made)) + self.assertIn("REFUSED", err) + self.assertEqual(made, "", "a refused format must not run mkfs") + return err + finally: + d.close() + + def test_the_empty_part_next_to_felhoms_own_drive_is_formatted(self): + self.check(OWN_SDB1) + + def test_a_sibling_mounted_raw_only_is_refused(self): + self.check([("/dev/sdb1", "/mnt/hdd_1")], want_ok=False) + + def test_a_sibling_mounted_elsewhere_is_refused_even_with_a_managed_mount(self): + for elsewhere in ("/srv/data", "/media/usb", "/mnt/a/b", "/home/x"): + self.check([("/dev/sdb1", elsewhere), ("/dev/sdb1", "/mnt/felhom-drives/hdd_1")], want_ok=False) + + def test_the_target_itself_mounted_raw_is_refused(self): + self.check([("/dev/sdb2", "/mnt/films"), ("/dev/sdb2", "/mnt/felhom-drives/films")], want_ok=False) + + def test_the_target_mounted_only_under_felhom_drives_is_formatted(self): + self.check([("/dev/sdb2", "/mnt/felhom-drives/films")]) + + def test_an_unlistable_disk_is_refused(self): + self.check([], want_ok=False, lsblk_empty=True) + + def test_the_catastrophic_cases_still_refuse(self): + self.check([("/dev/sdb1", "/")], want_ok=False) # system disk + self.check(OWN_SDB1, nodes=disk_sdb(sdb1_fstype="LVM2_member"), want_ok=False) # a member signature + self.check(OWN_SDB1, pvs="/dev/sdb1", want_ok=False) # an LVM PV (pvs) + self.check(OWN_SDB1, ro="1", want_ok=False) # read-only + + +if __name__ == "__main__": + unittest.main() diff --git a/internal/capability/manifest.go b/internal/capability/manifest.go index 0e3ac81..2e843a5 100644 --- a/internal/capability/manifest.go +++ b/internal/capability/manifest.go @@ -118,6 +118,8 @@ var manifest = []Capability{ {"guest-shutdown", "household power-off: guest shutdown (R-998)", "/usr/sbin/pct", []string{"shutdown", "9201", "--timeout", "120"}, false, ""}, {"host-poweroff", "household power-off: host power-off (R-998)", "/usr/bin/systemctl", []string{"poweroff"}, false, ""}, {"vzdump-partial-clean", "dead half-written vzdump removal (R-1006)", "/usr/local/sbin/felhom-vzdump-partial-clean", []string{"local", "9201", "vzdump-lxc-9201-2026_10_14-04_42_18.tar.dat"}, false, ""}, + {"internal-place-plan", "internal data place: the size plan (16 §7)", "/usr/local/sbin/felhom-internal-place", []string{"plan", "0"}, false, ""}, + {"internal-place-create", "internal data place: create on an old box (signed add_internal_place)", "/usr/local/sbin/felhom-internal-place", []string{"create", "0"}, false, ""}, // ---- LAN split-horizon resolver (dnsmasq) ---- {"dnsmasq-install", "dnsmasq package install", "/usr/bin/apt-get", []string{"install", "-y", "-q", "dnsmasq"}, false, ""}, diff --git a/internal/capability/r861_injection_test.go b/internal/capability/r861_injection_test.go index a14e94b..ea040ef 100644 --- a/internal/capability/r861_injection_test.go +++ b/internal/capability/r861_injection_test.go @@ -72,6 +72,11 @@ var r861Injections = []string{ "/usr/local/sbin/felhom-vzdump-partial-clean local;id 9201 vzdump-lxc-9201-2026_10_14-04_42_18.tar.dat", "/usr/local/sbin/felhom-vzdump-partial-clean local 9201", "/usr/local/sbin/felhom-vzdump-partial-clean --help", + "/usr/local/sbin/felhom-internal-place create 132", + "/usr/local/sbin/felhom-internal-place create 0 extra", + "/usr/local/sbin/felhom-internal-place create 0;id", + "/usr/local/sbin/felhom-internal-place wipe 0", + "/usr/local/sbin/felhom-internal-place create", } func TestSudoersRefusesTheR861Injections(t *testing.T) { diff --git a/internal/localapi/internalplace.go b/internal/localapi/internalplace.go new file mode 100644 index 0000000..7c342d1 --- /dev/null +++ b/internal/localapi/internalplace.go @@ -0,0 +1,207 @@ +package localapi + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path" + "path/filepath" + "regexp" + "sync" + "time" + + "gitea.dooplex.hu/admin/felhom-agent/internal/storage" +) + +// The internal data place („Belső tárhely", agent v0.158.0) — felhom.eu architecture/16-storage-physical-identity.md +// §5, §7. On a one-disk box the root helper configs/felhom-internal-place makes a second thin volume in local-lvm +// (/felhom-belso, ext4, label felhom-belso). The installer (a new box) or the signed add_internal_place job (an old +// box) then writes InternalPlaceRecord to the agent's state dir. From then on the agent treats the volume exactly like +// an enrolled drive: a Felhom mount unit by fs-UUID at /mnt/belso (EnsureMount), the `enrolled` intent, and — when the +// controller asks with its own token (POST /disks/guest-attach) — the bind at /mnt/felhom-drives/belso. So it is a +// HOST mount under the guest's mp8 parent: not in the guest config, not in vzdump, not in PBS (`16` §5). +// +// What it is NOT: a drive the household can format, wipe, eject or decommission. Its device is a device-mapper volume +// on the system disk, so the role gate (roleForMountPath → system) and the format path (ValidateBlockDevice refuses +// dm) refuse all four — pinned by internalplace_test.go. Its identity is the system disk's (build A), so a Tier-2 copy +// between it and sys_drive is never a second copy. +const ( + InternalPlaceName = "belso" + InternalPlaceWhere = "/mnt/belso" + // InternalPlaceFile is the record's name inside the agent's state dir. + InternalPlaceFile = "internal-place.json" +) + +var reFSUUID = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) + +// InternalPlaceRecord is the state file the maker writes once the volume exists and is formatted. +type InternalPlaceRecord struct { + FSUUID string `json:"fs_uuid"` + GiB int `json:"gib,omitempty"` + MadeBy string `json:"made_by,omitempty"` // "installer" | "signed-job" + MadeAt string `json:"made_at,omitempty"` +} + +// ReadInternalPlace reads the record; ok=false when there is none (the common case: a box with no internal place). +func ReadInternalPlace(file string) (InternalPlaceRecord, bool, error) { + var rec InternalPlaceRecord + b, err := os.ReadFile(file) + if errors.Is(err, os.ErrNotExist) { + return rec, false, nil + } + if err != nil { + return rec, false, err + } + if err := json.Unmarshal(b, &rec); err != nil { + return rec, false, fmt.Errorf("internal place record %s: %w", file, err) + } + if !reFSUUID.MatchString(rec.FSUUID) { + return rec, false, fmt.Errorf("internal place record %s: fs_uuid %q is not a filesystem UUID", file, rec.FSUUID) + } + return rec, true, nil +} + +// WriteInternalPlace writes the record atomically (0600). +func WriteInternalPlace(file string, rec InternalPlaceRecord) error { + if !reFSUUID.MatchString(rec.FSUUID) { + return fmt.Errorf("internal place: fs_uuid %q is not a filesystem UUID", rec.FSUUID) + } + b, err := json.MarshalIndent(rec, "", " ") + if err != nil { + return err + } + tmp := file + ".tmp" + if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil { + return err + } + return os.Rename(tmp, file) +} + +// internalPlaceState memoises what EnsureInternalPlace last saw, so the 20-second tick logs a state CHANGE once and +// does no work on the steady state. +type internalPlaceState struct { + mu sync.Mutex + done string // the fs-UUID that is mounted and recorded enrolled + lastWhy string // the last reason it could not be ensured (logged once per change) +} + +// internalPlaceUUID returns the configured internal place's fs-UUID, or "" when this box has none (or the record is +// unreadable — logged by EnsureInternalPlace). +func (s *Server) internalPlaceUUID() string { + if s.internalPlaceFile == "" { + return "" + } + rec, ok, err := ReadInternalPlace(s.internalPlaceFile) + if err != nil || !ok { + return "" + } + return rec.FSUUID +} + +// isInternalPlace reports whether a host path is this box's internal place: its raw mount (/mnt/belso) or its bind +// under the guests' shared parent (/mnt/felhom-drives/belso — the path POST /disks/same-disk judges). +func (s *Server) isInternalPlace(hostPath string) bool { + p := path.Clean(hostPath) + return (p == InternalPlaceWhere || p == StableParentDir+"/"+InternalPlaceName) && s.internalPlaceUUID() != "" +} + +// EnsureInternalPlace mounts the internal place on the host at /mnt/belso by fs-UUID and records it `enrolled`, +// when this box has one. Idempotent and cheap on the steady state; safe on the 20-second tick. It never formats, +// never unmounts, and never mounts anything at /mnt/belso that is not the recorded UUID: a household drive that +// happens to be named „belso" keeps its mount, and the internal place waits (logged once). +func (s *Server) EnsureInternalPlace(ctx context.Context) { + if s.internalPlaceFile == "" { + return + } + rec, ok, err := ReadInternalPlace(s.internalPlaceFile) + if err != nil { + s.internalPlaceNote("record unreadable: " + err.Error()) + return + } + if !ok { + return + } + uuid := rec.FSUUID + st := &s.ipState + st.mu.Lock() + done := st.done == uuid + st.mu.Unlock() + + // What is at /mnt/belso now, if anything? + mountedUUID, mounted := "", false + if mounts, merr := s.hostReader().Mounts(); merr == nil { + for _, m := range mounts { + if path.Clean(m.MountPoint) == InternalPlaceWhere { + mounted = true + if u, okU := s.hostReader().ResolveUUID(m.Device); okU { + mountedUUID = u + } + } + } + } + if mounted && mountedUUID != "" && mountedUUID != uuid { + s.internalPlaceNote("another filesystem (uuid " + mountedUUID + ") is mounted at " + InternalPlaceWhere + " — the internal place waits") + return + } + // A Felhom unit for /mnt/belso that names another UUID (a household drive called „belso", unplugged): never + // overwrite it — that would silently take the drive's name and mount point. + if s.driveTargets != nil { + if drives, derr := s.driveTargets.Known(ctx); derr == nil { + for _, d := range drives { + if path.Clean(d.MountPath) == InternalPlaceWhere && d.UUID != "" && d.UUID != uuid { + s.internalPlaceNote("a drive unit for " + InternalPlaceWhere + " names another uuid (" + d.UUID + ") — the internal place waits") + return + } + } + } + } + if mounted && mountedUUID == uuid { + if !done { + s.recordIntent(ctx, InternalPlaceWhere, "enrolled") + st.mu.Lock() + st.done, st.lastWhy = uuid, "" + st.mu.Unlock() + s.logger.Info("internal place: mounted and enrolled", "where", InternalPlaceWhere, "fs_uuid", uuid, "gib", rec.GiB) + } + return + } + if s.disks == nil { + s.internalPlaceNote("disk management is not configured on this host") + return + } + if s.resolveStorageDevice != nil { + if _, rerr := s.resolveStorageDevice("uuid:" + uuid); rerr != nil { + s.internalPlaceNote("its volume is not present (uuid " + uuid + ")") + return + } + } + start := time.Now() + if err := s.disks.EnsureMount(ctx, storage.MountSpec{Name: InternalPlaceName, UUID: uuid, Where: InternalPlaceWhere, FSType: "ext4"}); err != nil { + s.internalPlaceNote("mount failed: " + err.Error()) + return + } + s.recordIntent(ctx, InternalPlaceWhere, "enrolled") + st.mu.Lock() + st.done, st.lastWhy = uuid, "" + st.mu.Unlock() + s.logger.Info("internal place: mounted on the host", "where", InternalPlaceWhere, "fs_uuid", uuid, "gib", rec.GiB, + "duration_ms", time.Since(start).Milliseconds()) +} + +func (s *Server) internalPlaceNote(why string) { + st := &s.ipState + st.mu.Lock() + changed := st.lastWhy != why + st.lastWhy, st.done = why, "" + st.mu.Unlock() + if changed { + s.logger.Warn("internal place: not ensured", "why", why) + } else { + s.logger.Debug("internal place: still not ensured", "why", why) + } +} + +// InternalPlaceFileIn is the record's path inside a state dir. +func InternalPlaceFileIn(stateDir string) string { return filepath.Join(stateDir, InternalPlaceFile) } diff --git a/internal/localapi/internalplace_test.go b/internal/localapi/internalplace_test.go new file mode 100644 index 0000000..ab8b502 --- /dev/null +++ b/internal/localapi/internalplace_test.go @@ -0,0 +1,249 @@ +package localapi + +import ( + "context" + "errors" + "io" + "log/slog" + "net/http" + "os/exec" + "path/filepath" + "runtime" + "sync" + "testing" + + "gitea.dooplex.hu/admin/felhom-agent/internal/hub" + "gitea.dooplex.hu/admin/felhom-agent/internal/storage" +) + +// The internal data place (felhom.eu architecture/16 §5, agent v0.158.0): mounted like an enrolled drive, but never +// at the cost of a household drive, never formatted, ejected or decommissioned, and the same disk as sys_drive. + +const ipUUID = "0b6e4c5a-1d2e-4f3a-9b8c-7d6e5f4a3b2c" +const ipDev = "/dev/mapper/pve-felhom--belso" + +// ipHost is a HostReader whose mount table the test changes, and whose by-uuid map it controls. +type ipHost struct { + mu sync.Mutex + mounts []storage.Mount + uuids map[string]string // device → fs-UUID +} + +func (h *ipHost) Mounts() ([]storage.Mount, error) { + h.mu.Lock() + defer h.mu.Unlock() + return append([]storage.Mount(nil), h.mounts...), nil +} +func (h *ipHost) ResolveUUID(dev string) (string, bool) { u, ok := h.uuids[dev]; return u, ok } +func (h *ipHost) DeviceExists(string) bool { return true } +func (h *ipHost) Rotational(string) (bool, bool) { return false, false } +func (h *ipHost) Removable(string) (bool, bool) { return false, false } +func (h *ipHost) BlockSlaves(string) ([]string, bool) { return nil, false } + +func newIPHost(extra ...storage.Mount) *ipHost { + return &ipHost{ + // root on a whole-disk partition (as sysOnSDA): the system disk resolves, so a USB drive is user-data. + mounts: append([]storage.Mount{{Device: "/dev/sda1", MountPoint: "/", FSType: "ext4"}}, extra...), + uuids: map[string]string{ipDev: ipUUID, "/dev/sdb1": "99999999-aaaa-bbbb-cccc-dddddddddddd"}, + } +} + +type ipRig struct { + srv *Server + disks *fakeDiskOps + intent *fakeIntent +} + +func newIPRig(t *testing.T, host *ipHost, known []storage.KnownTarget, record bool) ipRig { + t.Helper() + dir := t.TempDir() + file := filepath.Join(dir, InternalPlaceFile) + if record { + if err := WriteInternalPlace(file, InternalPlaceRecord{FSUUID: ipUUID, GiB: 183, MadeBy: "installer"}); err != nil { + t.Fatal(err) + } + } + d, in := &fakeDiskOps{}, newFakeIntent() + srv, err := NewServer(Options{ + ListenAddr: "127.0.0.1:0", Guests: &fakeGuests{}, Backups: &fakeBackups{}, Store: &fakeStore{}, + Storage: fakeStorage{}, Tokens: staticTokens{"A": 8200}, Disks: d, DiskGate: &fakeGate{}, + HostReader: host, Intent: in, DriveTargets: fakeKnownTargets{drives: known}, + InternalPlaceFile: file, Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + }) + if err != nil { + t.Fatal(err) + } + srv.baseCtx = context.Background() + srv.resolveStorageDevice = func(id string) (string, error) { + if id == "uuid:"+ipUUID { + return "/dev/dm-7", nil + } + return "", errors.New("absent") + } + return ipRig{srv, d, in} +} + +func TestInternalPlace_NoRecord_DoesNothing(t *testing.T) { + r := newIPRig(t, newIPHost(), nil, false) + r.srv.EnsureInternalPlace(context.Background()) + if len(r.disks.mountCalls) != 0 { + t.Fatalf("no record: no mount may be made, got %+v", r.disks.mountCalls) + } +} + +func TestInternalPlace_Record_MountsByUUIDAtMntBelso(t *testing.T) { + r := newIPRig(t, newIPHost(), nil, true) + r.srv.EnsureInternalPlace(context.Background()) + if len(r.disks.mountCalls) != 1 { + t.Fatalf("want exactly one EnsureMount, got %+v", r.disks.mountCalls) + } + got := r.disks.mountCalls[0] + if got.UUID != ipUUID || got.Where != InternalPlaceWhere || got.FSType != "ext4" || got.Name != InternalPlaceName { + t.Fatalf("EnsureMount spec: %+v", got) + } +} + +func TestInternalPlace_AlreadyMounted_EnrolledOnceNoRemount(t *testing.T) { + host := newIPHost(storage.Mount{Device: ipDev, MountPoint: InternalPlaceWhere, FSType: "ext4"}) + r := newIPRig(t, host, nil, true) + r.srv.EnsureInternalPlace(context.Background()) + r.srv.EnsureInternalPlace(context.Background()) + if len(r.disks.mountCalls) != 0 { + t.Fatalf("already mounted: no EnsureMount, got %+v", r.disks.mountCalls) + } + if r.intent.Get("uuid:"+ipUUID) != storage.IntentEnrolled { + t.Fatalf("the internal place must be recorded enrolled") + } +} + +// A household drive called „belso" keeps its mount point: the internal place never takes it. +// Positive control: TestInternalPlace_Record_MountsByUUIDAtMntBelso (the same record, /mnt/belso free). +func TestInternalPlace_NeverTakesAHouseholdDriveNamedBelso(t *testing.T) { + mounted := newIPHost(storage.Mount{Device: "/dev/sdb1", MountPoint: InternalPlaceWhere, FSType: "ext4"}) + r := newIPRig(t, mounted, nil, true) + r.srv.EnsureInternalPlace(context.Background()) + if len(r.disks.mountCalls) != 0 || r.intent.Get("uuid:"+ipUUID) != "" { + t.Fatalf("a foreign filesystem at /mnt/belso: nothing may be mounted or recorded, got %+v", r.disks.mountCalls) + } + unplugged := []storage.KnownTarget{{Name: "belso", UUID: "99999999-aaaa-bbbb-cccc-dddddddddddd", MountPath: InternalPlaceWhere, MountBacked: true}} + r2 := newIPRig(t, newIPHost(), unplugged, true) + r2.srv.EnsureInternalPlace(context.Background()) + if len(r2.disks.mountCalls) != 0 { + t.Fatalf("an unplugged household drive's unit for /mnt/belso must not be overwritten, got %+v", r2.disks.mountCalls) + } +} + +func TestInternalPlace_VolumeAbsent_NoMount(t *testing.T) { + r := newIPRig(t, newIPHost(), nil, true) + r.srv.resolveStorageDevice = func(string) (string, error) { return "", errors.New("absent") } + r.srv.EnsureInternalPlace(context.Background()) + if len(r.disks.mountCalls) != 0 { + t.Fatalf("absent volume: no mount, got %+v", r.disks.mountCalls) + } +} + +// The household cannot eject or decommission it: its device is a device-mapper volume on the system disk. +// Positive control: the same calls for a whole-disk USB drive (/dev/sdb1) are not refused by role. +func TestInternalPlace_EjectAndDecommissionRefused(t *testing.T) { + host := newIPHost( + storage.Mount{Device: ipDev, MountPoint: InternalPlaceWhere, FSType: "ext4"}, + storage.Mount{Device: "/dev/sdb1", MountPoint: "/mnt/hdd_1", FSType: "ext4"}, + ) + r := newIPRig(t, host, nil, true) + h := r.srv.Handler() + for _, route := range []string{"/disks/eject", "/disks/decommission"} { + if w := do(t, h, "POST", route, "A", `{"where":"/mnt/belso"}`); w.Code != http.StatusForbidden { + t.Fatalf("%s /mnt/belso: want 403, got %d %s", route, w.Code, w.Body.String()) + } + } + if w := do(t, h, "POST", "/disks/eject", "A", `{"where":"/mnt/hdd_1"}`); w.Code == http.StatusForbidden { + t.Fatalf("positive control: a USB drive's eject must not be refused by role, got %d %s", w.Code, w.Body.String()) + } + for _, u := range r.disks.unmountCalls { + if u == InternalPlaceWhere { + t.Fatalf("the internal place must never be unmounted, got %+v", r.disks.unmountCalls) + } + } +} + +// The consequence `16` §5 needs: a copy between the internal place and sys_drive is the SAME disk (build A then never +// counts it), and the place is reported as kind internal. +type ipIdentHost struct{ fakeIdentHost } + +func (h *ipIdentHost) KernelName(dev string) (string, bool) { + if dev == ipDev { + return "dm-7", true + } + return h.fakeIdentHost.KernelName(dev) +} +func (h *ipIdentHost) Slaves(n string) []string { + if n == "dm-7" { + return []string{"dm-3"} // felhom-belso → the thin pool + } + if n == "dm-3" { + return []string{"sda3"} // the pool → the system disk's LVM partition + } + return nil +} + +func TestInternalPlace_IsTheSystemDiskAndKindInternal(t *testing.T) { + ih := &ipIdentHost{fakeIdentHost{ + virt: "none", + parent: map[string]string{"sda3": "sda"}, + props: map[string]map[string]string{"sda": {"ID_BUS": "ata", "ID_MODEL": "SanDisk", "ID_SERIAL_SHORT": "1"}}, + mounts: []storage.Mount{ + {Device: "/dev/mapper/pve-root", MountPoint: "/", FSType: "ext4"}, + {Device: "/dev/mapper/pve-root", MountPoint: "/mnt/felhom-drives", FSType: "ext4"}, + {Device: ipDev, MountPoint: InternalPlaceWhere, FSType: "ext4"}, + {Device: ipDev, MountPoint: "/mnt/felhom-drives/belso", FSType: "ext4"}, + }, + }} + fg := &fakeGuestsCfg{mounts: map[int]map[string]string{8200: { + "mp0": "local-lvm:vm-8200-disk-1,mp=/var/lib/felhom,backup=1,size=100G", + "mp8": "/mnt/felhom-drives,mp=/mnt/felhom-drives", + }}} + dir := t.TempDir() + file := filepath.Join(dir, InternalPlaceFile) + if err := WriteInternalPlace(file, InternalPlaceRecord{FSUUID: ipUUID}); err != nil { + t.Fatal(err) + } + srv, err := NewServer(Options{ + ListenAddr: "127.0.0.1:0", Guests: fg, Backups: &fakeBackups{}, Store: &fakeStore{}, + Storage: fakeStorage{targets: []hub.StorageTarget{provenSSD}}, Tokens: staticTokens{"A": 8200}, + HostReader: fakeHostReader{mounts: ih.mounts}, Identity: &storage.IdentityResolver{Host: ih}, + InternalPlaceFile: file, Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + }) + if err != nil { + t.Fatal(err) + } + srv.baseCtx = context.Background() + got, code := sameDisk(t, srv.Handler(), "/mnt/felhom-drives/belso/felhom-data/jellyfin", "/mnt/sys_drive") + if code != http.StatusOK || got.Different || !got.Proven || got.Reason != "same_disk" { + t.Fatalf("internal place vs sys_drive: want same disk, proven; got %d %+v", code, got) + } + if got.A.Kind != storage.PlaceKindInternal { + t.Fatalf("kind: want %q, got %q", storage.PlaceKindInternal, got.A.Kind) + } +} + +func TestInternalPlaceRecord_RefusesANonUUID(t *testing.T) { + file := filepath.Join(t.TempDir(), InternalPlaceFile) + if err := WriteInternalPlace(file, InternalPlaceRecord{FSUUID: "../../etc"}); err == nil { + t.Fatal("a non-UUID must not be written") + } +} + +// The root helper's own tests (configs/test_felhom_internal_place.py — the ruled size table, never formatting what +// holds anything) run with the Go suite. +func TestInternalPlaceHelperPythonSuite(t *testing.T) { + py, err := exec.LookPath("python3") + if err != nil { + t.Skip("python3 not found") + } + _, file, _, _ := runtime.Caller(0) + script := filepath.Join(filepath.Dir(file), "..", "..", "configs", "test_felhom_internal_place.py") + out, err := exec.Command(py, script).CombinedOutput() + if err != nil { + t.Fatalf("helper suite failed: %v\n%s", err, out) + } +} diff --git a/internal/localapi/samedisk.go b/internal/localapi/samedisk.go index d721b3b..4e16bcd 100644 --- a/internal/localapi/samedisk.go +++ b/internal/localapi/samedisk.go @@ -135,6 +135,8 @@ func (s *Server) ownMountIdentity(hostPath string) (storage.PlaceIdentity, strin } kind := storage.PlaceKindDisk switch { + case s.isInternalPlace(hostPath): // v0.158.0 `16` §5: the one-disk box's data place, on the system disk + kind = storage.PlaceKindInternal case id.Reason == storage.ReasonNetwork: kind = storage.PlaceKindNetwork case strings.HasPrefix(m.Device, "/dev/") && !strings.HasPrefix(m.Device, "/dev/mapper/") && isPartitionPath(m.Device): diff --git a/internal/localapi/server.go b/internal/localapi/server.go index 077d60f..c0d5211 100644 --- a/internal/localapi/server.go +++ b/internal/localapi/server.go @@ -208,6 +208,9 @@ type Options struct { // Intent records drive enroll/eject intent for the self-heal watchdog (slice 10 P3). OPTIONAL — // when nil, no intent is recorded (self-heal runs ungated). Intent IntentRecorder + // InternalPlaceFile (v0.158.0, felhom.eu `16` §5) is the path of the internal data place record + // (InternalPlaceRecord). OPTIONAL — "" means this host has no internal place and EnsureInternalPlace is a no-op. + InternalPlaceFile string // GuestBinds persists which user-data drives (by durable-id) are enrolled into each guest, so the // startup re-assert (ReassertGuestBinds) can restore a bind that a re-provision dropped (F9). // OPTIONAL — when nil, guest binds are not recorded and the startup re-assert is a no-op. @@ -316,12 +319,14 @@ type Server struct { // identity blob from the hub, unseal it with the customer's recovery code, return ONLY the // offsite repository password. OPTIONAL — nil (no hub client configured) makes // POST /escrow/recover-offsite-password answer 503 rather than pretending. - escrowRecovery EscrowRecoverer - intent IntentRecorder // slice 10 P3 (optional) - guestBinds *GuestBindStore // F9 startup bind re-assert record (optional) - formatJobs *FormatJobStore // F20-BUG3 detached-format job record (optional) - staleLock StaleLockController // F2-b startup stale-lock recovery (optional) - partialSweep func(ctx context.Context, vmid int) // R-1006 start-up sweep of dead vzdump leftovers (optional) + escrowRecovery EscrowRecoverer + intent IntentRecorder // slice 10 P3 (optional) + internalPlaceFile string // v0.158.0 `16` §5 (optional) + ipState internalPlaceState // EnsureInternalPlace's memo + guestBinds *GuestBindStore // F9 startup bind re-assert record (optional) + formatJobs *FormatJobStore // F20-BUG3 detached-format job record (optional) + staleLock StaleLockController // F2-b startup stale-lock recovery (optional) + partialSweep func(ctx context.Context, vmid int) // R-1006 start-up sweep of dead vzdump leftovers (optional) // guestPower (F-REBOOT) is per-guest start-attempt state for the guest-power watchdog. // Guarded by guestPowerMu in guestpower.go; in-memory on purpose (see guestPowerState). guestPower map[int]guestPowerState @@ -455,43 +460,44 @@ func NewServer(o Options) (*Server, error) { cadence = defaultBackupCadence } s := &Server{ - addr: o.ListenAddr, - cert: o.Cert, - guests: o.Guests, - backups: o.Backups, - store: o.Store, - storage: o.Storage, - driveTargets: o.DriveTargets, - smart: o.Smart, - tokens: o.Tokens, - cadence: cadence, - logger: o.Logger, - now: func() time.Time { return time.Now().UTC() }, - disks: o.Disks, - diskGate: o.DiskGate, - guestList: o.Guests2, - guestAttach: o.GuestAttach, - mem: o.Memory, - netStorage: o.NetStorage, - privileged: o.Privileged, - configPath: o.ConfigPath, - stateDir: o.StateDir, - netMountRoot: storage.NetworkMountRoot, - smbCredsDir: o.SmbCredsDir, - escrowStagePath: o.EscrowStagePath, - escrowRecovery: o.EscrowRecovery, - intent: o.Intent, - guestBinds: o.GuestBinds, - formatJobs: o.FormatJobs, - staleLock: o.StaleLock, - host: o.HostReader, - ident: o.Identity, - hostMetrics: o.HostMetrics, - hostID: o.HostID, - agentVersion: o.AgentVersion, - logRing: o.LogRing, - jobs: map[backupJobKey]*backupJob{}, - swapInFlight: map[int]bool{}, + addr: o.ListenAddr, + cert: o.Cert, + guests: o.Guests, + backups: o.Backups, + store: o.Store, + storage: o.Storage, + driveTargets: o.DriveTargets, + smart: o.Smart, + tokens: o.Tokens, + cadence: cadence, + logger: o.Logger, + now: func() time.Time { return time.Now().UTC() }, + disks: o.Disks, + diskGate: o.DiskGate, + guestList: o.Guests2, + guestAttach: o.GuestAttach, + mem: o.Memory, + netStorage: o.NetStorage, + privileged: o.Privileged, + configPath: o.ConfigPath, + stateDir: o.StateDir, + netMountRoot: storage.NetworkMountRoot, + smbCredsDir: o.SmbCredsDir, + escrowStagePath: o.EscrowStagePath, + escrowRecovery: o.EscrowRecovery, + intent: o.Intent, + internalPlaceFile: o.InternalPlaceFile, + guestBinds: o.GuestBinds, + formatJobs: o.FormatJobs, + staleLock: o.StaleLock, + host: o.HostReader, + ident: o.Identity, + hostMetrics: o.HostMetrics, + hostID: o.HostID, + agentVersion: o.AgentVersion, + logRing: o.LogRing, + jobs: map[backupJobKey]*backupJob{}, + swapInFlight: map[int]bool{}, } // R-82 tier resolution. Options.BackupTiers is authoritative when supplied; otherwise ONE tier // is synthesized from Backups + BackupCadence — the pre-R-82 shape, so every existing caller diff --git a/internal/reconcile/classify.go b/internal/reconcile/classify.go index adb37e0..4b3df6a 100644 --- a/internal/reconcile/classify.go +++ b/internal/reconcile/classify.go @@ -64,6 +64,11 @@ const ( // The config bundle (agent v0.143.0, R-840, `11` §5.4.2): the box's ROOT-OWNED files (sudoers, wrappers, units). // Destructive-class (signed, operational key) like agent_update; the root wrapper re-verifies the signature itself. ClassAgentConfigUpdate OpClass = "agent_config_update" + + // The internal data place on an OLD one-disk box (agent v0.158.0, felhom.eu `16` §9): thin space on the system + // disk, made by the root helper felhom-internal-place. Destructive-class (signed, operational key): it is an + // operator act, never a household button, and no agent-internal provenance may start it. + ClassAddInternalPlace OpClass = "add_internal_place" ) // Disposition is the classifier verdict. @@ -132,7 +137,7 @@ func Classify(class OpClass, prov Provenance) Disposition { return Destructive case ClassKeyRotation: return Destructive - case ClassAgentUpdate, ClassOSDockerStep, ClassOSPVEStep, ClassOSKernelStep, ClassAgentConfigUpdate: + case ClassAgentUpdate, ClassOSDockerStep, ClassOSPVEStep, ClassOSKernelStep, ClassAgentConfigUpdate, ClassAddInternalPlace: // Never benign — no agent-internal provenance can make replacing the agent binary // unsigned-safe (a compromised process must not be able to self-bless an update). return Destructive diff --git a/internal/signedjobs/internalplace.go b/internal/signedjobs/internalplace.go new file mode 100644 index 0000000..8ef4a4e --- /dev/null +++ b/internal/signedjobs/internalplace.go @@ -0,0 +1,91 @@ +package signedjobs + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log/slog" + "regexp" + "strconv" + "strings" + "time" + + "gitea.dooplex.hu/admin/felhom-agent/internal/localapi" + "gitea.dooplex.hu/admin/felhom-agent/internal/proxmox" +) + +// opAddInternalPlace is the op class this executor serves (mirrors reconcile.ClassAddInternalPlace). +const opAddInternalPlace = "add_internal_place" + +// InternalPlaceHelper is the root helper's path (configs/felhom-internal-place, from the config bundle). +const InternalPlaceHelper = "/usr/local/sbin/felhom-internal-place" + +var reHelperResult = regexp.MustCompile(`^(created|existing|formatted) uuid=([0-9a-fA-F-]{36}) gib=([0-9]+)$`) + +// InternalPlaceExecutor adds the internal data place to an OLD one-disk box (felhom.eu architecture/16 §9: „one-disk +// boxes get no internal place by themselves; adding one later is a signed operator job, not a household button"). +// It runs the SAME root helper the installer runs — `felhom-internal-place create 0` (0 GiB planned: the guest's +// volumes already exist and are counted) — writes the record the agent mounts from, and asks the local API to mount +// it now. Every safety rule (the ruled size, never format what holds anything) lives in the helper, as root. +// +// The op carries no parameters: the size is the rule's, never the signer's. Destructive-class by default (the +// classifier's fail-safe; ClassAddInternalPlace documents it): it takes thin space on the system disk, so only an +// operator signature may start it. +type InternalPlaceExecutor struct { + runner proxmox.Runner + recordFile string + ensure func(context.Context) // late-bound: the local API server is built after the jobs runner + logger *slog.Logger +} + +// NewInternalPlaceExecutor wires the executor. ensure may be nil at construction and set later through SetEnsure. +func NewInternalPlaceExecutor(runner proxmox.Runner, recordFile string, logger *slog.Logger) *InternalPlaceExecutor { + if logger == nil { + logger = slog.Default() + } + return &InternalPlaceExecutor{runner: runner, recordFile: recordFile, logger: logger} +} + +// SetEnsure late-binds the mount step (localapi.Server.EnsureInternalPlace). +func (e *InternalPlaceExecutor) SetEnsure(f func(context.Context)) { e.ensure = f } + +// Execute implements signedjobs.Executor for add_internal_place. +func (e *InternalPlaceExecutor) Execute(ctx context.Context, op string, params json.RawMessage) error { + if op != opAddInternalPlace { + return ErrNoExecutor + } + if p := bytes.TrimSpace(params); len(p) > 0 && !bytes.Equal(p, []byte("{}")) && !bytes.Equal(p, []byte("null")) { + return fmt.Errorf("add_internal_place: takes no parameters (the size is the ruled rule's), got %s", p) + } + if e.runner == nil || e.recordFile == "" { + return fmt.Errorf("add_internal_place: not wired on this host (no privileged runner or state dir)") + } + start := time.Now() + e.logger.Warn("add_internal_place: executing operator-signed job (the internal data place, `16` §9)") + out, stderr, err := e.runner.Run(ctx, InternalPlaceHelper, "create", "0") + line := strings.TrimSpace(string(out)) + if err != nil { + return fmt.Errorf("add_internal_place: helper refused: %v: %s", err, strings.TrimSpace(string(stderr))) + } + if strings.HasPrefix(line, "skipped ") { + // Not an error the operator can retry away: the pool has no room by the ruled rule. Fail the job with the + // helper's own numbers so the hub shows why. + return fmt.Errorf("add_internal_place: %s", line) + } + m := reHelperResult.FindStringSubmatch(line) + if m == nil { + return fmt.Errorf("add_internal_place: unexpected helper output %q", line) + } + gib, _ := strconv.Atoi(m[3]) + rec := localapi.InternalPlaceRecord{FSUUID: m[2], GiB: gib, MadeBy: "signed-job", MadeAt: time.Now().UTC().Format(time.RFC3339)} + if err := localapi.WriteInternalPlace(e.recordFile, rec); err != nil { + return fmt.Errorf("add_internal_place: writing the record: %w", err) + } + if e.ensure != nil { + e.ensure(ctx) + } + e.logger.Warn("add_internal_place: complete", "result", m[1], "fs_uuid", m[2], "gib", gib, + "duration_ms", time.Since(start).Milliseconds()) + return nil +} diff --git a/internal/signedjobs/internalplace_test.go b/internal/signedjobs/internalplace_test.go new file mode 100644 index 0000000..1ff7ea0 --- /dev/null +++ b/internal/signedjobs/internalplace_test.go @@ -0,0 +1,94 @@ +package signedjobs + +import ( + "context" + "errors" + "io" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-agent/internal/localapi" + "gitea.dooplex.hu/admin/felhom-agent/internal/reconcile" +) + +// add_internal_place (felhom.eu architecture/16 §9, agent v0.158.0): the operator's job for an OLD one-disk box. + +type ipRunner struct { + out, stderr string + err error + calls [][]string +} + +func (r *ipRunner) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) { + r.calls = append(r.calls, append([]string{name}, args...)) + return []byte(r.out), []byte(r.stderr), r.err +} +func (r *ipRunner) RunStdin(ctx context.Context, _ io.Reader, name string, args ...string) ([]byte, []byte, error) { + return r.Run(ctx, name, args...) +} + +func ipExec(t *testing.T, r *ipRunner) (*InternalPlaceExecutor, string, *int) { + t.Helper() + file := filepath.Join(t.TempDir(), localapi.InternalPlaceFile) + e := NewInternalPlaceExecutor(r, file, slog.New(slog.NewTextHandler(io.Discard, nil))) + n := 0 + e.SetEnsure(func(context.Context) { n++ }) + return e, file, &n +} + +func TestAddInternalPlace_RunsTheHelperWritesTheRecordAndMounts(t *testing.T) { + r := &ipRunner{out: "created uuid=0b6e4c5a-1d2e-4f3a-9b8c-7d6e5f4a3b2c gib=33\n"} + e, file, ensured := ipExec(t, r) + if err := e.Execute(context.Background(), "add_internal_place", []byte(`{}`)); err != nil { + t.Fatal(err) + } + if len(r.calls) != 1 || strings.Join(r.calls[0], " ") != InternalPlaceHelper+" create 0" { + t.Fatalf("the helper must be called exactly as `create 0`, got %v", r.calls) + } + rec, ok, err := localapi.ReadInternalPlace(file) + if err != nil || !ok || rec.FSUUID != "0b6e4c5a-1d2e-4f3a-9b8c-7d6e5f4a3b2c" || rec.GiB != 33 || rec.MadeBy != "signed-job" { + t.Fatalf("record: %+v ok=%v err=%v", rec, ok, err) + } + if *ensured != 1 { + t.Fatalf("the mount must be asked for once, got %d", *ensured) + } +} + +func TestAddInternalPlace_NoRoomOrRefusalOrGarbage_WritesNothing(t *testing.T) { + for name, r := range map[string]*ipRunner{ + "skipped": {out: "skipped only 7 GiB free for it (pool 53, reserve 16, promised 102, planned 0; needs 20)"}, + "refused": {err: errors.New("exit status 1"), stderr: "felhom-internal-place: REFUSED: holds something"}, + "garbage": {out: "created uuid=../../etc gib=1"}, + } { + e, file, ensured := ipExec(t, r) + if err := e.Execute(context.Background(), "add_internal_place", nil); err == nil { + t.Fatalf("%s: want an error", name) + } + if _, statErr := os.Stat(file); !os.IsNotExist(statErr) || *ensured != 0 { + t.Fatalf("%s: no record and no mount may follow, stat=%v ensured=%d", name, statErr, *ensured) + } + } +} + +func TestAddInternalPlace_TakesNoParametersAndIgnoresOtherOps(t *testing.T) { + r := &ipRunner{out: "created uuid=0b6e4c5a-1d2e-4f3a-9b8c-7d6e5f4a3b2c gib=33"} + e, _, _ := ipExec(t, r) + if err := e.Execute(context.Background(), "add_internal_place", []byte(`{"gib":900}`)); err == nil || len(r.calls) != 0 { + t.Fatalf("a size from the signer must be refused before the helper runs, err=%v calls=%v", err, r.calls) + } + if err := e.Execute(context.Background(), "decommission", []byte(`{}`)); !errors.Is(err, ErrNoExecutor) { + t.Fatalf("another op: want ErrNoExecutor, got %v", err) + } +} + +func TestAddInternalPlace_IsSignatureOnly(t *testing.T) { + if got := reconcile.Classify(reconcile.ClassAddInternalPlace, reconcile.Provenance{}); got != reconcile.Destructive { + t.Fatalf("add_internal_place must need an operator signature, classified %v", got) + } + if string(reconcile.ClassAddInternalPlace) != opAddInternalPlace { + t.Fatalf("op string drift: %q vs %q", reconcile.ClassAddInternalPlace, opAddInternalPlace) + } +} diff --git a/internal/storage/candidates.go b/internal/storage/candidates.go index 63f44ab..1a95d58 100644 --- a/internal/storage/candidates.go +++ b/internal/storage/candidates.go @@ -13,14 +13,16 @@ import ( // (guarded, Impl-1) and/or mounts + binds. Blank disks are "initialize"-only; disks already carrying a // mountable FS are ALSO "attach" candidates. type CandidateDisk struct { - Device string `json:"device"` // whole disk, e.g. /dev/sdd - SizeBytes int64 `json:"size_bytes"` // 0 when unreadable + Device string `json:"device"` // whole disk, e.g. /dev/sdd + SizeBytes int64 `json:"size_bytes"` // 0 when unreadable Model string `json:"model,omitempty"` - FSType string `json:"fstype,omitempty"` // first filesystem found ("" = blank) - DataBearing bool `json:"data_bearing"` // has any FS / partition (→ wipe-confirm downstream) - Mountable bool `json:"mountable"` // carries an ext4/xfs FS the agent can mount as-is + FSType string `json:"fstype,omitempty"` // first filesystem found ("" = blank) + DataBearing bool `json:"data_bearing"` // has any FS / partition (→ wipe-confirm downstream) + Mountable bool `json:"mountable"` // carries an ext4/xfs FS the agent can mount as-is MountSource string `json:"mount_source,omitempty"` // the node to mount for attach (e.g. /dev/sdd1) DurableID string `json:"durable_id,omitempty"` // "uuid:" if it has an FS; "" for a blank disk + // Partitions (v0.158.0, felhom.eu `16` §6): each partition of this disk as its own place — see partitions.go. + Partitions []CandidatePartition `json:"partitions,omitempty"` } // mountableFSTypes are the filesystems the agent can mount as-is (EnsureMount) → an "attach" candidate. @@ -83,6 +85,8 @@ func (h *SudoHostOps) buildCandidate(dev, name string) CandidateDisk { if len(nodes) > 1 { c.DataBearing = true } + c.Partitions = candidatePartitions(lout, name, readSysPartSize, readSysPartNumber, h.host.ResolveUUID, + felhomOwnedMounts(dev, nodes, h.mountTable)) } } return c diff --git a/internal/storage/durable_device.go b/internal/storage/durable_device.go index b959362..3a10a94 100644 --- a/internal/storage/durable_device.go +++ b/internal/storage/durable_device.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "sort" "strings" ) @@ -24,8 +25,13 @@ var devDiskRoot = "/dev/disk" const ( durableByID = "byid:" durableByUUID = "byuuid:" + // partFSUUIDSep joins a partition's by-id link and its fs-UUID in one wipe id (v0.158.0, `16` §6). + partFSUUIDSep = "+fsuuid:" ) +// rePartLink matches a by-id link of a PARTITION (udev appends -part to the disk's link). +var rePartLink = regexp.MustCompile(`-part[1-9][0-9]{0,2}$`) + // byIDPriority ranks /dev/disk/by-id link prefixes most-stable-first: wwn (hardware world-wide // name) > nvme-eui (NVMe EUI) > nvme-/ata-/scsi-/usb- (model+serial). dm-/lvm-/md- names are // excluded (they are mapper constructs, not the physical disk we want to bind a wipe to). @@ -41,6 +47,14 @@ func DeviceDurableID(device string) (string, error) { } // 1. best by-id link pointing at this device. if name := bestByIDLink(target); name != "" { + // v0.158.0 (felhom.eu `16` §6): a PARTITION's link already carries the disk id + the part number + // (wwn-…-part2). When the part holds a filesystem, its fs-UUID is bound too, so a part that was reformatted — + // or another disk's part2 that took the link — derives a different id and the wipe refuses. + if rePartLink.MatchString(name) { + if uuid := byUUIDFor(target); uuid != "" { + return durableByID + name + partFSUUIDSep + uuid, nil + } + } return durableByID + name, nil } // 2. fallback: a filesystem UUID symlink. @@ -56,10 +70,29 @@ func ResolveDurableDevice(durableID string) (string, error) { switch { case strings.HasPrefix(durableID, durableByID): name := strings.TrimPrefix(durableID, durableByID) + fsUUID := "" + if i := strings.Index(name, partFSUUIDSep); i >= 0 { + name, fsUUID = name[:i], name[i+len(partFSUUIDSep):] + if !rePartLink.MatchString(name) || !safeLinkName(fsUUID) { + return "", fmt.Errorf("storage: unsafe durable id %q", durableID) + } + } if !safeLinkName(name) { return "", fmt.Errorf("storage: unsafe durable id %q", durableID) } - return filepath.EvalSymlinks(filepath.Join(devDiskRoot, "by-id", name)) + dev, err := filepath.EvalSymlinks(filepath.Join(devDiskRoot, "by-id", name)) + if err != nil || fsUUID == "" { + return dev, err + } + // Both halves must name the SAME device now (`16` §6): the part by its disk id + number, and its filesystem. + byFS, ferr := filepath.EvalSymlinks(filepath.Join(devDiskRoot, "by-uuid", fsUUID)) + if ferr != nil { + return "", fmt.Errorf("storage: durable id %q: its filesystem %s no longer resolves: %w", durableID, fsUUID, ferr) + } + if byFS != dev { + return "", fmt.Errorf("storage: durable id %q: the part (%s) and its filesystem (%s) are different devices now", durableID, dev, byFS) + } + return dev, nil case strings.HasPrefix(durableID, durableByUUID): uuid := strings.TrimPrefix(durableID, durableByUUID) if !safeLinkName(uuid) { diff --git a/internal/storage/mkfs_guarded_suite_test.go b/internal/storage/mkfs_guarded_suite_test.go new file mode 100644 index 0000000..007b300 --- /dev/null +++ b/internal/storage/mkfs_guarded_suite_test.go @@ -0,0 +1,24 @@ +package storage + +import ( + "os/exec" + "path/filepath" + "runtime" + "testing" +) + +// The root mkfs wrapper's own tests (configs/test_felhom_mkfs_guarded.py, agent v0.158.0 — a sibling partition that +// is Felhom's own drive no longer blocks formatting the part next to it; every other mount still refuses) run with +// the Go suite. +func TestMkfsGuardedPythonSuite(t *testing.T) { + py, err := exec.LookPath("python3") + if err != nil { + t.Skip("python3 not found") + } + _, file, _, _ := runtime.Caller(0) + script := filepath.Join(filepath.Dir(file), "..", "..", "configs", "test_felhom_mkfs_guarded.py") + out, err := exec.Command(py, script).CombinedOutput() + if err != nil { + t.Fatalf("wrapper suite failed: %v\n%s", err, out) + } +} diff --git a/internal/storage/partition_durable_test.go b/internal/storage/partition_durable_test.go new file mode 100644 index 0000000..0fc7e6b --- /dev/null +++ b/internal/storage/partition_durable_test.go @@ -0,0 +1,112 @@ +package storage + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// The data-bearing partition path binds the disk id + the part number + the fs-UUID (felhom.eu architecture/16 §6, +// agent v0.158.0). The wipe re-resolve (localapi antiRetargetResolveExpect) formats only when resolve(id) succeeds AND +// DeviceDurableID(that device) == id — the round trip these tests pin, in the three ways a target can move. + +type partFixture struct{ base, root string } + +func newPartFixture(t *testing.T) partFixture { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("symlink fixtures run on Linux") + } + f := partFixture{base: t.TempDir()} + f.root = filepath.Join(f.base, "disk") + for _, d := range []string{"sdb", "sdb1", "sdb2", "sdc2"} { + if err := os.WriteFile(filepath.Join(f.base, d), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + } + os.MkdirAll(filepath.Join(f.root, "by-id"), 0o755) + os.MkdirAll(filepath.Join(f.root, "by-uuid"), 0o755) + withDevDiskRoot(t, f.root) + return f +} + +func (f partFixture) link(t *testing.T, sub, name, dev string) { + t.Helper() + p := filepath.Join(f.root, sub, name) + os.Remove(p) + if err := os.Symlink(filepath.Join(f.base, dev), p); err != nil { + t.Fatal(err) + } +} + +func (f partFixture) dev(name string) string { return filepath.Join(f.base, name) } + +func (f partFixture) standard(t *testing.T) { + f.link(t, "by-id", "wwn-0x5000aaaa", "sdb") + f.link(t, "by-id", "ata-TOSHIBA_MQ04_65NOP-part1", "sdb1") + f.link(t, "by-id", "wwn-0x5000aaaa-part1", "sdb1") + f.link(t, "by-id", "wwn-0x5000aaaa-part2", "sdb2") + f.link(t, "by-uuid", "1111-films", "sdb2") +} + +func roundTrip(id string) (string, bool) { + dev, err := ResolveDurableDevice(id) + if err != nil { + return "", false + } + again, err := DeviceDurableID(dev) + return dev, err == nil && again == id +} + +func TestPartitionWipeID_BindsDiskPartAndFilesystem(t *testing.T) { + f := newPartFixture(t) + f.standard(t) + id, err := DeviceDurableID(f.dev("sdb2")) + if err != nil || id != "byid:wwn-0x5000aaaa-part2+fsuuid:1111-films" { + t.Fatalf("a part with a filesystem: want disk id + part + fs-UUID, got %q %v", id, err) + } + if dev, ok := roundTrip(id); !ok || dev != f.dev("sdb2") { + t.Fatalf("the unchanged part must round-trip, got %q %v", dev, ok) + } + // A blank part (no filesystem) and a whole disk keep the plain by-id binding. + if id1, _ := DeviceDurableID(f.dev("sdb1")); id1 != "byid:wwn-0x5000aaaa-part1" { + t.Fatalf("a blank part: want the plain part link, got %q", id1) + } + if idDisk, _ := DeviceDurableID(f.dev("sdb")); idDisk != "byid:wwn-0x5000aaaa" { + t.Fatalf("a whole disk keeps today's id, got %q", idDisk) + } +} + +func TestPartitionWipeID_ReformattedPartRefuses(t *testing.T) { + f := newPartFixture(t) + f.standard(t) + id, _ := DeviceDurableID(f.dev("sdb2")) + os.Remove(filepath.Join(f.root, "by-uuid", "1111-films")) + f.link(t, "by-uuid", "2222-other", "sdb2") // someone reformatted the part since the household confirmed + if _, ok := roundTrip(id); ok { + t.Fatalf("a reformatted part must not round-trip (the wipe would hit a different filesystem)") + } +} + +func TestPartitionWipeID_LinkMovedToAnotherDiskRefuses(t *testing.T) { + f := newPartFixture(t) + f.standard(t) + id, _ := DeviceDurableID(f.dev("sdb2")) + f.link(t, "by-id", "wwn-0x5000aaaa-part2", "sdc2") // a different node took the part link; the fs is still sdb2 + if _, err := ResolveDurableDevice(id); err == nil || !strings.Contains(err.Error(), "different devices") { + t.Fatalf("the part link and its filesystem on different devices must refuse, got %v", err) + } +} + +func TestPartitionWipeID_RefusesAForgedCompound(t *testing.T) { + f := newPartFixture(t) + f.standard(t) + f.link(t, "by-uuid", "3333-whole", "sdb") // a filesystem straight on the whole disk: both halves name sdb + for _, id := range []string{"byid:wwn-0x5000aaaa+fsuuid:3333-whole", "byid:wwn-0x5000aaaa-part2+fsuuid:../x"} { + if _, err := ResolveDurableDevice(id); err == nil { + t.Fatalf("%q must be refused", id) + } + } +} diff --git a/internal/storage/partitions.go b/internal/storage/partitions.go new file mode 100644 index 0000000..b95fe82 --- /dev/null +++ b/internal/storage/partitions.go @@ -0,0 +1,104 @@ +package storage + +import ( + "encoding/json" + "os" + "path/filepath" + "strconv" + "strings" +) + +// Partitions as places (agent v0.158.0) — felhom.eu architecture/16-storage-physical-identity.md §6. +// +// A disk that passed the WHOLE-disk unclaimed filter (classifyClaim: not the system disk, no LVM/ZFS/RAID/LUKS/swap +// member on any node, no foreign mount on any node) also lists its partitions, each with its own state: +// +// empty no filesystem, no children → „format this part" (self-serve: the agent's probe says blank) +// filesystem ext4/xfs (mountable) or another fs → „use this part" when mountable; „format this part" only through +// the customer-confirmed data-bearing path (the agent's own probe) +// in_use already Felhom's own drive → shown, never offered (it is a registered drive already) +// +// A partition with children (LVM, a mapper) is not listed. The design's separate „a filesystem with no files" state is +// folded into `filesystem` on purpose: telling the two apart needs a root read-only mount, and the data-bearing path's +// confirmation is the safer default for both (`16` §13). Creating, deleting or resizing a partition: never here (D5). +// The format binding is the partition's own by-id link (…-partN: the disk id + the partition number) via +// DeviceDurableID, re-resolved before mkfs (wipe_reresolve.go); the attach key is its fs-UUID. +type CandidatePartition struct { + Device string `json:"device"` // e.g. /dev/sdb2 + Number int `json:"number,omitempty"` + SizeBytes int64 `json:"size_bytes"` + FSType string `json:"fstype,omitempty"` + State string `json:"state"` // empty | filesystem | in_use + Mountable bool `json:"mountable"` + DurableID string `json:"durable_id,omitempty"` // uuid: — the attach key ("" when empty) + MountedAt string `json:"mounted_at,omitempty"` // in_use: Felhom's own mount +} + +const ( + PartEmpty = "empty" + PartFilesystem = "filesystem" + PartInUse = "in_use" +) + +// candidatePartitions is the pure lister: the whole disk's lsblk tree → its partitions. sizeOf/numberOf read /sys, +// uuidOf the by-uuid links, owned is the set of mountpoints corroborated as Felhom's own (felhomOwnedMounts). +func candidatePartitions(lsblkJSON []byte, wholeName string, sizeOf func(string) int64, numberOf func(string) int, + uuidOf func(string) (string, bool), owned map[string]bool) []CandidatePartition { + var doc struct { + BlockDevices []lsblkDev `json:"blockdevices"` + } + if json.Unmarshal(lsblkJSON, &doc) != nil { + return nil + } + var out []CandidatePartition + for _, d := range doc.BlockDevices { + if d.Name != wholeName { + continue + } + for _, p := range d.Children { + if len(p.Children) > 0 || memberFSTypes[p.FSType] { + continue // LVM/mapper on it, or a member: not a place (the disk filter refuses these anyway) + } + c := CandidatePartition{Device: "/dev/" + p.Name, Number: numberOf(p.Name), SizeBytes: sizeOf(p.Name), FSType: p.FSType} + switch { + case p.MountPoint != "" && (underFelhomDrives(p.MountPoint) || owned[p.MountPoint]): + c.State, c.MountedAt = PartInUse, p.MountPoint + case p.MountPoint != "": + continue // mounted by something else: never offered (the disk filter refuses the disk anyway) + case p.FSType == "": + c.State = PartEmpty + default: + c.State = PartFilesystem + c.Mountable = mountableFSTypes[p.FSType] + if u, ok := uuidOf(c.Device); ok && u != "" { + c.DurableID = "uuid:" + u + } + } + out = append(out, c) + } + } + return out +} + +// readSysPartNumber reads /sys/class/block//partition (0 when unreadable). +func readSysPartNumber(name string) int { + b, err := os.ReadFile(filepath.Join("/sys/class/block", name, "partition")) + if err != nil { + return 0 + } + n, _ := strconv.Atoi(strings.TrimSpace(string(b))) + return n +} + +// readSysPartSize reads a partition's size in bytes from /sys/class/block//size (512-byte sectors). +func readSysPartSize(name string) int64 { + b, err := os.ReadFile(filepath.Join("/sys/class/block", name, "size")) + if err != nil { + return 0 + } + s, err := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64) + if err != nil { + return 0 + } + return s * 512 +} diff --git a/internal/storage/partitions_test.go b/internal/storage/partitions_test.go new file mode 100644 index 0000000..4c6672f --- /dev/null +++ b/internal/storage/partitions_test.go @@ -0,0 +1,73 @@ +package storage + +import ( + "strings" + "testing" +) + +// Partitions as places (felhom.eu architecture/16 §6, agent v0.158.0): every partition of a free disk with its own +// state; Felhom's own drive shown but never offered; anything foreign, layered or a member never listed. + +const partTree = `{"blockdevices":[ + {"name":"sdb","fstype":null,"mountpoint":null,"children":[ + {"name":"sdb1","fstype":"ext4","mountpoint":"/mnt/felhom-drives/hdd_1"}, + {"name":"sdb2","fstype":null,"mountpoint":null}, + {"name":"sdb3","fstype":"ntfs","mountpoint":null}, + {"name":"sdb4","fstype":"xfs","mountpoint":null}, + {"name":"sdb5","fstype":"ext4","mountpoint":"/mnt/films"}, + {"name":"sdb6","fstype":"ext4","mountpoint":"/srv/data"}, + {"name":"sdb7","fstype":"LVM2_member","mountpoint":null,"children":[{"name":"vg-lv","fstype":"ext4","mountpoint":null}]}, + {"name":"sdb8","fstype":"crypto_LUKS","mountpoint":null} + ]}, + {"name":"sdc","fstype":null,"mountpoint":null,"children":[{"name":"sdc1","fstype":null,"mountpoint":null}]} +]}` + +func listParts(owned map[string]bool) map[string]CandidatePartition { + got := candidatePartitions([]byte(partTree), "sdb", + func(n string) int64 { return int64(len(n)) << 30 }, + func(n string) int { return int(n[len(n)-1] - '0') }, + func(dev string) (string, bool) { return "uuid-of-" + strings.TrimPrefix(dev, "/dev/"), true }, + owned) + m := map[string]CandidatePartition{} + for _, p := range got { + m[p.Device] = p + } + return m +} + +func TestPartitions_StatesPerPartition(t *testing.T) { + m := listParts(map[string]bool{"/mnt/films": true}) + want := map[string]string{"/dev/sdb1": PartInUse, "/dev/sdb2": PartEmpty, "/dev/sdb3": PartFilesystem, + "/dev/sdb4": PartFilesystem, "/dev/sdb5": PartInUse} + if len(m) != len(want) { + t.Fatalf("want exactly %d listed partitions, got %+v", len(want), m) + } + for dev, st := range want { + if m[dev].State != st { + t.Fatalf("%s: want %s, got %+v", dev, st, m[dev]) + } + } + if m["/dev/sdb2"].DurableID != "" || m["/dev/sdb2"].Mountable { + t.Fatalf("an empty part has no attach key: %+v", m["/dev/sdb2"]) + } + if !m["/dev/sdb4"].Mountable || m["/dev/sdb4"].DurableID != "uuid:uuid-of-sdb4" || m["/dev/sdb4"].Number != 4 { + t.Fatalf("an xfs part is usable by its fs-UUID: %+v", m["/dev/sdb4"]) + } + if m["/dev/sdb3"].Mountable { + t.Fatalf("ntfs is a filesystem the agent does not mount as-is: %+v", m["/dev/sdb3"]) + } +} + +// A raw /mnt/ mount counts as Felhom's own ONLY when corroborated (felhomOwnedMounts); else it is foreign and +// the part is not listed. Positive control: TestPartitions_StatesPerPartition (the same mount, corroborated). +func TestPartitions_UncorroboratedMountIsNotOffered(t *testing.T) { + m := listParts(nil) + if _, ok := m["/dev/sdb5"]; ok { + t.Fatalf("an uncorroborated /mnt/films mount must not be listed, got %+v", m["/dev/sdb5"]) + } + for _, dev := range []string{"/dev/sdb6", "/dev/sdb7", "/dev/sdb8", "/dev/sdc1"} { + if _, ok := m[dev]; ok { + t.Fatalf("%s must never be listed (foreign mount, LVM, LUKS, another disk)", dev) + } + } +}