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:
@@ -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
|
||||
|
||||
|
||||
@@ -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) };
|
||||
|
||||
Reference in New Issue
Block a user