Files
MIKROTIK-RMM/backend/src/utils/__tests__/sshKeys.test.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

94 lines
3.7 KiB
TypeScript

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');
// Pushed but never proved: not yet evidence the device will accept it.
expect(preferredAuth('deployed', true, true)).toBe('password');
});
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);
});
});