a blessed directory for container bind mounts

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 20:54:39 +00:00
co-authored by Claude Opus 5
parent f0af7237db
commit 401dcb710c
2 changed files with 77 additions and 1 deletions
+23 -1
View File
@@ -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 disk today so nothing is broken, but account deletion will need `userdel` and a sudo `rm` to stop
leaving an orphaned, unremovable directory behind. leaving an orphaned, unremovable directory behind.
- **`confineUserTree` sets `DATA_PATH` itself to 711.** If a container ever bind-mounts a path under - **`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 ## Stages
+54
View File
@@ -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 }; return { ok: true };
} catch (ex) { } catch (ex) {
return { ok: false, error: ex instanceof Error ? ex.message : String(ex) }; return { ok: false, error: ex instanceof Error ? ex.message : String(ex) };