row 240: installer I4, root's host check, its pins and the boot unit; I3's N1, N4 and N5 (#1746, Vikunja #432)
Lands Darkwing's round 1 candidate, manifest 573ece95a4a9 (ad1b23ab2on darkwing/row240-r3, basec771a36f4), approved by Filbert (29945, rev 2696). Nine paths: scripts/case1-hostcheck.sh (new), scripts/case1-pins.sh (new), packages/cli/systemd/mosaic-hostcheck.service (new), packages/cli/tests/case1-hostcheck.test.mjs (new), scripts/stack-upgrade.sh, packages/cli/tests/stack-upgrade.test.mjs, docs/guides/host-run-sheet.md, docs/TOOLS.md and agents/darkwing/work/queue-240/BUILD.md. File modes match the candidate. case1-hostcheck.sh and stack-upgrade.sh are 100755. case1-pins.sh is 100644, because the host check sources it. Evidence: Darkwing's first slot run failed one test, the N1 test's after hook (ENOENT once the fixture had removed its root). The fixb45852daais test-only. The second run onb45852daawas green: singles 37/0, mutants M1-M8 8 of 8 killed, cli 639/0, scripts 585/0. Sincec771a36f4no commit touches the nine paths. Sage's gate on060a17b54plus the nine paths (06:45:05Z to 06:56:21Z): packages/cli 639 pass, 0 fail, and all ten scripts/test-*.sh 585 pass, 0 fail (out/, run.log). `queue review verify-commit 240` matches the manifest on all 9 paths. Filbert's notes: N1 (the boot unit orders only mosaic-bus@mosaic-stack) and N2 (the run sheet writes the apt hook before the first --check) are in docs/plans/DEFERRED.md. N3, repeated DRIFT lines after bwrap is closed, is by design and is noted with N2.
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
# Row 240 (I4): root's host check, its pins and the boot unit
|
||||
|
||||
Brief: `docs/plans/2026-10-20_installer-rows.md` section I4, blob `4a5a65020f00` (commit b00d7d94d, rev 2665,
|
||||
the pin row 240 carries; the I3 carry items came in with it). Sage split it out of I3 (sage-1650). Gitea #1746, Vikunja P19 #432. Owner Darkwing, reviewer Filbert, gate owner Sage.
|
||||
Design: row 195 DESIGN r6 B2, `agents/darkwing/work/queue-195/DESIGN.md` at b7239bb66, :262-303 (the script,
|
||||
the three callers) and :839-860 (files, test cases, mutants). Where the brief and r6 differ, the brief wins.
|
||||
|
||||
## Files
|
||||
|
||||
Drafted on `darkwing/row240-r1` from d99166ade (row 233's candidate) while row 233's gate ran, then applied
|
||||
as one patch onto row 233's landing, 57f59e0c3, on `darkwing/row240-r2` (sage-1753). It applied cleanly; between
|
||||
the two bases the only change on this row's paths is 233's N1 test fix in `stack-upgrade.test.mjs`. Row 234
|
||||
then landed as c771a36f4 on 57f59e0c3. Sage preferred that the gate run with it in the tree (sage-1773), so both
|
||||
commits were picked onto c771a36f4 as `darkwing/row240-r3`. That was a clean pick: the 42 paths changed from
|
||||
57f59e0c3 to c771a36f4 share none with this row's 9.
|
||||
|
||||
- `scripts/case1-pins.sh` (new, 0644): `PIN_PROFILES_VERSION`, `PIN_PROFILE_SHA256`, `PIN_BUBBLEWRAP_VERSION`,
|
||||
r6 section 0's values.
|
||||
- `scripts/case1-hostcheck.sh` (new, 0755): the seven items and `--check`, `--enforce`, `--upstream`.
|
||||
- `packages/cli/systemd/mosaic-hostcheck.service` (new): the boot oneshot.
|
||||
- `packages/cli/tests/case1-hostcheck.test.mjs` (new, 16 tests). The cli suite's `tests/*.test.mjs` glob picks
|
||||
it up.
|
||||
- `docs/guides/host-run-sheet.md` section 4: the install lines that replace "Install them from row 240's run
|
||||
sheet lines once it lands", plus rollback, drift recovery and `--upstream` before a re-pin window. Sections
|
||||
3, 6, 8 and 10 for the I3 carry items below.
|
||||
- `scripts/stack-upgrade.sh` and `packages/cli/tests/stack-upgrade.test.mjs`: I3 N1 below.
|
||||
- `docs/TOOLS.md`: a new subsection "Root's host check (`scripts/case1-hostcheck.sh`, row 240)" after the
|
||||
upgrade driver's.
|
||||
|
||||
Brief line 257 said I3's `stack-upgrade.sh` doesn't change. Sage's ruling sage-1727 widened the row to it for
|
||||
N1 alone. Its default `STACK_UPGRADE_HOSTCHECK=/usr/local/lib/mosaic-stack/hostcheck.sh` is where the run
|
||||
sheet installs this script.
|
||||
|
||||
## Carried from I3
|
||||
|
||||
Filbert's row 233 approve (Gitea 29922) left five notes. Sage ruled N1, N4 and N5 into this row and N2 and N3
|
||||
to DEFERRED.md (sage-1727).
|
||||
|
||||
| Note | Change |
|
||||
|---|---|
|
||||
| N1: `stack-upgrade.sh` exits 1 `args-failed` when the args move after A5 fails, but exit 1 means the old copy runs | The move's failure exits 5 `args-failed`. The header, TOOLS.md's step 5 and exit line, and run sheet section 6's exit list name exit 5 and what root does: move the new file the result line names into place. Test `row 240: I3 N1: a failed args move after A5 exits 5 ...` makes `/etc/mosaic-stack` read-only at confirm and checks exit 5, the step names, `args rc=1`, the result line, both units on the new copy, the old args file's bytes and the new file beside it. The step log regex takes `exit=5`. Mutant M8. |
|
||||
| N4: section 3's `npm install` has no npm on root's PATH, and the `--claude` path is unnamed while `node_modules/.bin/claude` is a symlink `case2-unit.sh:140` refuses | The install line runs with section 2's pinned node first on PATH (the postinstall runs `node` too). The `--claude` path is `node_modules/@anthropic-ai/claude-code/bin/claude.exe`, and a `--version` line catches the postinstall's 500-byte stub. |
|
||||
| N5: sections 8 and 10 lack precheck lines | Each gets "Precheck line: none" and its missing line. |
|
||||
|
||||
Exit 5 is new rather than 2 because 2 means both units are stopped, and here both run. Exit 4 stays usage.
|
||||
|
||||
The `claude.exe` path comes from the 2.1.304 package itself: `npm view` gives `bin.claude` as `bin/claude.exe`,
|
||||
and its `install.cjs` hardlinks the platform binary over that placeholder, or copies it across devices, then
|
||||
sets 0755. I downloaded the tarball into `/mnt/storage/scratch/tmp/r240/cc304` and read it; nothing ran. The
|
||||
run sheet says a newer pin needs the same check.
|
||||
|
||||
## The brief, item by item
|
||||
|
||||
| Brief line | Where |
|
||||
|---|---|
|
||||
| 247 `case1-pins.sh` holds the three pins | `scripts/case1-pins.sh`; the pins test checks the values against r6's |
|
||||
| 248-249 seven items, `--check` exits 0 or 1 | `items()` and the final dispatch; the check tests |
|
||||
| 250-251 `--enforce`: `apparmor_parser -R`, `root:root 0700`, `DRIFT`, a log line, exit 0 | `enforce()` and `stop_bwrap()`; the four enforce tests |
|
||||
| 252 `--upstream` compares the current deb to the pin | `upstream()`; the upstream test |
|
||||
| 253 the boot oneshot | `mosaic-hostcheck.service`; the boot unit test |
|
||||
| 254-255 fakes for every host tool, r6's seven case groups, seven mutants | the test file; Tests and Mutants below |
|
||||
| 257 the I3 run sheet item points here | run sheet section 4 |
|
||||
|
||||
## Tests
|
||||
|
||||
Fakes on PATH, each logging its argv to `$HC/calls.log`: `aa-status`, `dpkg`, `dpkg-statoverride`,
|
||||
`apparmor_parser`, `systemd-run`, `stat`, `chown`, `chmod`, `apt-get`, `dpkg-deb`. `host(t)` builds a scratch
|
||||
root (`/etc`, `/proc/sys`, `/usr/bin/bwrap`, `/var/log`) and a lib dir with a copy of the script and fixture
|
||||
pins that hold the fixture profile's sha256. Every run passes `--root`.
|
||||
|
||||
r6's case groups (:843-856) against the tests:
|
||||
|
||||
| r6 case | Tests |
|
||||
|---|---|
|
||||
| `--check` exits 0 with all seven, 1 with each failing alone, its line naming it | `check: exits 0 ...`, `check: exits 1 with each item failing alone ...`, plus one test per item for the item's edge cases |
|
||||
| item 3: a second file naming `bwrap` as a word, `/{usr/,}bin/bwrap` included; passes with the profile alone | `check: item 3 ...` |
|
||||
| item 4: `unpriv_bwrap (enforce)` or a trailing space fails; the argv carries `-p NoNewPrivileges=yes` and the 2a' properties | `check: item 4 ...` checks the whole argv |
|
||||
| item 7: the floor at 732 fails | `check: item 6 ...; item 7 ...` (733 passes as the control) |
|
||||
| `--enforce` exits 0 on a pass and every failure; unload, close, `DRIFT`, one log line; `DRIFT` present with every item passing still closes | `enforce: a pass ...`, `enforce: each failure ...`, `enforce: with DRIFT present ...` |
|
||||
| `--enforce` never calls `dpkg-statoverride` with `--update`, `--add` or `--remove` | `enforce: no unload ...; never writes the statoverride` (every call is `--list /usr/bin/bwrap`) |
|
||||
| a bubblewrap version other than the pin is a line, not a failure | `check: a bubblewrap version other than the install pin ...` |
|
||||
| `--upstream` against a differing deb prints the difference, exits 0 | `upstream: ...` (also: a match, a deb with no profile, a failed download) |
|
||||
| the unit: `Type=oneshot`, `After=apparmor.service`, `[email protected]`, runs `--enforce` | `boot unit: ...` |
|
||||
|
||||
The last test also checks that the repo script reads `scripts/case1-pins.sh` beside it, that bad usage exits
|
||||
2, and that a non-root run without `--root` refuses.
|
||||
|
||||
Singles are the two files that run a changed script: `case1-hostcheck.test.mjs` and `stack-upgrade.test.mjs`.
|
||||
Sage asked (sage-1759) whether `stack-release.test.mjs` or `stack-release-external.test.mjs`, which share
|
||||
`stack-release-fixture.mjs`, run `stack-upgrade.sh` or `case1-pins.sh`. They don't. `grep -cF` for
|
||||
`stack-upgrade`, `case1-pins`, `case1-hostcheck` and `hostcheck` gives 0 in all three files; the control
|
||||
`stack-release.sh` gives 2 in the fixture and 8 in `stack-release.test.mjs`. `scripts/stack-release.sh` names
|
||||
`stack-upgrade.sh` once, in a comment (:2061). The gate's cli suite runs both files anyway.
|
||||
|
||||
## Mutants
|
||||
|
||||
r6's seven (:858-860) and one for I3 N1, scripted in `~/darkwing-scratch/r240/mutants.sh` with r233's method: a checked backup,
|
||||
an exact replacement that must match once, `bash -n`, the named killer by `--test-name-pattern`, the restore
|
||||
and a sha256 check. A dry run matched all eight once each. Results below.
|
||||
|
||||
| Mutant | Change | Killer |
|
||||
|---|---|---|
|
||||
| M1 | `--enforce` exits 1 after the stop | `enforce: each failure exits 0 ...` |
|
||||
| M2 | `[ ! -e "$DRIFT" ] \|\| had=1` becomes `true \|\| had=1` | `enforce: with DRIFT present ...` |
|
||||
| M3 | item 1's `dpkg -S` becomes `owner=""; rc=1` | `check: item 1 fails when a package owns ...` |
|
||||
| M4 | item 3's `grep -rlw bwrap` becomes `grep -rlF /usr/bin/bwrap` | `check: item 3 ...` |
|
||||
| M5 | item 4's `[ "$label" = "$LABEL" ]` becomes a substring match | `check: item 4 ...` |
|
||||
| M6 | item 4 drops `-p NoNewPrivileges=yes` | `check: item 4 ...` (argv) |
|
||||
| M7 | item 7's `-gt` becomes `-ge` | `check: item 6 ...; item 7 fails with the floor at 732 ...` |
|
||||
| M8 | `stack-upgrade.sh`'s `finish 5 args-failed` becomes `finish 1 args-failed` (I3 N1) | `row 240: I3 N1: a failed args move ...` |
|
||||
|
||||
## Deviations
|
||||
|
||||
- Repo name `scripts/case1-hostcheck.sh` (r6), installed as `/usr/local/lib/mosaic-stack/hostcheck.sh`, the
|
||||
name in brief line 239 and I3's `stack-upgrade.sh` default. r6's apt hook line says `case1-hostcheck.sh`;
|
||||
the run sheet's hook uses the installed name.
|
||||
- `stat` is a fake too. r6 lists seven fakes; a test can't make a root-owned file, so item 5's `stat` reads a
|
||||
fixture.
|
||||
- Item 7 with no Console unit installed reads the port as 1023 and says so ("1023, because no Console unit
|
||||
is installed"). Until I2 installs the unit the floor must be above 1023. A unit with no `--port` fails.
|
||||
- Item 4 runs under `timeout 60`. A hung `systemd-run` would otherwise hang the apt hook and the boot unit.
|
||||
|
||||
## Decisions
|
||||
|
||||
- Fail closed where r6 is silent:
|
||||
- pins with no 64-hex sha fail item 1;
|
||||
- `dpkg -S` exit 1 (no owner) passes, exit 0 fails with the owner, any other exit fails with the code;
|
||||
- a failed `aa-status` fails item 2 and counts as loaded, so `--enforce` tries the unload.
|
||||
- `--root DIR` is the test seam (r6 :840). Root refuses it; a non-root user needs it. Both exit 2. Root runs
|
||||
with `PATH=/usr/sbin:/usr/bin:/sbin:/bin`.
|
||||
- `DRIFT` is append-only (`<stamp> <failed line>`), and `--enforce` never removes it. The log line is written
|
||||
only when the stop runs: `<stamp> enforce stop items=<ids or none> drift-before=<0|1> unload=<rc=N|no-file|skipped> chown=N chmod=N`.
|
||||
- `--upstream` exits 1 when the download or the unpack fails, since it then compared nothing. A deb without
|
||||
the profile file is a difference (exit 0), per r6 :302-303.
|
||||
- The bubblewrap note reads the version from `dpkg -s bubblewrap`.
|
||||
- The run sheet's recovery steps (`chown root:mosaic`, `chmod 0750`, `apparmor_parser -r`, then `--check`
|
||||
before root removes `DRIFT`) are mine. r6 says only that root removes `DRIFT` in a reviewed step.
|
||||
|
||||
## Run environment
|
||||
|
||||
Worktree `/mnt/storage/scratch/darkwing-r240b`, branch `darkwing/row240-r3` on c771a36f4, with `node_modules`
|
||||
linked to the canonical checkout's. Node v26.8.1, kernel 7.2.2-1-cachyos, 16 CPUs, TMPDIR
|
||||
`/mnt/storage/scratch/tmp/r240`, compose project `darkwing-r240b`. Sage's slot (sage-1773, 06:16Z). Each run is
|
||||
one chain behind `busy.sh`, launched from `/`: `~/darkwing-scratch/r240/run-all.sh` checks the HEAD pin and a
|
||||
clean tree, runs the singles, then the mutants (8 killed, none surviving, the tree clean after), then the gate
|
||||
(the cli suite and every `scripts/test-*.sh`), each stopping the chain on failure, then sweeps `/proc` cwd links
|
||||
for anything left in the r240 trees.
|
||||
|
||||
## Results
|
||||
|
||||
Run r1, 06:17:10Z on 47ec9fe2f, failed at the singles. `case1-hostcheck.test.mjs` passed 16/0/0, and
|
||||
`stack-upgrade.test.mjs` went 20 pass, 1 fail. The failure was the N1 test's own `t.after` hook, which ran
|
||||
`chmodSync(e.etc, 0o755)` after the fixture's cleanup had already removed the scratch root, so it threw ENOENT.
|
||||
Every assertion in the test body had passed, exit 5 included. The fixture's cleanup runs `chmod -R u+w` on the
|
||||
root before `rmSync`, so the 0555 dir never needed the hook. b45852daa drops it and changes nothing else. The
|
||||
mutants and the gate didn't run. The sweep found nothing left. Sage counted r1 as this slot's one failure
|
||||
(sage-1788).
|
||||
|
||||
Run r2, 06:20:41Z to 06:33:28Z on b45852daa, passed. The counts come from awk over the .out files
|
||||
(`~/darkwing-scratch/r233/sum.sh`):
|
||||
|
||||
| Step | Result |
|
||||
|---|---|
|
||||
| `case1-hostcheck.test.mjs` | 16/0/0 |
|
||||
| `stack-upgrade.test.mjs` | 21/0/0 |
|
||||
| c0 control | exit 0 |
|
||||
| M1-M8 | 8/8 killed, none survived, every restore's sha256 matched, tree clean after |
|
||||
| `packages/cli` suite | 639/0/0 |
|
||||
| `scripts/test-*.sh` | auth 15, conductor 17, config 31, discord 78, extension-package 18, foundation 44, land-work 86, queue 156 node + 28 selftest, release 14, task 98; 585 pass, 0 fail |
|
||||
| Gate total | 11 suites, 1224 pass, 0 fail |
|
||||
| Sweep | nothing left in the r240 trees; the compose network was removed |
|
||||
|
||||
This commit adds only these BUILD.md lines. No test reads BUILD.md.
|
||||
+36
-3
@@ -837,8 +837,9 @@ user's clone `<home>/src/mosaic-stack`:
|
||||
`--copy` (which also starts the bus, then the Console), A5 `confirm`.
|
||||
The driver writes the new args file beside the old one before A2
|
||||
(`args-failed`, exit 3) and moves it into place after A5. A failed move
|
||||
exits 1 `args-failed`: the new copy is live and confirmed, and root puts
|
||||
the file in place by hand.
|
||||
exits 5 `args-failed`: the new copy is live and confirmed, the args file
|
||||
still names the old one, and root moves the new file (the result line
|
||||
names it) into place by hand.
|
||||
|
||||
A failure in A2 or a stage that exits 3 starts the old units again and gates
|
||||
them (A2f). A later failure stops both units, runs `revert`, renders the old
|
||||
@@ -854,11 +855,43 @@ against a fixed pattern (`BACKUP_STORE none` otherwise). The step log
|
||||
`<time> result exit=N <code> <text>` line.
|
||||
|
||||
Exit 0 ok, 1 failed with the old stack running and gated, 2 rollback-failed
|
||||
with both units stopped, 3 refused with nothing stopped, 4 usage. A refusal
|
||||
with both units stopped, 3 refused with nothing stopped, 4 usage, 5
|
||||
args-failed with the new stack live and confirmed. A refusal
|
||||
after step 2 leaves the user's clone fast-forwarded and the new copy made. After exit
|
||||
2 the activation stays open, so the next run's plan refuses until root
|
||||
finishes it.
|
||||
|
||||
#### Root's host check (`scripts/case1-hostcheck.sh`, row 240)
|
||||
|
||||
```
|
||||
/usr/local/lib/mosaic-stack/hostcheck.sh --check | --enforce | --upstream
|
||||
```
|
||||
|
||||
Installed from `scripts/case1-hostcheck.sh` as `hostcheck.sh`, with
|
||||
`scripts/case1-pins.sh` beside it (`docs/guides/host-run-sheet.md` section 4).
|
||||
The pins are the profile's sha256, the `apparmor-profiles` version it came
|
||||
from and the bubblewrap install pin; changing one is a reviewed commit.
|
||||
Seven items, one `ok N` or `FAIL N` line each: the profile file matches the
|
||||
pin and no package owns it; `aa-status` has `bwrap` and `unpriv_bwrap` in
|
||||
enforce mode and no `mosaic-bwrap`; no other file under `/etc/apparmor.d`
|
||||
names `bwrap`; a bwrap child under the units' no-new-privs reads
|
||||
`bwrap//&unpriv_bwrap (enforce)`; `/usr/bin/bwrap` is `root mosaic 0750` by
|
||||
the statoverride and by `stat`; the user-namespace restriction and
|
||||
`fs.protected_hardlinks` are 1; `net.ipv4.ip_unprivileged_port_start` is
|
||||
above the Console unit's `--port`, or above 1023 with no unit. A `note` line
|
||||
says whether bubblewrap is the install pin and never fails.
|
||||
|
||||
`--check` exits 0 or 1; `stack-upgrade.sh` refuses `profile-drift` on 1.
|
||||
`--enforce` exits 0. On a failure, or while `/etc/mosaic-stack/DRIFT` exists,
|
||||
it unloads the profile if loaded, sets `/usr/bin/bwrap` to `root:root` 0700,
|
||||
appends the failed items to `DRIFT` and one line to
|
||||
`/var/log/mosaic-stack/hostcheck.log`. It never writes the statoverride. The
|
||||
apt hook runs it after every package run, and `mosaic-hostcheck.service`
|
||||
(`packages/cli/systemd/`) at boot before the bus. `--upstream` compares the
|
||||
`apparmor-profiles` candidate's profile to the pin, prints any difference
|
||||
and exits 0, or 1 when the download fails. Exit 2 is usage or a refusal:
|
||||
root takes no `--root` (the test seam), and other users run only with it.
|
||||
|
||||
## Console service (`scripts/webui-service.sh`)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -66,8 +66,10 @@ The pin is 2.1.304 unless the window names a newer one.
|
||||
|
||||
```sh
|
||||
install -d -o root -g root -m 0755 /opt/mosaic-stack/claude-<pin> /etc/claude-code
|
||||
npm install --prefix /opt/mosaic-stack/claude-<pin> @anthropic-ai/claude-code@<pin>
|
||||
PATH=/opt/mosaic-stack/node-<v>/bin:/usr/sbin:/usr/bin:/sbin:/bin \
|
||||
npm install --prefix /opt/mosaic-stack/claude-<pin> @anthropic-ai/claude-code@<pin>
|
||||
chown -R root:root /opt/mosaic-stack/claude-<pin>
|
||||
/opt/mosaic-stack/claude-<pin>/node_modules/@anthropic-ai/claude-code/bin/claude.exe --version
|
||||
printf '%s\n' '{"env":{"DISABLE_AUTOUPDATER":"1"}}' > /etc/claude-code/managed-settings.json
|
||||
chown root:root /etc/claude-code/managed-settings.json
|
||||
chmod 0644 /etc/claude-code/managed-settings.json
|
||||
@@ -75,8 +77,16 @@ chmod 0644 /etc/claude-code/managed-settings.json
|
||||
|
||||
Rollback: `rm -r /opt/mosaic-stack/claude-<pin> /etc/claude-code/managed-settings.json`.
|
||||
|
||||
The args file's `--claude` names the binary under `/opt/mosaic-stack/claude-<pin>`. `case2-unit.sh`
|
||||
refuses one that isn't a root-owned regular file outside the home.
|
||||
Root's PATH has no npm, so the install line puts section 2's pinned node first; the package's postinstall
|
||||
also runs `node`. The `--version` line must print `<pin> (Claude Code)`. Anything else means the postinstall
|
||||
left its 500-byte stub in `bin/claude.exe` (no native package for the platform, or scripts were off): stop and
|
||||
roll back.
|
||||
|
||||
The args file's `--claude` is
|
||||
`/opt/mosaic-stack/claude-<pin>/node_modules/@anthropic-ai/claude-code/bin/claude.exe`, the regular file the
|
||||
postinstall hardlinks or copies the native binary to. Not `node_modules/.bin/claude`: that is npm's symlink
|
||||
to it, and `case2-unit.sh` refuses a symlink, as it does anything that isn't a root-owned regular file outside
|
||||
the home. Checked against the 2.1.304 package's `install.cjs`; a newer pin needs the same check.
|
||||
|
||||
Precheck lines:
|
||||
- `ok claude replaces its prompt with --system-prompt-file (<n> input tokens)`. It runs the claude the bus
|
||||
@@ -120,17 +130,41 @@ lists no member, and `sysctl fs.protected_hardlinks` prints 1. `sysctl
|
||||
kernel.apparmor_restrict_unprivileged_userns` prints 1 too (Ubuntu 24.04's default); leave it, so nothing
|
||||
else on the host gains user namespaces.
|
||||
|
||||
The drift stop is row 240's (I4, #1746): `hostcheck.sh` and its pins in `/usr/local/lib/mosaic-stack/`, the
|
||||
apt hook `/etc/apt/apt.conf.d/80mosaic-hostcheck`
|
||||
(`DPkg::Post-Invoke { "/usr/local/lib/mosaic-stack/hostcheck.sh --enforce || true"; };`) and the boot unit
|
||||
`mosaic-hostcheck.service`. Install them from row 240's run sheet lines once it lands. Until then
|
||||
`stack-upgrade.sh` refuses `profile-drift` on every run, because it runs `hostcheck.sh --check` and a
|
||||
missing file refuses.
|
||||
The drift stop (row 240, I4, #1746) goes in last, after the profile and the statoverride. As root, from
|
||||
the root-owned checkout of the reviewed commit:
|
||||
|
||||
```sh
|
||||
install -d -o root -g root -m 0755 /usr/local/lib/mosaic-stack
|
||||
install -o root -g root -m 0755 scripts/case1-hostcheck.sh /usr/local/lib/mosaic-stack/hostcheck.sh
|
||||
install -o root -g root -m 0644 scripts/case1-pins.sh /usr/local/lib/mosaic-stack/case1-pins.sh
|
||||
printf '%s\n' 'DPkg::Post-Invoke { "/usr/local/lib/mosaic-stack/hostcheck.sh --enforce || true"; };' \
|
||||
> /etc/apt/apt.conf.d/80mosaic-hostcheck
|
||||
install -o root -g root -m 0644 packages/cli/systemd/mosaic-hostcheck.service /etc/systemd/system/
|
||||
systemctl daemon-reload && systemctl enable mosaic-hostcheck.service
|
||||
/usr/local/lib/mosaic-stack/hostcheck.sh --check
|
||||
```
|
||||
|
||||
`--check` prints one `ok` line for each of its seven items and exits 0. Item 7 reads 1023 as the port
|
||||
until I2 installs the Console unit (section 10), then the unit's `--port`. `stack-upgrade.sh` runs `--check`
|
||||
before its plan and refuses `profile-drift` on any exit but 0, a missing file included. Rollback:
|
||||
`systemctl disable mosaic-hostcheck.service`, then `rm` the unit, the hook file and
|
||||
`/usr/local/lib/mosaic-stack`.
|
||||
|
||||
On drift, `--enforce` (the apt hook after every package run, and the unit at boot) unloads the profile,
|
||||
closes `/usr/bin/bwrap` to `root:root` 0700, appends the failed items to `/etc/mosaic-stack/DRIFT` and logs
|
||||
a line in `/var/log/mosaic-stack/hostcheck.log`. Every role session then refuses to start. ops-01 reads the
|
||||
log in each STATE report and sends any `DRIFT` to Mos. Only root removes `DRIFT`, in a reviewed step with
|
||||
a new pin or a Mos go. After that, `chown root:mosaic /usr/bin/bwrap`, `chmod 0750 /usr/bin/bwrap` and
|
||||
`apparmor_parser -r /etc/apparmor.d/bwrap-userns-restrict` put bwrap back, and `--check` must exit 0.
|
||||
|
||||
Before each re-pin window, ops-01 runs `/usr/local/lib/mosaic-stack/hostcheck.sh --upstream`. It
|
||||
downloads the `apparmor-profiles` candidate and compares its `bwrap-userns-restrict` to the pin. A
|
||||
`DIFFERS` line goes to Mos as a new-pin request; it refuses nothing.
|
||||
|
||||
Precheck lines:
|
||||
- `ok bwrap --unshare-all runs`
|
||||
- The AppArmor profile, the statoverride, the no-new-privs result and the P9 checks: precheck line missing.
|
||||
A line that runs `/usr/local/lib/mosaic-stack/hostcheck.sh --check` would cover them once row 240 lands.
|
||||
`/usr/local/lib/mosaic-stack/hostcheck.sh --check` covers them (row 240).
|
||||
|
||||
## 5. The port floor
|
||||
|
||||
@@ -187,6 +221,9 @@ When a run doesn't exit 0, send Mos the exit and the last lines of `/var/log/mos
|
||||
it is finished with `confirm` or `revert`. One way here: a unit that doesn't stop within `--timeout`
|
||||
keeps its old start time, A2f's gate fails it `stale-start`, and the driver stops both (duckworth-0055
|
||||
N1). That is on purpose; a unit that won't stop is a reason to stop.
|
||||
- Exit 5: `args-failed`. The new copy is live and passed confirm, but `/etc/mosaic-stack/dev.args` still names
|
||||
the old copy. The next run refuses `args-differ`, and a render from that file would put the old copy back.
|
||||
Move the file the result line names into place with `mv -f`. The two differ only in the `--copy` value.
|
||||
|
||||
Precheck lines: none. Root's checkout at `<sha>` and the stack's user's clone: precheck line missing.
|
||||
After install, `stack-copy.sh check --units external` reads the copy the units run.
|
||||
@@ -209,6 +246,8 @@ their sudoers line and the sshd drop-in `/etc/ssh/sshd_config.d/<nn>-mosaic-keys
|
||||
it (`hosts.enroll`, the `host-enroll` decision) are rows 234 and 235, not yet on refactor. This item has no
|
||||
commands until they land.
|
||||
|
||||
Precheck line: none. The enrolled host's key and its `mosaic-keys` sudoers line: precheck line missing.
|
||||
|
||||
## 9. Credential slots
|
||||
|
||||
Jason places these, all in one sitting per host. No value appears here or in any message. Check a placed
|
||||
@@ -238,3 +277,5 @@ leaves.
|
||||
|
||||
None by hand. I2's `case2-unit.sh` enables both units, starts the bus, then the Console (Q189, mos-1122).
|
||||
`stack-upgrade.sh` stops and starts both units itself.
|
||||
|
||||
Precheck line: none. Both units active after the render: precheck line missing.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Mosaic Case 1 host check at boot (row 240, #1746; r6 B2). Root installs it as
|
||||
# /etc/systemd/system/mosaic-hostcheck.service, root:root 0644, and enables it
|
||||
# (docs/guides/host-run-sheet.md). It runs the drift stop before the bus starts, which covers a kernel
|
||||
# or parser change that takes effect at boot. --enforce always exits 0; on drift it closes bwrap to
|
||||
# root, and every role session then refuses to start.
|
||||
[Unit]
|
||||
Description=Mosaic Case 1 host check: the bwrap profile, the statoverride and the sysctls
|
||||
After=apparmor.service
|
||||
Before=[email protected]
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/lib/mosaic-stack/hostcheck.sh --enforce
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,335 @@
|
||||
// Row 240 (I4, #1746): scripts/case1-hostcheck.sh, the Case 1 host's drift stop for bwrap, and its
|
||||
// pins and boot unit. Brief: docs/plans/2026-10-20_installer-rows.md "I4 hostcheck"; design r6 B2
|
||||
// (agents/darkwing/work/queue-195/DESIGN.md at b7239bb66). Every case copies the script into a scratch
|
||||
// lib directory with a fixture case1-pins.sh beside it and runs it with --root on a scratch host tree.
|
||||
// Fake aa-status, dpkg, dpkg-statoverride, apparmor_parser, systemd-run, stat, chown, chmod, apt-get
|
||||
// and dpkg-deb on PATH record their argv. Nothing here reads or writes the real /etc, /proc or /usr.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { tmp } from "./helpers.mjs";
|
||||
|
||||
const REPO = fileURLToPath(new URL("../../../", import.meta.url));
|
||||
const SCRIPT = join(REPO, "scripts", "case1-hostcheck.sh");
|
||||
const PINS = join(REPO, "scripts", "case1-pins.sh");
|
||||
const UNIT = join(REPO, "packages", "cli", "systemd", "mosaic-hostcheck.service");
|
||||
const PROFILE = "/etc/apparmor.d/bwrap-userns-restrict";
|
||||
const LABEL = "bwrap//&unpriv_bwrap (enforce)";
|
||||
// A made-up profile; the fixture pins carry its sha256.
|
||||
const PROFILE_TEXT = "abi <abi/4.0>,\ninclude <tunables/global>\nprofile bwrap /usr/bin/bwrap flags=(attach_disconnected,mediate_deleted) {\n allow capability,\n change_profile /** -> &unpriv_bwrap,\n}\nprofile unpriv_bwrap flags=(attach_disconnected,mediate_deleted) {\n audit deny capability,\n}\n";
|
||||
const sha256 = (s) => createHash("sha256").update(s).digest("hex");
|
||||
const AA_OK = { version: "2", profiles: { "/usr/bin/man": "enforce", bwrap: "enforce", unpriv_bwrap: "enforce", "lsb_release": "enforce" }, processes: {} };
|
||||
const SYSTEMD_RUN = ["--wait", "--pipe", "--uid=mosaic", "--gid=mosaic", "-p", "NoNewPrivileges=yes", "-p", "ProtectSystem=strict",
|
||||
"-p", "ProtectHome=yes", "-p", "PrivateTmp=yes", "/usr/bin/bwrap", "--unshare-user", "--unshare-pid", "--ro-bind", "/", "/",
|
||||
"cat", "/proc/self/attr/current"];
|
||||
|
||||
function put(file, text, mode) {
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
writeFileSync(file, text);
|
||||
if (mode) chmodSync(file, mode);
|
||||
}
|
||||
|
||||
// Each fake appends `name argv...` to calls.log and answers from a file in the state directory.
|
||||
const FAKES = {
|
||||
"aa-status": `[ -f "$HC/aa-rc" ] && exit "$(cat "$HC/aa-rc")"; cat "$HC/aa-status.json"`,
|
||||
dpkg: `case "$1" in
|
||||
-S) [ -f "$HC/owner" ] && cat "$HC/owner"; exit "$(cat "$HC/dpkg-S-rc")" ;;
|
||||
-s) [ "$2" = bubblewrap ] && printf 'Package: bubblewrap\\nStatus: install ok installed\\nVersion: %s\\n' "$(cat "$HC/bwrap-version")" ;;
|
||||
*) exit 2 ;;
|
||||
esac`,
|
||||
"dpkg-statoverride": `[ "$1" = --list ] && cat "$HC/statoverride"`,
|
||||
apparmor_parser: `exit 0`,
|
||||
"systemd-run": `cat "$HC/label"`,
|
||||
stat: `cat "$HC/stat"`,
|
||||
chown: `exit 0`,
|
||||
chmod: `exit 0`,
|
||||
"apt-get": `[ -f "$HC/apt-rc" ] && exit "$(cat "$HC/apt-rc")"; [ "$1 $2" = "download apparmor-profiles" ] && cp "$HC/deb" "./apparmor-profiles_9.9_all.deb"`,
|
||||
"dpkg-deb": `case "$1" in
|
||||
-f) [ "$3" = Version ] && echo 9.9-upstream ;;
|
||||
-x) mkdir -p "$3/usr/share/apparmor/extra-profiles"; [ ! -f "$HC/upstream-profile" ] || cp "$HC/upstream-profile" "$3/usr/share/apparmor/extra-profiles/bwrap-userns-restrict" ;;
|
||||
esac`,
|
||||
};
|
||||
|
||||
// A scratch host where all seven items hold.
|
||||
function host(t) {
|
||||
const dir = tmp(t, "case1-hostcheck-");
|
||||
const root = join(dir, "root"), hc = join(dir, "hc"), bin = join(dir, "bin"), lib = join(dir, "lib");
|
||||
put(join(root, PROFILE), PROFILE_TEXT);
|
||||
put(join(root, "etc", "apparmor.d", "abstractions", "base"), " /etc/ld.so.cache r,\n");
|
||||
put(join(root, "etc", "apparmor.d", "tunables", "global"), "@{HOME}=/home/*/\n");
|
||||
put(join(root, "proc", "sys", "kernel", "apparmor_restrict_unprivileged_userns"), "1\n");
|
||||
put(join(root, "proc", "sys", "fs", "protected_hardlinks"), "1\n");
|
||||
put(join(root, "proc", "sys", "net", "ipv4", "ip_unprivileged_port_start"), "1024\n");
|
||||
put(join(root, "etc", "systemd", "system", "mosaic-webui.service"),
|
||||
"[Service]\nExecStart=/opt/mosaic-stack/node/bin/node /srv/mosaic/copies/x/packages/webui/src/cli.mjs serve --port 732 --business mosaic-stack --tester-port 7333\n");
|
||||
put(join(root, "usr", "bin", "bwrap"), "", 0o755);
|
||||
put(join(hc, "aa-status.json"), JSON.stringify(AA_OK));
|
||||
put(join(hc, "dpkg-S-rc"), "1");
|
||||
put(join(hc, "bwrap-version"), "0.9.0-1ubuntu0.3");
|
||||
put(join(hc, "statoverride"), "root mosaic 0750 /usr/bin/bwrap\n");
|
||||
put(join(hc, "label"), `${LABEL}\n`);
|
||||
put(join(hc, "stat"), "root mosaic 750\n");
|
||||
put(join(hc, "deb"), "fake deb\n");
|
||||
put(join(hc, "upstream-profile"), PROFILE_TEXT);
|
||||
for (const [name, body] of Object.entries(FAKES)) {
|
||||
put(join(bin, name), `#!/usr/bin/env bash\nprintf '%s' ${JSON.stringify(name)} >> "$HC/calls.log"; printf ' %s' "$@" >> "$HC/calls.log"; echo >> "$HC/calls.log"\n${body}\n`, 0o755);
|
||||
}
|
||||
mkdirSync(lib);
|
||||
copyFileSync(SCRIPT, join(lib, "hostcheck.sh"));
|
||||
chmodSync(join(lib, "hostcheck.sh"), 0o755);
|
||||
put(join(lib, "case1-pins.sh"), `PIN_PROFILES_VERSION=4.0.1-fixture\nPIN_PROFILE_SHA256=${sha256(PROFILE_TEXT)}\nPIN_BUBBLEWRAP_VERSION=0.9.0-1ubuntu0.3\n`);
|
||||
return { dir, root, hc, bin, lib, script: join(lib, "hostcheck.sh") };
|
||||
}
|
||||
|
||||
function run(h, ...args) {
|
||||
const r = spawnSync(h.script, [...args, "--root", h.root], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, PATH: `${h.bin}:${process.env.PATH}`, HC: h.hc, TMPDIR: h.dir },
|
||||
});
|
||||
return { status: r.status, out: r.stdout, err: r.stderr, lines: r.stdout.split("\n").filter(Boolean) };
|
||||
}
|
||||
const calls = (h, name) => (existsSync(join(h.hc, "calls.log")) ? readFileSync(join(h.hc, "calls.log"), "utf8") : "")
|
||||
.split("\n").filter((l) => l === name || l.startsWith(`${name} `)).map((l) => l.slice(name.length + 1));
|
||||
const fails = (r) => r.lines.filter((l) => l.startsWith("FAIL "));
|
||||
const drift = (h) => join(h.root, "etc", "mosaic-stack", "DRIFT");
|
||||
const hlog = (h) => join(h.root, "var", "log", "mosaic-stack", "hostcheck.log");
|
||||
const aa = (h, profiles) => put(join(h.hc, "aa-status.json"), JSON.stringify({ version: "2", profiles, processes: {} }));
|
||||
|
||||
// One fault per item; each makes that item, and only that one, fail.
|
||||
const FAULTS = {
|
||||
1: (h) => put(join(h.root, PROFILE), `${PROFILE_TEXT}# drift\n`),
|
||||
2: (h) => aa(h, { ...AA_OK.profiles, bwrap: "complain" }),
|
||||
3: (h) => put(join(h.root, "etc", "apparmor.d", "usr.bin.other"), "profile other /usr/bin/other {\n /usr/bin/bwrap ix,\n}\n"),
|
||||
4: (h) => put(join(h.hc, "label"), "unconfined\n"),
|
||||
5: (h) => put(join(h.hc, "statoverride"), "root root 0755 /usr/bin/bwrap\n"),
|
||||
6: (h) => put(join(h.root, "proc", "sys", "kernel", "apparmor_restrict_unprivileged_userns"), "0\n"),
|
||||
7: (h) => put(join(h.root, "proc", "sys", "net", "ipv4", "ip_unprivileged_port_start"), "700\n"),
|
||||
};
|
||||
|
||||
test("row 240 check: exits 0 with all seven items and a note that bubblewrap is the install pin", (t) => {
|
||||
const h = host(t);
|
||||
const r = run(h, "--check");
|
||||
assert.equal(r.status, 0, r.out + r.err);
|
||||
assert.deepEqual(r.lines.map((l) => l.split(" ").slice(0, 2).join(" ")), ["ok 1", "ok 2", "ok 3", "ok 4", "ok 5", "ok 6", "ok 7", "note bubblewrap"]);
|
||||
assert.match(r.lines[6], /is 1024, above the Console's --port 732$/);
|
||||
assert.equal(r.lines[7], "note bubblewrap 0.9.0-1ubuntu0.3 is the install pin");
|
||||
});
|
||||
|
||||
test("row 240 check: exits 1 with each item failing alone, and its line names it", (t) => {
|
||||
for (const [n, fault] of Object.entries(FAULTS)) {
|
||||
const h = host(t);
|
||||
fault(h);
|
||||
const r = run(h, "--check");
|
||||
assert.equal(r.status, 1, `item ${n}: ${r.out}`);
|
||||
assert.deepEqual(fails(r).map((l) => l.split(" ")[1]), [n], `item ${n}: ${r.out}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("row 240 check: item 1 fails when a package owns the profile, when dpkg -S errs, or with no pin", (t) => {
|
||||
let h = host(t);
|
||||
put(join(h.hc, "dpkg-S-rc"), "0");
|
||||
put(join(h.hc, "owner"), `apparmor-profiles: ${PROFILE}\n`);
|
||||
let r = run(h, "--check");
|
||||
assert.equal(r.status, 1);
|
||||
assert.deepEqual(fails(r), [`FAIL 1 ${PROFILE} is owned by a package: apparmor-profiles: ${PROFILE}`]);
|
||||
assert.deepEqual(calls(h, "dpkg").filter((a) => a.startsWith("-S")), [`-S ${PROFILE}`]);
|
||||
h = host(t);
|
||||
put(join(h.hc, "dpkg-S-rc"), "2");
|
||||
r = run(h, "--check");
|
||||
assert.deepEqual(fails(r), [`FAIL 1 dpkg -S ${PROFILE} exited 2`]);
|
||||
h = host(t);
|
||||
rmSync(join(h.lib, "case1-pins.sh"));
|
||||
r = run(h, "--check");
|
||||
assert.equal(r.status, 1);
|
||||
assert.deepEqual(fails(r), [`FAIL 1 no profile pin in ${h.lib}/case1-pins.sh`]);
|
||||
});
|
||||
|
||||
test("row 240 check: item 2 fails on a loaded mosaic-bwrap, a missing unpriv_bwrap or a failed aa-status", (t) => {
|
||||
for (const [fault, text] of [
|
||||
[(h) => aa(h, { ...AA_OK.profiles, "mosaic-bwrap": "enforce" }), "a mosaic-bwrap profile"],
|
||||
[(h) => aa(h, { bwrap: "enforce" }), "no unpriv_bwrap profile in enforce mode"],
|
||||
[(h) => put(join(h.hc, "aa-rc"), "1"), "aa-status --json failed"],
|
||||
]) {
|
||||
const h = host(t);
|
||||
fault(h);
|
||||
const r = run(h, "--check");
|
||||
assert.equal(r.status, 1);
|
||||
assert.equal(fails(r).length, 1, r.out);
|
||||
assert.ok(fails(r)[0].startsWith("FAIL 2 ") && fails(r)[0].includes(text), r.out);
|
||||
}
|
||||
});
|
||||
|
||||
test("row 240 check: item 3 fails on a second file naming bwrap as a word, /{usr/,}bin/bwrap included", (t) => {
|
||||
for (const text of ["profile x /{usr/,}bin/bwrap {\n}\n", "# runs bwrap\n", "/usr/bin/bwrap ix,\n"]) {
|
||||
const h = host(t);
|
||||
put(join(h.root, "etc", "apparmor.d", "local", "extra"), text);
|
||||
const r = run(h, "--check");
|
||||
assert.equal(r.status, 1, text);
|
||||
assert.deepEqual(fails(r).map((l) => l.split(" ")[1]), ["3"], text);
|
||||
assert.ok(fails(r)[0].includes(join(h.root, "etc", "apparmor.d", "local", "extra")), r.out);
|
||||
}
|
||||
const h = host(t);
|
||||
put(join(h.root, "etc", "apparmor.d", "local", "extra"), "# bwrapper and unpriv-bwrapx are other words\n");
|
||||
assert.equal(run(h, "--check").status, 0);
|
||||
});
|
||||
|
||||
test("row 240 check: item 4 reads the label under the units' no-new-privs and matches it exactly", (t) => {
|
||||
let h = host(t);
|
||||
assert.equal(run(h, "--check").status, 0);
|
||||
assert.deepEqual(calls(h, "systemd-run"), [SYSTEMD_RUN.join(" ")]);
|
||||
for (const label of ["unpriv_bwrap (enforce)", `${LABEL} `, `x${LABEL}`]) {
|
||||
h = host(t);
|
||||
put(join(h.hc, "label"), `${label}\n`);
|
||||
const r = run(h, "--check");
|
||||
assert.equal(r.status, 1, JSON.stringify(label));
|
||||
assert.deepEqual(fails(r), [`FAIL 4 a bwrap child under no-new-privs has the label '${label}', not '${LABEL}'`]);
|
||||
}
|
||||
});
|
||||
|
||||
test("row 240 check: item 5 needs the statoverride and stat to agree", (t) => {
|
||||
const h = host(t);
|
||||
put(join(h.hc, "stat"), "root root 700\n");
|
||||
const r = run(h, "--check");
|
||||
assert.deepEqual(fails(r), ["FAIL 5 /usr/bin/bwrap is 'root root 700', not 'root mosaic 750'"]);
|
||||
assert.deepEqual(calls(h, "dpkg-statoverride"), ["--list /usr/bin/bwrap"]);
|
||||
assert.deepEqual(calls(h, "stat"), [`-c %U %G %a ${h.root}/usr/bin/bwrap`]);
|
||||
});
|
||||
|
||||
test("row 240 check: item 6 fails on protected_hardlinks 0; item 7 fails with the floor at 732 and reads 1023 with no unit", (t) => {
|
||||
let h = host(t);
|
||||
put(join(h.root, "proc", "sys", "fs", "protected_hardlinks"), "0\n");
|
||||
assert.deepEqual(fails(run(h, "--check")).map((l) => l.split(" ")[1]), ["6"]);
|
||||
h = host(t);
|
||||
put(join(h.root, "proc", "sys", "net", "ipv4", "ip_unprivileged_port_start"), "732\n");
|
||||
assert.deepEqual(fails(run(h, "--check")), ["FAIL 7 net.ipv4.ip_unprivileged_port_start is 732, not above the Console's --port 732"]);
|
||||
put(join(h.root, "proc", "sys", "net", "ipv4", "ip_unprivileged_port_start"), "733\n");
|
||||
assert.equal(run(h, "--check").status, 0);
|
||||
rmSync(join(h.root, "etc", "systemd", "system", "mosaic-webui.service"));
|
||||
assert.deepEqual(fails(run(h, "--check")), ["FAIL 7 net.ipv4.ip_unprivileged_port_start is 733, not above 1023, because no Console unit is installed"]);
|
||||
put(join(h.root, "proc", "sys", "net", "ipv4", "ip_unprivileged_port_start"), "1024\n");
|
||||
assert.equal(run(h, "--check").status, 0);
|
||||
put(join(h.root, "etc", "systemd", "system", "mosaic-webui.service"), "[Service]\nExecStart=/x serve --business mosaic-stack --tester-port 7333\n");
|
||||
assert.deepEqual(fails(run(h, "--check")), ["FAIL 7 /etc/systemd/system/mosaic-webui.service has no --port"]);
|
||||
});
|
||||
|
||||
test("row 240 check: a bubblewrap version other than the install pin is a note, not a failure", (t) => {
|
||||
const h = host(t);
|
||||
put(join(h.hc, "bwrap-version"), "0.9.0-1ubuntu0.4");
|
||||
const r = run(h, "--check");
|
||||
assert.equal(r.status, 0);
|
||||
assert.equal(r.lines.at(-1), "note bubblewrap 0.9.0-1ubuntu0.4 is not the install pin 0.9.0-1ubuntu0.3, which is not a drift item");
|
||||
});
|
||||
|
||||
test("row 240 enforce: a pass with no DRIFT changes nothing and exits 0", (t) => {
|
||||
const h = host(t);
|
||||
const r = run(h, "--enforce");
|
||||
assert.equal(r.status, 0);
|
||||
assert.equal(r.lines.at(-1), "hostcheck: all seven hold");
|
||||
assert.deepEqual([calls(h, "apparmor_parser"), calls(h, "chown"), calls(h, "chmod")], [[], [], []]);
|
||||
assert.equal(existsSync(drift(h)) || existsSync(hlog(h)), false);
|
||||
});
|
||||
|
||||
test("row 240 enforce: each failure exits 0, unloads the profile, closes bwrap, writes DRIFT and one log line", (t) => {
|
||||
for (const [n, fault] of Object.entries(FAULTS)) {
|
||||
const h = host(t);
|
||||
fault(h);
|
||||
const r = run(h, "--enforce");
|
||||
assert.equal(r.status, 0, `item ${n}: ${r.out}${r.err}`);
|
||||
assert.deepEqual(calls(h, "apparmor_parser"), [`-R ${h.root}${PROFILE}`], `item ${n}`);
|
||||
assert.deepEqual(calls(h, "chown"), [`root:root ${h.root}/usr/bin/bwrap`], `item ${n}`);
|
||||
assert.deepEqual(calls(h, "chmod"), [`0700 ${h.root}/usr/bin/bwrap`], `item ${n}`);
|
||||
const d = readFileSync(drift(h), "utf8").split("\n").filter(Boolean);
|
||||
assert.equal(d.length, 1, `item ${n}`);
|
||||
assert.match(d[0], new RegExp(`^\\d{4}-\\d\\d-\\d\\dT\\d\\d:\\d\\d:\\d\\dZ ${n} `), `item ${n}`);
|
||||
const l = readFileSync(hlog(h), "utf8").split("\n").filter(Boolean);
|
||||
assert.equal(l.length, 1, `item ${n}`);
|
||||
assert.match(l[0], new RegExp(`^\\d{4}-\\d\\d-\\d\\dT\\d\\d:\\d\\d:\\d\\dZ enforce stop items=${n} drift-before=0 unload=rc=0 chown=0 chmod=0$`), `item ${n}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("row 240 enforce: with DRIFT present and every item passing, it still closes bwrap", (t) => {
|
||||
const h = host(t);
|
||||
put(drift(h), "2026-10-20T00:00:00Z 1 an earlier drift\n");
|
||||
const r = run(h, "--enforce");
|
||||
assert.equal(r.status, 0);
|
||||
assert.equal(fails(r).length, 0);
|
||||
assert.deepEqual(calls(h, "chown"), [`root:root ${h.root}/usr/bin/bwrap`]);
|
||||
assert.deepEqual(calls(h, "chmod"), [`0700 ${h.root}/usr/bin/bwrap`]);
|
||||
assert.equal(readFileSync(drift(h), "utf8"), "2026-10-20T00:00:00Z 1 an earlier drift\n");
|
||||
assert.match(readFileSync(hlog(h), "utf8"), /Z enforce stop items=none drift-before=1 unload=rc=0 chown=0 chmod=0\n$/);
|
||||
});
|
||||
|
||||
test("row 240 enforce: no unload when aa-status lists neither bwrap profile; never writes the statoverride", (t) => {
|
||||
const h = host(t);
|
||||
aa(h, { "/usr/bin/man": "enforce" });
|
||||
const r = run(h, "--enforce");
|
||||
assert.equal(r.status, 0);
|
||||
assert.deepEqual(calls(h, "apparmor_parser"), []);
|
||||
assert.deepEqual(calls(h, "chmod"), [`0700 ${h.root}/usr/bin/bwrap`]);
|
||||
assert.match(readFileSync(hlog(h), "utf8"), /Z enforce stop items=2 drift-before=0 unload=skipped chown=0 chmod=0\n$/);
|
||||
for (const [n, fault] of Object.entries(FAULTS)) {
|
||||
const g = host(t);
|
||||
fault(g);
|
||||
run(g, "--enforce");
|
||||
for (const a of calls(g, "dpkg-statoverride")) assert.doesNotMatch(a, /--(update|add|remove)/, `item ${n}`);
|
||||
assert.deepEqual(calls(g, "dpkg-statoverride"), ["--list /usr/bin/bwrap"], `item ${n}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("row 240 upstream: a candidate whose profile differs prints the difference and exits 0", (t) => {
|
||||
let h = host(t);
|
||||
put(join(h.hc, "upstream-profile"), `${PROFILE_TEXT} # upstream change\n`);
|
||||
let r = run(h, "--upstream");
|
||||
assert.equal(r.status, 0, r.err);
|
||||
assert.match(r.out, new RegExp(`^upstream: DIFFERS: apparmor-profiles 9.9-upstream's bwrap-userns-restrict has sha256 ${sha256(`${PROFILE_TEXT} # upstream change\n`)}; the pin is ${sha256(PROFILE_TEXT)} from 4.0.1-fixture\\.`));
|
||||
assert.match(r.out, /^\+ {2}# upstream change$/m);
|
||||
assert.deepEqual(calls(h, "apt-get"), ["download apparmor-profiles"]);
|
||||
assert.deepEqual([calls(h, "chown"), calls(h, "chmod"), existsSync(drift(h))], [[], [], false]);
|
||||
h = host(t);
|
||||
r = run(h, "--upstream");
|
||||
assert.equal(r.status, 0);
|
||||
assert.equal(r.out, "upstream: apparmor-profiles 9.9-upstream's bwrap-userns-restrict matches the pin from 4.0.1-fixture\n");
|
||||
h = host(t);
|
||||
rmSync(join(h.hc, "upstream-profile"));
|
||||
r = run(h, "--upstream");
|
||||
assert.equal(r.status, 0);
|
||||
assert.match(r.out, /^upstream: DIFFERS: apparmor-profiles 9.9-upstream has no bwrap-userns-restrict;/);
|
||||
h = host(t);
|
||||
put(join(h.hc, "apt-rc"), "100");
|
||||
r = run(h, "--upstream");
|
||||
assert.equal(r.status, 1);
|
||||
assert.equal(r.err, "hostcheck: upstream: apt-get download apparmor-profiles failed\n");
|
||||
});
|
||||
|
||||
test("row 240 boot unit: a oneshot after apparmor and before the bus that runs --enforce", () => {
|
||||
const text = readFileSync(UNIT, "utf8");
|
||||
const lines = text.split("\n").filter((l) => l && !l.startsWith("#"));
|
||||
assert.deepEqual(lines.filter((l) => /^(Type|After|Before|ExecStart|WantedBy)=/.test(l)), [
|
||||
"After=apparmor.service",
|
||||
"[email protected]",
|
||||
"Type=oneshot",
|
||||
"ExecStart=/usr/local/lib/mosaic-stack/hostcheck.sh --enforce",
|
||||
"WantedBy=multi-user.target",
|
||||
]);
|
||||
});
|
||||
|
||||
test("row 240 pins and usage: the shipped pins are r6's, the script reads the pins beside it, and bad usage exits 2", (t) => {
|
||||
assert.equal(readFileSync(PINS, "utf8").split("\n").filter((l) => /^PIN_/.test(l)).join("\n"),
|
||||
"PIN_PROFILES_VERSION=4.0.1really4.0.1-0ubuntu0.24.04.9\nPIN_PROFILE_SHA256=11d39094f044f0cda0febb3ad517b830301da6b2ce929664af09ee9e4dd264f9\nPIN_BUBBLEWRAP_VERSION=0.9.0-1ubuntu0.3");
|
||||
const h = host(t);
|
||||
const r = run({ ...h, script: SCRIPT }, "--check");
|
||||
assert.equal(r.status, 1);
|
||||
assert.deepEqual(fails(r), [`FAIL 1 ${PROFILE} sha256 is ${sha256(PROFILE_TEXT)}, not the pin 11d39094f044f0cda0febb3ad517b830301da6b2ce929664af09ee9e4dd264f9`]);
|
||||
for (const args of [[], ["--check", "--enforce"], ["--check", "--root", "relative"], ["--status"]]) {
|
||||
const u = spawnSync(h.script, args, { encoding: "utf8", env: { ...process.env, PATH: `${h.bin}:${process.env.PATH}`, HC: h.hc } });
|
||||
assert.equal(u.status, 2, args.join(" "));
|
||||
}
|
||||
if (process.getuid() !== 0) {
|
||||
const u = spawnSync(h.script, ["--check"], { encoding: "utf8", env: { ...process.env, PATH: `${h.bin}:${process.env.PATH}`, HC: h.hc } });
|
||||
assert.deepEqual([u.status, u.stderr], [2, "hostcheck: refusing: run it as root\n"]);
|
||||
}
|
||||
});
|
||||
@@ -140,7 +140,7 @@ const arm=(e,key,text)=>writeFileSync(join(e.sd,'arm',key),text);
|
||||
|
||||
// The step log: every line `<time> <step> rc=N ...` or the result line, root:root 0640 (here the
|
||||
// test user's), and nothing the stack's user printed. Returns the lines without the time.
|
||||
const STEP=/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ (start|fetch|ff|copy|hostcheck|units|plan|rehearse|rehearse-store|rehearsal-skipped|stop-console|stop-bus|wait-down|stage|render|confirm|args|revert|render-old|confirm-rollback|gate|start-bus|start-console|stop-units) rc=\d+ |^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ result exit=[0-3] [a-z-]+ /;
|
||||
const STEP=/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ (start|fetch|ff|copy|hostcheck|units|plan|rehearse|rehearse-store|rehearsal-skipped|stop-console|stop-bus|wait-down|stage|render|confirm|args|revert|render-old|confirm-rollback|gate|start-bus|start-console|stop-units) rc=\d+ |^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ result exit=[0-35] [a-z-]+ /;
|
||||
function slog(e){
|
||||
const l=lines(e.slog);
|
||||
for(const x of l){assert.match(x,STEP);assert.ok(!/stack-release:|fixture rehearse|PASS |FAIL |REHEARSAL/.test(x),x)}
|
||||
@@ -292,6 +292,21 @@ test('row 233: sequence 7: two upgrades and a rollback --to the previous copy wi
|
||||
assert.equal(lines(join(e.sd,'root-systemctl.log')).length,sc);assert.equal(argsCopy(e),C1);
|
||||
});
|
||||
|
||||
test('row 240: I3 N1: a failed args move after A5 exits 5 args-failed, the new copy live and confirmed and both args files kept',async(t)=>{
|
||||
const e=await useed(t);
|
||||
// A read-only /etc/mosaic-stack at confirm: the stage and the render are done, and the move fails.
|
||||
// The fixture's cleanup runs chmod -R u+w before it removes the root, so the 0555 dir needs no hook here.
|
||||
arm(e,'confirm.sh',`chmod 0555 ${e.etc}\n`);
|
||||
const l=await upgrade(e,C1);assert.equal(l.code,5,l.all);
|
||||
assert.deepEqual(names(e),[...PRE,...A2,'stage','render','confirm','args','result']);
|
||||
assert.ok(slog(e).includes(`args rc=1 copy: ${C1}`));
|
||||
assert.match(result(e),new RegExp(`^result exit=5 args-failed ${C1} is live and confirmed, but ${e.args} still names ${B}; put ${e.etc}/\\.dev\\.args\\.\\w{6} in its place$`));
|
||||
assert.deepEqual(open(e),[]);assert.deepEqual(live(e),['active','active',C1,C1]);
|
||||
assert.equal(readFileSync(e.args,'utf8'),e.argsText);
|
||||
const left=readdirSync(e.etc).filter((n)=>n!=='dev.args');assert.equal(left.length,1);
|
||||
assert.equal(readFileSync(join(e.etc,left[0]),'utf8'),e.argsText.replace(`--copy\n${B}\n`,`--copy\n${C1}\n`));
|
||||
});
|
||||
|
||||
test('row 233: sequence 8: a migration that writes and fails: the driver reverts to the backup byte for byte, --cause A3: exit 1',async(t)=>{
|
||||
const e=await useed(t);
|
||||
const s0=sha(e.store);
|
||||
|
||||
Executable
+216
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env bash
|
||||
# Row 240 (I4, #1746): the Case 1 host's drift stop for bwrap, r6 B2
|
||||
# (agents/darkwing/work/queue-195/DESIGN.md at b7239bb66, as docs/plans/2026-10-20_installer-rows.md
|
||||
# "I4 hostcheck" ships it). Deviations and decisions: agents/darkwing/work/queue-240/BUILD.md.
|
||||
#
|
||||
# case1-hostcheck.sh --check | --enforce | --upstream
|
||||
#
|
||||
# Installed as /usr/local/lib/mosaic-stack/hostcheck.sh, root:root 0755, with case1-pins.sh beside it,
|
||||
# root:root 0644. Three callers: stack-upgrade.sh A1 runs --check and refuses profile-drift on any
|
||||
# exit but 0; the apt hook /etc/apt/apt.conf.d/80mosaic-hostcheck runs --enforce after every dpkg run;
|
||||
# mosaic-hostcheck.service runs --enforce at boot, before the bus.
|
||||
#
|
||||
# Seven items, one line each, `ok N ...` or `FAIL N ...`:
|
||||
# 1. /etc/apparmor.d/bwrap-userns-restrict has the pinned sha256, and `dpkg -S` finds no owner.
|
||||
# 2. `aa-status --json` lists bwrap and unpriv_bwrap in enforce mode, and no mosaic-bwrap.
|
||||
# 3. `grep -rlw bwrap /etc/apparmor.d` lists that one file and nothing else.
|
||||
# 4. A bwrap child started as mosaic under the units' no-new-privs reads the label
|
||||
# `bwrap//&unpriv_bwrap (enforce)`, exactly.
|
||||
# 5. The statoverride on /usr/bin/bwrap reads `root mosaic 0750`, and stat agrees.
|
||||
# 6. kernel.apparmor_restrict_unprivileged_userns and fs.protected_hardlinks are 1.
|
||||
# 7. net.ipv4.ip_unprivileged_port_start is above the Console's --port in the installed unit, or
|
||||
# above 1023, the highest --port case2-unit.sh takes, while no unit is installed.
|
||||
# A `note` line says whether bubblewrap is the install pin; it is never a failure.
|
||||
#
|
||||
# --check exits 0 when all seven hold and 1 otherwise. --enforce runs the same items and exits 0. On
|
||||
# a failure, or while /etc/mosaic-stack/DRIFT exists, it stops bwrap: `apparmor_parser -R` on the
|
||||
# profile if it is loaded, then root:root 0700 on /usr/bin/bwrap. It appends the failed items to DRIFT
|
||||
# and one line to /var/log/mosaic-stack/hostcheck.log. Only root removes DRIFT, in a reviewed step.
|
||||
# It never writes the statoverride, because dpkg's lock may be held under apt's (r6).
|
||||
# --upstream downloads the apparmor-profiles candidate into a temp dir and compares its
|
||||
# bwrap-userns-restrict to the pin. A difference is printed and exits 0, because it refuses nothing;
|
||||
# it exits 1 when the download or the unpack fails.
|
||||
#
|
||||
# Exit 2 on usage or a refusal. Root takes no --root; without root it runs only with --root.
|
||||
#
|
||||
# Test seam, for packages/cli/tests/case1-hostcheck.test.mjs only:
|
||||
# --root DIR prefixes /etc, /proc/sys, /var/log and /usr/bin/bwrap
|
||||
set -uo pipefail
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
usage() { echo "usage: case1-hostcheck.sh --check | --enforce | --upstream" >&2; exit 2; }
|
||||
MODE="" ROOT=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--check|--enforce|--upstream) [ -z "$MODE" ] || usage; MODE="${1#--}"; shift ;;
|
||||
--root) [ $# -ge 2 ] && [[ "$2" =~ ^/[A-Za-z0-9/._+-]+$ ]] && [ -d "$2" ] || usage; ROOT="${2%/}"; shift 2 ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
[ -n "$MODE" ] || usage
|
||||
if [ "$(id -u)" = 0 ]; then
|
||||
[ -z "$ROOT" ] || { echo "hostcheck: refusing: --root is a test seam, and root takes none" >&2; exit 2; }
|
||||
PATH=/usr/sbin:/usr/bin:/sbin:/bin
|
||||
else
|
||||
[ -n "$ROOT" ] || { echo "hostcheck: refusing: run it as root" >&2; exit 2; }
|
||||
fi
|
||||
|
||||
PIN_PROFILES_VERSION="" PIN_PROFILE_SHA256="" PIN_BUBBLEWRAP_VERSION=""
|
||||
# shellcheck source=case1-pins.sh
|
||||
[ -f "$HERE/case1-pins.sh" ] && . "$HERE/case1-pins.sh"
|
||||
PROFILE=/etc/apparmor.d/bwrap-userns-restrict
|
||||
BWRAP=/usr/bin/bwrap
|
||||
DRIFT="$ROOT/etc/mosaic-stack/DRIFT"
|
||||
LOGF="$ROOT/var/log/mosaic-stack/hostcheck.log"
|
||||
LABEL='bwrap//&unpriv_bwrap (enforce)'
|
||||
stamp() { date -u +%Y-%m-%dT%H:%M:%SZ; }
|
||||
|
||||
FAILED=() LOADED=1
|
||||
pass() { echo "ok $*"; }
|
||||
fail() { echo "FAIL $*"; FAILED+=("$*"); }
|
||||
|
||||
item1() {
|
||||
local sum owner rc
|
||||
[[ "$PIN_PROFILE_SHA256" =~ ^[0-9a-f]{64}$ ]] || { fail "1 no profile pin in $HERE/case1-pins.sh"; return; }
|
||||
sum="$(sha256sum < "$ROOT$PROFILE" 2>/dev/null | cut -d' ' -f1)"
|
||||
[ "$sum" = "$PIN_PROFILE_SHA256" ] || { fail "1 $PROFILE sha256 is ${sum:-unreadable}, not the pin $PIN_PROFILE_SHA256"; return; }
|
||||
owner="$(dpkg -S "$PROFILE" 2>/dev/null)"; rc=$?
|
||||
case "$rc" in
|
||||
1) pass "1 $PROFILE matches the pin from apparmor-profiles $PIN_PROFILES_VERSION, and no package owns it" ;;
|
||||
0) fail "1 $PROFILE is owned by a package: $owner" ;;
|
||||
*) fail "1 dpkg -S $PROFILE exited $rc" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
item2() {
|
||||
local json list
|
||||
if ! json="$(aa-status --json 2>/dev/null)"; then fail "2 aa-status --json failed"; return; fi
|
||||
# The profiles object holds name-to-mode pairs and nothing nested; one pair per line.
|
||||
list="$(sed -n 's/.*"profiles": *{\([^}]*\)}.*/\1/p' <<< "$json" | tr ',' '\n' | sed 's/^ *//; s/ *$//')"
|
||||
grep -Eq '^"(bwrap|unpriv_bwrap)":' <<< "$list" || LOADED=0
|
||||
grep -qxE '"bwrap": *"enforce"' <<< "$list" || { fail "2 aa-status lists no bwrap profile in enforce mode"; return; }
|
||||
grep -qxE '"unpriv_bwrap": *"enforce"' <<< "$list" || { fail "2 aa-status lists no unpriv_bwrap profile in enforce mode"; return; }
|
||||
! grep -qE '^"mosaic-bwrap":' <<< "$list" || { fail "2 aa-status lists a mosaic-bwrap profile"; return; }
|
||||
pass "2 bwrap and unpriv_bwrap are in enforce mode, and no mosaic-bwrap is loaded"
|
||||
}
|
||||
|
||||
item3() {
|
||||
local found
|
||||
found="$(grep -rlw bwrap "$ROOT/etc/apparmor.d" 2>/dev/null | sort)"
|
||||
[ "$found" = "$ROOT$PROFILE" ] || { fail "3 the files under /etc/apparmor.d that name bwrap are not $PROFILE alone: $(tr '\n' ' ' <<< "$found")"; return; }
|
||||
pass "3 $PROFILE is the only file under /etc/apparmor.d that names bwrap"
|
||||
}
|
||||
|
||||
item4() {
|
||||
local label
|
||||
label="$(timeout 60 systemd-run --wait --pipe --uid=mosaic --gid=mosaic -p NoNewPrivileges=yes \
|
||||
-p ProtectSystem=strict -p ProtectHome=yes -p PrivateTmp=yes \
|
||||
"$BWRAP" --unshare-user --unshare-pid --ro-bind / / cat /proc/self/attr/current 2>/dev/null)"
|
||||
[ "$label" = "$LABEL" ] || { fail "4 a bwrap child under no-new-privs has the label '$label', not '$LABEL'"; return; }
|
||||
pass "4 a bwrap child under no-new-privs has the label '$LABEL'"
|
||||
}
|
||||
|
||||
item5() {
|
||||
local so st
|
||||
so="$(dpkg-statoverride --list "$BWRAP" 2>/dev/null)"
|
||||
st="$(stat -c '%U %G %a' "$ROOT$BWRAP" 2>/dev/null)"
|
||||
[ "$so" = "root mosaic 0750 $BWRAP" ] || { fail "5 the statoverride on $BWRAP reads '$so', not 'root mosaic 0750'"; return; }
|
||||
[ "$st" = "root mosaic 750" ] || { fail "5 $BWRAP is '$st', not 'root mosaic 750'"; return; }
|
||||
pass "5 $BWRAP is root mosaic 0750, by the statoverride and by stat"
|
||||
}
|
||||
|
||||
item6() {
|
||||
local userns links
|
||||
userns="$(cat "$ROOT/proc/sys/kernel/apparmor_restrict_unprivileged_userns" 2>/dev/null)"
|
||||
links="$(cat "$ROOT/proc/sys/fs/protected_hardlinks" 2>/dev/null)"
|
||||
[ "$userns" = 1 ] && [ "$links" = 1 ] ||
|
||||
{ fail "6 kernel.apparmor_restrict_unprivileged_userns is '$userns' and fs.protected_hardlinks is '$links', not 1 and 1"; return; }
|
||||
pass "6 kernel.apparmor_restrict_unprivileged_userns and fs.protected_hardlinks are 1"
|
||||
}
|
||||
|
||||
item7() {
|
||||
local floor unit="$ROOT/etc/systemd/system/mosaic-webui.service" ports port=0 p from
|
||||
floor="$(cat "$ROOT/proc/sys/net/ipv4/ip_unprivileged_port_start" 2>/dev/null)"
|
||||
[[ "$floor" =~ ^[0-9]{1,5}$ ]] || { fail "7 net.ipv4.ip_unprivileged_port_start is '$floor', not a number"; return; }
|
||||
if [ -e "$unit" ]; then
|
||||
ports="$(sed -n 's/^ExecStart=//p' "$unit" 2>/dev/null | grep -oE ' --port [0-9]{1,5}( |$)' | tr -dc '0-9\n')"
|
||||
[ -n "$ports" ] || { fail "7 /etc/systemd/system/mosaic-webui.service has no --port"; return; }
|
||||
for p in $ports; do [ "$((10#$p))" -le "$port" ] || port="$((10#$p))"; done
|
||||
from="the Console's --port $port"
|
||||
else
|
||||
port=1023 from="1023, because no Console unit is installed"
|
||||
fi
|
||||
[ "$((10#$floor))" -gt "$port" ] || { fail "7 net.ipv4.ip_unprivileged_port_start is $floor, not above $from"; return; }
|
||||
pass "7 net.ipv4.ip_unprivileged_port_start is $floor, above $from"
|
||||
}
|
||||
|
||||
items() {
|
||||
local v
|
||||
item1; item2; item3; item4; item5; item6; item7
|
||||
v="$(dpkg -s bubblewrap 2>/dev/null | sed -n 's/^Version: //p')"
|
||||
if [ "$v" = "$PIN_BUBBLEWRAP_VERSION" ]; then
|
||||
echo "note bubblewrap $v is the install pin"
|
||||
else
|
||||
echo "note bubblewrap ${v:-unknown} is not the install pin ${PIN_BUBBLEWRAP_VERSION:-unknown}, which is not a drift item"
|
||||
fi
|
||||
}
|
||||
|
||||
# The stop: unload the profile if it is loaded, then close bwrap to root. Prints the results.
|
||||
stop_bwrap() {
|
||||
local unload=skipped chown_rc=0 chmod_rc=0
|
||||
if [ "$LOADED" = 1 ]; then
|
||||
if [ -f "$ROOT$PROFILE" ]; then apparmor_parser -R "$ROOT$PROFILE" >/dev/null 2>&1; unload="rc=$?"; else unload=no-file; fi
|
||||
fi
|
||||
chown root:root "$ROOT$BWRAP" || chown_rc=$?
|
||||
chmod 0700 "$ROOT$BWRAP" || chmod_rc=$?
|
||||
echo "unload=$unload chown=$chown_rc chmod=$chmod_rc"
|
||||
}
|
||||
|
||||
enforce() {
|
||||
local had=0 now result f ids=""
|
||||
[ ! -e "$DRIFT" ] || had=1
|
||||
items
|
||||
[ ${#FAILED[@]} -gt 0 ] || [ "$had" = 1 ] || { echo "hostcheck: all seven hold"; exit 0; }
|
||||
umask 027
|
||||
now="$(stamp)"
|
||||
if [ ${#FAILED[@]} -gt 0 ]; then
|
||||
mkdir -p "$(dirname "$DRIFT")" && for f in "${FAILED[@]}"; do echo "$now $f"; done >> "$DRIFT" ||
|
||||
echo "hostcheck: cannot write $DRIFT" >&2
|
||||
fi
|
||||
for f in "${FAILED[@]}"; do ids+="${ids:+,}${f%% *}"; done
|
||||
result="$(stop_bwrap)"
|
||||
mkdir -p "$(dirname "$LOGF")" && echo "$now enforce stop items=${ids:-none} drift-before=$had $result" >> "$LOGF" ||
|
||||
echo "hostcheck: cannot write $LOGF" >&2
|
||||
echo "hostcheck: bwrap stopped ($result); only root removes $DRIFT"
|
||||
exit 0
|
||||
}
|
||||
|
||||
upstream() {
|
||||
local tmp deb ver f sum
|
||||
tmp="$(mktemp -d)" || exit 1
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
(cd "$tmp" && apt-get download apparmor-profiles) >/dev/null 2>&1 || { echo "hostcheck: upstream: apt-get download apparmor-profiles failed" >&2; exit 1; }
|
||||
deb=("$tmp"/apparmor-profiles_*.deb)
|
||||
[ ${#deb[@]} = 1 ] && [ -f "${deb[0]}" ] || { echo "hostcheck: upstream: no single apparmor-profiles deb in the download" >&2; exit 1; }
|
||||
ver="$(dpkg-deb -f "${deb[0]}" Version 2>/dev/null)" && dpkg-deb -x "${deb[0]}" "$tmp/x" >/dev/null 2>&1 ||
|
||||
{ echo "hostcheck: upstream: cannot unpack ${deb[0]##*/}" >&2; exit 1; }
|
||||
f="$tmp/x/usr/share/apparmor/extra-profiles/bwrap-userns-restrict"
|
||||
if [ ! -f "$f" ]; then
|
||||
echo "upstream: DIFFERS: apparmor-profiles $ver has no bwrap-userns-restrict; the pin is $PIN_PROFILE_SHA256 from $PIN_PROFILES_VERSION. Send it to Mos as a new-pin request."
|
||||
exit 0
|
||||
fi
|
||||
sum="$(sha256sum < "$f" | cut -d' ' -f1)"
|
||||
if [ "$sum" = "$PIN_PROFILE_SHA256" ]; then
|
||||
echo "upstream: apparmor-profiles $ver's bwrap-userns-restrict matches the pin from $PIN_PROFILES_VERSION"
|
||||
else
|
||||
echo "upstream: DIFFERS: apparmor-profiles $ver's bwrap-userns-restrict has sha256 $sum; the pin is $PIN_PROFILE_SHA256 from $PIN_PROFILES_VERSION. Send it to Mos as a new-pin request."
|
||||
diff -u --label "installed $PROFILE" --label "apparmor-profiles $ver" "$ROOT$PROFILE" "$f" || true
|
||||
fi
|
||||
exit 0
|
||||
}
|
||||
|
||||
case "$MODE" in
|
||||
check) items; [ ${#FAILED[@]} -eq 0 ] || exit 1; exit 0 ;;
|
||||
enforce) enforce ;;
|
||||
upstream) upstream ;;
|
||||
esac
|
||||
@@ -0,0 +1,12 @@
|
||||
# Row 240 (I4, #1746): the Case 1 host pins, sourced by case1-hostcheck.sh, which is installed as
|
||||
# /usr/local/lib/mosaic-stack/hostcheck.sh with this file beside it. r6 B2
|
||||
# (agents/darkwing/work/queue-195/DESIGN.md at b7239bb66). Changing a pin is a reviewed commit and
|
||||
# its own round.
|
||||
#
|
||||
# The AppArmor profile 2a' installs as /etc/apparmor.d/bwrap-userns-restrict, taken from
|
||||
# /usr/share/apparmor/extra-profiles/bwrap-userns-restrict in this apparmor-profiles version.
|
||||
PIN_PROFILES_VERSION=4.0.1really4.0.1-0ubuntu0.24.04.9
|
||||
PIN_PROFILE_SHA256=11d39094f044f0cda0febb3ad517b830301da6b2ce929664af09ee9e4dd264f9
|
||||
# The bubblewrap version 2a' installs. An install pin, not a drift item: a security update keeps the
|
||||
# statoverride, and the profile attaches by path. hostcheck prints a line when they differ.
|
||||
PIN_BUBBLEWRAP_VERSION=0.9.0-1ubuntu0.3
|
||||
@@ -27,7 +27,8 @@
|
||||
# <argv>`, and a last `<time> result exit=N <code> <text>` line.
|
||||
#
|
||||
# Exit 0 ok, 1 failed with the old stack running, 2 rollback-failed with the units stopped,
|
||||
# 3 refused with nothing stopped, 4 usage.
|
||||
# 3 refused with nothing stopped, 4 usage, 5 args-failed: the new stack is live and confirmed, but
|
||||
# the args file still names the old copy.
|
||||
#
|
||||
# Test seams, for packages/cli/tests/stack-upgrade.test.mjs only. Run as root, it refuses any:
|
||||
# STACK_UPGRADE_ETC default /etc/mosaic-stack
|
||||
@@ -278,7 +279,7 @@ run_steps() {
|
||||
step confirm "${AS_USER[@]}" "$SR" confirm "$id" "$BUSINESS" --units external --copies "$COPIES_DIR" --timeout "$TIMEOUT" ||
|
||||
rollback_to_old "$id" A5
|
||||
if mv -f "$NEW_ARGS" "$ETC/$INSTANCE.args"; then logl "args rc=0 copy: $id"
|
||||
else logl "args rc=1 copy: $id"; finish 1 args-failed "$id is live and confirmed, but $ETC/$INSTANCE.args still names $OLD; put $NEW_ARGS in its place"
|
||||
else logl "args rc=1 copy: $id"; finish 5 args-failed "$id is live and confirmed, but $ETC/$INSTANCE.args still names $OLD; put $NEW_ARGS in its place"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user