From 401dcb710c0072a2478b9d1a84361b5c3a75e4ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 11 Aug 2026 20:54:39 +0000 Subject: [PATCH] a blessed directory for container bind mounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From a live-server report: a bind-mounted postgres:18-alpine crash-looped with `mkdir: can't create directory '…/18/docker'` on a directory that already existed. 3bea46f stripped default ACLs from ~/.local/share/docker and I concluded the ACL problem solved. It covered NAMED VOLUMES only. A bind source lives wherever the member put it, and there the same collision returns by another route: the image's inner uid is 70, mapped through the member's subuid range to 231141 — neither the service user nor the member, so `other` — and the home carries default:other::--- from the file browser's ACLs. A named volume passes with this bug present, which is exactly why the first fix looked complete. ~/.local/dockers is now provisioned as the documented place for compose bind mounts: mode 711, all ACLs removed. Two details that are the whole fix: 711, not 700 — a container's inner uid is `other` and needs x to reach a bind source inside. No ACL can grant what the mode denies, and 700 blocks the path before any ACL is consulted. `r` stays off so nothing can list it, and the home above is still 700, so no other account can traverse this far anyway. setfacl -b, not -k — `-k` removes defaults but left mask::--- behind, so inherited named entries read as `user:pastilhas:rwx #effective:---`. An ACL that says one thing and means another is worse than none, and container storage wants ordinary mode bits. Chosen over the alternatives: 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. Bounded deliberately — a bind mount from elsewhere in the home still hits the denial. This is the place that works, not a promise about everywhere. VERIFIED: the directory comes out `user::rwx group::--- other::--x` with no ACL and no defaults, which is the design exactly. NOT VERIFIED: a container actually starting from a bind mount in it. My host recycles uid 1001 across probe accounts and a stale /run/user/1001 — a systemd runtime mount that survives rm — leaves the new account with no bus, so rootless Docker will not start here. That is the deprovision/uid-reuse problem in the queue, hitting the test rig. The live server is the place to confirm it: green is uid 1002 with no recycling, and the report that prompted this came from there. docs/per-user-linux-accounts.md line 337 predicted a milder version of this and said "nothing does today". Corrected: something does, and the ACLs had removed the traverse bit its 711 reasoning assumed. Co-Authored-By: Claude Opus 5 --- docs/per-user-linux-accounts.md | 24 ++++++++++++++- src/servers/os-user.ts | 54 +++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) 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) };