Compare commits

..

6 Commits

Author SHA1 Message Date
admin 37b99d73b3 Go 1.27.2 and x/crypto v0.58.0 (R-972) — built and tested, not released
gates / gates (push) Successful in 31s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-16 18:53:33 +02:00
admin dd8b2ace02 release-agent.sh: only a clean 404 proves a version is new (R-1098)
gates / gates (push) Successful in 31s
An unreachable registry (000) or a server error passed the existing-version check as new. Test:
scripts/test_release_push_once.sh, red-proved. No agent release.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-16 17:23:46 +02:00
admin 383522450a REPORT + CONTEXT: v0.158.1 build B, vouched; R-1093 filed
gates / gates (push) Successful in 32s
Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-16 10:10:21 +02:00
admin 1a8bed11e7 CHANGELOG: v0.158.1 (security review of 0.158.0; 0.158.0 superseded, do not vouch)
gates / gates (push) Successful in 25s
Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-16 04:02:17 +02:00
admin 98f3cdd76a Security review of 912f7c1: the mkfs wrapper trusts no sibling claim; the internal-place helper fails closed
gates / gates (push) Successful in 25s
- felhom-mkfs-guarded: the 0.158.0 exemption for a sibling part mounted at /mnt/<name> + under /mnt/felhom-drives is
  removed — the agent can make that corroborating mount itself. Every mount of every node outside /mnt/felhom-drives
  refuses (kernel mount table); an unlistable disk refuses. CandidatePartition.Formattable is false for every part of a
  disk with a mounted node, so it is never offered for formatting (attach still is).
- felhom-internal-place: a failed lvs listing, a listing without the pool, or a blkid exit other than 0/2 refuses
  (they read as "nothing promised" / "no signature" before).
