ssh for a member's linux account, both directions
Inbound and outbound are two keys doing two jobs, and treating them as
alternatives breaks the goal:
inbound ~/.ssh/authorized_keys, from an optional public key the owner pastes
on the create form. Their private half stays on their laptop.
outbound ~/.ssh/id_ed25519, generated in their home, never leaves the machine.
"They pasted a key, so skip generating one" is the obvious simplification. Agent
forwarding covers a human in an interactive session, but a platform-spawned agent
has no agent socket to borrow — so an edge checkout it is asked to commit and push
needs a key that lives on the box. The inbound key is therefore optional and the
outbound one is not.
No linux password, ever: useradd sets none, which blocks password login and does
not block key auth. So "real user, reachable over SSH, no password anywhere" is
the resting state, and the platform password stays the platform's business.
Validation is about line count, not key shape. Every line of authorized_keys is a
credential, so a pasted value with a newline would install a SECOND key silently.
Multi-line refused, a private key refused by name, an options prefix refused.
Every write goes through sudo install: the home is 700 and the member's, so the
service user cannot even create .ssh. install sets content, owner and mode in one
step, and content travels as a temp path so nothing quotes a form value into a
shell. ssh-keygen runs AS the member so the private key is never briefly root's.
known_hosts is not seeded — StrictHostKeyChecking accept-new instead. The Gitea
SSH endpoint is not knowable at create time, and the default setting makes a first
connection prompt, which in a non-interactive agent turn is a hang rather than an
error. accept-new still refuses a changed host key.
The generated public key is stored on the row and shown twice: on the after-create
panel and behind a key button on the user's row. It has an errand attached that
nothing else will remind anyone about — it must be added to their Gitea account.
Verified with a real useradd: .ssh 700 and id_ed25519 600 both owned by the member
and usable by them, authorized_keys byte-identical to the paste, no key rotation on
a second run, and a multi-line paste refused with authorized_keys untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ 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 { validatePassword } from '../auth/validate-password';
|
||||
import { validateUsername } from '../auth/validate-username';
|
||||
import { toPublicUser } from './manage-users';
|
||||
@@ -62,6 +63,19 @@ export const createUserHandler: Handler = async function (ctx) {
|
||||
);
|
||||
}
|
||||
|
||||
// The inbound SSH key, if the owner supplied one. Validated HERE rather than at use, so a bad paste is a
|
||||
// 400 on the form instead of an account that exists with a confusing warning attached.
|
||||
//
|
||||
// Optional by design: an account with no inbound key is platform-only, which is a perfectly good state.
|
||||
// The OUTBOUND key is generated regardless — see os-user-ssh.ts for why those are not alternatives.
|
||||
const rawKey = typeof body.sshPublicKey === 'string' ? body.sshPublicKey.trim() : '';
|
||||
let inboundKey: string | null = null;
|
||||
if (rawKey) {
|
||||
const checked = validatePublicKey(rawKey);
|
||||
if (!checked.ok) throw errors.BAD_REQUEST(`SSH public key: ${checked.error}`);
|
||||
inboundKey = checked.key;
|
||||
}
|
||||
|
||||
// Checked before the insert purely for the message — both columns are unique, so the database is the
|
||||
// real guard and this is a race it can lose harmlessly (the insert then throws).
|
||||
if (await getUserByEmail(email)) throw errors.CONFLICT('An account with that email already exists');
|
||||
@@ -95,16 +109,36 @@ export const createUserHandler: Handler = async function (ctx) {
|
||||
// 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;
|
||||
await updateUser(user.id, { osUser: result.osUser });
|
||||
|
||||
// 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}`);
|
||||
}
|
||||
|
||||
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 }, osUserError }, 201);
|
||||
return ctx.json({ user: { ...toPublicUser(user), osUser, osSshPublicKey: sshPublicKey }, osUserError }, 201);
|
||||
};
|
||||
|
||||
@@ -19,6 +19,14 @@ type PublicUser = {
|
||||
createdAt: Date;
|
||||
/** True for the bootstrap account. The UI uses it to lock the row; the database enforces it. */
|
||||
isOwner: boolean;
|
||||
/** The Linux account this runs as, or null where per-user OS accounts are off. */
|
||||
osUser: string | null;
|
||||
/**
|
||||
* The public half of their generated SSH key. Listed because it has an errand attached — it must be
|
||||
* added to their Gitea account — and the create form promises it is retrievable here afterwards. Public
|
||||
* by definition, so no reason to withhold it from the owner-only endpoint that already returns emails.
|
||||
*/
|
||||
osSshPublicKey: string | null;
|
||||
};
|
||||
|
||||
/** Shared with create-user.ts, so a created account and a listed one are described the same way. */
|
||||
@@ -32,6 +40,8 @@ export const toPublicUser = (u: Awaited<ReturnType<typeof getUsers>>[number]): P
|
||||
role: u.role,
|
||||
createdAt: u.createdAt,
|
||||
isOwner: u.id === OWNER_USER_ID,
|
||||
osUser: u.osUser,
|
||||
osSshPublicKey: u.osSshPublicKey,
|
||||
});
|
||||
|
||||
export const listUsersHandler: Handler = async function (ctx) {
|
||||
|
||||
Reference in New Issue
Block a user