diff --git a/docs/per-user-linux-accounts.md b/docs/per-user-linux-accounts.md index a919da83..33155b35 100644 --- a/docs/per-user-linux-accounts.md +++ b/docs/per-user-linux-accounts.md @@ -390,7 +390,29 @@ Two consequences worth knowing: disk today so nothing is broken, but account deletion will need `userdel` and a sudo `rm` to stop leaving an orphaned, unremovable directory behind. - **`confineUserTree` sets `DATA_PATH` itself to 711.** If a container ever bind-mounts a path under - `DATA_PATH` and runs as another uid, it will traverse but not list. Nothing does today. + `DATA_PATH` and runs as another uid, it will traverse but not list. + + **This happened, and it was worse than the note predicted.** Reported from a live server: a bind-mounted + `postgres:18-alpine` crash-looped with `mkdir: can't create directory '…/18/docker'` on a directory that + already existed. Two reasons the prediction was too mild. The image's inner uid is 70, which maps through + the member's subuid range to 231141 — neither the service user nor the member, so `other`. And by then the + home carried `default:other::---` from the ACL work, so `other` had lost even the traverse bit that the 711 + reasoning assumed. Traverse-but-not-list became no-traverse-at-all. + + `3bea46f`'s fix — stripping defaults from `~/.local/share/docker` — covered NAMED VOLUMES only. A bind + source lives wherever the member put it. A named volume passes with the bug present, which is exactly why + that fix looked complete. + + Now: `~/.local/dockers` is provisioned at `711` with **all** ACLs removed (`setfacl -R -b`, not `-k`), and + is the documented place for compose bind mounts. `711` rather than `700` is the point — a container's inner + uid needs `x` to reach a bind source inside, and no ACL can grant what the mode denies. `-b` rather than + `-k` because `-k` left `mask::---` behind, so inherited named entries read as `rwx #effective:---`: an ACL + that says one thing and means another. + + Bounded deliberately. A member bind-mounting from elsewhere in their home still hits the denial; this is + the place that works, not a guarantee about everywhere. The alternatives were worse — extending the strip + cannot work when the member chooses the path, and `d:other::--x` on the whole home loosens every directory + forever to fix one local case. ## Stages diff --git a/src/servers/os-user.ts b/src/servers/os-user.ts index 6f33127d..8d3354f9 100644 --- a/src/servers/os-user.ts +++ b/src/servers/os-user.ts @@ -371,6 +371,60 @@ export async function confineUserTree(params: { } } + // ── One directory where container bind mounts can live ── + // + // The default ACLs above are inherited by everything created in the home afterwards, including + // `default:other::---`. A rootless container's INNER uid is neither the service user nor the member — + // postgres:18-alpine runs as uid 70, which maps through the member's subuid range to 231141 — so it is + // `other`, and `other` has no `x`. It cannot traverse a directory it otherwise owns. + // + // `3bea46f` stripped defaults from `~/.local/share/docker` and concluded the problem solved. That fixed + // NAMED VOLUMES only. A bind mount lives wherever the member put it, and there it hits the same denial by + // a different route — reported from a real server as `mkdir: can't create directory '…/18/docker'` on a + // directory that already existed. A named volume passes with this bug present, which is exactly how the + // first fix looked complete. + // + // Three ways to fix it, and this is the third: + // + // - extend the strip to wherever the bind source is → unbounded, the member chooses the path + // - `d:other::--x` on the whole home → traverse for every uid, forever, to fix one local case + // - bless ONE directory → scoped, predictable, and already where members work + // + // The cost is that the file browser cannot read inside it, which is the same trade already accepted for + // Docker's internal storage — consistent rather than a new exception. Not enforced: a member can bind + // mount from anywhere and will hit the denial there. This is the documented place that works. + const composeDir = join(home, '.local', 'dockers'); + const madeComposeDir = await run([ + 'sudo', + '-n', + 'install', + '-d', + '-o', + String(params.uid), + '-g', + String(params.gid), + // 711, not 700, and this is the whole point of the directory. A container's inner uid is `other`, so it + // needs `x` HERE to reach a bind source inside — 700 blocks the path before any ACL matters. `r` stays + // off, so nothing can list it. Safe because the home above is 700: no other account can traverse this + // far in the first place, and the only uids that get here are the member's own containers. + '-m', + '711', + composeDir, + ]); + if (!madeComposeDir.ok) return { ok: false, error: `could not create ${composeDir}: ${madeComposeDir.out}` }; + + // `-b`, not `-k`: remove ACCESS entries as well as defaults, leaving plain POSIX modes. + // + // `-k` alone left `mask::---` behind — inherited named entries with every permission masked off, reading + // as `user:pastilhas:rwx #effective:---`. An ACL that says one thing and means another is worse than no + // ACL, and container storage is the one place in the home that wants ordinary mode bits and nothing else. + // + // AFTER the recursive grant above, or what it just set is re-inherited here. + const stripped = await run(['sudo', '-n', 'setfacl', '-R', '-b', composeDir]); + if (!stripped.ok) { + return { ok: false, error: `could not clear inherited ACLs from ${composeDir}: ${stripped.out}` }; + } + return { ok: true }; } catch (ex) { return { ok: false, error: ex instanceof Error ? ex.message : String(ex) };