diff --git a/README.md b/README.md index 9b939c4..248d633 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

- Version + Version License Docker TypeScript diff --git a/backend/package.json b/backend/package.json index 1218b4d..15efc2c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-backend", - "version": "0.23.23-beta", + "version": "0.23.24-beta", "description": "Mikrotik Network Management Platform - Backend API", "main": "dist/index.js", "scripts": { diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index d1e0303..9d9abd4 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -862,6 +862,37 @@ CREATE TABLE IF NOT EXISTS lte_data_cap_sends ( error TEXT ); CREATE INDEX IF NOT EXISTS idx_data_cap_sends ON lte_data_cap_sends(device_id, at DESC); + +-- Per-device SSH keypairs (#110). +-- +-- One key per device, never a fleet key: a shared key makes any single +-- compromise fleet-wide and makes rotating one device impossible without +-- touching all of them. +-- +-- status is deliberately not a boolean. A key that has been generated, a key +-- that has been pushed to the device, and a key that has been *proved* to +-- authenticate on a fresh connection are three different states, and only the +-- last is safe to rely on. Collapsing them is how a device ends up unreachable +-- because something looked applied. +-- +-- The password is retained alongside. Key auth falling back to a password is a +-- degraded state worth knowing about; key auth failing with no fallback is an +-- outage. +CREATE TABLE IF NOT EXISTS device_ssh_keys ( + device_id INTEGER PRIMARY KEY REFERENCES devices(id) ON DELETE CASCADE, + key_type VARCHAR(24) NOT NULL DEFAULT 'ed25519', + public_key TEXT NOT NULL, + private_key_encrypted TEXT NOT NULL, + fingerprint VARCHAR(80), + ssh_username VARCHAR(50), + -- pending | deployed | verified | failed + status VARCHAR(16) NOT NULL DEFAULT 'pending', + last_error TEXT, + last_verified_at TIMESTAMPTZ, + deployed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); `; const DEFAULT_SETTINGS = [ diff --git a/backend/src/routes/devices.ts b/backend/src/routes/devices.ts index 877f5ed..1f00f40 100644 --- a/backend/src/routes/devices.ts +++ b/backend/src/routes/devices.ts @@ -1571,6 +1571,121 @@ router.post('/:id/check-update', async (req: Request, res: Response) => { } }); +// ─── Per-device SSH keys (#110) ────────────────────────────────────────────── + +const SSH_TARGET_COLS = + `id, name, ip_address, ssh_port, ssh_username, ssh_password_encrypted, + api_port, api_username, api_password_encrypted`; + +/** + * GET /api/devices/:id/ssh-key — key status. + * + * The private half is never returned. Not here, not anywhere: it exists so that + * nobody has to hold a credential, and handing it back through the API would + * defeat the point while widening the surface at the same time. + */ +router.get('/:id/ssh-key', async (req: Request, res: Response) => { + try { + const row = await queryOne>( + `SELECT device_id, key_type, public_key, fingerprint, ssh_username, status, + last_error, last_verified_at, deployed_at, created_at + FROM device_ssh_keys WHERE device_id = $1`, + [req.params.id] + ); + res.json({ key: row ?? null }); + } catch (error) { + console.error('Error fetching SSH key:', error); + res.status(500).json({ error: 'Failed to fetch SSH key' }); + } +}); + +// POST /api/devices/:id/ssh-key — generate, deploy and verify (rotates if one exists) +router.post('/:id/ssh-key', requireWrite, async (req: Request, res: Response) => { + const target = await queryOne(`SELECT ${SSH_TARGET_COLS} FROM devices WHERE id = $1`, [req.params.id]); + if (!target) return res.status(404).json({ error: 'Device not found' }); + + try { + const { sshKeyService } = await import('../services/SshKeyService'); + const rotate = (req.body as { rotate?: boolean })?.rotate === true; + const key = await sshKeyService.deploy(target, { rotate }); + res.json({ + // Not "password access is unchanged" — that was wrong, and testing on + // hardware proved it. RouterOS refuses password SSH for a user once a key + // is bound to them, so this is a real trade the operator must know about. + message: rotate + ? 'New key deployed and verified; the previous one has been removed.' + : 'Key deployed and verified. Note that RouterOS now requires this key for SSH — ' + + 'password SSH is disabled for this user until the key is revoked. ' + + 'The API and WinBox are unaffected.', + key: { fingerprint: key.fingerprint, status: key.status, public_key: key.public_key }, + }); + } catch (error) { + // Deployment failures leave password auth untouched by design, and the + // message says so — otherwise the natural fear is that access was lost. + console.error('SSH key deployment failed:', (error as Error).message); + res.status(502).json({ error: (error as Error).message }); + } +}); + +// POST /api/devices/:id/ssh-key/verify — re-prove an existing key +router.post('/:id/ssh-key/verify', requireWrite, async (req: Request, res: Response) => { + const target = await queryOne(`SELECT ${SSH_TARGET_COLS} FROM devices WHERE id = $1`, [req.params.id]); + if (!target) return res.status(404).json({ error: 'Device not found' }); + + const { sshKeyService } = await import('../services/SshKeyService'); + const result = await sshKeyService.verify(target); + res.status(result.ok ? 200 : 502).json( + result.ok + ? { message: 'Key authenticated successfully.' } + : { error: `Key did not authenticate: ${result.error}` } + ); +}); + +// DELETE /api/devices/:id/ssh-key — stop using the key, and remove it if reachable +router.delete('/:id/ssh-key', requireWrite, async (req: Request, res: Response) => { + const target = await queryOne(`SELECT ${SSH_TARGET_COLS} FROM devices WHERE id = $1`, [req.params.id]); + if (!target) return res.status(404).json({ error: 'Device not found' }); + + const { sshKeyService } = await import('../services/SshKeyService'); + const result = await sshKeyService.revoke(target); + res.json({ + message: result.removedFromDevice + ? 'Key revoked and removed from the device.' + : `Key forgotten here, but it could not be removed from the device (${result.error}). Remove it manually via /user/ssh-keys.`, + removed_from_device: result.removedFromDevice, + }); +}); + +/** + * POST /api/devices/ssh-keys/deploy-all — retroactively key every device missing one. + * + * Sequential rather than parallel: each deployment opens two SSH sessions and + * ends by proving the key, and running sixty of those at once would be a + * thundering herd against the fleet for no wall-clock gain worth having. + */ +router.post('/ssh-keys/deploy-all', requireWrite, async (_req: Request, res: Response) => { + const targets = await query( + `SELECT ${SSH_TARGET_COLS} FROM devices d + WHERE d.status != 'disabled' AND d.ssh_username IS NOT NULL + AND d.ssh_password_encrypted IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM device_ssh_keys k WHERE k.device_id = d.id AND k.status = 'verified') + ORDER BY d.name` + ); + + const { sshKeyService } = await import('../services/SshKeyService'); + const results: { device_id: number; name: string; ok: boolean; error?: string }[] = []; + for (const t of targets) { + try { + await sshKeyService.deploy(t); + results.push({ device_id: t.id, name: t.name, ok: true }); + } catch (e) { + results.push({ device_id: t.id, name: t.name, ok: false, error: (e as Error).message }); + } + } + res.json({ considered: targets.length, succeeded: results.filter((r) => r.ok).length, results }); +}); + /** * POST /api/devices/:id/install-update — install a pending RouterOS update. * diff --git a/backend/src/services/BackupService.ts b/backend/src/services/BackupService.ts index 5bba669..c1af3a2 100644 --- a/backend/src/services/BackupService.ts +++ b/backend/src/services/BackupService.ts @@ -1,7 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { Client as SSHClient } from 'ssh2'; -import { query } from '../config/database'; +import { query, queryOne } from '../config/database'; import { decrypt } from '../utils/crypto'; const BACKUPS_DIR = process.env.BACKUPS_DIR || '/app/backups'; @@ -25,14 +25,41 @@ export class BackupService { } } - /** Run `/export compact` over SSH and return the raw .rsc text (no file written). */ + /** + * Run `/export compact` over SSH and return the raw .rsc text. + * + * Prefers a *verified* key over the stored password. Only verified, because a + * key that has merely been generated or pushed is not evidence the device will + * accept it — and a backup that fails on an unproven credential is worse than + * one that succeeds on a working password (#110). If the key fails at connect + * time we fall back rather than giving up, so introducing keys can never make + * backups less reliable than they were without them. + */ async exportConfig(device: BackupDevice): Promise { const sshUser = device.ssh_username || device.api_username; + const port = device.ssh_port || 22; + + const key = await queryOne<{ private_key_encrypted: string; ssh_username: string | null }>( + `SELECT private_key_encrypted, ssh_username FROM device_ssh_keys + WHERE device_id = $1 AND status = 'verified'`, + [device.id] + ).catch(() => null); + + if (key) { + try { + return await this.sshExport( + device.ip_address, port, key.ssh_username || sshUser, + { privateKey: decrypt(key.private_key_encrypted) } + ); + } catch (e) { + console.warn(`[Backup] ${device.name}: key auth failed, falling back to password: ${(e as Error).message}`); + } + } + const sshPass = device.ssh_password_encrypted ? decrypt(device.ssh_password_encrypted) : decrypt(device.api_password_encrypted); - - return this.sshExport(device.ip_address, device.ssh_port || 22, sshUser, sshPass); + return this.sshExport(device.ip_address, port, sshUser, { password: sshPass }); } /** Persist already-fetched .rsc text as a backup file + DB row. Returns the backup id. */ @@ -113,7 +140,7 @@ export class BackupService { host: string, port: number, username: string, - password: string + auth: { password: string } | { privateKey: string } ): Promise { return new Promise((resolve, reject) => { const conn = new SSHClient(); @@ -152,7 +179,7 @@ export class BackupService { reject(err); }); - conn.connect({ host, port, username, password, readyTimeout: 10_000 }); + conn.connect({ host, port, username, ...auth, readyTimeout: 10_000 }); }); } diff --git a/backend/src/services/SshKeyService.ts b/backend/src/services/SshKeyService.ts new file mode 100644 index 0000000..0b8feb3 --- /dev/null +++ b/backend/src/services/SshKeyService.ts @@ -0,0 +1,355 @@ +/** + * Deploying and verifying per-device SSH keys. + * + * The order of operations here is the whole point. A key is generated, pushed, + * imported, and only then *proved* by opening a completely fresh connection and + * authenticating with it. Nothing downstream trusts a key until that last step + * succeeds — because "imported without error" and "the device will let me in" + * are different claims, and the gap between them is where fleets become + * unreachable (#110). + * + * The existing password is never removed as a side effect. Losing password + * access is an explicit, separate decision, taken after keys are seen working. + */ +import { Client as SSHClient } from 'ssh2'; +import { query, queryOne } from '../config/database'; +import { encrypt, decrypt } from '../utils/crypto'; +import { + generateDeviceKeyPair, keyFileName, keyComment, isUsablePrivateKey, type KeyStatus, +} from '../utils/sshKeys'; +import { RouterOSClient } from './mikrotik/RouterOSClient'; + +const CONNECT_TIMEOUT_MS = 20_000; + +export interface SshTarget { + id: number; + name: string; + ip_address: string; + ssh_port: number | null; + ssh_username: string | null; + ssh_password_encrypted: string | null; + /** The binary API is the recovery path: it is unaffected by SSH key state. */ + api_port: number | null; + api_username: string | null; + api_password_encrypted: string | null; +} + +export interface DeviceKeyRow { + device_id: number; + key_type: string; + public_key: string; + private_key_encrypted: string; + fingerprint: string | null; + ssh_username: string | null; + status: KeyStatus; + last_error: string | null; + last_verified_at: string | null; +} + +type Auth = { password: string } | { privateKey: string }; + +/** One SSH session, with whichever credential the caller chose. */ +function withSsh( + target: SshTarget, + username: string, + auth: Auth, + work: (conn: SSHClient) => Promise, +): Promise { + return new Promise((resolve, reject) => { + const conn = new SSHClient(); + const timer = setTimeout(() => { + conn.end(); + reject(new Error(`SSH timed out connecting to ${target.ip_address}`)); + }, CONNECT_TIMEOUT_MS); + + conn.on('ready', () => { + work(conn) + .then((r) => { clearTimeout(timer); conn.end(); resolve(r); }) + .catch((e) => { clearTimeout(timer); conn.end(); reject(e); }); + }); + conn.on('error', (err) => { clearTimeout(timer); reject(err); }); + + conn.connect({ + host: target.ip_address, + port: target.ssh_port ?? 22, + username, + readyTimeout: CONNECT_TIMEOUT_MS, + ...auth, + }); + }); +} + +/** Run one command and collect its output. */ +function exec(conn: SSHClient, command: string): Promise { + return new Promise((resolve, reject) => { + conn.exec(command, (err, stream) => { + if (err) return reject(err); + let out = ''; + stream.on('data', (d: Buffer) => { out += d.toString(); }); + stream.stderr.on('data', (d: Buffer) => { out += d.toString(); }); + stream.on('close', () => resolve(out)); + }); + }); +} + +/** Upload the public key, which RouterOS needs as a real file before importing. */ +function putFile(conn: SSHClient, remoteName: string, contents: string): Promise { + return new Promise((resolve, reject) => { + conn.sftp((err, sftp) => { + if (err) return reject(err); + const ws = sftp.createWriteStream(remoteName); + ws.on('close', () => resolve()); + ws.on('error', reject); + ws.end(contents); + }); + }); +} + +export class SshKeyService { + async getKey(deviceId: number): Promise { + return queryOne(`SELECT * FROM device_ssh_keys WHERE device_id = $1`, [deviceId]); + } + + /** + * Remove an authorised key from the device over the binary API. + * + * The API is deliberately the tool for this. Once a key is bound to a user, + * RouterOS refuses password SSH for that user, so a failed key deployment + * would otherwise leave no way in over SSH at all. The API is unaffected by + * any of that, which makes it the one reliable way to undo this feature. + */ + private async removeKeyViaApi( + target: SshTarget, comment: string, fingerprint?: string | null, + ): Promise { + if (!target.api_username || !target.api_password_encrypted) { + throw new Error('No API credentials available to remove the key'); + } + const client = new RouterOSClient( + target.ip_address, target.api_port ?? 8728, + target.api_username, decrypt(target.api_password_encrypted), + ); + // RouterOS keeps the key comment in `info`, not `key-owner` — matching the + // latter silently matched nothing and left every rotation's old key behind. + // Fingerprint is the stronger identity, so it is preferred where we have it; + // RouterOS keeps the base64 padding that we strip, hence the normalisation. + const norm = (f: string) => f.replace(/^SHA256:/, '').replace(/=+$/, ''); + const wantFp = fingerprint ? norm(fingerprint) : null; + + let removed = 0; + try { + await client.connect(); + const keys = await client.execute('/user/ssh-keys/print', { detail: '' }); + for (const k of keys) { + const matchesFp = wantFp != null && norm(k['fingerprint'] || '') === wantFp; + const matchesComment = comment.length > 0 && (k['info'] || '').includes(comment); + if (matchesFp || matchesComment) { + await client.execute('/user/ssh-keys/remove', { '.id': k['.id'] }); + removed++; + } + } + } finally { + client.disconnect(); + } + return removed; + } + + /** + * Generate, deploy and verify a key for one device. + * + * Two things here were learned the hard way, on a switch that lost SSH. + * + * **Installing a key disables password SSH for that user.** RouterOS binds the + * key and then refuses password authentication — confirmed by removing the key + * and watching password login start working again. So a rotation cannot + * authenticate with the password; it must use the key already in place. + * + * **The stored private key is not replaced until the new one is proved.** It + * was, once, and a rotation that failed halfway left the database holding a + * key the device had never authorised while the device held one we no longer + * had. Nothing is overwritten until a fresh connection has authenticated with + * the new key, and a deployment that fails verification removes the key it + * just installed rather than leaving the device in that state. + */ + async deploy(target: SshTarget, opts: { rotate?: boolean } = {}): Promise { + const username = target.ssh_username?.trim(); + if (!username) throw new Error('No SSH username configured for this device'); + + const existing = await this.getKey(target.id); + const canUseExistingKey = existing && existing.status === 'verified'; + if (!canUseExistingKey && !target.ssh_password_encrypted) { + throw new Error('An SSH password is required to install the first key'); + } + + const pair = generateDeviceKeyPair(target.id); + if (!isUsablePrivateKey(pair.privateKey)) { + throw new Error('Generated key could not be parsed — refusing to deploy it'); + } + + // Authenticate with whatever currently works: the existing key if we have a + // proved one, otherwise the password. After the first key is installed the + // password is no longer an option, so this order is not a preference. + const auth: Auth = canUseExistingKey + ? { privateKey: decrypt(existing!.private_key_encrypted) } + : { password: decrypt(target.ssh_password_encrypted!) }; + + const fileName = keyFileName(target.id); + try { + await withSsh(target, username, auth, async (conn) => { + await putFile(conn, fileName, pair.publicKey + '\n'); + const out = await exec(conn, `/user/ssh-keys/import public-key-file=${fileName} user=${username}`); + // RouterOS reports failure in the output rather than an exit code. + if (/failure|error|no such/i.test(out)) { + throw new Error(`Device rejected the key import: ${out.trim().slice(0, 200)}`); + } + await exec(conn, `/file/remove ${fileName}`).catch(() => ''); + }); + } catch (e) { + const msg = (e as Error).message; + // The most likely cause of an auth failure here is a key this manager + // installed previously and no longer holds — it keeps password SSH + // disabled, so neither credential works. Say so, rather than leaving + // someone to work it out. + if (/authentication/i.test(msg) && !canUseExistingKey) { + const orphans = await this.countManagerKeys(target).catch(() => 0); + if (orphans > 0) { + throw new Error( + `Key deployment failed: ${msg}. This device already holds ${orphans} key(s) ` + + `from this manager, which disables password SSH. Revoke to clear them, then retry.`, + { cause: e } + ); + } + } + throw new Error(`Key deployment failed: ${msg}`, { cause: e }); + } + + // Prove it on a connection that shares nothing with the one above. + const proved = await this.tryKey(target, username, pair.privateKey); + if (!proved.ok) { + // Undo, or the device is left with a key it will demand and we cannot + // supply — which is exactly how SSH was lost the first time. + await this.removeKeyViaApi(target, pair.comment, pair.fingerprint).catch((e) => + console.error(`[SshKey] ${target.name}: could not remove the failed key: ${(e as Error).message}`)); + throw new Error( + `The key was installed but would not authenticate (${proved.error}). ` + + `It has been removed from the device and nothing was changed.` + ); + } + + // Only now is it safe to replace what we had. + await query( + `INSERT INTO device_ssh_keys + (device_id, key_type, public_key, private_key_encrypted, fingerprint, + ssh_username, status, last_error, deployed_at, last_verified_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,'verified',NULL,NOW(),NOW(),NOW()) + ON CONFLICT (device_id) DO UPDATE SET + key_type = EXCLUDED.key_type, public_key = EXCLUDED.public_key, + private_key_encrypted = EXCLUDED.private_key_encrypted, + fingerprint = EXCLUDED.fingerprint, ssh_username = EXCLUDED.ssh_username, + status = 'verified', last_error = NULL, deployed_at = NOW(), + last_verified_at = NOW(), updated_at = NOW()`, + [target.id, pair.keyType, pair.publicKey, encrypt(pair.privateKey), pair.fingerprint, username] + ); + + // Retire the previous key only once its replacement is proved, and target it + // by its own unique comment so this cannot remove the new one as well. + if (existing?.public_key) { + const oldComment = existing.public_key.trim().split(/\s+/).pop(); + if (oldComment && oldComment !== pair.comment) { + await this.removeKeyViaApi(target, oldComment, existing.fingerprint) + .then((n) => { if (n === 0) console.warn(`[SshKey] ${target.name}: old key not found on device`); }) + .catch((e) => console.warn(`[SshKey] ${target.name}: old key left in place: ${(e as Error).message}`)); + } + } + + void opts; + return (await this.getKey(target.id))!; + } + + /** How many keys on this device were installed by this manager? */ + private async countManagerKeys(target: SshTarget): Promise { + if (!target.api_username || !target.api_password_encrypted) return 0; + const client = new RouterOSClient( + target.ip_address, target.api_port ?? 8728, + target.api_username, decrypt(target.api_password_encrypted), + ); + try { + await client.connect(); + const keys = await client.execute('/user/ssh-keys/print', { detail: '' }); + return keys.filter((k) => (k['info'] || '').includes(keyComment(target.id))).length; + } finally { + client.disconnect(); + } + } + + /** Can this private key authenticate, right now, on its own connection? */ + private async tryKey( + target: SshTarget, username: string, privateKey: string, + ): Promise<{ ok: boolean; error?: string }> { + try { + await withSsh(target, username, { privateKey }, (conn) => exec(conn, '/system/identity/print')); + return { ok: true }; + } catch (e) { + return { ok: false, error: (e as Error).message }; + } + } + + /** + * Prove the key works, on a connection that shares nothing with deployment. + * + * This is the only evidence that counts. Reusing the deployment session would + * prove nothing — it authenticated with a password. + */ + async verify(target: SshTarget): Promise<{ ok: boolean; error?: string }> { + const row = await this.getKey(target.id); + if (!row) return { ok: false, error: 'No key on record for this device' }; + const username = row.ssh_username || target.ssh_username || ''; + if (!username) return { ok: false, error: 'No SSH username configured' }; + + const result = await this.tryKey(target, username, decrypt(row.private_key_encrypted)); + if (result.ok) { + await query( + `UPDATE device_ssh_keys SET status='verified', last_verified_at=NOW(), + last_error=NULL, updated_at=NOW() WHERE device_id=$1`, [target.id]); + } else { + await query( + `UPDATE device_ssh_keys SET status='failed', last_error=$2, updated_at=NOW() + WHERE device_id=$1`, [target.id, result.error]); + } + return result; + } + + /** + * Forget a key, and remove it from the device. + * + * Removal goes over the binary API rather than SSH, because SSH is precisely + * what may be broken. Deliberately tolerant of the device being unreachable: + * the operator's intent is that the manager stop using this key, and that must + * not depend on the hardware answering — but the message says plainly when the + * device still holds it, since a leftover key keeps password SSH disabled. + */ + async revoke(target: SshTarget): Promise<{ removedFromDevice: boolean; error?: string }> { + const row = await this.getKey(target.id); + if (!row) return { removedFromDevice: false, error: 'No key on record' }; + + let removed = false; + let error: string | undefined; + try { + // Match the device's *base* comment, which is a prefix of every key this + // manager has ever installed for it. Removing only the key we currently + // know about leaves orphans behind — from a rotation that failed halfway, + // or an older install — and every orphan keeps password SSH disabled with + // no way back through the product. Anything tagged for this device is + // unambiguously ours to remove. + const n = await this.removeKeyViaApi(target, keyComment(target.id), null); + removed = n > 0; + if (n === 0) error = 'the device does not appear to hold a key from this manager'; + } catch (e) { + error = (e as Error).message; + } + + await query(`DELETE FROM device_ssh_keys WHERE device_id = $1`, [target.id]); + return { removedFromDevice: removed, error }; + } +} + +export const sshKeyService = new SshKeyService(); diff --git a/backend/src/utils/__tests__/sshKeys.test.ts b/backend/src/utils/__tests__/sshKeys.test.ts new file mode 100644 index 0000000..7eb67f4 --- /dev/null +++ b/backend/src/utils/__tests__/sshKeys.test.ts @@ -0,0 +1,92 @@ +import { + generateDeviceKeyPair, fingerprintPublicKey, isUsablePrivateKey, + keyComment, keyFileName, preferredAuth, +} from '../sshKeys'; + +describe('generateDeviceKeyPair', () => { + const kp = generateDeviceKeyPair(42); + + it('produces an OpenSSH ed25519 pair', () => { + expect(kp.keyType).toBe('ed25519'); + expect(kp.publicKey.startsWith('ssh-ed25519 ')).toBe(true); + expect(kp.privateKey).toContain('BEGIN OPENSSH PRIVATE KEY'); + }); + + it('produces a private key ssh2 can authenticate with', () => { + // A key we cannot parse is a key that will fail at connect time, on a device + // we may have just removed password access from. + expect(isUsablePrivateKey(kp.privateKey)).toBe(true); + }); + + it('tags the public key exactly once', () => { + // The generator appends the comment itself; appending again produced keys + // tagged twice, which reads like a bug when seen on the device. + const occurrences = kp.publicKey.split(keyComment(42)).length - 1; + expect(occurrences).toBe(1); + expect(kp.publicKey.trim().split(/\s+/)).toHaveLength(3); + }); + + it('tags the public key so it can be attributed on the device', () => { + // An operator reading /user/ssh-keys/print must be able to tell which entry + // this is. A key nobody can attribute is a key nobody dares remove. + expect(kp.publicKey).toContain(keyComment(42)); + expect(keyFileName(42)).toBe('mm-device-42.pub'); + }); + + it('never repeats a key across devices', () => { + const a = generateDeviceKeyPair(1), b = generateDeviceKeyPair(2); + expect(a.publicKey).not.toBe(b.publicKey); + expect(a.fingerprint).not.toBe(b.fingerprint); + }); +}); + +describe('fingerprintPublicKey', () => { + it('is stable and OpenSSH-shaped', () => { + const kp = generateDeviceKeyPair(7); + expect(kp.fingerprint).toMatch(/^SHA256:[A-Za-z0-9+/]+$/); + expect(fingerprintPublicKey(kp.publicKey)).toBe(kp.fingerprint); + }); + + it('ignores the comment, so re-tagging does not change identity', () => { + const kp = generateDeviceKeyPair(8); + const [type, b64] = kp.publicKey.split(/\s+/); + expect(fingerprintPublicKey(`${type} ${b64} someone-elses-comment`)).toBe(kp.fingerprint); + }); +}); + +describe('isUsablePrivateKey', () => { + it('rejects junk rather than failing later at connect time', () => { + expect(isUsablePrivateKey('not a key')).toBe(false); + expect(isUsablePrivateKey('')).toBe(false); + }); +}); + +describe('preferredAuth', () => { + it('uses the key whenever one is installed', () => { + // Not a preference: RouterOS refuses password SSH for a user once a key is + // bound to them, confirmed on hardware by removing the key and watching + // password login start working again. + expect(preferredAuth('verified', true, true)).toBe('key'); + expect(preferredAuth('deployed', true, true)).toBe('key'); + }); + + it('uses the password only when no key is installed', () => { + expect(preferredAuth(null, false, true)).toBe('password'); + expect(preferredAuth('failed', false, true)).toBe('password'); + }); + + it('reports honestly when there is nothing to authenticate with', () => { + expect(preferredAuth(null, false, false)).toBe('none'); + }); +}); + +describe('key comments', () => { + it('are unique per key, not per device', () => { + // Sharing one comment across a device's keys meant removing "the old key" + // during a rotation removed the new one too, taking SSH with it. + const a = generateDeviceKeyPair(5), b = generateDeviceKeyPair(5); + expect(a.comment).not.toBe(b.comment); + expect(a.comment.startsWith(keyComment(5))).toBe(true); + expect(a.publicKey.trim().split(/\s+/).pop()).toBe(a.comment); + }); +}); diff --git a/backend/src/utils/sshKeys.ts b/backend/src/utils/sshKeys.ts new file mode 100644 index 0000000..950282e --- /dev/null +++ b/backend/src/utils/sshKeys.ts @@ -0,0 +1,108 @@ +/** + * Per-device SSH keypairs: generation, fingerprints, and the naming that ties a + * key on the manager to a key on the device. + * + * Keys are per device rather than per fleet. A shared key turns any single + * compromise into fleet-wide compromise, and makes rotating one device + * impossible without touching every other one — the blast radius should match + * the thing that failed (#110). + */ +import * as crypto from 'crypto'; +import { utils as sshUtils } from 'ssh2'; + +export interface GeneratedKeyPair { + keyType: 'ed25519'; + publicKey: string; + privateKey: string; + fingerprint: string; + /** The comment RouterOS will show as key-owner; unique per key. */ + comment: string; +} + +/** + * Comment embedded in the public key, and the filename used on the device. + * + * Both carry the device id so an operator reading `/user/ssh-keys/print` on the + * hardware can tell which manager entry a key belongs to. A key nobody can + * attribute is a key nobody dares remove. + */ +export function keyComment(deviceId: number, suffix?: string): string { + const base = `mikrotik-manager-device-${deviceId}`; + return suffix ? `${base}-${suffix}` : base; +} + +/** + * A short, unique tag per generated key. + * + * Every key for a device once shared one comment, and RouterOS matches on it — + * so removing "the old key" during a rotation removed the new one too, taking + * SSH access with it. Comments must identify a *key*, not a device. + */ +export function newKeySuffix(): string { + return crypto.randomBytes(4).toString('hex'); +} + +export function keyFileName(deviceId: number): string { + return `mm-device-${deviceId}.pub`; +} + +/** OpenSSH-style SHA256 fingerprint of a public key, for display and audit. */ +export function fingerprintPublicKey(publicKey: string): string { + const parts = publicKey.trim().split(/\s+/); + const b64 = parts.length > 1 ? parts[1] : parts[0]; + const hash = crypto.createHash('sha256').update(Buffer.from(b64, 'base64')).digest('base64'); + return `SHA256:${hash.replace(/=+$/, '')}`; +} + +/** + * Generate a keypair for one device. + * + * Ed25519 rather than RSA: shorter, faster to verify, and supported by the + * RouterOS versions this platform targets. The private half never leaves the + * manager and is never returned by the API. + */ +export function generateDeviceKeyPair(deviceId: number, suffix = newKeySuffix()): GeneratedKeyPair { + const comment = keyComment(deviceId, suffix); + const kp = sshUtils.generateKeyPairSync('ed25519', { comment }); + // generateKeyPairSync already appends the comment; appending it again produced + // keys tagged twice, which is harmless but reads like a bug on the device. + const generated = kp.public.trim(); + const publicKey = generated.endsWith(comment) ? generated : `${generated} ${comment}`; + return { + keyType: 'ed25519', + publicKey, + privateKey: kp.private, + fingerprint: fingerprintPublicKey(kp.public), + comment, + }; +} + +/** Is this a private key ssh2 can actually authenticate with? */ +export function isUsablePrivateKey(privateKey: string): boolean { + const parsed = sshUtils.parseKey(privateKey); + return !(parsed instanceof Error); +} + +export type KeyStatus = 'pending' | 'deployed' | 'verified' | 'failed'; + +/** + * Which credential should an SSH connection use? + * + * There is no password fallback once a key is installed. RouterOS binds a key to + * a user and then refuses password authentication for that user entirely — + * verified on hardware: removing the authorised key restored password login + * immediately. So a verified key is not merely preferred, it is the only thing + * that will work, and treating the password as a safety net would be fiction. + * + * The real recovery path is the binary API, which is unaffected by any of this + * and can always remove the key. + */ +export function preferredAuth( + status: KeyStatus | null, + hasPrivateKey: boolean, + hasPassword: boolean, +): 'key' | 'password' | 'none' { + if (hasPrivateKey && (status === 'verified' || status === 'deployed')) return 'key'; + if (hasPassword) return 'password'; + return 'none'; +} diff --git a/frontend/package.json b/frontend/package.json index f3e8636..0754e77 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-frontend", - "version": "0.23.23-beta", + "version": "0.23.24-beta", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/components/device-detail/SshKeyCard.tsx b/frontend/src/components/device-detail/SshKeyCard.tsx new file mode 100644 index 0000000..7b0b253 --- /dev/null +++ b/frontend/src/components/device-detail/SshKeyCard.tsx @@ -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

