rootless docker per member — provisioning works, running a container does not yet

Not finished. Committed because the diagnosis is worth more than the code.

WHY ROOTLESS AND NOT THE DOCKER GROUP. `usermod -aG docker <user>` is the one-line version
and it is root: `docker run -v /:/host -it alpine chroot /host` is a root shell, which reads
.env, every other member's home and the wallet seed. Every boundary from today, bypassed by
one documented command. Rootless gives what was actually asked for — a daemon per account,
containers in that account's user namespace, images in their own home.

VERIFIED on this host: provisioning succeeds, the server reports 29.5.0, the daemon runs as
the member, `docker pull` puts 403 MB under their own home, and `docker ps -a` shows nothing
while the owner has four containers. That last line is the isolation, measured.

NOT VERIFIED: actually running a container. It failed, and the cause is an interaction
between two things built today:

  failed to copy xattrs: failed to set xattr "system.posix_acl_default" on …/volumes/…/_data

Creating a volume copies xattrs, and the DEFAULT ACLs on a member's home — added so the file
browser could read their files — are inherited by Docker's storage, where a mapped id inside
a user namespace is not a valid id to set. Both features correct alone. The fix here strips
default ACLs from ~/.local/share/docker only, leaving the access ACLs the file browser needs.

That fix is UNPROVEN. The re-test failed for a different, environmental reason: probe users
recycle uid 1001, and a stale lingering systemd user manager from a previous probe answered
`systemctl --user`, so the unit appeared not to exist. Cleaned with `loginctl terminate-user`.
Retest on a machine that has not had a uid-1001 user, or on a fresh uid.

Also worth knowing before this ships: uid reuse after deleting a member is a real hazard, not
just a test artefact — the next member gets the previous member's uid, and anything left
lingering belongs to them.

setup.sh gains uidmap and dbus-user-session as core packages; the shell template exports
DOCKER_HOST from $XDG_RUNTIME_DIR when the socket exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 20:01:17 +00:00
co-authored by Claude Opus 5
parent 71589aee99
commit 3bea46f2d7
4 changed files with 246 additions and 5 deletions
+23 -5
View File
@@ -1,7 +1,8 @@
import { updateUser } from 'officerdb';
import { OS_USERS_ENABLED, ensureOsUser } from '@@/os-user';
import { OS_USERS_ENABLED, ensureOsUser, osUserHome } from '@@/os-user';
import { provisionSshAccess } from '@@/os-user-ssh';
import { seedShellConfig } from '@@/os-user-shell';
import { provisionRootlessDocker } from '@@/os-user-docker';
import { provisionUserDirs } from '@@/data-path';
// Giving an account its Linux side: the directory skeleton, the Linux user, the confinement, the keys.
@@ -68,16 +69,33 @@ export async function provisionOsAccount(params: {
authorizedKey: params.inboundKey,
});
// The shell configuration. Last because it is the only step whose failure leaves nothing broken — the
// account works, the keys work, the terminal opens; it just opens with zsh's bare defaults.
// The shell configuration. Late because its failure leaves nothing broken — the account works, the keys
// work, the terminal opens; it just opens with zsh's bare defaults.
const shell = await seedShellConfig({ email: params.email, uid: account.uid, gid: account.gid });
// Their own rootless Docker daemon. Last, and the most tolerant of failure: a host without the uidmap
// package or a kernel that will not do rootless still gets a perfectly good account, minus containers.
//
// Provisioned for every member rather than behind a toggle, because "can I run a database to develop
// against" should not be an administrative request. The cost — one daemon and one image cache per member —
// is real and is written down in os-user-docker.ts.
const docker = await provisionRootlessDocker({
osUser: account.osUser,
uid: account.uid,
home: osUserHome(params.email),
});
// The Linux account is recorded either way: it exists, it is confined, and a member's terminal can run as
// it. Only the keys are missing, and that is what the error says.
const sshPublicKey = ssh.ok ? ssh.publicKey : null;
await updateUser(params.userId, { osUser: account.osUser, osSshPublicKey: sshPublicKey });
// SSH first if both failed: no keys is the more consequential of the two.
const error = !ssh.ok ? ssh.error : !shell.ok ? shell.error : null;
// Reported in order of consequence, not in order of execution: no keys matters more than a plain prompt,
// which matters more than no containers. Only one is surfaced because the UI shows one line — the rest are
// in the log.
for (const step of [shell, docker] as const) {
if (!step.ok) console.warn(`[users] ${params.email}: ${step.error}`);
}
const error = !ssh.ok ? ssh.error : !shell.ok ? shell.error : !docker.ok ? docker.error : null;
return { osUser: account.osUser, sshPublicKey, error };
}