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:
@@ -1,11 +1,11 @@
|
||||
import type { Handler } from 'hono';
|
||||
import { createUser, updateUser, getUserByEmail, getUserByUsername, USER_ROLES } from 'officerdb';
|
||||
import { createUser, getUserByEmail, getUserByUsername, USER_ROLES } from 'officerdb';
|
||||
import type { UserRole } from 'officerdb';
|
||||
import argon2 from 'argon2';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { provisionUserDirs } from '@@/data-path';
|
||||
import { OS_USERS_ENABLED, ensureOsUser } from '@@/os-user';
|
||||
import { provisionSshAccess, validatePublicKey } from '@@/os-user-ssh';
|
||||
import { OS_USERS_ENABLED } from '@@/os-user';
|
||||
import { validatePublicKey } from '@@/os-user-ssh';
|
||||
import { provisionOsAccount } from './provision-os';
|
||||
import { validatePassword } from '../auth/validate-password';
|
||||
import { validateUsername } from '../auth/validate-username';
|
||||
import { toPublicUser } from './manage-users';
|
||||
@@ -90,55 +90,24 @@ export const createUserHandler: Handler = async function (ctx) {
|
||||
role: role as UserRole,
|
||||
});
|
||||
|
||||
// After the row, and not fatal if it fails. A missing directory is repairable from a shell
|
||||
// (`bun scripts/provision-user-dirs.ts <email>`); an account that half-exists because mkdir failed
|
||||
// is not, and the owner would have to go into Postgres to clean it up — the exact thing this route
|
||||
// is here to stop being necessary.
|
||||
try {
|
||||
provisionUserDirs(email);
|
||||
} catch (ex) {
|
||||
console.warn(`[users] created ${email} but could not provision its data directories`, ex);
|
||||
}
|
||||
|
||||
// The Linux account, when the host is set up for it. Same posture as the directories and for the same
|
||||
// reason: this is a side effect of creating a platform account, and a failed `useradd` must not undo an
|
||||
// account that otherwise exists and can sign in. The row simply keeps `osUser: null`, which every
|
||||
// consumer already has to handle — that is what an account made before this feature looks like.
|
||||
// The Linux side: directories, the Linux user, the confinement, the keys. Non-fatal and reported rather
|
||||
// than thrown — this is a side effect of creating a platform account, and a failed `useradd` must not undo
|
||||
// an account that otherwise exists and can sign in. The row simply keeps `osUser: null`, which is what an
|
||||
// account created before this feature looks like, and which every consumer already handles.
|
||||
//
|
||||
// Reported back in the response rather than only logged, so the owner sees "created, but no OS account"
|
||||
// at the moment they click rather than discovering it when a terminal opens in the wrong home.
|
||||
let osUser: string | null = null;
|
||||
let osUserError: string | null = null;
|
||||
let sshPublicKey: string | null = null;
|
||||
if (OS_USERS_ENABLED) {
|
||||
const result = await ensureOsUser({ email, username });
|
||||
if (result.ok) {
|
||||
osUser = result.osUser;
|
||||
// Retryable in place afterwards via POST /users/:id/provision-linux, so a host that was not ready when the
|
||||
// account was made does not cost anybody their password and dashboards.
|
||||
const os = OS_USERS_ENABLED
|
||||
? await provisionOsAccount({ userId: user.id, email, username, inboundKey })
|
||||
: { osUser: null, sshPublicKey: null, error: null };
|
||||
|
||||
// SSH after the account, because everything it writes lives inside a home that does not belong to
|
||||
// us until `ensureOsUser` has chowned it.
|
||||
const ssh = await provisionSshAccess({
|
||||
email,
|
||||
osUser: result.osUser,
|
||||
uid: result.uid,
|
||||
gid: result.gid,
|
||||
authorizedKey: inboundKey,
|
||||
});
|
||||
if (ssh.ok) {
|
||||
sshPublicKey = ssh.publicKey;
|
||||
} else {
|
||||
// The Linux account is real and usable either way — it just has no keys yet. Reported rather than
|
||||
// thrown for the same reason as the rest of this block.
|
||||
osUserError = ssh.error;
|
||||
console.warn(`[users] created the Linux account for ${email} but SSH setup failed: ${ssh.error}`);
|
||||
}
|
||||
if (os.error) console.warn(`[users] created ${email} but its Linux side did not finish: ${os.error}`);
|
||||
|
||||
await updateUser(user.id, { osUser: result.osUser, osSshPublicKey: sshPublicKey });
|
||||
} else {
|
||||
osUserError = result.error;
|
||||
console.warn(`[users] created ${email} but could not create its Linux account: ${result.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.json({ user: { ...toPublicUser(user), osUser, osSshPublicKey: sshPublicKey }, osUserError }, 201);
|
||||
return ctx.json(
|
||||
{
|
||||
user: { ...toPublicUser(user), osUser: os.osUser, osSshPublicKey: os.sshPublicKey },
|
||||
osUserError: os.error,
|
||||
},
|
||||
201,
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user