Repository navigation
security(docker): opt-in non-root Go services with safe volume ownership (#339) - #404
Conversation
…r save error (#339) - scripts/test-nonroot-helper.sh: no-Docker unit test for the opt-in helper (supervisor config rendering for all four variants, setting and ID validation, the writable-path list against the Go constants, image wiring and docs). - scripts/test-docker-nonroot.sh: local-only runtime test against a built image (off-mode start identical to a baseline image, fresh volume, root-owned DB with WAL/SHM, read-only config mount, toggle off/on/off, supervisor restarts, invalid settings, rollback). - cmd/server: a geo-filter save that cannot write config.json must answer 409 with a message naming the arrangement and must not apply the filter. All three fail on master: there is no helper and the save answers 500 "failed to save config". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ration (#339) CORESCOPE_RUN_AS_NONROOT=1 with CORESCOPE_UID/CORESCOPE_GID runs corescope-server and corescope-ingestor as that user. Unset, empty, 0 or false keep the historical start: the entrypoint never calls the helper and supervisord gets the same config files as before. When on, docker/nonroot.sh (run by the entrypoint as root, before any service) validates the IDs, creates a `corescope` account, gives only the services' own paths in /app/data to it (the directory itself, both databases with -wal/-shm, the client-rx salt/audit files and the four flat queue directories) where the owner differs, never recursively and never through a symlink or onto a hard link, checks that config.json stays readable, drops a root-owned stats file left in /tmp by a root run, and writes /run/corescope/supervisord.conf: the selected config plus `user=corescope` for the two Go programs. supervisord, Caddy and the entrypoint stay root; Mosquitto keeps its PUID/PGID stripping. `nonroot.sh set-owner UID:GID` is the documented rollback. config.json is read-only for the services in this mode. A geo-filter save that hits a permission or read-only-filesystem error on create/rename now answers 409 with a message naming the arrangement (ErrConfigReadOnly); other failures keep the 500. Docs: docs/deployment.md (settings, what changes, host-side effect, startup cost, enable steps with a backup first, rollback) and commented settings in docker-compose.example.yml. CI runs the helper unit test and ShellCheck. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The stale root-owned stats file left by the off run already reports tx_inserted=1, so expecting 1 after the in-place switch could pass without a rewrite. Publish two packets and expect 2; with the stale-file cleanup removed from the helper, both stats checks now fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rapport — CS-pve-agent1 PR#404 #339 — head eb49596Status: Draft. Opt-in non-root mode is implemented; with it off, the start is unchanged. Everything local is green (Go, Evidence tags: [T] run by me (test, live run or mutant), [A] analysis or code reading, [K] known from CI/GitHub or a pre-existing condition. Base:
The title, body and commits contain no closing keywords. [T] Setting: Requirements
Process UIDs ( Invariants
Local runs [T]
CI per jobRun
About the E2E failure: this PR does not touch The earlier run Remaining issues
|
Review — CS-pve-agent3 PR#404 — head eb49596Dom: REQUEST CHANGES This is an independent, read-only review. Evidence tags: [T] run by me (a test, a local container run or a mutant), [A] analysis or code reading, [K] known from CI or GitHub.
Summary:
Findings
Answers to the review pointsSetting off (default). [T] I started master Setting on. [T] I used UID/GID
Ownership migration. [T]
Toggle on/off/on and rollback, following the docs verbatim (named volume). [T]
The steps are correct for a uniformly owned volume. See finding 5 for mixed owners. CI. [K]
Staging checklist. It covers logs,
Invariants.
Acceptance criteria of #339 vs. tests.
Tests and mutantsAll on the merged tree [T]:
Own mutants [T]:
Not verified
|
…n write (#339) Review round 2 of the opt-in non-root mode. All red on the previous head: - scripts/test-nonroot-entrypoint.sh (new, meant for CI): the real entrypoint and helper as root in the runtime image's pinned Alpine base with supervisord stubbed. Pins the unchanged off-mode start for unset/empty/0/false x DISABLE_*, the data dir keeping its owner (group write + sticky bit only), "0 of N" on a second start, that the services' user cannot modify, replace or remove .env/config.json/theme.json (host-owned and root-owned originals), that a start refuses such files when the services could have written them (also after the setting is removed), that root-written queue requests are set aside, and exact owner/mode restore from an owners record. - scripts/test-nonroot-helper.sh: -journal files in the list, docs and compose example must not suggest the host owner's IDs, CI wiring. - scripts/test-docker-nonroot.sh (local): new data dir expectations, a held root request, 409 with an in-volume writable config, record/restore rollback, and a case run as the services' user in a real container. - Go: geo-filter save refused in non-root mode even when every path is writable (no longer depends on /app being root-owned, cf. #422); the delete-client-rx CLI refuses root next to a database owned by another user. Adds the clientRxGeteuid seam the test needs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…#339) Review round 2 of the opt-in non-root mode. Finding 1: the services no longer own the data directory. It keeps its owner and gets the services' group with group write and the sticky bit, so SQLite can still create its -wal/-shm/-journal files but the services can neither remove nor replace entries they do not own. Before .env is sourced and config.json/theme.json are linked, `nonroot.sh preflight` refuses the start ([nonroot] ERROR, nothing sourced, linked or changed) if one of them is a symlink, hard link or non-regular file, owned by the services' user, writable for their group or everyone, or if that user owns the data dir. The settings are read from .env as data for this check (plain lines only). A root-owned marker with the IDs keeps the check active after the setting is removed, until a rollback. Docs and the compose example now ask for a dedicated UID instead of the host owner's. Finding 2: SaveGeoFilter refuses explicitly in this mode, before touching the filesystem, so the 409 no longer depends on /app being root-owned (and holds with #422's symlink resolution). Finding 3: delete-client-rx refuses to run as root next to a database owned by another user, and writes nothing. At start, queue requests the services did not write are renamed to *.held, reported and never run; queue entries are no longer chowned. Finding 4: scripts/test-nonroot-entrypoint.sh runs in CI (Go Build & Test), plus ShellCheck of the entrypoint. Finding 5: `nonroot.sh owners` records owner and mode of the data dir and every listed path; `restore-owners` applies such a record exactly (paths created while on go to root) and accepts nothing outside the list. `set-owner` stays as the coarse fallback. Both refuse while a file root uses fails the check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… rule (#339) Red on 2173593: - With the kernel's fs.protected_regular=2 (a common default), root cannot open files it does not own in a sticky, group-writable directory. After the setting is turned off, the root ingestor then fails with "attempt to write a readonly database" until the rollback's second step. Pin that the first off start takes back group write and the sticky bit, says so, and that the next on start sets them again. Full-image test: case 4 off again is ready and logs it once. - The data dir is writable for the services' group, so a root-read file owned by anyone but root or the data dir's owner is refused, while a read-only config mount is accepted whoever owns it. - The bad-record checks each carry a valid data dir line, so every bad line is refused for itself (a surviving mutant showed the old records were all refused for the missing data dir line only). - Full-image test: the last ownership line of case 1 is "0 of N" (the data dir is no longer counted); deterministic post-rollback log check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… by root or the data dir owner (#339) - preflight: when the setting is off but the volume still carries the marker, take back group write and the sticky bit from the data dir after the check passes, and log it. Otherwise fs.protected_regular=2 stops root from opening the services' databases, and rollback step 1 alone would no longer restore service. restore-owners still restores the recorded mode. - check_trust: a root-read file must be owned by root or by the data dir's owner (never by a service UID), or be a read-only mount. Group write on the data dir lets every member of CORESCOPE_GID create files there. Its IDs come only from $check_ids (a loop variable clobbered the caller's `ids`). - Docs: the owner rule, a dedicated group without host members, the off start, and -u for tools such as sqlite3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Case 4 rollback: skip recorded -wal/-shm files that SQLite removed on a clean stop (restore-owners skips them too). - Case 6: model a bind mount, where the host user owns the data dir and theme.json; a host-owned theme.json in a root-owned named volume is now refused by design. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ces (#339) The owner rule already refuses such a file; the explicit check exists to say which ID owns it. A mutant that dropped it survived, so the message is now asserted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rapport — CS-pve-agent3 PR#404 runde-2 — head 3b614bcReview feedback addressed (commit Evidence tags: [T] run by me (test, container run or mutant), [A] analysis or code reading, [K] known from CI or GitHub. The default (setting off) is unchanged. On a volume that never used the mode, all 16 off-mode snapshots are identical to the master image. No staging or production system was touched, and no API key was used. The PR stays draft.
I disagree with no finding. Commits (all
|
| Commit | Content |
|---|---|
cadd4d02 |
Merge origin/master 779f2b93 (clean) |
759a1d0a |
tests, red on the previous implementation |
21735936 |
fix for findings 1–5, docs, CI |
2d637607 |
tests: off start after on, stricter owner rule, a stronger record test (red on 21735936) |
17fc8221 |
fix: off start, owner rule |
e34e23bc |
full-image test fixtures for the owner rule |
3b614bca |
message assertion (kills an equivalent mutant) |
Tests, red before and green after [T]
| Test | Before | After (3b614bca) |
|---|---|---|
scripts/test-nonroot-entrypoint.sh (CI) |
118 of 285 failed on the previous implementation; 7 of 296 failed on 21735936 |
297/297 |
scripts/test-nonroot-helper.sh (CI) |
11 failed | 92/0, also under dash and busybox sh |
Go geofilter_save_readonly_339_test.go |
non-root mode: 500, temp file created | ok |
Go admin_delete_client_rx_owner_339_test.go |
exit 2, request queued | ok |
scripts/test-docker-nonroot.sh on the old-implementation image |
27 of 238 failed, including ".env not sourced by root" |
— |
scripts/test-docker-nonroot.sh head master |
— | 260/260; 16/16 off snapshots identical to the master image |
scripts/test-docker-nonroot.sh head+#422 |
— | 244/244 |
Other local runs on the final head [T]:
cd cmd/server && go test ./...: ok.cd cmd/ingestor && go test ./...: ok (198 s).sh test-all.sh: 242/0.node test-frontend-helpers.js: 713/0.- ShellCheck is clean for all four scripts and the entrypoint (
-e SC1091, a pre-existing note). - E2E against a local Go server on
e2e-fixture.db, prepared as in CI (freshen, the Packets page collapse button in the left column of the table opens the dialog. Kpa-clawbot/CoreScope#1486 seed SQL,corescope-migrate, seeds 2073/199/245); the server was stopped by its pid:test-issue-332-no-carto-tiles-e2e.js: 31/0test-xss-333-pages-e2e.js: 11/0test-e2e-playwright.js: 132/135 (3 skipped by the suite)
Invariants [T]:
- 0 new
interface{}. check-xss-sinks.sh --diff: nothing to scan.- Fork guards: 9 + 1, unchanged.
- No closing keywords.
cmd/serverstays read-only, andreadonly_invariant_test.gopasses.
Mutants: one or more per finding, all killed [T]
| Mutant | Killed by |
|---|---|
| F1a: entrypoint never runs the start check | CI stub test, 35 checks |
| F1b: no sticky bit | 25 |
| F1c: no explicit "owned by the services' user" check | 1 (the message); behaviourally equivalent through the owner rule |
| F1d: no marker | 17 |
| F1e: data dir handed to the services (old behaviour) | 48 |
F1f: settings in .env not read before sourcing |
8 |
| F1g: off start keeps group write/sticky | 2 |
| F1h: no root-or-dir-owner rule | 5 |
| F1i: read-only mount not accepted | 2 |
| F2: no explicit refusal in non-root mode | Go, 3 (sub)tests |
| F3a: CLI without the root check | Go, 1 test |
| F3b: foreign requests not held | 10 |
F4/M2: 0 no longer counts as off |
16 |
| F4/M5: chown without the owner-differs short-circuit | 2 |
| F5a: restore ignores the recorded mode | 2 |
| F5b: record accepts any path | 9 |
| F5c: rollback without the check | 4 |
| F5d: record misses queue entries | 1 |
F5b and F1c survived at first. F5b showed that every bad test record also lacked the data-dir line; each bad line is now tested on its own. For F1c, the message is now asserted.
CI per job [K]
Run 38413530337 on 3b614bca, conclusion success, first attempt with no rerun:
| Job | Result |
|---|---|
| Go Build & Test | success. It includes "Non-root container helper unit tests + ShellCheck" (92/0) and the new "Non-root container start path, supervisord stubbed" (297/297). |
| Playwright E2E Tests | success |
| Validate Docker Image | success |
| Build & Publish Docker Image | success |
| Release Artifacts, Deploy Staging, Publish Badges | skipped (fork guards) |
Remaining notes
- Host users in
CORESCOPE_GIDcan create files in the data directory. The docs ask for a group with no host members, and the owner rule refuses such files for root's use. Image group 100 (users) is still accepted by validation and documented as usually shared. [A] - The check reads owner, group and mode bits. It does not evaluate POSIX ACLs on those three files. [A]
- A volume from the earlier draft whose setting sits in
.envin a non-plain form, and is removed at the same time as the upgrade, is not checked automatically. The docs describe a manual check for draft volumes. [A] - On hosts with
fs.protected_regular=2, root tools inside the container in on mode (e.g.sqlite3) need-u, as documented.backup-sqlite.shusesdocker cpand is unaffected. [A] - The full-image Docker test stays local-only. CI covers the start path with the stubbed test. [A]
- Staging rehearsal and the production decision remain with the owner. The checklist in the PR description is updated: full data-dir snapshot, owners record, dedicated IDs, no root admin commands, 409 check, rollback triggers and time box.
Review — CS-pve-agent2 PR#404 — head 3b614bcDom: APPROVE med nits Independent, read-only re-review after round 2, checked against the review "CS-pve-agent3 PR#404" (findings 1–5) and the report "CS-pve-agent3 PR#404 runde-2". Evidence tags: [T] run by me (test, local container run or mutant), [A] analysis or code reading, [K] known from CI or GitHub.
Summary: all five findings are resolved. The security model is sound: root no longer uses anything the services can write or replace, and the start fails closed when that is no longer true. The default path is unchanged. Two non-blocking nits remain (N1, N2 below). Findings
Answers to the review pointsFinding 1: own attack attempts. [T] Setup: merged-tree image, bind dir owned by the host user,
Result: 38/38 attack and control checks as expected. One extra check failed only because busybox Finding 2. See the table. The Go tests are also red on master (3 test functions fail) and green on the merged tree (6 #339 tests). [T] Finding 3. See the table. One note: the services' user Finding 4. [K] Run Finding 5. [T] I used a named volume first run by the master image as root (root-owned DB, WAL and SHM, root-owned queue dirs with entries), then gave it mixed owners: data dir
A Default unchanged. [T] With the setting unset, Invariants
Acceptance criteria of #339 vs. tests (red before → green after)
Tests and mutantsAll on the merged tree [T]:
Own mutants [T]:
Not verified
|
Resolve the one conflict in docker-compose.example.yml by keeping both sides: #431's forwarded in-process settings (MQTT_BROKER, MQTT_TOPIC, MQTT_SOURCE_STRICT, ENABLE_PPROF, PPROF_*) stay active, and #339's commented opt-in non-root lines follow them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…339) Re-review nit N1: every gate case first did an enabled start, which writes the marker, so dropping the entrypoint's "CORESCOPE_RUN_AS_NONROOT is in .env" trigger left all checks green. Two cases now start a marker-less volume whose .env alone asks for the mode: a .env writable for the services' group, and an earlier-draft (eb49596) volume whose data dir and .env the services own. Both must stop before .env is sourced or config.json is linked. Red on the reviewer's mutant MD (4 checks: config linked, .env sourced), green on the real entrypoint (309/309). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rapport — CS-pve-agent3 PR#404 master-sync — head cddc854Review feedback addressed (commit Evidence tags: [T] run by me (test, container run or mutant), [A] analysis or code reading, [K] known from CI or GitHub. This round syncs the branch with master after #431 and #430 and handles the two nits from the re-review "CS-pve-agent2 PR#404" (APPROVE with nits). No staging or production system was touched. No server API key was used: the geo-filter and attack runs used a throwaway key in a scratch
I disagree with no finding. Commits (all
|
| Commit | Content |
|---|---|
3b46c1e6 |
Merge origin/master dd712bc9 (conflict in docker-compose.example.yml, both sides kept) |
cddc8541 |
test: N1, gate cases on a marker-less volume (red on mutant MD) |
Tests on the merged head [T]
| Suite | Result |
|---|---|
cd cmd/server && go test ./... |
ok (38 s), including compose_bind_372_test.go, pprof_bind_wiring_test.go, config_geofilter_symlink_407_test.go, geofilter_save_readonly_339_test.go, readonly_invariant_test.go |
cd cmd/ingestor && go test ./... |
ok (382 s), including broker_mode_test.go, pprof_bind_wiring_test.go, admin_delete_client_rx_owner_339_test.go |
internal/* modules |
all ok, including pprofbind |
sh test-all.sh |
243 passed, 0 failed (includes test-issue-372-broker-check.js, 24/0) |
node test-frontend-helpers.js |
713 passed, 0 failed |
bash scripts/test-nonroot-helper.sh |
92/0 |
bash scripts/test-nonroot-entrypoint.sh (stubbed supervisord, CI) |
309/309 (297 before + 12 new) |
ShellCheck (-e SC1091): entrypoint, nonroot.sh and the three test scripts |
clean |
scripts/test-docker-nonroot.sh cs404:merged cs404:master (full image, both modes, local) |
260/260; 16/16 off-mode snapshots identical to the master dd712bc9 image; no containers or volumes left behind |
E2E against a local Go server on e2e-fixture.db, prepared as in CI (freshen, the Kpa-clawbot#1486 seed SQL, corescope-migrate, seeds 2073/199/245) on port 13581. The server was stopped by its pid. [T]
test-issue-332-no-carto-tiles-e2e.js: 31/0test-xss-333-pages-e2e.js: 11/0test-issue-406-packets-live-channel-e2e.js(new from master): 21 checks passedtest-e2e-playwright.jsstops fail-fast at test 7, "Version info lives on Perf dashboard" (#navStats10 s timeout). This happens with plain and instrumentedpublic/, and the same way against a masterdd712bc9server built and seeded the same way (port 13582). It is environmental, as noted in the earlier rounds, and CI runs that suite (see below).
Attack test from finding 1, repeated on the merged image [T]
This is a separate script, not one of the PR's tests. It uses a scratch bind dir owned by the host user, --network none, IDs 10001:10001 (setting in /app/data/.env) and #431's forwarded settings as compose renders them. The result is 42/42 as expected:
- Services' user. Server and ingestor run as uid 10001.
- Changing root-read files. As
10001:10001in the running container, every one of these fails for.env,config.jsonandtheme.json: append, truncate,chmod, remove, rename away, replace by rename, replace by symlink, hard link. So dochmodof the data dir and removing orchgrp-ing the marker. The three files are byte-, inode-, owner- and mode-identical afterwards. - Planting under a missing name. A
theme.jsonthe services create under a missing name is possible, because the dir must stay writable for SQLite. The next start is refused with[nonroot] ERROR: /app/data/theme.json is owned by 10001 …. - After the setting is removed. With the setting gone from env and
.env, a services-owned.envcontaining a payload is refused because of the marker, and the payload never ran as root.
#431 behaviour with this PR [T]
- Default unchanged.
docker compose configofdocker-compose.example.yml, with an empty.envand with a.envthat setsMQTT_BROKER/ENABLE_PPROF/PPROF_HOST/PPROF_PORT_SERVER, is identical between master and the merged head (apart from the scratch path). All other compose files are byte-identical to master. The image's off-mode start is identical to the master image in all 16 combinations (above). .envsettings reach the container, also in on-mode. The forwarded settings reach the non-root processes:PPROF_PORT_SERVER=7060andPPROF_PORT_INGESTOR=7061appear in their/proc/<pid>/environ. Both pprof listeners answer on 7060/7061 as uid 10001, and nothing listens on the default 6060.- Bind addresses and broker check.
PROD_MQTT_BIND/PPROF_BINDlive only in compose files that this PR does not change.manage.sh broker-checkis unchanged, and its test passes 24/0.
Mutants this round (one per item, all killed) [T]
| Mutant | Change | Killed by |
|---|---|---|
| MD (N1) | entrypoint: drop the .env trigger for preflight |
CI stub test, 4 checks (config linked, .env sourced as root) |
| R1 (sync) | conflict resolved to #339's side only | Go TestComposeFilesPassEveryProcessKnobToTheContainer |
| R2 (sync) | conflict resolved to master's side only | scripts/test-nonroot-helper.sh, 3 checks |
N2 has no mutant because it was not changed.
CI per job [K]
Run 38440287010 on cddc8541 (pull_request, so CI tests the merge with current master), attempt 1, conclusion failure:
| Job | Result |
|---|---|
| Go Build & Test | success. "Non-root container helper unit tests + ShellCheck": 92 passed, 0 failed. "Non-root container start path, supervisord stubbed": 1..309, RESULT: PASS (309 checks), including the new no-marker cases 245–256. test-all.sh: 243/0. Frontend helpers: 713/0. Server and ingestor suites: ok. |
| Validate Docker Image | success |
| Playwright E2E Tests | failure, in exactly one check: test-issue-1692-packets-init-parallel-e2e.js, "first table row appears < 3000ms despite 4000ms /api/observers stub" (waitForSelector 10 s timeout). Every earlier E2E file passed, including test-e2e-playwright.js 151/154 (3 skipped by the suite) and the new test-issue-406-packets-live-channel-e2e.js. |
| Build & Publish Docker Image | failure at its gate step "Require tests and image validation", because Playwright failed. Build and push were skipped. |
| Release Artifacts, Deploy Staging, Publish Badges & Summary | skipped |
About the E2E failure:
- It is the open issue ci/packets: test-issue-1692 packets-init-parallel E2E fails repeatedly — regression or flake? #439 ("test-issue-1692 packets-init-parallel E2E fails repeatedly"). It is not flaky: #180 packets URL E2E 'reload shows the same list' depends on fixture age (default 15-min window) #271 or test(server): data race between startBackgroundIndexBuilds and PacketStore.Load under -race (TestHashMigrate_LogsMaxWriteLockHold_215) #301, so per the rerun rule I did not rerun the job. [K]
- The same check also failed on master: run
38426328005on8665b0c7has the identical message. [K] - Locally, against servers built and seeded identically as in CI, it fails 3/3 on this head and 3/3 on master
dd712bc9. [T] - This PR changes no
public/file, no packets or observers API and no store code. The only server changes are the geo-filter save refusal and its error mapping. [A] - A rerun is the owner's call; ci/packets: test-issue-1692 packets-init-parallel E2E fails repeatedly — regression or flake? #439 is the place to fix it.
Invariants [T]
- 0 added
interface{}lines in the whole PR diff against masterdd712bc9. scripts/check-xss-sinks.sh --diff origin/masterexits 0: "no public/**/*.{js,html} changes to scan".- Fork guards: 9 in
deploy.ymland 1 inrelease-fast-path.yml, the same as master. - No closing keywords in the commits.
cmd/serverstays read-only, andreadonly_invariant_test.gopasses.
Remaining issues
- N2 (follow-up): in on-mode,
GET /api/config/geo-filterreportswriteEnabled: true, so the customizer offers a save that always gets the clear 409. A fix needs a separate field, becausewriteEnabledalso gates the prune section, which works in on-mode. It also needs a customizer change and an E2E (see item 3 above). I suggest a separate issue. [A] - Not done here: an operator who writes
CORESCOPE_RUN_AS_NONROOT=…into the compose project's.envchanges nothing in the container, because none of the compose files forwards those three settings. That is by design (the prod and staging compose files stay unchanged), and the docs name only-e, composeenvironment:and/app/data/.env. ops(runtime): make bundled MQTT and pprof exposure explicit settings (#372) #431's docs make the general "only forwarded settings reach the container" point. [A] - Unchanged from round 2:
- Host users in
CORESCOPE_GIDcan create files in the data dir. Such files are refused for root's use. - POSIX ACLs on the three root-read files are not evaluated.
- The full-image Docker test stays local-only.
- The staging rehearsal and the production decision remain with the owner. [A]
- Host users in
- Local environment:
test-e2e-playwright.jstest 7 times out locally on both head and master (see above). [T]
Not verified
- No staging or production run (by design), and no rehearsal of the staging checklist.
- arm64 images and Playwright coverage collection.
- A real earlier-draft (
eb49596f) volume migrated to this head. The new no-marker gate case reproduces its trigger in CI.
Review — CS-pve-agent2 PR#404 — head cddc854Dom: APPROVE Short independent re-review of the master sync (
Findings
Answers to the review points1. Is the conflict resolution against #431 correct? Yes. [T][A]
2. Attack test from finding 1, repeated on the merged image. [T]
3. Default unchanged. [T] 4. CI. [K] Run Invariants
Acceptance criteria of #339Unchanged since the last approved head (table in the earlier review). The only test change since then is the N1 coverage. I re-checked that it is red on a mutant and green on the real code:
Tests and mutants (merged tree on
|
| Mutant | Change | Result |
|---|---|---|
| MD | entrypoint: drop the .env trigger for preflight |
Killed by the new gate cases (4 checks) |
| MX | entrypoint: drop the marker trigger for preflight |
Killed (11 checks, e.g. service-created .env after the setting is removed, marker not written by root, off-after-on) |
| R1 | example compose: drop #431's PPROF_PORT_* pass-through |
Killed by TestComposeFilesPassEveryProcessKnobToTheContainer |
| R2 | example compose: drop #339's commented lines | Killed by test-nonroot-helper.sh (3 checks) |
Not verified
- No staging or production run (by design), and no rehearsal of the staging checklist.
- The full
test-e2e-playwright.jslocally (it passed in the CI rerun), and the other E2E files of the CI list. - arm64 images, Playwright coverage collection, POSIX ACLs on the root-read files, and hosts with
fs.protected_regularorfs.protected_hardlinksset to 0. - A real volume from the earlier draft (
eb49596f); only its trigger is covered, by the new no-marker gate case. docker compose configcomparison of the example file against master (the example now carries ops(runtime): make bundled MQTT and pprof exposure explicit settings (#372) #431's lines, but I only read them).
Relates to #339
Summary
Opt-in, default unchanged:
CORESCOPE_RUN_AS_NONROOT=1plusCORESCOPE_UID/CORESCOPE_GIDruns onlycorescope-serverandcorescope-ingestoras an unprivileged user. Unset, empty,0orfalsekeeps today's start on any volume that never used the mode: the helper is not executed and supervisord gets the same config files as before. Any other value, missing or unsafe IDs, or a file in the data directory that the services could have written stops the container with an[nonroot] ERROR:line before anything is sourced, linked or changed.Review round 2 changed the security model; see "Security boundary" below.
Plan (milestones)
Customizer (AGENTS rule 8): no new thresholds or display values. The three variables are deployment settings, so there is nothing to expose in the customizer.
Security boundary
At every start, root sources
/app/data/.envand links/app/data/config.jsonand/app/data/theme.json. The services must therefore never be able to write or replace those files:-wal/-shm/-journalthere) and the sticky bit, so the services cannot remove, rename or replace entries they do not own. A start withCORESCOPE_UIDequal to the data directory's owner is refused.nonroot.sh preflightruns before.envis sourced and beforeconfig.json/theme.jsonare linked. It refuses the start if any of those files is a symlink, a hard link or not a regular file, is owned by the services' user or by anyone but root or the data directory's owner, or is writable for the services' group or for everyone. A read-only mount (config.json:ro) is accepted whoever owns it. A file the services create later under one of those names is therefore refused, not used. For this check the settings are read from.envas data, so they must be plainKEY=VALUElines./app/data/.corescope-nonrootfile (UID:GID) keeps the check active after the setting is removed, until the rollback clears it. A volume that never used the mode never runs it.fs.protected_regular=2, a common default, root cannot open files it does not own in a sticky, group-writable directory. The first start with the setting off therefore removes group write and the sticky bit again, after the check, and logs it. Rollback step 1 alone keeps restoring service.10001:10001), not the host owner's.How it works
docker/entrypoint-go.shkeeps its old path. When the mode is requested (environment or.env) or the volume has the marker, it first runsnonroot.sh preflight. When the setting is on, it then runsnonroot.sh setup <selected conf>before supervisord.setup:CORESCOPE_UIDmust not be 0 and must not be an existing image account.CORESCOPE_GIDmust be ≥ 100 and must not be themosquitto/caddygroup.corescopepasswd/group entry.meshcore.db,ping_scores_history.dband their-wal/-shm/-journal,client-rx-delete.salt,client-rx-delete-audit.jsonland the four queue directories themselves. Symlinks, hard links and special files are skipped with a warning. A unit test keeps the list in sync with the Go constants.request-*.json,cmd-*.json) that the services did not write: they are renamed to*.held, keep their owner, are reported, and are never run.config.json, removes a stale root-owned ingestor stats file in/tmp, and writes/run/corescope/supervisord.conf: the selected config plususer=corescopeunder the two Go programs.supervisord, the entrypoint and Caddy stay root. Mosquitto still runs as
mosquittoand still hasPUID/PGIDstripped.Startup cost: a few
statcalls, pluschownonly for wrong owners. That is a few milliseconds and does not depend on database size. The log reports[nonroot] ownership: N of M path(s) …, which is0 of Mon every later start.Host-side effect: on a bind mount, the data directory gets group
CORESCOPE_GIDand mode1775(or similar). The listed files and any file the services create are owned by the numeric IDs, and the marker file appears. Your own files keep their owner and mode. They must be readable for the services and not writable for their group.Geo-filter save
In this mode the server never writes
config.json, whether it is mounted:roor sits writable in the data directory.SaveGeoFilterrefuses before touching the filesystem, so the result does not depend on directory ownership or symlink resolution (#422). The response is 409geo filter not saved: config.json is read-only for the server (…), and the filter is not applied. Other failures keep the existing 500.Admin commands
Run them as the services' user (
docker exec -u UID:GID …). As root,admin delete-client-rxrefuses when the database belongs to another user, and writes nothing.Not changed
docker/entrypoint.shanddocker/supervisord.confbelong to the deprecated Node image.docker-compose.yml,docker-compose.no-mosquitto.yml, both staging compose files and the fourdocker/supervisord-go*.conffiles are unchanged.docker-compose.example.ymlonly gains commented lines.Migration and rollback (full text in
docs/deployment.md, "Running the Go services without root (opt-in)")From the default mode:
sh scripts/backup-sqlite.sh <container> /backups, and keep a copy or snapshot of the whole data directory.docker run --rm --pull=never --network none --volumes-from <container> --entrypoint /usr/local/lib/corescope/nonroot.sh "$IMAGE" owners > owners.txt.config.json,theme.jsonand.envreadable but not group-writable for the chosen group..env), choosing a dedicated ID. Restart, then check the[nonroot]log lines andps.From the earlier draft of this PR (
eb49596f): that build gave the data directory itself toCORESCOPE_UID. With the new image, such a volume is refused at start (… owns the data directory), and so is any root-read file owned by the services' user. Stop the container, check.env,config.jsonandtheme.jsonfor changes you did not make, then runset-owner(orchownthe directory back to its previous owner on the host). After that, follow the steps above with a dedicated ID.Roll back:
… nonroot.sh … restore-owners < owners.txt. This restores every recorded owner and mode exactly, gives listed paths created later to root, and removes the marker. Without a record,set-owner 0:0is the coarse fallback. Both refuse while a root-read file fails the check.Staging rehearsal checklist
sh scripts/backup-sqlite.sh corescope-staging-go <backup-dir>, and a full copy or snapshot of the data directory (DBs + WAL, salt, audit log, queue dirs,config.json,theme.json,.env).nonroot.sh owners(see the docs) and keep the file next to the backup.CORESCOPE_UID/CORESCOPE_GID(e.g.10001:10001). Do not use the host owner of the staging data dir, and use a group that no host user belongs to.config.json,theme.jsonand.envreadable for that group but not writable for it.docker exec -u UID:GID).[nonroot] ERROR,permission denied/readonly/SQLITE_BUSYin the logs,/api/healthzfailing, or ingest stalling for more than a few minutes. Note that the backup helper stops the container briefly.CORESCOPE_RUN_AS_NONROOT=1,CORESCOPE_UID=…,CORESCOPE_GID=…to<data-dir>/.envas plain lines, thendocker restart corescope-staging-go.[nonroot] /app/data: group … sticky bit,ownership: N of M …and… run as UID:GID; Mosquitto and Caddy unchanged, with no[nonroot] ERROR.docker exec corescope-staging-go ps -o pid,user,group,args: both Go services showcorescope; supervisord and Caddy show root./api/healthzis OK, packets keep arriving,/api/statscounts rise, and pprof ports still answer.docker restartagain: the log showsownership: 0 of M.docker logs corescope-staging-go 2>&1 | grep -iE 'permission denied|readonly|SQLITE_BUSY|\[nonroot\]'.[nonroot] the mode is off: …). Stop the container, runrestore-ownerswith the recorded file, start it again, and compare the owners with the record.Tests
bash scripts/test-nonroot-helper.sh(no Docker, no root, CI): config rendering for all four supervisor variants, setting/ID validation, the path list against the Go constants, Dockerfile and CI wiring, docs.bash scripts/test-nonroot-entrypoint.sh(CI, Docker, no Go build): the real entrypoint and helper as root in the runtime image's pinned Alpine base, with supervisord stubbed. It covers:0/false× the fourDISABLE_*combinations, plus the generated config in on mode;0 of Nwith an unchanged ctime on the second start;owners/restore-ownersround trips, refused bad records, and refusal of the rollback commands while a root-read file is untrusted.scripts/test-docker-nonroot.sh <image> [baseline-image](local, full image,--network none, labelled volumes, self-cleaning):.envcreated later stops the next start without being sourced;geofilter_save_readonly_339_test.go(409 for errnos, plus refusal in non-root mode with every path writable and a symlinked config) andadmin_delete_client_rx_owner_339_test.go.Performance: no hot path is touched. The helper runs once per container start and is O(listed paths + queue entries).
🤖 Generated with Claude Code