diff --git a/app/src/components/Layout.tsx b/app/src/components/Layout.tsx index ae99652..9ce04a5 100644 --- a/app/src/components/Layout.tsx +++ b/app/src/components/Layout.tsx @@ -7,6 +7,7 @@ import { ChevronRight, Clock, Globe, + KeyRound, LayoutDashboard, Menu, Package, @@ -44,6 +45,7 @@ const NAV: (NavLeaf | NavGroup)[] = [ { to: "/programs", label: "Programs", icon: Package }, { to: "/map", label: "System Map", icon: MapIcon }, { to: "/mesh", label: "Mesh", icon: Share2 }, + { to: "/secrets", label: "Secrets", icon: KeyRound }, ] const COLLAPSE_KEY = "castle-nav-collapsed" diff --git a/app/src/pages/SecretsPage.tsx b/app/src/pages/SecretsPage.tsx new file mode 100644 index 0000000..6610e22 --- /dev/null +++ b/app/src/pages/SecretsPage.tsx @@ -0,0 +1,229 @@ +import { useState } from "react" +import { + Check, + Copy, + Eye, + EyeOff, + KeyRound, + Loader2, + Lock, + Plus, + Save, + Trash2, +} from "lucide-react" +import { PageHeader } from "@/components/PageHeader" +import { apiClient } from "@/services/api/client" +import { + useDeleteSecret, + useSecrets, + useSecretsInfo, + useSetSecret, +} from "@/services/api/hooks" + +async function writeClipboard(text: string): Promise { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text) + return true + } catch { + /* fall through */ + } + } + try { + const ta = document.createElement("textarea") + ta.value = text + ta.style.position = "fixed" + ta.style.opacity = "0" + document.body.appendChild(ta) + ta.select() + const ok = document.execCommand("copy") + document.body.removeChild(ta) + return ok + } catch { + return false + } +} + +export function SecretsPage() { + const { data: info } = useSecretsInfo() + const { data: names, isLoading } = useSecrets() + const writable = info?.writable ?? false + + return ( +
+ + + {info && ( +
+ + + backend: {info.backend} + + {info.addr && {info.addr}} + role: {info.role} + {!writable && ( + + read-only (write on the authority node) + + )} +
+ )} + + {writable && } + + {isLoading ? ( +

Loading…

+ ) : !names?.length ? ( +

No secrets.

+ ) : ( +
+ {names.map((name) => ( + + ))} +
+ )} +
+ ) +} + +function SecretRow({ name, writable }: { name: string; writable: boolean }) { + const [value, setValue] = useState(null) + const [visible, setVisible] = useState(false) + const [dirty, setDirty] = useState(false) + const [copied, setCopied] = useState(false) + const [loading, setLoading] = useState(false) + const setSecret = useSetSecret() + const delSecret = useDeleteSecret() + + const reveal = async () => { + if (value === null && !loading) { + setLoading(true) + try { + const data = await apiClient.get<{ value: string }>(`/secrets/${name}`) + setValue(data.value) + } catch { + setValue("") + } finally { + setLoading(false) + } + } + setVisible((v) => !v) + } + + const copy = async () => { + let v = value + if (v === null) { + const data = await apiClient.get<{ value: string }>(`/secrets/${name}`).catch(() => null) + v = data?.value ?? "" + setValue(v) + } + if (await writeClipboard(v)) { + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } + } + + return ( +
+ + {name} + + { + setValue(e.target.value) + setDirty(true) + }} + className="flex-1 min-w-0 rounded border border-[var(--border)] bg-black/30 px-2 py-1 text-xs font-mono focus:border-[var(--primary)] focus:outline-none" + /> + + + {writable && ( + <> + + + + )} +
+ ) +} + +function AddSecret() { + const [open, setOpen] = useState(false) + const [name, setName] = useState("") + const [value, setValue] = useState("") + const setSecret = useSetSecret() + + const submit = () => { + if (!name.trim() || !value) return + setSecret.mutate( + { name: name.trim(), value }, + { + onSuccess: () => { + setName("") + setValue("") + setOpen(false) + }, + }, + ) + } + + if (!open) { + return ( + + ) + } + + return ( +
+ setName(e.target.value)} + className="w-40 rounded border border-[var(--border)] bg-black/30 px-2 py-1 text-xs font-mono focus:border-[var(--primary)] focus:outline-none" + /> + setValue(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && submit()} + className="flex-1 min-w-0 rounded border border-[var(--border)] bg-black/30 px-2 py-1 text-xs font-mono focus:border-[var(--primary)] focus:outline-none" + /> + + +
+ ) +} diff --git a/app/src/router/routes.tsx b/app/src/router/routes.tsx index 45ca86a..40eaf4c 100644 --- a/app/src/router/routes.tsx +++ b/app/src/router/routes.tsx @@ -7,6 +7,7 @@ import { Tools } from "@/pages/Tools" import { Programs } from "@/pages/Programs" import { GatewayPage } from "@/pages/GatewayPage" import { MeshPage } from "@/pages/MeshPage" +import { SecretsPage } from "@/pages/SecretsPage" import { SystemMapPage } from "@/pages/SystemMap" import { ServiceDetailPage } from "@/pages/ServiceDetail" import { ScheduledDetailPage } from "@/pages/ScheduledDetail" @@ -27,6 +28,7 @@ export const router = createBrowserRouter([ { path: "programs", element: }, { path: "gateway", element: }, { path: "mesh", element: }, + { path: "secrets", element: }, { path: "map", element: }, { path: "services/:name", element: }, { path: "jobs/:name", element: }, diff --git a/app/src/services/api/hooks.ts b/app/src/services/api/hooks.ts index 1575676..8381528 100644 --- a/app/src/services/api/hooks.ts +++ b/app/src/services/api/hooks.ts @@ -543,3 +543,43 @@ export function useEventStream() { return () => es.close() }, [qc]) } + +// --- Secrets --- + +export interface SecretsInfo { + backend: string + addr: string | null + role: string + writable: boolean +} + +export function useSecretsInfo() { + return useQuery({ + queryKey: ["secrets-info"], + queryFn: () => apiClient.get("/secrets/info"), + }) +} + +export function useSecrets() { + return useQuery({ + queryKey: ["secrets"], + queryFn: () => apiClient.get("/secrets"), + }) +} + +export function useSetSecret() { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ name, value }: { name: string; value: string }) => + apiClient.put(`/secrets/${name}`, { value }), + onSuccess: () => qc.invalidateQueries({ queryKey: ["secrets"] }), + }) +} + +export function useDeleteSecret() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (name: string) => apiClient.delete(`/secrets/${name}`), + onSuccess: () => qc.invalidateQueries({ queryKey: ["secrets"] }), + }) +} diff --git a/castle-api/src/castle_api/secrets.py b/castle-api/src/castle_api/secrets.py index fd28fea..dc425e5 100644 --- a/castle-api/src/castle_api/secrets.py +++ b/castle-api/src/castle_api/secrets.py @@ -5,14 +5,17 @@ from __future__ import annotations from fastapi import APIRouter, HTTPException, status from pydantic import BaseModel -from castle_core.config import SECRETS_DIR -from castle_core.secret_backends import build_backend +from castle_core.config import SECRETS_DIR, _secrets_settings +from castle_core.secret_backends import OpenBaoBackend, build_backend + +from castle_api.config import get_registry router = APIRouter(prefix="/secrets", tags=["secrets"]) def _backend(): - return build_backend(SECRETS_DIR) + # Same selection as the rest of castle (castle.yaml `secrets:` block). + return build_backend(SECRETS_DIR, _secrets_settings()) class SecretValue(BaseModel): @@ -25,6 +28,23 @@ def list_secrets() -> list[str]: return _backend().list_names() +@router.get("/info") +def secrets_info() -> dict: + """The active backend + whether this node may write (for the UI).""" + settings = _secrets_settings() + backend = _backend() + kind = "openbao" if isinstance(backend, OpenBaoBackend) else "file" + role = get_registry().node.role + # File is always writable; a vault follower holds a read-only token. + writable = kind == "file" or role == "authority" + return { + "backend": kind, + "addr": settings.get("addr") if kind == "openbao" else None, + "role": role, + "writable": writable, + } + + @router.get("/{name}") def get_secret(name: str) -> dict: """Get a secret value.""" diff --git a/castle-api/tests/conftest.py b/castle-api/tests/conftest.py index e707acc..9c906d0 100644 --- a/castle-api/tests/conftest.py +++ b/castle-api/tests/conftest.py @@ -1,5 +1,11 @@ """Test fixtures for castle-api.""" +import os as _os +# Tests must not read the host's real secret backend (castle.yaml may point +# at OpenBao); force the file backend unless CI explicitly overrides. +_os.environ.setdefault("CASTLE_SECRET_BACKEND", "file") + + import socket import subprocess import time diff --git a/core/src/castle_core/secret_backends.py b/core/src/castle_core/secret_backends.py index 380cfd2..6a245d4 100644 --- a/core/src/castle_core/secret_backends.py +++ b/core/src/castle_core/secret_backends.py @@ -107,7 +107,9 @@ class OpenBaoBackend: url = f"{self._addr}/v1/{self._mount}/metadata?list=true" try: data = self._request("GET", url) - return sorted(data.get("data", {}).get("keys", [])) + keys = data.get("data", {}).get("keys", []) + # Drop folder entries (e.g. "nodes/") — those group per-node overrides. + return sorted(k for k in keys if not k.endswith("/")) except Exception: return [] diff --git a/core/tests/conftest.py b/core/tests/conftest.py index b4fa278..a53f223 100644 --- a/core/tests/conftest.py +++ b/core/tests/conftest.py @@ -2,9 +2,14 @@ from __future__ import annotations +import os as _os from collections.abc import Generator from pathlib import Path +# Tests must not read the host's real secret backend (castle.yaml may point +# at OpenBao); force the file backend unless CI explicitly overrides. +_os.environ.setdefault("CASTLE_SECRET_BACKEND", "file") + import pytest import yaml