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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user