feat(ssh): per-device SSH keys, deployed and proved (v0.23.24-beta)
Closes #110. Also the prerequisite for #118 — arbitrary console commands need SSH on every device, and stored passwords do not scale to sixty of them. Each device gets its own ed25519 keypair, generated on the manager, pushed over the existing password session, imported, and then *proved* by opening a fresh connection and authenticating with it. Nothing trusts a key until that last step succeeds. Backups and config export use a verified key where one exists. Verified end to end on hardware: deploy, rotate twice leaving exactly one authorised key each time, verify, and a 4.6 KB config backup pulled over key-only SSH with no password fallback. **The design note on the issue was wrong about the central safety property**, and testing on a switch is what proved it. I claimed the stored password remained as a fallback. It does not: RouterOS binds a key to a user and then refuses password authentication for that user entirely. Removing the key restored password login immediately, which settles the causation. That mistake compounded with a second one. Deployment wrote the new private key to the database before proving it, so a rotation that failed halfway left the database holding a key the device had never authorised while the device demanded one we no longer had — SSH lost on the test switch, recoverable only through the binary API. Three changes follow: * Rotation authenticates with the *existing* key, since the password is no longer an option once a key is installed. * Nothing is written until a fresh connection has authenticated with the new key. A deployment that fails verification removes the key it just installed rather than leaving the device demanding a credential nobody holds. * The binary API, unaffected by any of this, is the removal path — and is therefore the real recovery route, not the password. Two further faults came out of live testing rather than reasoning. Every key for a device shared one comment, so removing "the old key" during a rotation removed the new one too; comments are now unique per key. And RouterOS stores that comment in `info`, not `key-owner` — matching the wrong field silently matched nothing and left every rotation's old key behind, which is why the switch accumulated keys that later blocked it. Revoke now clears *every* key this manager installed for a device, not just the one on record. Orphans from a half-failed rotation otherwise keep password SSH disabled with no way back through the product, which is exactly the state the test switch got stuck in. When deployment fails with an authentication error and orphans are present, the message says so instead of leaving someone guessing. The UI states the trade before it is made: installing a key disables password SSH for that user, the API and WinBox are unaffected, and revoking restores it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a06ebd4d71
commit
1ab2e6547e
@@ -0,0 +1,147 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { KeyRound, ShieldCheck, AlertTriangle, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { sshKeyApi } from '../../services/api';
|
||||
|
||||
/**
|
||||
* Per-device SSH key management.
|
||||
*
|
||||
* The warning about password SSH is not boilerplate. RouterOS refuses password
|
||||
* authentication for a user once a key is bound to them — verified on hardware —
|
||||
* so installing a key is a real trade, not a pure addition, and the operator has
|
||||
* to be told before they make it rather than after (#110).
|
||||
*/
|
||||
export default function SshKeyCard({ deviceId }: { deviceId: number }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
const [confirmRevoke, setConfirmRevoke] = useState(false);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['ssh-key', deviceId],
|
||||
queryFn: () => sshKeyApi.get(deviceId).then(r => r.data.key),
|
||||
});
|
||||
|
||||
const run = (fn: () => Promise<{ data: { message: string } }>) => ({
|
||||
mutationFn: fn,
|
||||
onSuccess: (r: { data: { message: string } }) => {
|
||||
setMsg({ ok: true, text: r.data.message });
|
||||
setConfirmRevoke(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['ssh-key', deviceId] });
|
||||
},
|
||||
onError: (e: unknown) => setMsg({
|
||||
ok: false,
|
||||
text: (e as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Request failed',
|
||||
}),
|
||||
});
|
||||
|
||||
const deploy = useMutation(run(() => sshKeyApi.deploy(deviceId, false)));
|
||||
const rotate = useMutation(run(() => sshKeyApi.deploy(deviceId, true)));
|
||||
const verify = useMutation(run(() => sshKeyApi.verify(deviceId)));
|
||||
const revoke = useMutation(run(() => sshKeyApi.revoke(deviceId)));
|
||||
const busy = deploy.isPending || rotate.isPending || verify.isPending || revoke.isPending;
|
||||
|
||||
if (isLoading) return <div className="text-sm text-gray-500 dark:text-slate-400">Loading key status…</div>;
|
||||
|
||||
const verified = data?.status === 'verified';
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<KeyRound className="w-5 h-5 mt-0.5 text-gray-400 dark:text-slate-500" />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">SSH key</h3>
|
||||
{data && (
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||
verified
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300'
|
||||
: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300'}`}>
|
||||
{data.status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-slate-300 mt-0.5">
|
||||
A unique keypair for this device, used for backups and config export instead of a
|
||||
stored password. The private half never leaves the server and is never shown.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data ? (
|
||||
<div className="rounded-lg border border-gray-200 dark:border-slate-700 p-3 text-xs space-y-1">
|
||||
<div className="flex gap-2">
|
||||
<span className="text-gray-500 dark:text-slate-400 w-24 shrink-0">Fingerprint</span>
|
||||
<span className="font-mono text-gray-900 dark:text-white break-all">{data.fingerprint}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="text-gray-500 dark:text-slate-400 w-24 shrink-0">User</span>
|
||||
<span className="text-gray-900 dark:text-white">{data.ssh_username}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="text-gray-500 dark:text-slate-400 w-24 shrink-0">Last proved</span>
|
||||
<span className="text-gray-900 dark:text-white">
|
||||
{data.last_verified_at ? new Date(data.last_verified_at).toLocaleString() : 'never'}
|
||||
</span>
|
||||
</div>
|
||||
{data.last_error && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-gray-500 dark:text-slate-400 w-24 shrink-0">Last error</span>
|
||||
<span className="text-red-600 dark:text-red-400 break-words">{data.last_error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2 rounded-lg border border-amber-300 bg-amber-50 p-3 text-xs dark:border-amber-700/60 dark:bg-amber-900/20">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5 text-amber-600 dark:text-amber-400" />
|
||||
<div className="text-amber-800 dark:text-amber-300">
|
||||
<span className="font-medium">Installing a key disables password SSH for this user.</span>{' '}
|
||||
RouterOS requires key authentication once a key is bound to an account. The binary API and
|
||||
WinBox are unaffected, and revoking the key restores password SSH.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{!data && (
|
||||
<button className="btn-primary text-xs flex items-center gap-1.5"
|
||||
onClick={() => deploy.mutate()} disabled={busy}>
|
||||
{deploy.isPending ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <KeyRound className="w-3.5 h-3.5" />}
|
||||
Install key
|
||||
</button>
|
||||
)}
|
||||
{data && (
|
||||
<>
|
||||
<button className="btn-secondary text-xs flex items-center gap-1.5"
|
||||
onClick={() => verify.mutate()} disabled={busy}>
|
||||
<ShieldCheck className="w-3.5 h-3.5" /> Test
|
||||
</button>
|
||||
<button className="btn-secondary text-xs flex items-center gap-1.5"
|
||||
onClick={() => rotate.mutate()} disabled={busy}>
|
||||
{rotate.isPending ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
Rotate
|
||||
</button>
|
||||
{confirmRevoke ? (
|
||||
<>
|
||||
<button className="btn-danger text-xs" onClick={() => revoke.mutate()} disabled={busy}>
|
||||
Confirm revoke
|
||||
</button>
|
||||
<button className="btn-secondary text-xs" onClick={() => setConfirmRevoke(false)}>Cancel</button>
|
||||
</>
|
||||
) : (
|
||||
<button className="btn-secondary text-xs flex items-center gap-1.5 ml-auto"
|
||||
onClick={() => setConfirmRevoke(true)} disabled={busy}>
|
||||
<Trash2 className="w-3.5 h-3.5" /> Revoke
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<p className={`text-xs ${msg.ok ? 'text-green-700 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
|
||||
{msg.text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import SshKeyCard from './SshKeyCard';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
RefreshCw, Plus, Trash2, CheckCircle, AlertCircle, Download, Server, Cpu, FileText,
|
||||
@@ -576,6 +577,13 @@ export default function SystemConfigTab({ deviceId, device }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card p-5">
|
||||
|
||||
<SshKeyCard deviceId={deviceId} />
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
{/* ── RouterOS ── */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -366,6 +366,26 @@ export const dataCapApi = {
|
||||
api.post<{ message: string }>(`/devices/${deviceId}/lte/data-cap/${encodeURIComponent(iface)}/send`),
|
||||
};
|
||||
|
||||
|
||||
export interface DeviceSshKey {
|
||||
device_id: number; key_type: string; public_key: string;
|
||||
fingerprint: string | null; ssh_username: string | null;
|
||||
status: 'pending' | 'deployed' | 'verified' | 'failed';
|
||||
last_error: string | null; last_verified_at: string | null;
|
||||
deployed_at: string | null; created_at: string;
|
||||
}
|
||||
|
||||
export const sshKeyApi = {
|
||||
get: (deviceId: number) => api.get<{ key: DeviceSshKey | null }>(`/devices/${deviceId}/ssh-key`),
|
||||
deploy: (deviceId: number, rotate = false) =>
|
||||
api.post<{ message: string }>(`/devices/${deviceId}/ssh-key`, { rotate }),
|
||||
verify: (deviceId: number) => api.post<{ message: string }>(`/devices/${deviceId}/ssh-key/verify`),
|
||||
revoke: (deviceId: number) => api.delete<{ message: string }>(`/devices/${deviceId}/ssh-key`),
|
||||
deployAll: () => api.post<{ considered: number; succeeded: number;
|
||||
results: { device_id: number; name: string; ok: boolean; error?: string }[] }>(
|
||||
'/devices/ssh-keys/deploy-all', undefined, { timeout: 600_000 }),
|
||||
};
|
||||
|
||||
export const devicesApi = {
|
||||
list: () => api.get<Device[]>('/devices'),
|
||||
discovered: () => api.get<DiscoveredDevice[]>('/devices/discovered'),
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const APP_VERSION = 'v0.23.23 Beta';
|
||||
export const APP_VERSION = 'v0.23.24 Beta';
|
||||
|
||||
Reference in New Issue
Block a user