Every new test was seen failing against the 0.158.0 versions.

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-16 04:02:00 +02:00
admin 888a98571b CHANGELOG: v0.158.0 — the internal data place, partitions, add_internal_place
gates / gates (push) Successful in 28s
Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-16 03:56:29 +02:00
14 changed files with 256 additions and 81 deletions
+58
View File
@@ -1,3 +1,61 @@
## unreleased — Go 1.27.2 and golang.org/x/crypto v0.58.0 (R-972, 2026-10-16)
**Not released, not deployed** — agent releases wait for the operator's vouch; the next vouched release carries this.
`go.mod`: `go 1.27.2` (was 1.25.0; the shipped 0.158.1 binary was built with go1.26.0), x/crypto v0.58.0 (was v0.52.0),
x/sys v0.49.0. `go build`, `go vet`, the full `go test ./...` and `agent_gates.py --fast` green on Go 1.27.2.
`release-agent.sh` builds with the toolchain `go.mod` names (GOTOOLCHAIN=auto), so no script change.
## scripts only — `release-agent.sh` refuses when the registry cannot say the version is new (R-1098, 2026-10-16)
No agent release; no binary changes. `scripts/release-agent.sh` already refused a version whose tag exists or whose
package answers 200. It let everything else through — a registry it could not reach (curl prints `000`) or a 500 read
as „new". Now only a clean `404` proves the version is new; anything else refuses with the code it got. New
`scripts/test_release_push_once.sh` (the real script, a fake `curl` and `go` on PATH; published / absent / unreachable /
server error / tag exists), red-proved: with the 404 rule removed, the unreachable and server-error cases fail. Same rule
as the controller `build.sh` and, since today, felhom.eu `scripts/build-hub.sh`.
## v0.158.1 — security review of v0.158.0: the mkfs wrapper trusts no sibling claim; the internal-place helper fails closed (2026-10-16)
**v0.158.0 is superseded — do not vouch it.** A security review of `912f7c1` found two defects; both fixed here, each test
seen failing against the 0.158.0 script:
- **`felhom-mkfs-guarded`**: 0.158.0 let a format through when a SIBLING part was mounted at /mnt/<name> AND under
/mnt/felhom-drives („Felhom's own drive"). That corroboration is a mount the agent can make itself, so a compromised agent
could unlock a host owner's disk — the exact case this root wrapper exists for. Removed: any mount of any node outside
/mnt/felhom-drives refuses again (now read from the kernel's mount table for every node; an unlistable disk refuses).
Consequence: the free part next to an in-use Felhom drive cannot be formatted — `CandidatePartition.Formattable` is false
for every part of a disk with a mounted node, and the controller does not offer it (a part with a filesystem can still be
USED). Pinned by configs/test_felhom_mkfs_guarded.py + partitions_test.go.
- **`felhom-internal-place`**: a failed `lvs` listing read as „nothing promised" (a too-large place) and a `blkid` error read
as „no signature" (the only gate before mkfs). Now: a failed listing, a listing without the pool, or any blkid exit other
than 0/2 REFUSES. Three new tests, all failing against 0.158.0.
- Released: tag `v0.158.1` (code `98f3cdd`), sha256 `20264df6…84b76a1`, bundle `c23c3d35…cb1c41b`. **Not vouched.**
## v0.158.0 — the internal data place; partitions as places; add_internal_place (2026-10-16)
Design: felhom.eu `architecture/16-storage-physical-identity.md` §5, §6, §7, §9 (operator rulings D3–D5; the D3 sizes
ruled 2026-10-16 after the space check, felhom.eu `audits/storage-identity-buildB-2026-10-15/SPACE-CHECK.md`).
- **The internal data place („Belső tárhely")** (`internal/localapi/internalplace.go`): when the record
`/var/lib/felhom-agent/internal-place.json` names a filesystem, the agent mounts it at `/mnt/belso` by fs-UUID like an
enrolled drive (startup + every 20 s tick) and records it `enrolled`; `GET /disks` reports it with kind `internal`.
It never takes `/mnt/belso` from a household drive, and eject/decommission/format of it are refused (device-mapper on
the system disk). Its identity is the system disk's, so build A never counts a copy to `sys_drive` as a second copy.
- **Root helper `configs/felhom-internal-place`** (new in the bundle): `plan|create <planned GiB>` — the ruled size rule
(reserve max(16 GiB, 10 %) ≤ 48 GiB, made only when ≥ 20 GiB, nothing promised beyond pool − reserve), the only
lvcreate/mkfs of a system-disk volume; formats only a `felhom-belso` with no signature. Run by the installer (1.33.0)
and by the new signed op.
- **Signed op `add_internal_place`** (old one-disk boxes, `16` §9): no parameters (the size is the rule's), destructive
class (operator key). Runs the helper with `create 0`, writes the record, mounts.
- **Partitions as places** (`internal/storage/partitions.go`): `GET /disks/candidates` lists each partition of a free
disk as empty / filesystem / in_use. A data-bearing part's wipe id binds disk id + part number + fs-UUID
(`byid:<…-partN>+fsuuid:<uuid>`); both must name the same device at format time.
- **`felhom-mkfs-guarded`** (REVERTED in v0.158.1): a SIBLING part that is Felhom's own drive no longer blocked formatting
the free part next to it. First tests of the wrapper: `configs/test_felhom_mkfs_guarded.py`.
- `configs/` changed (sudoers `FELHOM_INTERNALPLACE`, the helper, the wrapper, the bundle list) — **a config-bundle job
is needed** with the agent update. **Not vouched** (the operator's act).
- Released: tag `v0.158.0` (code `912f7c1`), sha256 `a3589d1d…2caba`, bundle `17066273…fb5c76`. **Superseded by v0.158.1 —
do not vouch** (its mkfs wrapper trusted a sibling claim the agent can fake; its helper failed open).
## v0.157.0 — every storage place knows its physical disk(s); POST /disks/same-disk (2026-10-15) ## v0.157.0 — every storage place knows its physical disk(s); POST /disks/same-disk (2026-10-15)
Design: felhom.eu `architecture/16-storage-physical-identity.md` §3, §4, §10 (operator rulings D1–D6, 2026-10-15). Spike: Design: felhom.eu `architecture/16-storage-physical-identity.md` §3, §4, §10 (operator rulings D1–D6, 2026-10-15). Spike:
+6
View File
@@ -1,5 +1,11 @@
# CONTEXT — felhom-agent working state # CONTEXT — felhom-agent working state
> **2026-10-16 — v0.158.1 RELEASED and VOUCHED (storage identity build B; 0.158.0 superseded, do not vouch).** The internal
> data place (root helper `felhom-internal-place`, the record in the state dir, mounted at /mnt/belso like a drive), the
> signed `add_internal_place`, partitions in `/disks/candidates` (`formattable` only while nothing on the disk is mounted),
> the partition wipe id `byid:<…-partN>+fsuuid:<uuid>`, the strict mkfs wrapper. Delivered through step bundle
> `0.158.1-step1` (a bundle that adds a path, R16). Next: R-1093 (an empty part's probe reads the parent's partition table).
> **2026-10-15 (night) — v0.157.0 RELEASED, NOT VOUCHED (storage identity build A, felhom.eu `16` §13).** Every storage > **2026-10-15 (night) — v0.157.0 RELEASED, NOT VOUCHED (storage identity build A, felhom.eu `16` §13).** Every storage
> place carries `kind`, `physical_disks`, `identity` (+ reason) in `GET /disks` and the hub report; `POST /disks/same-disk` > place carries `kind`, `physical_disks`, `identity` (+ reason) in `GET /disks` and the hub report; `POST /disks/same-disk`
> answers the controller (v0.317.0, MinAgent 0.157.0). Computed on every read, never cached. Delivered by signed jobs to > answers the controller (v0.317.0, MinAgent 0.157.0). Computed on every read, never cached. Delivered by signed jobs to
+7 -38
View File
@@ -1,43 +1,12 @@
# REPORT — v0.157.0: every storage place knows its physical disk(s); POST /disks/same-disk (2026-10-15) # REPORT — the agent on Go 1.27.2 (R-972, 2026-10-16) — NOT released
Written in ASD-STE100 Simplified Technical English. Design read first: felhom.eu `architecture/16-storage-physical-identity.md` Written in ASD-STE100 Simplified Technical English. Part of burndown 2 (felhom.eu `REPORT-burndown2-2026-10-16.md`).
(§3, §4, §8, §10; ACCEPTED 2026-10-15, §13 = what was built), `03-host-agent.md`. Task: storage identity build A (operator
rulings D1–D6). Controller counterpart: felhom-controller v0.317.0 (MinAgent 0.157.0); hub v0.154.0 shows the fields.
## What changed - **What changed:** `go.mod` `go 1.27.2` and golang.org/x/crypto v0.58.0. No code change.
- **Proof:** `go build ./...`, `go vet ./...`, `go test ./...` rc 0 on Go 1.27.2; `agent_gates.py --fast` all OK.
- `internal/storage/identity.go`: the physical disk(s) under a place, from `/sys`, `/run/udev/data`, `/proc/mounts` only - **Not done, on purpose:** no release and no deploy — agent releases wait for the operator's vouch. R-972 stays
(no block I/O). `proven`, or `unknown` with `vm` / `network` / `usb_bridge` / `weak_wwn` / `no_serial`. NARROWED for the agent: the next vouched release carries the new Go.
- `GET /disks` rows and the hub report's `storage_targets` gain `kind`, `physical_disks`, `identity`, `identity_reason`.
- `POST /disks/same-disk` `{a, b}` → `{different, proven, reason, a, b}`; scoped like every `/disks` call; a path the agent
did not mount itself is `unmapped`.
- `configs/` unchanged.
## Tests and red-proofs
Fixture `/sys` + udev trees for every spike case and five same-disk handler tests. Seen failing first: the LVM thin
„diamond" (tmeta/tdata under one pool) read as a failure — fixed by counting a seen node once. Broken on purpose and seen
failing: the VM rule, the vendor-less WWN rule, the own-mount rule (an absent drive), the `sys_drive` mapping.
`python3 scripts/agent_gates.py`: all OK.
## Release and delivery
- `scripts/release-agent.sh 0.157.0`: tag `v0.157.0`, sha256 `addd4041…0aef`, bundle `0604bc60…397a`.
- Delivered by signed `agent_update` jobs to Tester 1, demo-hp and the N100 (the three test boxes). **NOT VOUCHED** — the
operator's act. Until the vouch, the global floor cannot declare MinAgent 0.157.0.
## Live proof (method: the agent's local API on each PVE host, with the guest's own token)
| Box | `GET /disks` | same-disk: drive vs system data |
|---|---|---|
| demo-hp | `local`/`local-lvm` proven (SATA SSD), NVMe drive proven (`eui:`), PBS `network` | different, proven |
| N100 | system proven (SATA SSD serial), USB drive proven (the bridge passes the real serial) | different, proven |
| Tester 1 (VM) | every place `unknown/vm` | not proven (`vm`) |
On all three: system data vs `/var/lib/docker` → same disk, proven; a drive name that is not mounted → `unmapped`. The hub
stored the same fields (read from its database with the WAL). Evidence: felhom.eu `audits/storage-identity-buildA-2026-10-15/`.
## Observations ## Observations
1. FILED: R-1083 — scratch box 9202 has no `local_api` in its `bootstrap.json`, so it cannot use any agent call. 1. The release script needs no change: it builds with the toolchain `go.mod` names. NOT-A-FINDING: GOTOOLCHAIN=auto on DooPlex fetched go1.27.2 for this build and will for a release build.
2. NOT-A-FINDING: `usb_bridge` is built from the design's rule and was not seen on a real enclosure; that is R-1077, already open.
+1 -1
View File
@@ -44,7 +44,7 @@
| `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 | | `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 | | `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 <planned GiB>` | 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 | | `configs/felhom-internal-place` | configs/ (bundle → /usr/local/sbin) | `plan\|create <planned GiB>` | 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 | | `storage.candidatePartitions` / `CandidatePartition` | internal/storage/partitions.go | `CandidateDisk.Partitions` | partitions as places (`16` §6): empty / filesystem / in_use per part of a free disk; `Formattable` only while no node of the disk is mounted | A part with children or a member type is never listed; a raw /mnt mount counts as Felhom's only when corroborated (felhomOwnedMounts) — for LISTING only: the root mkfs wrapper trusts no such claim (v0.158.1). Pinned by partitions_test.go |
| partition wipe id `byid:<…-partN>+fsuuid:<uuid>` | 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 | | partition wipe id `byid:<…-partN>+fsuuid:<uuid>` | 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 | | `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` | | `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` |
+19 -4
View File
@@ -55,15 +55,19 @@ 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 # 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. # snapshots (snap_*_vzdump), which exist only during a whole-box copy and are what the reserve is for.
promised_b=0; have_lv=false # FAIL CLOSED: a failed or empty listing must never read as „nothing promised" (that would size the place too big).
listing=$(lvs --noheadings --nosuffix --units b -o lv_name,pool_lv,lv_size "$VG" 2>/dev/null) || die "cannot list the volumes of $VG"
pool_seen=false; promised_b=0; have_lv=false
while read -r name pl size; do while read -r name pl size; do
[[ -n "${name:-}" ]] || continue [[ -n "${name:-}" ]] || continue
[[ "$name" == "$POOL" ]] && pool_seen=true
[[ "$pl" == "$POOL" ]] || continue [[ "$pl" == "$POOL" ]] || continue
if [[ "$name" == "$LV_NAME" ]]; then have_lv=true; continue; fi if [[ "$name" == "$LV_NAME" ]]; then have_lv=true; continue; fi
[[ "$name" =~ ^snap_.*_vzdump$ ]] && continue [[ "$name" =~ ^snap_.*_vzdump$ ]] && continue
[[ "$size" =~ ^[0-9]+$ ]] || die "cannot read the size of $VG/$name" [[ "$size" =~ ^[0-9]+$ ]] || die "cannot read the size of $VG/$name"
promised_b=$(( promised_b + size )) promised_b=$(( promised_b + size ))
done < <(lvs --noheadings --nosuffix --units b -o lv_name,pool_lv,lv_size "$VG" 2>/dev/null) done <<< "$listing"
$pool_seen || die "the listing of $VG does not contain the pool $POOL — refusing to guess what is promised"
promised_gib=$(( (promised_b + 1073741823) / 1073741824 )) # rounded UP: never promise a partial GiB twice 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 reserve=$(( pool_gib / 10 )); (( reserve < 16 )) && reserve=16; (( reserve > 48 )) && reserve=48
@@ -79,6 +83,17 @@ 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_uuid() { blkid -p -s UUID -o value "$DEV" 2>/dev/null; }
fs_type() { blkid -p -s TYPE -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; } fs_label() { blkid -p -s LABEL -o value "$DEV" 2>/dev/null; }
# has_signature: FAIL CLOSED. blkid -p exits 0 when it found something and 2 when it found NOTHING; any other result (an
# error, an ambivalent probe, blkid missing) must never read as „empty" — that is the only gate in front of mkfs.
has_signature() {
local rc=0
blkid -p -o value "$DEV" >/dev/null 2>&1 || rc=$?
case $rc in
0) return 0 ;;
2) return 1 ;;
*) die "cannot probe $DEV for a signature (blkid exit $rc) — refusing" ;;
esac
}
lv_gib() { gib_floor "$(lvs --noheadings --nosuffix --units b -o lv_size "$VG/$LV_NAME" 2>/dev/null | tr -d ' ')"; } lv_gib() { gib_floor "$(lvs --noheadings --nosuffix --units b -o lv_size "$VG/$LV_NAME" 2>/dev/null | tr -d ' ')"; }
if $have_lv; then if $have_lv; then
@@ -89,12 +104,12 @@ if $have_lv; then
echo "existing uuid=$(fs_uuid) gib=$(lv_gib)"; exit 0 echo "existing uuid=$(fs_uuid) gib=$(lv_gib)"; exit 0
fi fi
# A felhom-belso with NO signature: a crash between lvcreate and mkfs. Nothing can be on it — format it. # 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" has_signature && die "$DEV carries a signature blkid cannot name — left alone"
else 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; } (( 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" 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" 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" has_signature && die "the new $DEV already carries a signature — left alone"
made=created made=created
fi fi
mkfs.ext4 -q -L "$LABEL" "$DEV" >/dev/null || die "mkfs.ext4 failed on $DEV" mkfs.ext4 -q -L "$LABEL" "$DEV" >/dev/null || die "mkfs.ext4 failed on $DEV"
+7 -15
View File
@@ -58,31 +58,23 @@ fi
# 3) mounted OUTSIDE Felhom's own drive area = a live foreign filesystem → catastrophic. Mounts under # 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. # /mnt/felhom-drives are our own drives (the agent detaches before a re-init) → allowed.
# Agent v0.158.0 (felhom.eu architecture/16 §6, partitions as places): a SIBLING partition that is Felhom's own # Agent v0.158.0: read from the kernel's mount table for EVERY node of the disk (lsblk's MOUNTPOINT shows one mount
# enrolled drive is mounted TWICE — raw at /mnt/<name> and under /mnt/felhom-drives (R-220). That raw mount is # per node; a bind elsewhere hid behind it), and a disk whose nodes cannot be listed REFUSES (it passed before).
# allowed only when the same device is ALSO mounted under /mnt/felhom-drives, a state only Felhom's enrolment # Deliberately NO exemption for a sibling that is Felhom's own enrolled drive (raw /mnt/<name> + /mnt/felhom-drives):
# produces. The TARGET itself mounted anywhere outside /mnt/felhom-drives, or any mount elsewhere (/srv, /media, # the only corroboration available here is a mount the agent itself can make, so trusting it would let a compromised
# /mnt/a/b), still REFUSES. Read from the kernel's mount table, every node of the disk (partitions and children). # agent unlock a host owner's disk. Consequence (felhom.eu `16` §13): the free part next to an in-use Felhom drive
# cannot be formatted; the dashboard does not offer it.
PROC_MOUNTS=/proc/mounts PROC_MOUNTS=/proc/mounts
node_mounts() { awk -v d="$1" '$1 == d { print $2 }' "$PROC_MOUNTS"; } node_mounts() { awk -v d="$1" '$1 == d { print $2 }' "$PROC_MOUNTS"; }
disk_nodes="$(lsblk -nrpo NAME "$whole" 2>/dev/null || true)" disk_nodes="$(lsblk -nrpo NAME "$whole" 2>/dev/null || true)"
[[ -n "$disk_nodes" ]] || die "cannot list the nodes of $whole — refusing" [[ -n "$disk_nodes" ]] || die "cannot list the nodes of $whole — refusing"
while read -r node; do while read -r node; do
[[ -z "$node" ]] && continue [[ -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 while read -r mp; do
[[ -z "$mp" ]] && continue [[ -z "$mp" ]] && continue
case "$mp" in case "$mp" in
/mnt/felhom-drives|/mnt/felhom-drives/*) : ;; /mnt/felhom-drives|/mnt/felhom-drives/*) : ;;
*) *) die "device (or a partition) is mounted at $mp ($node)" ;;
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 esac
done < <(node_mounts "$node") done < <(node_mounts "$node")
done <<< "$disk_nodes" done <<< "$disk_nodes"
+42 -4
View File
@@ -14,12 +14,16 @@ GIB = 1 << 30
STUB_LVS = r'''#!/usr/bin/env python3 STUB_LVS = r'''#!/usr/bin/env python3
import json, os, sys import json, os, sys
st = json.load(open(os.environ["IP_STATE"])) st = json.load(open(os.environ["IP_STATE"]))
if st.get("lvs_fail_listing") and "lv_name,pool_lv,lv_size" in sys.argv: sys.exit(5)
args = [a for a in sys.argv[1:] if not a.startswith("-") and a not in ("b",)] 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(",") fields = sys.argv[sys.argv.index("-o") + 1].split(",")
target = args[-1] target = args[-1]
listing = target == st["vg"]
for lv in st["lvs"]: for lv in st["lvs"]:
full = st["vg"] + "/" + lv["name"] full = st["vg"] + "/" + lv["name"]
if target == st["vg"] or target == full: if listing and st.get("hide_pool_in_listing") and lv.get("pool", "") == "":
continue # a listing that silently lost the pool row (the pool's own size stays readable)
if listing or target == full:
row = {"lv_name": lv["name"], "pool_lv": lv.get("pool", ""), "lv_size": str(lv["size"])} row = {"lv_name": lv["name"], "pool_lv": lv.get("pool", ""), "lv_size": str(lv["size"])}
print(" " + " ".join(row[f] for f in fields)) print(" " + " ".join(row[f] for f in fields))
''' '''
@@ -35,15 +39,18 @@ if st.get("dirty_new"): st["fs"]["felhom-belso"] = {"TYPE": "", "RAW": "junk"}
json.dump(st, open(p, "w")) json.dump(st, open(p, "w"))
''' '''
STUB_BLKID = r'''#!/usr/bin/env python3 STUB_BLKID = r'''#!/usr/bin/env python3
# like blkid -p: exit 0 when something is found, 2 when nothing is; st["blkid_rc"] forces an error exit
import json, os, sys import json, os, sys
st = json.load(open(os.environ["IP_STATE"])) st = json.load(open(os.environ["IP_STATE"]))
if st.get("blkid_rc"): sys.exit(st["blkid_rc"])
fs = st["fs"].get(os.path.basename(sys.argv[-1]), {}) fs = st["fs"].get(os.path.basename(sys.argv[-1]), {})
if "-s" in sys.argv: if "-s" in sys.argv:
v = fs.get(sys.argv[sys.argv.index("-s") + 1], "") v = fs.get(sys.argv[sys.argv.index("-s") + 1], "")
if v: print(v) if v: print(v)
else: sys.exit(0 if v else 2)
vals = [fs.get(k, "") for k in ("TYPE", "LABEL", "UUID", "RAW") if fs.get(k)] vals = [fs.get(k, "") for k in ("TYPE", "LABEL", "UUID", "RAW") if fs.get(k)]
if vals: print("\n".join(vals)) if vals: print("\n".join(vals))
sys.exit(0 if vals else 2)
''' '''
STUB_MKFS = r'''#!/usr/bin/env python3 STUB_MKFS = r'''#!/usr/bin/env python3
import json, os, sys import json, os, sys
@@ -213,5 +220,36 @@ class Create(unittest.TestCase):
b.close() b.close()
class FailsClosed(unittest.TestCase):
"""A read that fails must never read as „empty" or „nothing promised". Positive control: Create (same setups)."""
def test_a_failed_volume_listing_creates_nothing(self):
b = Box(349, lvs_fail_listing=True)
try:
rc, out, err = b.run("create", "132")
self.assertEqual(rc, 1, out)
self.assertEqual(b.calls(), [])
finally:
b.close()
def test_a_listing_without_the_pool_creates_nothing(self):
b = Box(349, [thin("vm-9201-disk-1", 250)], hide_pool_in_listing=True)
try:
self.assertEqual(b.run("create", "132")[0], 1)
self.assertEqual(b.calls(), [])
finally:
b.close()
def test_a_blkid_error_never_reads_as_empty(self):
for setup in ({"lvs": [thin("felhom-belso", 183)], "fs": {}}, {}):
b = Box(349, **({"lvs": setup["lvs"], "fs": setup["fs"]} if setup else {}), blkid_rc=4)
try:
rc, out, err = b.run("create", "0" if setup else "132")
self.assertEqual(rc, 1, (setup, out))
self.assertNotIn("mkfs", " ".join(b.calls()), setup)
finally:
b.close()
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+13 -8
View File
@@ -1,9 +1,10 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Tests for configs/felhom-mkfs-guarded.sh — the ONLY mkfs path the agent's sudoers permits. """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 First tests of this wrapper (agent v0.158.0), written for partitions as places (felhom.eu architecture/16 §6). Every
architecture/16 §6): a SIBLING partition that is Felhom's own enrolled drive (mounted raw at /mnt/<name> AND under mount of every node of the disk outside /mnt/felhom-drives refuses — INCLUDING a sibling that is Felhom's own drive
/mnt/felhom-drives) no longer blocks formatting the empty partition next to it; every other mount still refuses. (its raw /mnt/<name> mount): the agent can make that corroborating mount itself, so the root wrapper must not trust
it (security review of 912f7c1). An unlistable disk refuses.
HOW IT RUNS ANYWHERE: the wrapper is copied with five literal host paths replaced (each replacement is asserted, so a 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 drifted wrapper fails here instead of silently testing nothing): /proc/mounts → a fake mount table, the block-device
@@ -110,8 +111,12 @@ class Partitions(unittest.TestCase):
finally: finally:
d.close() d.close()
def test_the_empty_part_next_to_felhoms_own_drive_is_formatted(self): def test_a_free_part_on_an_unmounted_disk_is_formatted(self):
self.check(OWN_SDB1) self.check([]) # positive control for every refusal below
def test_the_free_part_next_to_felhoms_own_drive_is_refused(self):
# The agent could make the „corroborating" managed mount itself: the root wrapper does not trust it.
self.check(OWN_SDB1, want_ok=False)
def test_a_sibling_mounted_raw_only_is_refused(self): def test_a_sibling_mounted_raw_only_is_refused(self):
self.check([("/dev/sdb1", "/mnt/hdd_1")], want_ok=False) self.check([("/dev/sdb1", "/mnt/hdd_1")], want_ok=False)
@@ -131,9 +136,9 @@ class Partitions(unittest.TestCase):
def test_the_catastrophic_cases_still_refuse(self): def test_the_catastrophic_cases_still_refuse(self):
self.check([("/dev/sdb1", "/")], want_ok=False) # system disk 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([], 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([], pvs="/dev/sdb1", want_ok=False) # an LVM PV (pvs)
self.check(OWN_SDB1, ro="1", want_ok=False) # read-only self.check([], ro="1", want_ok=False) # read-only
if __name__ == "__main__": if __name__ == "__main__":
+5 -4
View File
@@ -1,7 +1,8 @@
module gitea.dooplex.hu/admin/felhom-agent module gitea.dooplex.hu/admin/felhom-agent
go 1.25.0 go 1.27.2
require golang.org/x/crypto v0.52.0 require (
golang.org/x/crypto v0.58.0
require golang.org/x/sys v0.45.0 // indirect golang.org/x/sys v0.49.0
)
+6 -6
View File
@@ -1,6 +1,6 @@
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
+20 -1
View File
@@ -13,7 +13,8 @@ import (
// A disk that passed the WHOLE-disk unclaimed filter (classifyClaim: not the system disk, no LVM/ZFS/RAID/LUKS/swap // 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: // 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) // empty no filesystem, no children → „format this part" (self-serve: the agent's probe says blank) —
// only while NO part of the disk is mounted (Formattable)
// filesystem ext4/xfs (mountable) or another fs → „use this part" when mountable; „format this part" only through // 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) // 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) // in_use already Felhom's own drive → shown, never offered (it is a registered drive already)
@@ -32,6 +33,10 @@ type CandidatePartition struct {
Mountable bool `json:"mountable"` Mountable bool `json:"mountable"`
DurableID string `json:"durable_id,omitempty"` // uuid:<fs-uuid> — the attach key ("" when empty) DurableID string `json:"durable_id,omitempty"` // uuid:<fs-uuid> — the attach key ("" when empty)
MountedAt string `json:"mounted_at,omitempty"` // in_use: Felhom's own mount MountedAt string `json:"mounted_at,omitempty"` // in_use: Felhom's own mount
// Formattable is false when ANY node of the disk is mounted (another part in use): the root mkfs wrapper refuses
// every mount outside /mnt/felhom-drives on the disk and deliberately does not trust a sibling that claims to be
// Felhom's own (a compromised agent could fake that). Such a part can still be USED (attach erases nothing).
Formattable bool `json:"formattable"`
} }
const ( const (
@@ -55,6 +60,19 @@ func candidatePartitions(lsblkJSON []byte, wholeName string, sizeOf func(string)
if d.Name != wholeName { if d.Name != wholeName {
continue continue
} }
diskMounted := d.MountPoint != ""
var walk func(n lsblkDev)
walk = func(n lsblkDev) {
if n.MountPoint != "" {
diskMounted = true
}
for _, c := range n.Children {
walk(c)
}
}
for _, c := range d.Children {
walk(c)
}
for _, p := range d.Children { for _, p := range d.Children {
if len(p.Children) > 0 || memberFSTypes[p.FSType] { 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) continue // LVM/mapper on it, or a member: not a place (the disk filter refuses these anyway)
@@ -74,6 +92,7 @@ func candidatePartitions(lsblkJSON []byte, wholeName string, sizeOf func(string)
c.DurableID = "uuid:" + u c.DurableID = "uuid:" + u
} }
} }
c.Formattable = c.State != PartInUse && !diskMounted
out = append(out, c) out = append(out, c)
} }
} }
+18
View File
@@ -71,3 +71,21 @@ func TestPartitions_UncorroboratedMountIsNotOffered(t *testing.T) {
} }
} }
} }
// The root wrapper refuses any format on a disk with a mounted node (and trusts no „it is Felhom's own" claim), so a
// part next to a mounted one is never offered for formatting — only for use. Positive control: an unmounted disk.
func TestPartitions_FormattableOnlyWhenNothingOnTheDiskIsMounted(t *testing.T) {
m := listParts(map[string]bool{"/mnt/films": true})
for dev, p := range m {
if p.Formattable {
t.Fatalf("%s: a part of a disk with a mounted part must not be formattable: %+v", dev, p)
}
}
free := `{"blockdevices":[{"name":"sdc","fstype":null,"mountpoint":null,"children":[
{"name":"sdc1","fstype":null,"mountpoint":null},{"name":"sdc2","fstype":"ext4","mountpoint":null}]}]}`
got := candidatePartitions([]byte(free), "sdc", func(string) int64 { return 1 << 30 }, func(string) int { return 1 },
func(string) (string, bool) { return "u", true }, nil)
if len(got) != 2 || !got[0].Formattable || !got[1].Formattable {
t.Fatalf("parts of an unmounted disk are formattable, got %+v", got)
}
}
+4
View File
@@ -69,6 +69,10 @@ fi
existing="$(curl -fsS -o /dev/null -w '%{http_code}' \ existing="$(curl -fsS -o /dev/null -w '%{http_code}' \
"$GITEA_BASE/api/packages/$GITEA_OWNER/generic/felhom-agent/$VERSION/felhom-agent" 2>/dev/null || true)" "$GITEA_BASE/api/packages/$GITEA_OWNER/generic/felhom-agent/$VERSION/felhom-agent" 2>/dev/null || true)"
[[ "$existing" != "200" ]] || die "version $VERSION is ALREADY PUBLISHED — bump the version instead" [[ "$existing" != "200" ]] || die "version $VERSION is ALREADY PUBLISHED — bump the version instead"
# R-1098 (2026-10-16): only a clean 404 proves the version is new. Until now a registry that could not be reached
# (curl prints 000) or answered anything else passed this check — „I could not ask" is not that proof.
# Test: scripts/test_release_push_once.sh.
[[ "$existing" == "404" ]] || die "could not establish whether $VERSION is published (registry answered HTTP '${existing:-none}') — refusing"
# ── 3. Build ──────────────────────────────────────────────────────────────────────────────────── # ── 3. Build ────────────────────────────────────────────────────────────────────────────────────
BIN="$(mktemp -t felhom-agent-XXXXXX)" BIN="$(mktemp -t felhom-agent-XXXXXX)"
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# test_release_push_once.sh — release-agent.sh releases a version only when the registry says it is NEW (R-1098).
#
# The REAL release-agent.sh runs from a copy inside a scratch git repo (so its tag check reads scratch tags), with
# RELEASE_ALLOW_DIRTY=1 (the clean-tree gate has its own history) and a fake `curl` and `go` first on PATH: curl answers
# the package probe as FAKE_MODE says, and `go` stops the run right after the check, so nothing is built or published.
# published (200) -> refused, "ALREADY PUBLISHED"
# absent (404) -> passes the check, reaches the build
# unreachable (000) -> refused, "could not establish"
# server error (500) -> refused, "could not establish"
# the tag already exists -> refused, "already exists"
# Run: bash scripts/test_release_push_once.sh (exit 0 all cases right · 1 a case wrong)
set -uo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
WS="$(mktemp -d)"; trap 'rm -rf "${WS:?}"' EXIT
fails=0; ran=0
mkdir -p "$WS/bin" "$WS/repo/scripts"
cp "$HERE/release-agent.sh" "$WS/repo/scripts/release-agent.sh"
(cd "$WS/repo" && git init -q -b main && git -c user.name=t -c user.email=t@t commit -q --allow-empty -m init)
cat > "$WS/bin/curl" <<'FAKE'
#!/bin/bash
case "${FAKE_MODE}" in
published) printf 200; exit 0 ;;
absent) printf 404; exit 22 ;;
down) printf 000; exit 7 ;;
error) printf 500; exit 22 ;;
esac
FAKE
printf '#!/bin/sh\necho "FAKE-GO-REACHED $*"; exit 1\n' > "$WS/bin/go"
chmod +x "$WS/bin/curl" "$WS/bin/go"
run() { # name, mode, expected rc, required fragment, forbidden fragment
local out rc
out=$(cd "$WS/repo" && FAKE_MODE="$2" RELEASE_ALLOW_DIRTY=1 GITEA_USER=u GITEA_TOKEN=t PATH="$WS/bin:$PATH" \
bash scripts/release-agent.sh 9.9.9 2>&1); rc=$?
ran=$((ran+1))
if [[ "$rc" != "$3" ]] || ! grep -qi -- "$4" <<<"$out" || { [[ -n "$5" ]] && grep -qi -- "$5" <<<"$out"; }; then
fails=$((fails+1)); echo " FAIL $1 (exit $rc)"; echo "$out" | tail -4 | sed 's/^/ /'; return
fi
echo " ok $1"
}
echo "release-agent.sh — a version is released once, and only when the registry says it is new"
run "published (200) -> refused" published 1 "ALREADY PUBLISHED" "FAKE-GO-REACHED"
run "absent (404) -> proceeds" absent 1 "FAKE-GO-REACHED" "could not establish"
run "unreachable (000) -> refused" down 1 "could not establish" "FAKE-GO-REACHED"
run "server error (500) -> refused" error 1 "could not establish" "FAKE-GO-REACHED"
(cd "$WS/repo" && git tag v9.9.9)
run "the tag exists -> refused" absent 1 "already exists" "FAKE-GO-REACHED"
echo
if [[ $fails -eq 0 ]]; then echo "all $ran cases behaved"; exit 0; fi
echo "$fails of $ran cases FAILED"; exit 1