host verified green's reprovision: claude 2.1.228 installs and runs as the member, the file browser reads their home, rootless Docker runs and sees 0 containers while the owner has 8. First end-to-end proof of any of this. One thing came out dirty. ~/.local/share/docker carried the home's inherited default ACLs after a "successful" strip, because the strip was guarded on existsSync and only the daemon creates that directory. On a first run the guard was false and the strip no-opped; the retry then started the daemon, which created the directory and inherited the defaults. The run meant to clean it up was the one that made it, and the guard could not tell "nothing to strip" from "nothing there yet". Now created by us before the daemon exists — member-owned, 700, nothing to inherit — and the strip is unconditional afterwards, repairing an account provisioned before this and no-opping on a clean one. A guard that depends on another process having got there first is a race however it is written; the fix is owning the order rather than testing for it. Third bug of this class tonight: an implicit parent directory, a strip guarded on another process's work, and an installer piped into the wrong shell. All three were invisible until a real member account existed, which is the argument for making the second one sooner than feels necessary. Left alone deliberately: .local being unreadable by the platform (a decision about intent, not a defect, and the owner's), and the -u 70 + bind mount observation, whose probe host already distrusts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
226 lines
11 KiB
TypeScript
226 lines
11 KiB
TypeScript
import { existsSync } from 'node:fs';
|
|
import { runAs } from './os-user';
|
|
|
|
// A member's own Docker: their daemon, their images, their containers, running as their uid.
|
|
//
|
|
// ── Why rootless, and why the alternative is not on the table ──
|
|
//
|
|
// The one-line version of "give the user Docker" is `usermod -aG docker <user>`, and it is root. Membership of
|
|
// that group means talking to the host daemon, which runs as root, so:
|
|
//
|
|
// docker run -v /:/host -it alpine chroot /host
|
|
//
|
|
// is a root shell on the machine. It reads the platform's `.env`, every other member's home, the wallet seed
|
|
// — every boundary in docs/per-user-linux-accounts.md, bypassed by one documented command. The group is not
|
|
// "access to Docker", it is "root, by a longer route".
|
|
//
|
|
// Rootless gives the thing that was actually wanted: a daemon per account, containers in that account's user
|
|
// namespace, images in their own home. Root inside their container is their uid outside it, which is nobody.
|
|
// They cannot see the owner's containers and the owner cannot break theirs.
|
|
//
|
|
// ── What it needs from the host ──
|
|
//
|
|
// uidmap newuidmap/newgidmap, to map subordinate ids. Rootless cannot start without them.
|
|
// /etc/subuid,gid a range per account. `useradd` allocates one automatically wherever login.defs sets
|
|
// SUB_UID_COUNT (Ubuntu does), and `userdel` reclaims it — verified on this host.
|
|
// linger `loginctl enable-linger`, or the daemon dies with the session. Officer's shells are
|
|
// NOT login sessions, so without this a member's Docker would stop the moment their
|
|
// terminal closed, which is the opposite of a daemon.
|
|
// dbus-user-session systemd --user needs a bus to talk to.
|
|
//
|
|
// ── The costs, stated rather than discovered ──
|
|
//
|
|
// Each account has its own image cache, so three members pulling postgres:16 store it three times. Ports
|
|
// below 1024 need an explicit capability grant. Both are acceptable for what this buys; neither is hidden.
|
|
|
|
/** Their own daemon's socket. The value `DOCKER_HOST` must point at. */
|
|
export const dockerSocketFor = (uid: number): string => `/run/user/${uid}/docker.sock`;
|
|
|
|
type Result = { ok: true; alreadyInstalled: boolean } | { ok: false; error: string };
|
|
|
|
async function sudo(args: string[]): Promise<{ ok: boolean; out: string }> {
|
|
const proc = Bun.spawn(['sudo', '-n', ...args], { stdout: 'pipe', stderr: 'pipe' });
|
|
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
|
return { ok: (await proc.exited) === 0, out: `${out}${err}`.trim() };
|
|
}
|
|
|
|
/**
|
|
* Run something as the member with a systemd user session in scope.
|
|
*
|
|
* `runAs` clears the environment, which is right everywhere else and fatal here: `systemctl --user` and the
|
|
* rootless setup tool locate the user manager through `XDG_RUNTIME_DIR` and `DBUS_SESSION_BUS_ADDRESS`. With
|
|
* those unset the tool reports "systemd not detected" and installs nothing, successfully.
|
|
*/
|
|
function asMemberWithSession(osUser: string, uid: number, command: string[]) {
|
|
const runtime = `/run/user/${uid}`;
|
|
return runAs(osUser, [
|
|
'env',
|
|
`XDG_RUNTIME_DIR=${runtime}`,
|
|
`DBUS_SESSION_BUS_ADDRESS=unix:path=${runtime}/bus`,
|
|
// The tool shells out to newuidmap, rootlesskit and dockerd, and --reset-env left PATH at the passwd
|
|
// default which does not include /usr/sbin.
|
|
'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
|
|
...command,
|
|
]);
|
|
}
|
|
|
|
const text = async (proc: ReturnType<typeof runAs>) =>
|
|
`${await new Response(proc.stdout).text()}${await new Response(proc.stderr).text()}`.trim();
|
|
|
|
/** Everything that must be true of the HOST before any account can have rootless Docker. */
|
|
export function checkDockerPrerequisites(): { ok: true } | { ok: false; error: string } {
|
|
const missing: string[] = [];
|
|
if (!existsSync('/usr/bin/newuidmap') || !existsSync('/usr/bin/newgidmap')) missing.push('uidmap');
|
|
if (!existsSync('/usr/bin/dockerd-rootless-setuptool.sh')) missing.push('docker-ce-rootless-extras');
|
|
if (missing.length) {
|
|
return {
|
|
ok: false,
|
|
error: `rootless Docker needs these packages on the host: ${missing.join(', ')} (apt install ${missing.join(' ')})`,
|
|
};
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
/**
|
|
* Give the account its own rootless Docker daemon, and start it.
|
|
*
|
|
* Idempotent: the setup tool is safe to re-run, `enable-linger` on a lingering account is a no-op, and an
|
|
* already-running daemon is reported as `alreadyInstalled` rather than restarted — a reprovision must not
|
|
* bounce a member's containers.
|
|
*
|
|
* Never throws. Docker is the least essential of the provisioning steps: without it the account still has a
|
|
* shell, a home and keys.
|
|
*/
|
|
export async function provisionRootlessDocker(params: { osUser: string; uid: number; home: string }): Promise<Result> {
|
|
const prereq = checkDockerPrerequisites();
|
|
if (!prereq.ok) return prereq;
|
|
|
|
// Subordinate id range. `useradd` allocates one, so a missing entry means either an unusual login.defs or
|
|
// an account made by hand — worth naming rather than letting rootlesskit fail obscurely later.
|
|
const subuid = await sudo(['grep', '-q', `^${params.osUser}:`, '/etc/subuid']);
|
|
if (!subuid.ok) {
|
|
return {
|
|
ok: false,
|
|
error: `${params.osUser} has no /etc/subuid range, which rootless Docker requires. Add one with: sudo usermod --add-subuids 100000-165535 ${params.osUser}`,
|
|
};
|
|
}
|
|
|
|
// Linger FIRST: it is what creates /run/user/<uid> and starts the user manager, and everything below needs
|
|
// both to exist.
|
|
const linger = await sudo(['loginctl', 'enable-linger', params.osUser]);
|
|
if (!linger.ok) return { ok: false, error: `could not enable linger for ${params.osUser}: ${linger.out}` };
|
|
|
|
// The user manager appears asynchronously. Waiting beats a bare sleep, and the failure below is clearer
|
|
// than "systemd not detected" from the setup tool.
|
|
for (let attempt = 0; attempt < 25 && !existsSync(`/run/user/${params.uid}`); attempt++) {
|
|
await Bun.sleep(200);
|
|
}
|
|
if (!existsSync(`/run/user/${params.uid}`)) {
|
|
return {
|
|
ok: false,
|
|
error: `/run/user/${params.uid} never appeared, so ${params.osUser} has no systemd user session`,
|
|
};
|
|
}
|
|
|
|
const already = existsSync(dockerSocketFor(params.uid));
|
|
|
|
// Docker's storage, created by us and created CLEAN, before the daemon exists to create it dirty.
|
|
//
|
|
// The strip below used to be the whole story, guarded on the directory existing — which is false on a first
|
|
// run, because only the daemon creates it. So on a fresh account the strip no-opped, the daemon then made
|
|
// the directory itself and inherited the home's default ACLs, and the only cure was a retry: the very run
|
|
// that was supposed to clean it up was the one that created it. Verified on green's first provision, where
|
|
// it came out carrying `default:other::---` after a "successful" strip.
|
|
//
|
|
// A guard that depends on another process having got there first is a race however it is written, so the
|
|
// fix is ownership of the order: make it ourselves, with the member's uid and no defaults to inherit. Same
|
|
// shape as creating `~/.local` explicitly rather than letting `install -d` invent it as root.
|
|
const dockerStorage = `${params.home}/.local/share/docker`;
|
|
const madeStorage = await sudo([
|
|
'install',
|
|
'-d',
|
|
'-o',
|
|
String(params.uid),
|
|
'-g',
|
|
String(params.uid),
|
|
'-m',
|
|
'700',
|
|
dockerStorage,
|
|
]);
|
|
if (!madeStorage.ok) {
|
|
return { ok: false, error: `could not create ${dockerStorage}: ${madeStorage.out}` };
|
|
}
|
|
|
|
// ── The setup tool's exit code is deliberately NOT the gate ──
|
|
//
|
|
// It writes `~/.config/systemd/user/docker.service` and then runs `systemctl --user start docker.service`
|
|
// itself — which fails with "Unit docker.service not found" on a manager that was already running when the
|
|
// file appeared, because nothing reloaded it. Measured here: the unit was written correctly and the tool
|
|
// still exited 1.
|
|
//
|
|
// So: run it, reload, and start it ourselves. The tool's output is kept for the error message if the start
|
|
// then genuinely fails, since its diagnosis (a missing kernel module, an unsupported filesystem) is better
|
|
// than anything paraphrased.
|
|
const install = asMemberWithSession(params.osUser, params.uid, ['dockerd-rootless-setuptool.sh', 'install']);
|
|
const installOut = await text(install);
|
|
await install.exited;
|
|
|
|
const reload = asMemberWithSession(params.osUser, params.uid, ['systemctl', '--user', 'daemon-reload']);
|
|
await reload.exited;
|
|
|
|
const enable = asMemberWithSession(params.osUser, params.uid, ['systemctl', '--user', 'enable', '--now', 'docker']);
|
|
const enableOut = await text(enable);
|
|
if ((await enable.exited) !== 0) {
|
|
// Both halves, because the useful sentence is usually in the setup tool's output rather than systemd's.
|
|
const detail = [installOut, enableOut]
|
|
.map((s) => s.split('\n').slice(-3).join(' ').trim())
|
|
.filter(Boolean)
|
|
.join(' | ');
|
|
return { ok: false, error: `could not start ${params.osUser}'s Docker: ${detail}` };
|
|
}
|
|
|
|
// ── Undo our own ACLs, for this one subtree ──
|
|
//
|
|
// The home carries DEFAULT ACLs so the file browser can read a member's files (os-user.ts explains why).
|
|
// Docker inherits them under `~/.local/share/docker`, then fails every `docker run` with:
|
|
//
|
|
// failed to copy xattrs: failed to set xattr "system.posix_acl_default" on …/volumes/…/_data:
|
|
// invalid argument
|
|
//
|
|
// Creating a volume copies xattrs, and inside a rootless user namespace the mapped id in an inherited
|
|
// default ACL is not a valid id, so setting it is EINVAL. Two features built the same day, each correct
|
|
// alone. Measured: the image pulled fine — 403 MB into their home — and every container failed to start.
|
|
//
|
|
// `-k` removes DEFAULT entries only, so nothing inside Docker's storage inherits them from here on. The
|
|
// access ACLs on the home itself are untouched, which is what the file browser depends on. Losing the
|
|
// platform's reach into Docker's internal storage is no loss: it is image layers and volume data, read
|
|
// through `docker` or not at all.
|
|
// Unconditional now, and no longer the thing that has to win a race: the directory is ours from above, so
|
|
// this is repair for an account provisioned before that existed, and a no-op on a clean one. The guard it
|
|
// replaces could not tell "nothing to strip" from "nothing there yet", and answered the same way to both.
|
|
const stripped = await sudo(['setfacl', '-R', '-k', dockerStorage]);
|
|
if (!stripped.ok) {
|
|
return { ok: false, error: `could not clear inherited ACLs from ${dockerStorage}: ${stripped.out}` };
|
|
}
|
|
|
|
// Proof, not assumption: ask their daemon who it is. `docker version --format` on the SERVER half only
|
|
// answers if the socket is live and talking.
|
|
const verify = asMemberWithSession(params.osUser, params.uid, [
|
|
'env',
|
|
`DOCKER_HOST=unix://${dockerSocketFor(params.uid)}`,
|
|
'docker',
|
|
'version',
|
|
'--format',
|
|
'{{.Server.Version}}',
|
|
]);
|
|
const version = await text(verify);
|
|
if ((await verify.exited) !== 0) {
|
|
return {
|
|
ok: false,
|
|
error: `${params.osUser}'s Docker did not answer: ${version.split('\n').slice(-2).join(' ').trim()}`,
|
|
};
|
|
}
|
|
|
|
return { ok: true, alreadyInstalled: already };
|
|
}
|