From 3f79939bd3a4cd32df68db9c1804073aa74f7ab1 Mon Sep 17 00:00:00 2001 From: Ryan Moon Date: Sun, 5 Apr 2026 09:36:56 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20SSH=20key=20management=20on=20dev=20pod?= =?UTF-8?q?=20page=20=E2=80=94=20list,=20add,=20remove=20keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/index.html | 65 ++++++++++++++++++++++++++++++++++++++++++++ src/routes/devpod.ts | 31 ++++++++++++++++++++- 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/frontend/index.html b/frontend/index.html index 8802c86..15c0448 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1067,8 +1067,19 @@ dev +
+
SSH Keys
+
Loading…
+
+ + +
+
`; + loadSshKeys(); + // Auto-refresh while starting clearInterval(devpodRefreshTimer); if (state === 'starting') { @@ -1095,6 +1106,60 @@ } } + async function loadSshKeys() { + const el = document.getElementById('devpod-keys-list'); + if (!el) return; + try { + const res = await apiFetch('/api/devpod/keys'); + const { keys } = await res.json(); + renderSshKeys(keys); + } catch { + if (el) el.innerHTML = 'Failed to load keys'; + } + } + + function renderSshKeys(keys) { + const el = document.getElementById('devpod-keys-list'); + if (!el) return; + if (!keys.length) { el.innerHTML = 'No SSH keys configured'; return; } + el.innerHTML = keys.map(k => { + const parts = k.split(' '); + const label = parts.slice(2).join(' ') || parts[0]; + const short = parts[1] ? parts[1].slice(0, 20) + '…' : k; + return `
+ ${label} ${short} + +
`; + }).join(''); + } + + async function addSshKey() { + const input = document.getElementById('devpod-key-input'); + const key = input.value.trim(); + if (!key) return; + try { + const res = await apiFetch('/api/devpod/keys', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ key }) }); + if (!res.ok) throw new Error((await res.json()).message); + const { keys } = await res.json(); + renderSshKeys(keys); + input.value = ''; + } catch (err) { + alert('Failed to add key: ' + err.message); + } + } + + async function removeSshKey(key) { + if (!confirm('Remove this SSH key?')) return; + try { + const res = await apiFetch('/api/devpod/keys', { method: 'DELETE', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ key }) }); + if (!res.ok) throw new Error((await res.json()).message); + const { keys } = await res.json(); + renderSshKeys(keys); + } catch (err) { + alert('Failed to remove key: ' + err.message); + } + } + // ── Account ─────────────────────────────────────────────────────────────── async function changePassword() { diff --git a/src/routes/devpod.ts b/src/routes/devpod.ts index 3f09bbf..7dd06c1 100644 --- a/src/routes/devpod.ts +++ b/src/routes/devpod.ts @@ -1,8 +1,11 @@ import type { FastifyInstance } from "fastify"; -import { k8sFetch, rolloutRestart } from "../lib/k8s"; +import { z } from "zod"; +import { k8sFetch, rolloutRestart, getSecret, patchSecret } from "../lib/k8s"; const NAMESPACE = "dev"; const DEPLOYMENT = "dev"; +const SECRET = "dev-secrets"; +const KEY_FIELD = "ssh-authorized-keys"; export async function devpodRoutes(app: FastifyInstance) { app.get("/devpod/status", async (_req, reply) => { @@ -63,4 +66,30 @@ export async function devpodRoutes(app: FastifyInstance) { await rolloutRestart(NAMESPACE, DEPLOYMENT); return reply.send({ message: "Dev pod restarting" }); }); + + app.get("/devpod/keys", async (_req, reply) => { + const secrets = await getSecret(NAMESPACE, SECRET); + const raw = secrets[KEY_FIELD] ?? ""; + const keys = raw.split("\n").map(k => k.trim()).filter(Boolean); + return reply.send({ keys }); + }); + + app.post("/devpod/keys", async (req, reply) => { + const { key } = z.object({ key: z.string().min(10) }).parse(req.body); + const secrets = await getSecret(NAMESPACE, SECRET); + const existing = (secrets[KEY_FIELD] ?? "").split("\n").map(k => k.trim()).filter(Boolean); + if (existing.includes(key.trim())) return reply.send({ keys: existing }); + const updated = [...existing, key.trim()].join("\n"); + await patchSecret(NAMESPACE, SECRET, { [KEY_FIELD]: updated }); + return reply.send({ keys: [...existing, key.trim()] }); + }); + + app.delete("/devpod/keys", async (req, reply) => { + const { key } = z.object({ key: z.string().min(10) }).parse(req.body); + const secrets = await getSecret(NAMESPACE, SECRET); + const existing = (secrets[KEY_FIELD] ?? "").split("\n").map(k => k.trim()).filter(Boolean); + const updated = existing.filter(k => k !== key.trim()); + await patchSecret(NAMESPACE, SECRET, { [KEY_FIELD]: updated.join("\n") }); + return reply.send({ keys: updated }); + }); }