retry a linux account in place instead of deleting the person

POST /users/:id/provision-linux, and a terminal button on each user row. One
operation covering three needs that were all previously answered by "delete the
account and make it again":

  backfill  an account created before the feature existed, or while the host was not
            set up for it
  retry     the first attempt failed for something since fixed — the traversable
            ancestor chmod being the one everybody hits once
  re-key    replace authorized_keys with a new public key

Deleting to redo a retryable side effect throws away the password, the dashboards and
everything else keyed to the row.

The provisioning block moves out of create-user into provisionOsAccount, shared by
both entry points for the same reason app-store/members.ts is shaped that way: two
moments, one piece of work.

Found by testing the retry rather than the create: provisionUserDirs re-chmods every
directory including home, and home belongs to the MEMBER after the first successful
run — chmod requires ownership, so it threw EPERM and took every retry down before it
started. Those chmods are now a default for directories being created, not an
assertion about ones that already exist; os-user.ts sets the home's mode through sudo
and is the authority for it.

The route answers 200 with the error in the body, because the interesting cases are
partial: "the account exists and is confined but the keys failed" is not nothing
having happened, and the row shows both halves.

Verified end to end: blocked ancestor reports the chmod and leaves osUser null, the
retry after that chmod succeeds and records the row, and a re-key replaces
authorized_keys without rotating the outbound key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 17:58:46 +00:00
co-authored by Claude Opus 5
parent ea0d2396f7
commit 2c9d4e55aa
7 changed files with 232 additions and 54 deletions
+76
View File
@@ -0,0 +1,76 @@
import { updateUser } from 'officerdb';
import { OS_USERS_ENABLED, ensureOsUser } from '@@/os-user';
import { provisionSshAccess } from '@@/os-user-ssh';
import { provisionUserDirs } from '@@/data-path';
// Giving an account its Linux side: the directory skeleton, the Linux user, the confinement, the keys.
//
// Shared by account creation and the retry route, because they are the same work and there are two moments
// it has to happen at — the same reasoning as app-store/members.ts. The list of reasons a retry is needed is
// not exotic:
//
// - the host was not set up for it when the account was made (`OFFICER_OS_USERS` off, no sudoers entry)
// - an ancestor directory was not traversable, which is the one everybody hits once
// - the owner wants to replace the inbound SSH key
//
// Before this existed the answer to all three was "delete the account and make it again", which loses the
// password, the dashboards and anything else keyed to the row — for what is really a retryable side effect.
export type OsProvisionOutcome = {
osUser: string | null;
sshPublicKey: string | null;
/** Null on success. Everything here is non-fatal — the platform account works regardless. */
error: string | null;
};
/**
* Idempotent. Every step underneath adopts what already exists: `useradd` is skipped for an account whose
* home already matches, the confinement re-applies mode bits, and an existing `id_ed25519` is kept rather
* than rotated (it has been added to Gitea by then).
*
* Never throws. A failure here must not undo or block a platform account that otherwise works, so the error
* is returned and the row keeps `osUser: null` — which is exactly what an account created before this
* feature looks like, and which every consumer already handles.
*/
export async function provisionOsAccount(params: {
userId: number;
email: string;
username: string;
/** Inbound SSH key for `authorized_keys`. Already validated by the caller. */
inboundKey?: string | null;
}): Promise<OsProvisionOutcome> {
if (!OS_USERS_ENABLED) {
return { osUser: null, sshPublicKey: null, error: 'per-user Linux accounts are not enabled on this server' };
}
// First, because a missing skeleton is the reason `useradd --home-dir … -M` would have nothing to point at.
try {
provisionUserDirs(params.email);
} catch (ex) {
return {
osUser: null,
sshPublicKey: null,
error: `could not provision data directories: ${ex instanceof Error ? ex.message : String(ex)}`,
};
}
const account = await ensureOsUser({ email: params.email, username: params.username });
if (!account.ok) return { osUser: null, sshPublicKey: null, error: account.error };
// SSH after the account, because everything it writes lives inside a home that is not ours until
// `ensureOsUser` has chowned it away.
const ssh = await provisionSshAccess({
email: params.email,
osUser: account.osUser,
uid: account.uid,
gid: account.gid,
authorizedKey: params.inboundKey,
});
// 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 });
return { osUser: account.osUser, sshPublicKey, error: ssh.ok ? null : ssh.error };
}