Three things, from a member sitting on /music with no music capability on a server with no music sidecar: an empty library, and 403s in the console. PERMISSIONS AT THE ROUTE. `canVisit` filtered the dock and nothing else, so the tile was hidden and the route was wide open — typing the path, following an old link or restoring a tab rendered the screen anyway. RouteGate now wraps every screen in one place, inside the error boundary. It does not redirect. Sending someone to `/` erases what they asked for and reads as a bug: they clicked Music and landed on Home. It says why instead, and the URL stays put so a reload after installing the thing just works. And it says which of the two reasons applies, because they need different screens and send the reader to different places. `not-installed` is a fact about the SERVER — the owner gets a link to the app store. `not-granted` is a fact about the ACCOUNT, and only the owner can change it. Presenting either as the other sends you looking in the wrong place. ROUTES FOLLOW THE SIDECAR. Free, once the above exists: `deniedRoutes` already covers "held but its sidecar is not installed", so an uninstalled feature has no tile AND no screen. The dock, the Permissions list and the routes now agree because they read one answer. NO MORE SEEDING. Downloads/Documents/Music/Videos/Pictures are gone from both places that made them — the member's provisioning and, older and worse, `/ls`, which created folders in somebody's home as a side effect of LOOKING at it. A listing that invents its own contents is a listing you cannot trust, and the platform has no standing to choose a person's folder layout. A new home is empty. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
421 lines
20 KiB
TypeScript
421 lines
20 KiB
TypeScript
import { chmod, mkdir, readdir, stat } from 'node:fs/promises';
|
|
import { existsSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { DATA_PATH, USER_DIRS, toShellUsername } from './data-path';
|
|
|
|
// Real Linux accounts for members, so the surfaces that execute code can run as them.
|
|
//
|
|
// Design, prerequisites and the staging plan: docs/per-user-linux-accounts.md. Read it before changing
|
|
// anything here — several of the choices below look arbitrary and are not.
|
|
//
|
|
// ── The one thing to know ──
|
|
//
|
|
// `Bun.spawn` SILENTLY IGNORES `uid` and `gid`. Verified on bun 1.3.10: from uid 1000,
|
|
// `Bun.spawn(['id','-u'], { uid: 65534 })` exits 0 and prints 1000. No throw, no warning. So a privilege
|
|
// drop written the obvious way would look like it worked while every member's process ran as the owner —
|
|
// an isolation boundary that is silently absent, which is worse than none at all because it is believed.
|
|
//
|
|
// Everything here goes through `setpriv`. os-user.test.ts pins Bun's behaviour so that if it is ever
|
|
// fixed, a test tells us we may simplify, rather than someone assuming it and being wrong.
|
|
|
|
/** Off unless explicitly enabled: this needs root, and a light install has no sudoers entry. */
|
|
export const OS_USERS_ENABLED = process.env.OFFICER_OS_USERS === 'true' || process.env.OFFICER_OS_USERS === '1';
|
|
|
|
const MAX_USERNAME = 32;
|
|
|
|
/**
|
|
* The Linux account name for a platform account: **the username the owner chose**, verbatim.
|
|
*
|
|
* This carried an `officer_` prefix until 2026-08-11. The prefix bought two things — no collision with a
|
|
* system account, and a greppable record of what we created — and cost the thing the owner actually wants,
|
|
* which is that `whoami` in a member's terminal says who they are. Measured before removing it: `useradd`
|
|
* on this host accepts everything `validateUsername` permits, including dots, hyphens, underscores and
|
|
* uppercase.
|
|
*
|
|
* What replaced the prefix's safety is a stricter ADOPTION rule in `ensureOsUser`: an existing Linux account
|
|
* is only reused when its passwd home is already the home we expect. Without that, a platform account named
|
|
* `root` would have adopted root. See there.
|
|
*
|
|
* `toShellUsername` still handles the fallback when there is no username, since the email local part can
|
|
* contain things a Linux name cannot.
|
|
*/
|
|
export function osUserNameFor(params: { username: string | null; email: string }): string {
|
|
const chosen = params.username?.trim();
|
|
if (chosen) return chosen.slice(0, MAX_USERNAME);
|
|
return toShellUsername('', params.email).slice(0, MAX_USERNAME);
|
|
}
|
|
|
|
/** The account's home as passwd records it, or null if it has none / does not exist. */
|
|
async function passwdHome(osUser: string): Promise<string | null> {
|
|
const result = await run(['getent', 'passwd', osUser]);
|
|
if (!result.ok) return null;
|
|
return result.out.split(':')[5] ?? null;
|
|
}
|
|
|
|
export type RunAsOptions = {
|
|
/** Passed through to the wrapped command. `setpriv --reset-env` means nothing else survives. */
|
|
env?: Record<string, string>;
|
|
cwd?: string;
|
|
};
|
|
|
|
/**
|
|
* The argv that runs `command` as `osUser`. Pure, so the shape is testable without spawning anything.
|
|
*
|
|
* sudo -n REQUIRED, and not merely for the uid. Measured 2026-08-11: `--init-groups` fails
|
|
* with "initgroups failed: Operation not permitted" for an unprivileged caller even
|
|
* when reuid'ing to its OWN account — setgroups(2) is root-only, full stop. So this
|
|
* cannot be done without privilege, and `-n` makes a missing sudoers entry an
|
|
* immediate error instead of a process blocking on a password prompt nobody will see.
|
|
* --reuid/--regid the REAL ids, not merely effective — there is nothing to switch back to.
|
|
* --init-groups apply the account's supplementary groups. Without it the process keeps the OWNER'S
|
|
* groups, which quietly retains access we just took away.
|
|
* --reset-env drop the inherited environment, then set HOME/SHELL/USER/LOGNAME/PATH from the
|
|
* target's passwd entry. Both halves matter: the parent's env carries the owner's HOME
|
|
* and — in a PM2 process started in the platform directory — everything Bun auto-loaded
|
|
* from `.env`. Verified: `POSTGRES_URL` is unset on the far side, and HOME arrives as
|
|
* the member's own.
|
|
*
|
|
* `sudo -u <user>` alone would also work and would be shorter. It is not used because its environment
|
|
* handling is sudoers policy (`env_reset`, `env_keep`, `always_set_home`) rather than something this file
|
|
* states — and "which variables cross into a member's shell" is exactly the question that must not depend
|
|
* on a config file somebody may have edited.
|
|
*/
|
|
export function runAsArgv(osUser: string, command: string[]): string[] {
|
|
if (!osUser) throw new Error('runAsArgv: no OS user');
|
|
if (!command.length) throw new Error('runAsArgv: empty command');
|
|
return [
|
|
'sudo',
|
|
'-n',
|
|
'setpriv',
|
|
`--reuid=${osUser}`,
|
|
`--regid=${osUser}`,
|
|
'--init-groups',
|
|
'--reset-env',
|
|
'--',
|
|
...command,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Run a command as another Linux account.
|
|
*
|
|
* Deliberately does NOT accept a `uid` option. The only supported way to change user in this codebase is
|
|
* this function, precisely because the option that looks like it would work does nothing.
|
|
*/
|
|
export function runAs(osUser: string, command: string[], options: RunAsOptions = {}) {
|
|
return Bun.spawn(runAsArgv(osUser, command), {
|
|
cwd: options.cwd,
|
|
// Reaches sudo and setpriv, NOT the command — `--reset-env` clears it on the way through. Anything
|
|
// the command needs beyond the passwd-derived HOME/SHELL/USER/LOGNAME/PATH has to be stated inside
|
|
// `command` itself (`env FOO=bar cmd …`). That asymmetry is deliberate: it means a variable can only
|
|
// cross into a member's process because someone wrote it there.
|
|
env: options.env,
|
|
stdout: 'pipe',
|
|
stderr: 'pipe',
|
|
});
|
|
}
|
|
|
|
async function run(command: string[]): Promise<{ ok: boolean; out: string }> {
|
|
const proc = Bun.spawn(command, { stdout: 'pipe', stderr: 'pipe' });
|
|
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
|
const code = await proc.exited;
|
|
return { ok: code === 0, out: `${out}${err}`.trim() };
|
|
}
|
|
|
|
/** uid/gid from the passwd database, or null when the account does not exist. */
|
|
export async function lookupOsUser(osUser: string): Promise<{ uid: number; gid: number } | null> {
|
|
const uid = await run(['id', '-u', osUser]);
|
|
if (!uid.ok) return null;
|
|
const gid = await run(['id', '-g', osUser]);
|
|
if (!gid.ok) return null;
|
|
return { uid: Number(uid.out), gid: Number(gid.out) };
|
|
}
|
|
|
|
/** The member's home: `DATA_PATH/<email>/home`, where `provisionUserDirs` already put it. */
|
|
export const osUserHome = (email: string): string => join(DATA_PATH, email, 'home');
|
|
|
|
/**
|
|
* The first directory on the way to `path` that `osUser` cannot traverse, or null if the whole chain is fine.
|
|
*
|
|
* Exists because of a real and thoroughly confusing failure. A member's home is under `DATA_PATH`, which on
|
|
* a normal install is under the OWNER'S home — and `/home/<owner>` is 750 on Debian and Ubuntu. So every
|
|
* mode bit we set on the account tree was correct, the directory existed, and the member still could not
|
|
* reach it, because they had no `x` on an ancestor four levels up. What surfaced was
|
|
* `ssh-keygen: Could not stat …/.ssh: Permission denied`, which points at exactly the wrong place.
|
|
*
|
|
* `x` without `r` is the ask: traversal, not listing. Nobody gains the ability to enumerate the owner's home.
|
|
*/
|
|
export async function firstUntraversableAncestor(osUser: string, path: string): Promise<string | null> {
|
|
const parts = path.split('/').filter(Boolean);
|
|
const chain = parts.map((_, i) => `/${parts.slice(0, i + 1).join('/')}`);
|
|
|
|
// One spawn rather than one per level: this runs on every account creation, and the chain is ~6 deep.
|
|
const proc = runAs(osUser, [
|
|
'sh',
|
|
'-c',
|
|
'for p in "$@"; do [ -x "$p" ] || { printf %s "$p"; exit 0; }; done',
|
|
'sh',
|
|
...chain,
|
|
]);
|
|
const out = (await new Response(proc.stdout).text()).trim();
|
|
await proc.exited;
|
|
return out || null;
|
|
}
|
|
|
|
export type EnsureOsUserResult =
|
|
| { ok: true; osUser: string; uid: number; gid: number; created: boolean }
|
|
| { ok: false; error: string };
|
|
|
|
/**
|
|
* Create the Linux account if it does not exist, then place the ownership and mode bits.
|
|
*
|
|
* Idempotent in both halves: an existing account is adopted rather than recreated, and the modes are
|
|
* re-applied every time, so a directory the platform added later is confined without needing a
|
|
* migration.
|
|
*
|
|
* Never throws. Account creation is a side effect of creating a platform account, and a `useradd` that
|
|
* failed must not leave a half-made user behind — the caller records the error and the platform account
|
|
* simply has no OS account yet.
|
|
*/
|
|
export async function ensureOsUser(params: { email: string; username: string | null }): Promise<EnsureOsUserResult> {
|
|
const osUser = osUserNameFor(params);
|
|
const home = osUserHome(params.email);
|
|
|
|
let created = false;
|
|
let ids = await lookupOsUser(osUser);
|
|
|
|
// ── Adoption, and why it is this strict ──
|
|
//
|
|
// An account that already exists is REUSED, which is what makes this function re-runnable. While names
|
|
// carried an `officer_` prefix that was safe by construction: nothing else creates those. Now that the
|
|
// name is whatever the owner typed, adoption is the dangerous path — a platform account named `root`
|
|
// would find root in passwd, and every `runAs` for that member would then be a root shell.
|
|
//
|
|
// The test is the account's own home. If passwd already points it at exactly the directory we are about
|
|
// to confine, it is ours (or a previous run's). Anything else is somebody else's account that happens to
|
|
// share a name, and the answer is to refuse rather than to touch it.
|
|
//
|
|
// The uid floor is belt and braces: a system account below 1000 could in principle be created with a
|
|
// matching home, and none of them should ever be handed to a member.
|
|
if (ids) {
|
|
const existingHome = await passwdHome(osUser);
|
|
if (ids.uid < 1000) {
|
|
return { ok: false, error: `'${osUser}' is a system account on this machine. Choose another username.` };
|
|
}
|
|
if (existingHome !== home) {
|
|
return {
|
|
ok: false,
|
|
error:
|
|
`'${osUser}' is already a user on this machine, with its home at ${existingHome ?? 'an unknown path'}. ` +
|
|
`Refusing to take it over — choose another username.`,
|
|
};
|
|
}
|
|
}
|
|
|
|
if (!ids) {
|
|
// `-M` because provisionUserDirs already made the directory, and letting useradd create it would
|
|
// copy /etc/skel in as root-owned. `-s` explicitly: /etc/default/useradd here says /bin/sh, and a
|
|
// member opening a terminal should get the same shell everyone else gets.
|
|
const create = await run([
|
|
'sudo',
|
|
'-n',
|
|
'useradd',
|
|
'--home-dir',
|
|
home,
|
|
'-M',
|
|
'--shell',
|
|
process.env.SHELL ?? '/bin/bash',
|
|
osUser,
|
|
]);
|
|
if (!create.ok) return { ok: false, error: `useradd failed: ${create.out}` };
|
|
created = true;
|
|
ids = await lookupOsUser(osUser);
|
|
if (!ids) return { ok: false, error: `useradd reported success but ${osUser} is not in passwd` };
|
|
}
|
|
|
|
const confined = await confineUserTree({ email: params.email, uid: ids.uid, gid: ids.gid });
|
|
if (!confined.ok) return { ok: false, error: confined.error };
|
|
|
|
// Checked before anything tries to USE the home, so the error names the actual problem. Every mode bit on
|
|
// the account tree can be right while the member still cannot get there, because `DATA_PATH` normally
|
|
// lives under the owner's home and `/home/<owner>` is 750 on Debian and Ubuntu.
|
|
const blocked = await firstUntraversableAncestor(osUser, home);
|
|
if (blocked) {
|
|
return {
|
|
ok: false,
|
|
error:
|
|
`${osUser} cannot traverse into ${blocked}, so it cannot reach its own home. ` +
|
|
`Fix with: chmod o+x ${blocked} ` +
|
|
`(that grants traversal only — the directory stays unlistable.)`,
|
|
};
|
|
}
|
|
|
|
// A new home is EMPTY, deliberately. This used to create Downloads/Documents/Music/Videos/Pictures — a
|
|
// habit inherited from the file browser, which did the same lazily for the owner. Nothing needs them:
|
|
// guessing at somebody's folder layout is a decision the platform has no standing to make, and an empty
|
|
// home is honest about being new.
|
|
return { ok: true, osUser, uid: ids.uid, gid: ids.gid, created };
|
|
}
|
|
|
|
/**
|
|
* Place the mode bits described in docs/per-user-linux-accounts.md § "The layout".
|
|
*
|
|
* DATA_PATH 711 service user traverse only — a member cannot enumerate the members
|
|
* DATA_PATH/<email> 711 service user traverse only — a member cannot list their OWN siblings
|
|
* …/home 700 the member their home
|
|
* every sibling 700 service user platform-written, unreachable even by name
|
|
*
|
|
* 711 on the account directory is the load-bearing one. The member needs `x` to reach `home`, and must
|
|
* not have `r`, or `ls` would show them the platform's private tree beside it. Because every sibling is
|
|
* 700 and owned by the service user, guessing a name gains nothing either.
|
|
*
|
|
* `chown` on the home is done with sudo: the service user owns the directory but cannot give it away —
|
|
* `chown` to another user is a root-only operation on Linux regardless of who owns the file.
|
|
*/
|
|
export async function confineUserTree(params: {
|
|
email: string;
|
|
uid: number;
|
|
gid: number;
|
|
}): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
const accountDir = join(DATA_PATH, params.email);
|
|
const home = join(accountDir, 'home');
|
|
|
|
try {
|
|
if (!existsSync(home)) await mkdir(home, { recursive: true });
|
|
|
|
// Traversable, not listable. Applied to DATA_PATH itself too: without it a member can read the
|
|
// directory and learn every other member's email address.
|
|
await chmod(DATA_PATH, 0o711);
|
|
await chmod(accountDir, 0o711);
|
|
|
|
// Every sibling of `home` is the platform's. 700 means traversal alone does not open them.
|
|
const entries = await readdir(accountDir, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (entry.name === 'home') continue;
|
|
if (!entry.isDirectory()) continue;
|
|
await chmod(join(accountDir, entry.name), 0o700);
|
|
}
|
|
// And any of the standard set that does not exist yet, so a directory created later starts confined
|
|
// rather than at the process umask.
|
|
for (const dir of USER_DIRS) {
|
|
if (dir === 'home') continue;
|
|
const path = join(accountDir, dir);
|
|
if (!existsSync(path)) await mkdir(path, { recursive: true, mode: 0o700 });
|
|
}
|
|
|
|
// The home goes through sudo for BOTH operations, and that is the only form that is idempotent.
|
|
// `chmod` requires ownership, so:
|
|
// - chmod then chown, unprivileged: works once, then fails EPERM forever after, because the home now
|
|
// belongs to the member. Re-running an install would report failure on a correct tree.
|
|
// - chown then chmod, unprivileged: fails immediately, for the same reason.
|
|
// Both were observed. Root does not care about either ordering, so both go through sudo and the
|
|
// function can be run any number of times.
|
|
const give = await run(['sudo', '-n', 'chown', '-R', `${params.uid}:${params.gid}`, home]);
|
|
if (!give.ok) return { ok: false, error: `chown of ${home} failed: ${give.out}` };
|
|
const close = await run(['sudo', '-n', 'chmod', '700', home]);
|
|
if (!close.ok) return { ok: false, error: `chmod of ${home} failed: ${close.out}` };
|
|
|
|
// ── And then let the PLATFORM in, by ACL ──
|
|
//
|
|
// A 700 home owned by the member locks out the service user, which is correct for a shell and fatal for
|
|
// the file browser: it runs inside the platform process, so `readdir` returned EACCES and `/ls` reported
|
|
// "This folder is empty" over five directories that were sitting right there. Observed 2026-08-11.
|
|
//
|
|
// These are two different doors and they need different boundaries. The terminal and the agent RUN AS the
|
|
// member, and there the kernel is the boundary. The file browser acts on the member's behalf from inside
|
|
// the platform, which already applies its own containment (`resolveUserPath`) and which is the owner's
|
|
// process on the owner's machine — it can read anything via sudo regardless. Giving it access is not a
|
|
// hole, it is the honest description of who is doing the work.
|
|
//
|
|
// Why ACLs and not mode bits or a group. It has to work in BOTH directions: a file the platform writes
|
|
// must be editable by the member, and a file the member writes must be editable by the platform. Mode
|
|
// bits cannot express that — whichever of the two is neither owner nor group ends up as "other", and
|
|
// widening "other" would open the home to every account on the box. A shared group fails the same way
|
|
// once you notice both parties would have to be in it, which would put every member in a group that can
|
|
// read every other member's home. Named ACL entries grant exactly two users, and the `d:` defaults are
|
|
// inherited by everything created afterwards, by either party, whatever their umask.
|
|
const serviceUid = process.getuid?.();
|
|
if (serviceUid !== undefined) {
|
|
const entries = [
|
|
`u:${serviceUid}:rwx`,
|
|
`u:${params.uid}:rwx`,
|
|
`d:u:${serviceUid}:rwx`,
|
|
`d:u:${params.uid}:rwx`,
|
|
].flatMap((entry) => ['-m', entry]);
|
|
// After the chmod, never before: chmod recomputes the ACL mask and would clamp entries set earlier.
|
|
const acl = await run(['sudo', '-n', 'setfacl', '-R', ...entries, home]);
|
|
if (!acl.ok) {
|
|
return {
|
|
ok: false,
|
|
error:
|
|
`could not set access control lists on ${home}: ${acl.out}. ` +
|
|
`The file browser cannot read a member's home without them. ` +
|
|
`Install the acl package (apt install acl) and retry.`,
|
|
};
|
|
}
|
|
}
|
|
|
|
return { ok: true };
|
|
} catch (ex) {
|
|
return { ok: false, error: ex instanceof Error ? ex.message : String(ex) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Refuse to enable OS users while a secret in the project tree is readable by them.
|
|
*
|
|
* `platform/.env` was 664 on this machine when this was written — world-readable, holding the JWT signing
|
|
* secret and `POSTGRES_URL`. A member with a shell could read it and mint an owner token, which would
|
|
* leave the capability model intact and entirely bypassed.
|
|
*
|
|
* Checked at boot rather than documented, because a prerequisite that is only written down is one that
|
|
* gets skipped. Returns the offending paths; the caller decides whether that is fatal.
|
|
*/
|
|
export async function findReadableSecrets(projectDir: string): Promise<string[]> {
|
|
const candidates = ['.env', '.env.local', '.env.production'];
|
|
const bad: string[] = [];
|
|
for (const name of candidates) {
|
|
const path = join(projectDir, name);
|
|
if (!existsSync(path)) continue;
|
|
try {
|
|
const info = await stat(path);
|
|
// Anything readable by group or other. 0o044 covers both read bits.
|
|
if (info.mode & 0o044) bad.push(path);
|
|
} catch {
|
|
// Unreadable to us is not a leak to them; nothing to report.
|
|
}
|
|
}
|
|
return bad;
|
|
}
|
|
|
|
/**
|
|
* Refuse to boot with OS users enabled while a secret in the project tree is readable by them.
|
|
*
|
|
* Same posture as `assertCapabilityTotality`, and for the same reason: this is a prerequisite that
|
|
* silently not holding would make the whole feature theatre. Confirmed exploitable while testing — a
|
|
* member's shell read `platform/.env` and printed `JWT_SECRET`, which is enough to mint an owner token and
|
|
* bypass every capability check in the codebase.
|
|
*
|
|
* A no-op when the feature is off, so an existing install is unaffected until the owner opts in.
|
|
*/
|
|
export async function assertSecretsClosed(projectDir: string): Promise<void> {
|
|
if (!OS_USERS_ENABLED) return;
|
|
const readable = await findReadableSecrets(projectDir);
|
|
if (!readable.length) return;
|
|
|
|
throw new Error(
|
|
[
|
|
'OFFICER_OS_USERS is enabled, but these files are readable by other accounts on this machine:',
|
|
'',
|
|
...readable.map((p) => ` • ${p}`),
|
|
'',
|
|
'A member with a shell can read them. JWT_SECRET alone is enough to mint an owner token, which',
|
|
'bypasses every capability check. Fix with:',
|
|
'',
|
|
...readable.map((p) => ` chmod 600 ${p}`),
|
|
'',
|
|
'Then restart. See docs/per-user-linux-accounts.md → "Hard prerequisite".',
|
|
].join('\n'),
|
|
);
|
|
}
|