the tenth install in a row with something new wrong, and the pattern behind them is not the individual faults. every step in provisionOsAccount is deliberately non-fatal — a member whose account provisioned fine should not be rolled back because their shell config failed. that is right, and it means "nothing reported an error" is a far weaker statement than it reads as. the acl-mask bug was the sharp version: a clean provisioning run, every named entry correct, and an account that could reach nothing. it was found by a person three days later, through a file browser message three steps removed from the cause. so provisioning now VERIFIES rather than assuming, and it verifies by making the same readdir the file browser makes. deliberately not by inspecting the acl: reading getfacl and reasoning about masks is exactly how this was missed, because the acl looked right. a check that tests something adjacent to the failure is how you get a green report and a broken server. a failed check is reported FIRST, ahead of keys and shells — an account that cannot read its own home is broken in a way that working ssh does not make up for. and `bun run doctor` for the nine servers that already exist: same check across every account, plus the one that is invisible by construction — a confined grant held by an account with no linux user, which sits in the table, shows as ticked in the UI, and resolves to nothing. read-only, so it is safe on a production box. exits 1, so it can gate the end of a setup run. same posture as assertPermissionTotality, except this reports instead of refusing to boot: one broken member must not stop the platform serving everyone else.
80 lines
3.5 KiB
TypeScript
80 lines
3.5 KiB
TypeScript
import { readdir } from 'node:fs/promises';
|
|
import { existsSync } from 'node:fs';
|
|
import { osUserHome } from './os-user';
|
|
|
|
// Checking that a member's Linux account actually WORKS, rather than that provisioning reported success.
|
|
//
|
|
// ── Why this exists ──
|
|
//
|
|
// Every step in `provisionOsAccount` is deliberately non-fatal: a member whose account provisioned fine
|
|
// should not be rolled back because their shell config failed. That is right, and it has a cost — the
|
|
// failures accumulate silently, and the owner learns about them from a file browser saying "Officer
|
|
// cannot read this folder", which is three steps removed from the cause.
|
|
//
|
|
// The 2026-08-19 case was the sharp version. Every named ACL entry was correct. The Linux user existed.
|
|
// The home existed. The grants were right. And the platform still could not read the home, because a
|
|
// later `chmod 700` had recomputed the ACL mask to `---` and clamped the entries to nothing. Nothing in
|
|
// the pipeline was in a position to notice, because nothing tried the thing that actually matters.
|
|
//
|
|
// So: try it. `readdir` on the member's home, as the process that will do it for real. It is the same
|
|
// call the file browser makes, which is the point — a check that tests something ADJACENT to the failure
|
|
// is how you get a green report and a broken server.
|
|
//
|
|
// This is the same posture as `assertPermissionTotality` and `assertInstallLayout`: state the invariant,
|
|
// verify it, and say plainly what is wrong. The difference is that those refuse to boot and this does
|
|
// not — a member with a broken home must not stop the platform serving everyone else.
|
|
|
|
export type OsAccountCheck = {
|
|
email: string;
|
|
osUser: string | null;
|
|
home: string;
|
|
ok: boolean;
|
|
/** Null when `ok`. Otherwise what is wrong and what fixes it. */
|
|
problem: string | null;
|
|
};
|
|
|
|
/**
|
|
* Can the platform actually read this member's home?
|
|
*
|
|
* Deliberately not a permissions inspection. Reading `getfacl` and reasoning about masks is how this was
|
|
* missed in the first place: the ACL *looked* right. The only trustworthy question is whether the call
|
|
* the file browser makes succeeds.
|
|
*/
|
|
export async function checkOsAccount(email: string, osUser: string | null): Promise<OsAccountCheck> {
|
|
const home = osUserHome(email);
|
|
const base = { email, osUser, home };
|
|
|
|
if (!osUser) {
|
|
return {
|
|
...base,
|
|
ok: false,
|
|
problem:
|
|
'no Linux user — every `confined` permission (files, terminal, chat) is dropped for this account, ' +
|
|
'whatever the role grants. Fix: POST /api/users/<id>/provision-linux, or Settings → User management.',
|
|
};
|
|
}
|
|
|
|
if (!existsSync(home)) {
|
|
return { ...base, ok: false, problem: `home does not exist at ${home}. Fix: re-provision the Linux account.` };
|
|
}
|
|
|
|
try {
|
|
await readdir(home);
|
|
return { ...base, ok: true, problem: null };
|
|
} catch (ex) {
|
|
const code = (ex as { code?: string }).code;
|
|
if (code === 'EACCES' || code === 'EPERM') {
|
|
return {
|
|
...base,
|
|
ok: false,
|
|
problem:
|
|
`the platform cannot read ${home} (${code}). The named ACL entries may look correct and still ` +
|
|
`grant nothing: a chmod recomputes the ACL mask from the group bits, so a directory closed to ` +
|
|
`700 gets \`mask::---\` and clamps every entry to \`#effective:---\`. ` +
|
|
`Fix: re-provision the Linux account, or \`sudo setfacl -R -m m::rwx -m d:m::rwx '${home}'\`.`,
|
|
};
|
|
}
|
|
return { ...base, ok: false, problem: `could not read ${home}: ${ex instanceof Error ? ex.message : ex}` };
|
|
}
|
|
}
|