feat(keystore,api,ui,docker): encrypted secrets store with a write-only admin page and a secret run helper for the model
This commit is contained in:
1 parent
2a47c6771d
commit
7b36cb2156
22 files changed
+1569
-5
No files matched your search
@@ -0,0 +1,426 @@
|
||||
<script lang="ts">
|
||||
import FileIcon from "@lucide/svelte/icons/file";
|
||||
import PlusIcon from "@lucide/svelte/icons/plus";
|
||||
import { toast } from "svelte-sonner";
|
||||
import type { SecretRow, SecretWrite } from "$lib/api/types";
|
||||
import EmptyState from "$lib/components/empty-state.svelte";
|
||||
import ErrorNote from "$lib/components/error-note.svelte";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import * as Tabs from "$lib/components/ui/tabs";
|
||||
import { fmtBytes, fmtDateTime, fmtRelative } from "$lib/format";
|
||||
import { session } from "$lib/session.svelte";
|
||||
|
||||
const NAME = /^[A-Z][A-Z0-9_]{0,63}$/;
|
||||
const MAX_SIZE = 1024 * 1024;
|
||||
|
||||
let secrets = $state<SecretRow[] | null>(null);
|
||||
let enabled = $state(true);
|
||||
let failure = $state<string | null>(null);
|
||||
|
||||
let editOpen = $state(false);
|
||||
let editing = $state<SecretRow | null>(null);
|
||||
let secretName = $state("");
|
||||
let description = $state("");
|
||||
let mode = $state<"value" | "file">("value");
|
||||
let value = $state("");
|
||||
let files = $state<FileList | undefined>();
|
||||
let busy = $state(false);
|
||||
|
||||
let doomed = $state<SecretRow | null>(null);
|
||||
|
||||
const file = $derived(files?.[0]);
|
||||
const nameError = $derived(
|
||||
!editing && secretName && !NAME.test(secretName)
|
||||
? "Capital letters, digits and _, starting with a letter."
|
||||
: null
|
||||
);
|
||||
const fileError = $derived(
|
||||
file && file.size > MAX_SIZE ? "Files are capped at 1 MB." : null
|
||||
);
|
||||
const hasPayload = $derived(mode === "value" ? value !== "" : !!file);
|
||||
const canSave = $derived(
|
||||
!(busy || nameError || fileError) &&
|
||||
(editing ? true : NAME.test(secretName) && hasPayload)
|
||||
);
|
||||
|
||||
async function load() {
|
||||
const { client } = session;
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
failure = null;
|
||||
try {
|
||||
({ secrets, enabled } = await client.secrets());
|
||||
} catch (cause) {
|
||||
failure = cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
load();
|
||||
});
|
||||
|
||||
function openEditor(row: SecretRow | null) {
|
||||
editing = row;
|
||||
secretName = row?.name ?? "";
|
||||
description = row?.description ?? "";
|
||||
mode = row?.kind ?? "value";
|
||||
value = "";
|
||||
files = undefined;
|
||||
editOpen = true;
|
||||
}
|
||||
|
||||
function base64Of(blob: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const url = String(reader.result);
|
||||
resolve(url.slice(url.indexOf(",") + 1));
|
||||
};
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const { client } = session;
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
try {
|
||||
const body: SecretWrite = { description };
|
||||
if (mode === "value" && value) {
|
||||
body.value = value;
|
||||
}
|
||||
if (mode === "file" && file) {
|
||||
body.file = { content: await base64Of(file), name: file.name };
|
||||
}
|
||||
const target = editing?.name ?? secretName;
|
||||
await client.putSecret(target, body);
|
||||
toast.success(editing ? `Updated ${target}` : `Saved ${target}`);
|
||||
editOpen = false;
|
||||
value = "";
|
||||
files = undefined;
|
||||
await load();
|
||||
} catch (cause) {
|
||||
toast.error(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(row: SecretRow) {
|
||||
const { client } = session;
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
try {
|
||||
await client.deleteSecret(row.name);
|
||||
toast.success(`Deleted ${row.name}`);
|
||||
doomed = null;
|
||||
await load();
|
||||
} catch (cause) {
|
||||
toast.error(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<h2 class="font-medium text-sm">Secrets</h2>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
write-only · the agent runs
|
||||
<code class="rounded bg-muted px-1 py-0.5">secret run NAME -- cmd</code>
|
||||
</span>
|
||||
<Button
|
||||
class="ml-auto"
|
||||
disabled={!enabled}
|
||||
onclick={() => openEditor(null)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<PlusIcon class="size-4" />
|
||||
New secret
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
{#if !enabled}
|
||||
<div
|
||||
class="flex flex-col gap-1 rounded-lg border border-warn/40 bg-warn/5 p-3 text-sm"
|
||||
role="status"
|
||||
>
|
||||
<p class="font-medium">Secrets are off: SECRETS_KEY is not set.</p>
|
||||
<p class="text-muted-foreground">
|
||||
Put a key in the gateway's environment (<code
|
||||
class="rounded bg-muted px-1 py-0.5"
|
||||
>openssl rand -hex 32</code
|
||||
>) and recreate the container. Existing names stay listed; values cannot
|
||||
be set or read until then.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if failure}
|
||||
<ErrorNote message={failure} retry={() => load()} />
|
||||
{:else if secrets === null}
|
||||
<p class="text-muted-foreground text-sm">Loading…</p>
|
||||
{:else if secrets.length === 0}
|
||||
<EmptyState
|
||||
hint="Keys and files the agent runs commands with. They are sealed with SECRETS_KEY in the gateway's database, reach a command as environment variables, and are never shown again after saving."
|
||||
title="No secrets"
|
||||
>
|
||||
{#if enabled}
|
||||
<Button onclick={() => openEditor(null)} size="sm" variant="outline">
|
||||
Add the first secret
|
||||
</Button>
|
||||
{/if}
|
||||
</EmptyState>
|
||||
{:else}
|
||||
<div class="overflow-x-auto rounded-lg border bg-strip">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-muted-foreground text-xs">
|
||||
<tr class="border-b text-left">
|
||||
<th class="px-3 py-2 font-medium">name</th>
|
||||
<th class="hidden px-3 py-2 font-medium sm:table-cell">
|
||||
description
|
||||
</th>
|
||||
<th class="hidden px-3 py-2 font-medium sm:table-cell">kind</th>
|
||||
<th class="hidden px-3 py-2 font-medium sm:table-cell">changed</th>
|
||||
<th class="hidden px-3 py-2 font-medium sm:table-cell">
|
||||
last used
|
||||
</th>
|
||||
<th class="px-3 py-2 text-right font-medium"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each secrets as row (row.name)}
|
||||
<tr class="row-hover border-b last:border-b-0">
|
||||
<td class="px-3 py-1.5">
|
||||
<code class="break-all font-medium">{row.name}</code>
|
||||
<div
|
||||
class="flex flex-col gap-0.5 pt-0.5 text-muted-foreground text-xs sm:hidden"
|
||||
>
|
||||
{#if row.description}
|
||||
<span class="text-foreground/80">{row.description}</span>
|
||||
{/if}
|
||||
<span class="tabular">
|
||||
{row.filename
|
||||
? `${row.filename} · ${fmtBytes(row.size)}`
|
||||
: "value"}
|
||||
· changed {fmtRelative(row.updated_at)} ·
|
||||
{row.used_at
|
||||
? `used ${fmtRelative(row.used_at)}`
|
||||
: "never used"}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="hidden min-w-48 px-3 py-1.5 sm:table-cell">
|
||||
{#if row.description}
|
||||
{row.description}
|
||||
{:else}
|
||||
<span class="text-muted-foreground">–</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td
|
||||
class="hidden whitespace-nowrap px-3 py-1.5 text-xs sm:table-cell"
|
||||
>
|
||||
{#if row.filename}
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<FileIcon class="size-3.5 text-icon" />
|
||||
{row.filename}
|
||||
<span class="tabular text-muted-foreground"
|
||||
>{fmtBytes(row.size)}</span
|
||||
>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">value</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td
|
||||
class="tabular hidden whitespace-nowrap px-3 py-1.5 text-xs sm:table-cell"
|
||||
title={fmtDateTime(row.updated_at)}
|
||||
>
|
||||
{fmtRelative(row.updated_at)}
|
||||
</td>
|
||||
<td
|
||||
class="tabular hidden whitespace-nowrap px-3 py-1.5 text-xs sm:table-cell"
|
||||
title={row.used_at ? fmtDateTime(row.used_at) : undefined}
|
||||
>
|
||||
{#if row.used_at}
|
||||
{fmtRelative(row.used_at)}
|
||||
{:else}
|
||||
<span class="text-muted-foreground">never</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td
|
||||
class="flex flex-col items-end px-1 py-1.5 sm:table-cell sm:whitespace-nowrap sm:px-3 sm:text-right"
|
||||
>
|
||||
<Button
|
||||
disabled={!enabled}
|
||||
onclick={() => openEditor(row)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
Replace
|
||||
</Button>
|
||||
<Button
|
||||
class="text-destructive hover:text-destructive"
|
||||
onclick={() => {
|
||||
doomed = row;
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Root bind:open={editOpen}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>
|
||||
{editing ? `Replace ${editing.name}` : "New secret"}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{editing
|
||||
? "Leave the value empty to change only the description."
|
||||
: "Stored encrypted. After saving, the value is not shown anywhere."}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<form
|
||||
autocomplete="off"
|
||||
class="flex flex-col gap-3"
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
save();
|
||||
}}
|
||||
>
|
||||
{#if !editing}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="secret-name">Name</Label>
|
||||
<Input
|
||||
aria-invalid={nameError ? true : undefined}
|
||||
autocapitalize="characters"
|
||||
class="font-mono"
|
||||
id="secret-name"
|
||||
oninput={() => {
|
||||
secretName = secretName.toUpperCase().replaceAll(/[\s-]/g, "_");
|
||||
}}
|
||||
placeholder="NODEREAL_BSC"
|
||||
required
|
||||
spellcheck={false}
|
||||
bind:value={secretName}
|
||||
/>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
{nameError ?? `The command reads it as $${secretName || "NAME"}.`}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="secret-description">Description</Label>
|
||||
<Input
|
||||
id="secret-description"
|
||||
placeholder="What it opens and who issued it"
|
||||
bind:value={description}
|
||||
/>
|
||||
</div>
|
||||
<Tabs.Root bind:value={mode}>
|
||||
<Tabs.List class="w-full">
|
||||
<Tabs.Trigger value="value">Value</Tabs.Trigger>
|
||||
<Tabs.Trigger value="file">File</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Content class="flex flex-col gap-1.5 pt-2" value="value">
|
||||
<Label class="sr-only" for="secret-value">Value</Label>
|
||||
<Input
|
||||
autocomplete="new-password"
|
||||
class="font-mono"
|
||||
id="secret-value"
|
||||
placeholder={editing ? "New value" : "Paste the value"}
|
||||
spellcheck={false}
|
||||
type="password"
|
||||
bind:value
|
||||
/>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
One line. Multi-line keys and JSON go in as a file.
|
||||
</p>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content class="flex flex-col gap-1.5 pt-2" value="file">
|
||||
<Label class="sr-only" for="secret-file">File</Label>
|
||||
<Input
|
||||
aria-invalid={fileError ? true : undefined}
|
||||
id="secret-file"
|
||||
type="file"
|
||||
bind:files
|
||||
/>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
{fileError ??
|
||||
"Up to 1 MB. The command gets $NAME as the path to a private copy, removed when it exits."}
|
||||
</p>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
<Dialog.Footer>
|
||||
<Button
|
||||
onclick={() => {
|
||||
editOpen = false;
|
||||
}}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={!canSave} type="submit">
|
||||
{editing && !hasPayload ? "Save description" : "Save"}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<Dialog.Root
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
doomed = null;
|
||||
}
|
||||
}}
|
||||
open={doomed !== null}
|
||||
>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Delete {doomed?.name}?</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Commands that ask for it will fail with “unknown secret”. The value
|
||||
cannot be recovered.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<Dialog.Footer>
|
||||
<Button
|
||||
onclick={() => {
|
||||
doomed = null;
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busy}
|
||||
onclick={() => doomed && remove(doomed)}
|
||||
variant="destructive"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
Reference in new issue
Block a user