From 864ca4042754f7dc5a5daf828b5e001f3bbba85b Mon Sep 17 00:00:00 2001
From: 2GT-Rich <107219997+2GT-Rich@users.noreply.github.com>
Date: Wed, 2 Sep 2026 12:01:20 -0700
Subject: [PATCH] fix(ssh): state what installing a key actually costs, and ask
before doing it (v0.23.25-beta)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
v0.23.24 shipped the SSH key feature with a warning that undersold it and a
confirmation flow that was the wrong way round. Five gaps, all in how the change
was presented rather than in what it does.
**Installing was one click; revoking asked for confirmation.** Backwards.
Revoking restores password SSH, while installing takes it away — the
consequential action was the unguarded one. Installation now requires an
explicit acknowledgement naming the account that will lose password access.
**The warning disappeared once a key existed.** Whoever arrives at a device
later and finds their password refused over SSH is exactly the person who needs
the explanation, and they were the one person not shown it. The notice is now
permanent, worded for the current state rather than only the impending one.
**It said "this user" rather than naming the account**, which is the difference
between a caution and a fact someone can act on.
**It never mentioned losing the database.** That is the serious one. Password
SSH is already refused once a key is bound, so a manager that loses its database
or encryption key leaves SSH recoverable only through the API or WinBox. That
consequence outlives the session, the operator, and quite possibly the person who
clicked the button, and it went unstated.
**Fleet-wide deployment had no guard whatsoever.** One request would disable
password SSH on every device it touched, while the single-device path asked for
an acknowledgement — an endpoint that can do a thing sixty times should not be
easier to trigger than the one that does it once. It now refuses without an
explicit confirm and explains why in the refusal.
The notice also spells out what was previously left implicit: colleagues, scripts
and other tools that SSH in with a password will stop working. On a shared fleet
that is a change to announce, not to discover.
Co-Authored-By: Claude Opus 5
---
README.md | 2 +-
backend/package.json | 2 +-
backend/src/routes/devices.ts | 14 +++-
frontend/package.json | 2 +-
.../components/device-detail/SshKeyCard.tsx | 64 +++++++++++++++----
.../device-detail/SystemConfigTab.tsx | 2 +-
frontend/src/services/api.ts | 3 +-
frontend/src/version.ts | 2 +-
8 files changed, 70 insertions(+), 21 deletions(-)
diff --git a/README.md b/README.md
index 248d633..932eed8 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
-
+
diff --git a/backend/package.json b/backend/package.json
index 15efc2c..be0c8f4 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-backend",
- "version": "0.23.24-beta",
+ "version": "0.23.25-beta",
"description": "Mikrotik Network Management Platform - Backend API",
"main": "dist/index.js",
"scripts": {
diff --git a/backend/src/routes/devices.ts b/backend/src/routes/devices.ts
index 1f00f40..d8ca450 100644
--- a/backend/src/routes/devices.ts
+++ b/backend/src/routes/devices.ts
@@ -1663,7 +1663,19 @@ router.delete('/:id/ssh-key', requireWrite, async (req: Request, res: Response)
* 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) => {
+router.post('/ssh-keys/deploy-all', requireWrite, async (req: Request, res: Response) => {
+ // This is the most consequential call in the feature: it disables password SSH
+ // across an entire fleet in one request. The per-device path asks for an
+ // acknowledgement in the UI, and an endpoint that can do it sixty times over
+ // should not be easier to trigger than the one that does it once.
+ if ((req.body as { confirm?: boolean })?.confirm !== true) {
+ return res.status(400).json({
+ error: 'Refusing to deploy fleet-wide without confirmation. This disables password ' +
+ 'SSH for the configured user on every device it touches; the API and WinBox ' +
+ 'are unaffected. Repeat with {"confirm": true} once that is understood.',
+ });
+ }
+
const targets = await query(
`SELECT ${SSH_TARGET_COLS} FROM devices d
WHERE d.status != 'disabled' AND d.ssh_username IS NOT NULL
diff --git a/frontend/package.json b/frontend/package.json
index 0754e77..387bb92 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-frontend",
- "version": "0.23.24-beta",
+ "version": "0.23.25-beta",
"private": true,
"type": "module",
"scripts": {
diff --git a/frontend/src/components/device-detail/SshKeyCard.tsx b/frontend/src/components/device-detail/SshKeyCard.tsx
index 7b0b253..a7ba98f 100644
--- a/frontend/src/components/device-detail/SshKeyCard.tsx
+++ b/frontend/src/components/device-detail/SshKeyCard.tsx
@@ -11,10 +11,16 @@ import { sshKeyApi } from '../../services/api';
* 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 }) {
+export default function SshKeyCard(
+ { deviceId, sshUsername }: { deviceId: number; sshUsername?: string },
+) {
const queryClient = useQueryClient();
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
const [confirmRevoke, setConfirmRevoke] = useState(false);
+ // Installing a key changes how this device can be reached, and cannot be
+ // undone by retrying — it is undone by revoking. A single click was the wrong
+ // weight for that, especially when revoking already asked for confirmation.
+ const [acknowledged, setAcknowledged] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ['ssh-key', deviceId],
@@ -43,6 +49,7 @@ export default function SshKeyCard({ deviceId }: { deviceId: number }) {
if (isLoading) return
@@ -90,24 +97,53 @@ export default function SshKeyCard({ deviceId }: { deviceId: number }) {
)}
- ) : (
-
-
-
- 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.
+ ) : null}
+
+ {/* Shown before installing *and* after. Someone arriving at a keyed device
+ needs to know why their password no longer works over SSH just as much
+ as the person about to cause it. */}
+
+
+
+
+ {data
+ ? `Password SSH is disabled for "${user}" on this device.`
+ : `Installing a key disables password SSH for "${user}".`}
+
+
RouterOS requires key authentication once a key is bound to an account.
+
The binary API and WinBox are unaffected, and remain a way in.
+
Anyone who signs in to this device over SSH with a password — a colleague,
+ a script, another tool — will stop being able to.
+
+ If this server loses its database or encryption key,
+ the private key goes with it.{' '}
+ SSH would then need recovering through the API or WinBox, because the password
+ will already have been refused.
+