Files
MIKROTIK-RMM/backend/src/utils/sshKeys.ts
T
2GT-RichandClaude Opus 5 63b5677522 fix(firmware,ssh,logs): five reported bugs (v0.24.1-beta)
Closes #136. Also fixes #133, #137, #138, #139 (GitHub closes only the first).

None of these came from multi-site; two came from me.

#136 — waves failed on the second device. A regression from v0.23.31, the
firmware rewrite that replaced fire-and-forget `install` with `download` plus
polling. `/system/package/update/download` blocks for a minute or more, and
the client's read timeout is 30 seconds. The timeout path was the real damage:
it nulled the pending read but left the socket open, so when the reply finally
arrived processBuffer() queued it and the *next* command took it as its own
answer. Every later call on that connection returned the previous one's data,
which is why the status polls could never see "Downloaded".

  - a timed-out read now abandons the connection instead of silently
    desynchronising it, so the failure is loud rather than wrong
  - long-running commands can state their own budget; check-for-updates,
    download and /log/print now do
  - a download whose command times out is re-checked on a fresh connection
    rather than assumed failed, because the image may well have landed

#133 — the SSH terminal could not use a deployed key. It read only
ssh_password_encrypted and refused to connect without one, so retiring the
password after deploying a key -- the workflow the feature exists for -- broke
the console. It now uses the same resolver as every other SSH consumer.
preferredAuth(), written to make exactly this decision, had never run outside
its own tests, and disagreed with the resolver that did: it accepted a
'deployed' key where the live path required 'verified'. Unified on the
stricter rule and wired in.

#139 — the rollout was written before start() was attempted, so a rejected
start left the rows behind: "already running", press again, another rollout
banked each time. Devices already in an unfinished rollout are now refused by
name, before anything is persisted.

#138 — cancel worked but the API's explanation that the in-flight device is
never interrupted was discarded, so the button looked dead. It is shown.

#137 — log overfetching. /log/print returns the whole ring buffer, and the
count was incremented per attempted insert rather than per accepted row, so a
poll that stored nothing still reported "Collected 1000 new log entries" after
a thousand round trips. Inserts are batched and counted by RETURNING, and the
id watermark is the maximum over recent rows rather than the last one
inserted, which could move backwards and make the whole buffer look new.

One self-inflicted wound worth recording. The first cut of the desync guard
discarded sentences whenever `connected` was false -- but `connected` is only
set *after* login succeeds, so it threw away every login reply and took the
whole fleet offline until it was caught. "Not yet connected" and "abandoned"
are different states and now have different flags. Every existing test pre-set
connected = true, so none of them could see it; the test added for it fails
against the bad version.

Verified on hardware: devices back online, check-update 5.2s (was a 30s
failure), full sync completes, and log collection now reports 298 / 22 / 12 /
12 across successive polls -- real decreasing counts rather than a constant
1000 -- with no failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 19:17:52 -07:00

115 lines
4.3 KiB
TypeScript

/**
* 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.
*
* Only a *verified* key counts. One that was generated or pushed but never
* authenticated is not evidence the device will accept it, and 'deployed' is a
* transient state on the way to being proved. This function previously admitted
* it, which disagreed with the resolver that actually ran -- the disagreement
* went unnoticed because nothing outside these tests ever called this (#133).
*/
export function preferredAuth(
status: KeyStatus | null,
hasPrivateKey: boolean,
hasPassword: boolean,
): 'key' | 'password' | 'none' {
if (hasPrivateKey && status === 'verified') return 'key';
if (hasPassword) return 'password';
return 'none';
}