Loading key status…
; + + const verified = data?.status === 'verified'; + + return ( +
+
+ +
+
+

SSH key

+ {data && ( + + {data.status} + + )} +
+

+ 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. +

+
+
+ + {data ? ( +
+
+ Fingerprint + {data.fingerprint} +
+
+ User + {data.ssh_username} +
+
+ Last proved + + {data.last_verified_at ? new Date(data.last_verified_at).toLocaleString() : 'never'} + +
+ {data.last_error && ( +
+ Last error + {data.last_error} +
+ )} +
+ ) : ( +
+ +
+ Installing a key disables password SSH for this user.{' '} + 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. +
+
+ )} + +
+ {!data && ( + + )} + {data && ( + <> + + + {confirmRevoke ? ( + <> + + + + ) : ( + + )} + + )} +
+ + {msg && ( +

+ {msg.text} +

+ )} +
+ ); +} diff --git a/frontend/src/components/device-detail/SystemConfigTab.tsx b/frontend/src/components/device-detail/SystemConfigTab.tsx index 15720dd..282b46d 100644 --- a/frontend/src/components/device-detail/SystemConfigTab.tsx +++ b/frontend/src/components/device-detail/SystemConfigTab.tsx @@ -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) { )} +
+ + + +
+ + {/* ── RouterOS ── */}
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 9012733..5e485a5 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -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('/devices'), discovered: () => api.get('/devices/discovered'), diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 1b028cb..7937c07 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = 'v0.23.23 Beta'; +export const APP_VERSION = 'v0.23.24 Beta';