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:
@@ -6,6 +6,7 @@ import { useClient } from 'hooks/useClient';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
|
||||
// The owner creating an account. Until this existed the only way to add one was an INSERT in Postgres.
|
||||
@@ -45,7 +46,22 @@ function generatePassword(): string {
|
||||
return chars.join('');
|
||||
}
|
||||
|
||||
const EMPTY = { email: '', name: '', username: '', password: '', role: 'Member' };
|
||||
const EMPTY = { email: '', name: '', username: '', password: '', role: 'Member', sshPublicKey: '' };
|
||||
|
||||
/**
|
||||
* What the server did, shown after the fact.
|
||||
*
|
||||
* Kept on screen rather than announced in a toast because two of these values are only obtainable now: the
|
||||
* password is stored as an argon2 hash, and the generated public key sits in a 700 home. A toast that
|
||||
* carries something unrecoverable is a toast that gets dismissed by a stray click.
|
||||
*/
|
||||
type CreatedAccount = {
|
||||
email: string;
|
||||
password: string;
|
||||
osUser: string | null;
|
||||
osSshPublicKey: string | null;
|
||||
osUserError: string | null;
|
||||
};
|
||||
|
||||
export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
|
||||
const client = useClient();
|
||||
@@ -53,28 +69,32 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState(EMPTY);
|
||||
const [created, setCreated] = useState<CreatedAccount | null>(null);
|
||||
|
||||
const set = (key: keyof typeof EMPTY) => (value: string) => setForm((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const close = () => {
|
||||
setOpen(false);
|
||||
setForm(EMPTY);
|
||||
setCreated(null);
|
||||
};
|
||||
|
||||
const submit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
await client.post('/users', form);
|
||||
const res = await client.post<{
|
||||
user: { osUser: string | null; osSshPublicKey: string | null };
|
||||
osUserError: string | null;
|
||||
}>('/users', form);
|
||||
await queryClient.invalidateQueries({ queryKey: usersKey });
|
||||
// The password is named in the toast on purpose. It is the only moment it is recoverable — the
|
||||
// server stores an argon2 hash and there is no reset flow yet, so an owner who closed the form
|
||||
// without noting it would have to delete the account and make it again.
|
||||
toast.success(`${form.email} created`, {
|
||||
description: `Password: ${form.password}`,
|
||||
duration: 30_000,
|
||||
setCreated({
|
||||
email: form.email,
|
||||
password: form.password,
|
||||
osUser: res.user.osUser,
|
||||
osSshPublicKey: res.user.osSshPublicKey,
|
||||
osUserError: res.osUserError,
|
||||
});
|
||||
close();
|
||||
} catch (ex) {
|
||||
// The server's message is the useful one here — which field, and why.
|
||||
toast.error(ex instanceof Error ? ex.message : 'Could not create the account');
|
||||
@@ -83,6 +103,83 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const copy = (value: string, what: string) => {
|
||||
void navigator.clipboard.writeText(value);
|
||||
toast.success(`${what} copied`);
|
||||
};
|
||||
|
||||
// ── After creation ──
|
||||
//
|
||||
// Deliberately a wall you have to dismiss. Both values below are unrecoverable once this closes, and the
|
||||
// public key has a job attached to it that nothing else will remind you to do.
|
||||
if (created) {
|
||||
return (
|
||||
<div className="space-y-4 rounded-lg border border-duck-teal/40 bg-duck-teal/5 p-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">{created.email} created</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Copy what you need before closing — none of it can be shown again.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Password</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input readOnly value={created.password} className="font-mono" />
|
||||
<Button type="button" variant="outline" size="icon" onClick={() => copy(created.password, 'Password')}>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Stored as a hash — this is the only time it exists in readable form. They can change it from their own
|
||||
profile once signed in.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{created.osUser && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Linux account</Label>
|
||||
<Input readOnly value={created.osUser} className="font-mono" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{created.osSshPublicKey && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Their SSH public key</Label>
|
||||
<div className="flex gap-2">
|
||||
<Textarea readOnly value={created.osSshPublicKey} rows={3} className="font-mono text-xs" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => copy(created.osSshPublicKey!, 'Public key')}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* The one action this screen cannot do for you. Without it their pushes fail with a
|
||||
permission error that says nothing about a missing key. */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Generated on the machine; the private half never leaves it.{' '}
|
||||
<strong>Add this to their Gitea account</strong> so they can push. Retrievable later from their row.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{created.osUserError && (
|
||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-xs">
|
||||
<div className="font-medium">The account works, but its Linux side did not finish</div>
|
||||
<p className="mt-1 whitespace-pre-wrap text-muted-foreground">{created.osUserError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="button" onClick={close}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||
@@ -198,6 +295,27 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
|
||||
At least 12 characters, with upper and lower case, a number and a symbol.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Inbound only, and optional. The OUTBOUND key is generated either way — pasting one here does
|
||||
not replace it, because a key on a laptop is no use to an agent running on the server. */}
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor="new-user-ssh">
|
||||
Their SSH public key <span className="ml-1 text-xs opacity-60">(optional)</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="new-user-ssh"
|
||||
rows={3}
|
||||
spellCheck={false}
|
||||
placeholder="ssh-ed25519 AAAAC3Nza… ana@laptop"
|
||||
className="font-mono text-xs"
|
||||
value={form.sshPublicKey}
|
||||
onChange={(ev) => set('sshPublicKey')(ev.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Lets them SSH into this machine as their own Linux user. Leave empty for platform-only access — either way
|
||||
they get a keypair of their own for pushing to Gitea, and you will be shown its public half next.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Crown, Trash2, Loader2 } from 'lucide-react';
|
||||
import { Crown, Trash2, Loader2, KeyRound } from 'lucide-react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
@@ -26,6 +26,8 @@ type ManagedUser = {
|
||||
role: string;
|
||||
createdAt: string;
|
||||
isOwner: boolean;
|
||||
osUser: string | null;
|
||||
osSshPublicKey: string | null;
|
||||
};
|
||||
|
||||
type UsersResponse = {
|
||||
@@ -112,6 +114,9 @@ export const UsersSection = () => {
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
{user.status !== 'Active' && ` · ${user.status}`}
|
||||
{/* Shown because "does this person have a Linux account" is otherwise invisible, and it
|
||||
decides whether their terminal and agent run as them or not at all. */}
|
||||
{user.osUser && ` · ${user.osUser}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -134,6 +139,24 @@ export const UsersSection = () => {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* The errand the create screen promised would still be here: this key has to end up on
|
||||
their Gitea account, and nothing else will remind anyone. */}
|
||||
{user.osSshPublicKey && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
aria-label={`Copy ${user.email}'s SSH public key`}
|
||||
title="Copy their SSH public key (add it to their Gitea account)"
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(user.osSshPublicKey!);
|
||||
toast.success('Public key copied');
|
||||
}}
|
||||
>
|
||||
<KeyRound className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
Reference in New Issue
Block a user