4 Commits

Author SHA1 Message Date
domrichardson 4ea7f369f1 updates
Agent Release / build (push) Successful in 44s
Server Deploy / deploy (push) Successful in 1m24s
2026-06-16 10:28:46 +01:00
domrichardson 2166a483ca updates
Server Deploy / deploy (push) Successful in 1m29s
2026-06-16 10:02:55 +01:00
domrichardson f9c3fc5379 updates
Server Deploy / deploy (push) Successful in 34s
2026-06-16 09:57:03 +01:00
domrichardson f62b0054db updates
Server Deploy / deploy (push) Successful in 1m39s
2026-06-16 09:53:12 +01:00
11 changed files with 381 additions and 47 deletions
+4
View File
@@ -51,6 +51,10 @@ type ServerCommand struct {
type GenerateKeyCmd struct { type GenerateKeyCmd struct {
Label string `json:"label"` Label string `json:"label"`
KeyType string `json:"key_type,omitempty"`
KeySize int `json:"key_size,omitempty"`
Passphrase string `json:"passphrase,omitempty"`
Comment string `json:"comment,omitempty"`
} }
type AgentMessage struct { type AgentMessage struct {
+24 -7
View File
@@ -93,19 +93,36 @@ func fingerprint(pubKey string) string {
return "MD5:" + strings.Join(pairs, ":") return "MD5:" + strings.Join(pairs, ":")
} }
// GenerateKeyPair generates an ed25519 SSH keypair and returns the public key. // KeyGenOptions controls how ssh-keygen is invoked.
type KeyGenOptions struct {
KeyType string // ed25519 (default), rsa, ecdsa
KeySize int // bits; used for rsa and ecdsa
Passphrase string // empty = no passphrase
Comment string // embedded in the public key
}
// GenerateKeyPair generates an SSH keypair and returns the public key.
// The private key is written to keyPath; keyPath+".pub" holds the public key. // The private key is written to keyPath; keyPath+".pub" holds the public key.
func GenerateKeyPair(keyPath, comment string) (string, error) { func GenerateKeyPair(keyPath string, opts KeyGenOptions) (string, error) {
if err := os.MkdirAll(filepath.Dir(keyPath), 0700); err != nil { if err := os.MkdirAll(filepath.Dir(keyPath), 0700); err != nil {
return "", err return "", err
} }
args := []string{ keyType := opts.KeyType
"-t", "ed25519", if keyType == "" {
"-f", keyPath, keyType = "ed25519"
"-N", "",
"-C", comment,
} }
args := []string{
"-t", keyType,
"-f", keyPath,
"-N", opts.Passphrase,
"-C", opts.Comment,
}
if opts.KeySize > 0 && keyType != "ed25519" {
args = append(args, "-b", fmt.Sprintf("%d", opts.KeySize))
}
cmd := exec.Command("ssh-keygen", args...) cmd := exec.Command("ssh-keygen", args...)
out, err := cmd.CombinedOutput() out, err := cmd.CombinedOutput()
if err != nil { if err != nil {
+10 -3
View File
@@ -166,10 +166,17 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
} }
func handleGenerateKey(cfg *config.Config, cmd *pb.ServerCommand) { func handleGenerateKey(cfg *config.Config, cmd *pb.ServerCommand) {
label := cmd.GenerateKey.Label g := cmd.GenerateKey
label := g.Label
keyPath := fmt.Sprintf("/root/.ssh/keymanager_%s", strings.ReplaceAll(label, " ", "_")) keyPath := fmt.Sprintf("/root/.ssh/keymanager_%s", strings.ReplaceAll(label, " ", "_"))
pubKey, err := keys.GenerateKeyPair(keyPath, label) opts := keys.KeyGenOptions{
KeyType: g.KeyType,
KeySize: g.KeySize,
Passphrase: g.Passphrase,
Comment: g.Comment,
}
pubKey, err := keys.GenerateKeyPair(keyPath, opts)
if err != nil { if err != nil {
log.Printf("key generation failed (cmd=%s): %v", cmd.CommandId, err) log.Printf("key generation failed (cmd=%s): %v", cmd.CommandId, err)
return return
@@ -214,7 +221,7 @@ func GenerateAndUpload(cfg *config.Config, label string) error {
defer client.Close() defer client.Close()
keyPath := fmt.Sprintf("/root/.ssh/keymanager_%s", strings.ReplaceAll(label, " ", "_")) keyPath := fmt.Sprintf("/root/.ssh/keymanager_%s", strings.ReplaceAll(label, " ", "_"))
pubKey, err := keys.GenerateKeyPair(keyPath, label) pubKey, err := keys.GenerateKeyPair(keyPath, keys.KeyGenOptions{Comment: label})
if err != nil { if err != nil {
return err return err
} }
+4
View File
@@ -72,4 +72,8 @@ message ServerCommand {
message GenerateKeyCmd { message GenerateKeyCmd {
string label = 1; string label = 1;
string key_type = 2; // ed25519 | rsa | ecdsa (default: ed25519)
int32 key_size = 3; // bits; used for rsa and ecdsa
string passphrase = 4; // empty = no passphrase
string comment = 5; // embedded in public key
} }
+68 -1
View File
@@ -13,6 +13,7 @@ import (
func RegisterRoutes(r *gin.Engine) { func RegisterRoutes(r *gin.Engine) {
r.GET("/install", handleInstallScript) r.GET("/install", handleInstallScript)
r.GET("/update", handleUpdateScript)
// Auth endpoints (no session required) // Auth endpoints (no session required)
r.GET("/auth/login", auth.HandleLogin) r.GET("/auth/login", auth.HandleLogin)
@@ -126,6 +127,10 @@ func generateKey(c *gin.Context) {
var body struct { var body struct {
Label string `json:"label"` Label string `json:"label"`
KeyType string `json:"key_type"`
KeySize int `json:"key_size"`
Passphrase string `json:"passphrase"`
Comment string `json:"comment"`
} }
_ = c.ShouldBindJSON(&body) _ = c.ShouldBindJSON(&body)
if body.Label == "" { if body.Label == "" {
@@ -138,7 +143,13 @@ func generateKey(c *gin.Context) {
return return
} }
cmdID, err := services.DispatchGenerateKey(s.ServerID, body.Label) cmdID, err := services.DispatchGenerateKey(s.ServerID, services.KeyGenParams{
Label: body.Label,
KeyType: body.KeyType,
KeySize: body.KeySize,
Passphrase: body.Passphrase,
Comment: body.Comment,
})
if err != nil { if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return return
@@ -236,6 +247,62 @@ func revokeAssignment(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"revoked": true}) c.JSON(http.StatusOK, gin.H{"revoked": true})
} }
func handleUpdateScript(c *gin.Context) {
giteaHost := os.Getenv("GITEA_HOST")
if giteaHost == "" {
giteaHost = "gitea.example.com"
}
script := fmt.Sprintf(`#!/usr/bin/env bash
set -euo pipefail
GITEA_HOST="%s"
ARCH=$(uname -m)
case "$ARCH" in
x86_64) ARCH="amd64" ;;
aarch64) ARCH="arm64" ;;
*) echo "Unsupported architecture: $ARCH" >&2; exit 1 ;;
esac
# Get latest agent release tag
LATEST=$(curl -fsSL "https://${GITEA_HOST}/api/v1/repos/mrhid6/keymanager/releases?limit=10" \
| grep -o '"tag_name":"agent/v[^"]*"' | head -1 | sed 's/"tag_name":"//;s/"//')
if [ -z "$LATEST" ]; then
echo "Could not determine latest agent version" >&2
exit 1
fi
VERSION="${LATEST#agent/}"
LATEST_ENCODED="${LATEST/\//%%2F}"
BINARY_URL="https://${GITEA_HOST}/mrhid6/keymanager/releases/download/${LATEST_ENCODED}/keymanager-agent-linux-${ARCH}"
CHECKSUM_URL="https://${GITEA_HOST}/mrhid6/keymanager/releases/download/${LATEST_ENCODED}/checksums.txt"
echo "Updating keymanager-agent to ${VERSION} (${ARCH})..."
curl -fsSL -o /tmp/keymanager-agent "${BINARY_URL}"
curl -fsSL -o /tmp/checksums.txt "${CHECKSUM_URL}"
cd /tmp
EXPECTED=$(grep "keymanager-agent-linux-${ARCH}" checksums.txt | awk '{print $1}')
ACTUAL=$(sha256sum keymanager-agent | awk '{print $1}')
if [ "$EXPECTED" != "$ACTUAL" ]; then
echo "Checksum mismatch!" >&2
exit 1
fi
systemctl stop keymanager-agent || true
install -m 0755 /tmp/keymanager-agent /usr/local/bin/keymanager-agent
systemctl start keymanager-agent
echo "keymanager-agent updated to ${VERSION} and restarted."
`, giteaHost)
c.Header("Content-Type", "text/x-shellscript")
c.String(http.StatusOK, script)
}
func handleInstallScript(c *gin.Context) { func handleInstallScript(c *gin.Context) {
serverID := c.Query("server_id") serverID := c.Query("server_id")
token := c.Query("token") token := c.Query("token")
+4
View File
@@ -54,6 +54,10 @@ type ServerCommand struct {
type GenerateKeyCmd struct { type GenerateKeyCmd struct {
Label string `json:"label"` Label string `json:"label"`
KeyType string `json:"key_type,omitempty"`
KeySize int `json:"key_size,omitempty"`
Passphrase string `json:"passphrase,omitempty"`
Comment string `json:"comment,omitempty"`
} }
type AgentMessage struct { type AgentMessage struct {
+17 -2
View File
@@ -58,16 +58,31 @@ func (d *commandDispatcher) dispatch(serverID string, cmd *pb.ServerCommand) err
} }
} }
// KeyGenParams carries all options for a generate-key command.
type KeyGenParams struct {
Label string
KeyType string
KeySize int
Passphrase string
Comment string
}
// DispatchGenerateKey sends a generate-key command to the named server's agent. // DispatchGenerateKey sends a generate-key command to the named server's agent.
// Returns the command ID that can be used to correlate the agent's result. // Returns the command ID that can be used to correlate the agent's result.
func DispatchGenerateKey(serverID, label string) (string, error) { func DispatchGenerateKey(serverID string, p KeyGenParams) (string, error) {
if !Dispatcher.IsConnected(serverID) { if !Dispatcher.IsConnected(serverID) {
return "", fmt.Errorf("agent is not connected to the command stream") return "", fmt.Errorf("agent is not connected to the command stream")
} }
cmdID := uuid.New().String() cmdID := uuid.New().String()
cmd := &pb.ServerCommand{ cmd := &pb.ServerCommand{
CommandId: cmdID, CommandId: cmdID,
GenerateKey: &pb.GenerateKeyCmd{Label: label}, GenerateKey: &pb.GenerateKeyCmd{
Label: p.Label,
KeyType: p.KeyType,
KeySize: p.KeySize,
Passphrase: p.Passphrase,
Comment: p.Comment,
},
} }
if err := Dispatcher.dispatch(serverID, cmd); err != nil { if err := Dispatcher.dispatch(serverID, cmd); err != nil {
return "", err return "", err
+7 -5
View File
@@ -98,7 +98,10 @@ func DeleteKey(keyID string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
_, err := db.Col("keys").DeleteOne(ctx, bson.M{"key_id": keyID}) if _, err := db.Col("keys").DeleteOne(ctx, bson.M{"key_id": keyID}); err != nil {
return err
}
_, err := db.Col("assignments").DeleteMany(ctx, bson.M{"key_id": keyID})
return err return err
} }
@@ -212,12 +215,11 @@ func GetAssignmentsWithKeysForServer(serverID string) ([]AssignmentWithKey, erro
result := make([]AssignmentWithKey, 0, len(assignments)) result := make([]AssignmentWithKey, 0, len(assignments))
for _, a := range assignments { for _, a := range assignments {
item := AssignmentWithKey{Assignment: a}
var key models.Key var key models.Key
if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": a.KeyID}).Decode(&key); err == nil { if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": a.KeyID}).Decode(&key); err != nil {
item.Key = &key continue
} }
result = append(result, item) result = append(result, AssignmentWithKey{Assignment: a, Key: &key})
} }
return result, nil return result, nil
} }
+202 -5
View File
@@ -4,7 +4,7 @@ import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { api, ServerStatus } from "@/lib/api"; import { api, ServerStatus, GenerateKeyOptions } from "@/lib/api";
import { Badge, Button, Card, CardHeader, CardTitle } from "@/components/ui"; import { Badge, Button, Card, CardHeader, CardTitle } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui"; import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
@@ -20,12 +20,174 @@ function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString(); return new Date(dateStr).toLocaleString();
} }
const KEY_SIZES: Record<string, number[]> = {
rsa: [2048, 3072, 4096],
ecdsa: [256, 384, 521],
};
const DEFAULT_SIZE: Record<string, number> = {
rsa: 4096,
ecdsa: 256,
};
function GenerateKeyModal({
onClose,
onSubmit,
isPending,
}: {
onClose: () => void;
onSubmit: (opts: GenerateKeyOptions) => void;
isPending: boolean;
}) {
const [label, setLabel] = useState("");
const [keyType, setKeyType] = useState<"ed25519" | "rsa" | "ecdsa">("ed25519");
const [keySize, setKeySize] = useState<number>(4096);
const [passphrase, setPassphrase] = useState("");
const [comment, setComment] = useState("");
function handleKeyTypeChange(t: "ed25519" | "rsa" | "ecdsa") {
setKeyType(t);
if (t !== "ed25519") {
setKeySize(DEFAULT_SIZE[t]);
}
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
onSubmit({
label: label || "generated",
key_type: keyType,
key_size: keyType !== "ed25519" ? keySize : undefined,
passphrase: passphrase || undefined,
comment: comment || undefined,
});
}
const sizes = KEY_SIZES[keyType];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div className="relative z-10 w-full max-w-md rounded-xl border border-border bg-surface-1 p-6 shadow-2xl">
<div className="mb-5 flex items-center justify-between">
<h2 className="text-lg font-semibold text-text-primary">Generate SSH Key</h2>
<button
onClick={onClose}
className="rounded-md p-1 text-text-secondary hover:text-text-primary transition-colors"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
Label <span className="text-text-tertiary">(used as the key name in KeyManager)</span>
</label>
<input
type="text"
value={label}
onChange={e => setLabel(e.target.value)}
placeholder="e.g. server-deploy-key"
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Key Type</label>
<div className="grid grid-cols-3 gap-2">
{(["ed25519", "rsa", "ecdsa"] as const).map(t => (
<button
key={t}
type="button"
onClick={() => handleKeyTypeChange(t)}
className={`rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
keyType === t
? "border-accent bg-accent/10 text-accent"
: "border-border bg-surface-2 text-text-secondary hover:border-accent/40 hover:text-text-primary"
}`}
>
{t}
</button>
))}
</div>
{keyType === "ed25519" && (
<p className="mt-1.5 text-xs text-text-tertiary">Modern, fast, and secure. Recommended for new keys.</p>
)}
{keyType === "rsa" && (
<p className="mt-1.5 text-xs text-text-tertiary">Widely compatible with older systems.</p>
)}
{keyType === "ecdsa" && (
<p className="mt-1.5 text-xs text-text-tertiary">Elliptic curve shorter keys, good compatibility.</p>
)}
</div>
{sizes && (
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Key Size (bits)</label>
<select
value={keySize}
onChange={e => setKeySize(Number(e.target.value))}
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
>
{sizes.map(s => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
)}
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
Comment <span className="text-text-tertiary">(embedded in the public key)</span>
</label>
<input
type="text"
value={comment}
onChange={e => setComment(e.target.value)}
placeholder="e.g. user@hostname"
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
Passphrase <span className="text-text-tertiary">(leave blank for no passphrase)</span>
</label>
<input
type="password"
value={passphrase}
onChange={e => setPassphrase(e.target.value)}
placeholder="Optional passphrase"
autoComplete="new-password"
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
</div>
<div className="flex gap-3 pt-1">
<Button type="submit" variant="primary" loading={isPending} className="flex-1">
Generate Key
</Button>
<Button type="button" variant="ghost" onClick={onClose}>
Cancel
</Button>
</div>
</form>
</div>
</div>
);
}
export default function ServerDetailPage() { export default function ServerDetailPage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const serverId = params.id as string; const serverId = params.id as string;
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
const [showGenerateModal, setShowGenerateModal] = useState(false);
const [copiedUpdate, setCopiedUpdate] = useState(false);
const { data: server, isLoading, error } = useQuery({ const { data: server, isLoading, error } = useQuery({
queryKey: ["servers", serverId], queryKey: ["servers", serverId],
@@ -34,8 +196,9 @@ export default function ServerDetailPage() {
}); });
const { mutate: generateKey, isPending: isGenerating } = useMutation({ const { mutate: generateKey, isPending: isGenerating } = useMutation({
mutationFn: () => api.generateKeyForServer(serverId), mutationFn: (opts: GenerateKeyOptions) => api.generateKeyForServer(serverId, opts),
onSuccess: () => { onSuccess: () => {
setShowGenerateModal(false);
queryClient.invalidateQueries({ queryKey: ["servers", serverId] }); queryClient.invalidateQueries({ queryKey: ["servers", serverId] });
queryClient.invalidateQueries({ queryKey: ["keys"] }); queryClient.invalidateQueries({ queryKey: ["keys"] });
}, },
@@ -69,6 +232,14 @@ export default function ServerDetailPage() {
return ( return (
<div className="p-8"> <div className="p-8">
{showGenerateModal && (
<GenerateKeyModal
onClose={() => setShowGenerateModal(false)}
onSubmit={opts => generateKey(opts)}
isPending={isGenerating}
/>
)}
<div className="mb-6 flex items-start justify-between"> <div className="mb-6 flex items-start justify-between">
<div> <div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -85,8 +256,7 @@ export default function ServerDetailPage() {
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
variant="secondary" variant="secondary"
loading={isGenerating} onClick={() => setShowGenerateModal(true)}
onClick={() => generateKey()}
> >
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" /> <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
@@ -115,6 +285,33 @@ export default function ServerDetailPage() {
</div> </div>
</div> </div>
<div className="mb-6">
<Card>
<CardHeader>
<CardTitle>Update Agent</CardTitle>
</CardHeader>
<p className="mb-4 text-sm text-text-secondary">
Run this command on the server as <code className="rounded bg-surface-2 px-1 py-0.5 text-xs font-mono text-text-primary">root</code> to update the agent to the latest version:
</p>
<div className="relative rounded-lg border border-border bg-[#0a0c14] p-4 font-mono text-sm">
<pre className="overflow-x-auto whitespace-pre-wrap break-all text-text-secondary leading-relaxed">
<span className="text-accent">$</span>{" "}
<span className="text-text-primary">{api.getUpdateCommand()}</span>
</pre>
<button
onClick={async () => {
await navigator.clipboard.writeText(api.getUpdateCommand());
setCopiedUpdate(true);
setTimeout(() => setCopiedUpdate(false), 2000);
}}
className="absolute right-3 top-3 rounded-md border border-border bg-surface-2 px-2.5 py-1 text-xs font-medium text-text-secondary transition-colors hover:border-accent/50 hover:text-text-primary"
>
{copiedUpdate ? <span className="text-success">Copied!</span> : "Copy"}
</button>
</div>
</Card>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<Card className="lg:col-span-1"> <Card className="lg:col-span-1">
<CardHeader> <CardHeader>
@@ -176,7 +373,7 @@ export default function ServerDetailPage() {
</Tr> </Tr>
</Thead> </Thead>
<Tbody> <Tbody>
{server.keys.map((assignment) => ( {server.keys.filter(a => a.key).map((assignment) => (
<Tr key={assignment.key_id}> <Tr key={assignment.key_id}>
<Td> <Td>
<span className="font-medium">{assignment.key.label}</span> <span className="font-medium">{assignment.key.label}</span>
+15 -2
View File
@@ -38,6 +38,14 @@ export interface NewServerResponse {
install_command: string; install_command: string;
} }
export interface GenerateKeyOptions {
label: string;
key_type: "ed25519" | "rsa" | "ecdsa";
key_size?: number;
passphrase?: string;
comment?: string;
}
export interface KeyWithAssignments extends Key { export interface KeyWithAssignments extends Key {
assignments: (Assignment & { server: Server })[]; assignments: (Assignment & { server: Server })[];
} }
@@ -96,12 +104,17 @@ export const api = {
return request<void>(`/servers/${serverId}`, { method: "DELETE" }); return request<void>(`/servers/${serverId}`, { method: "DELETE" });
}, },
generateKeyForServer(serverId: string): Promise<{ key_id: string }> { generateKeyForServer(serverId: string, opts: GenerateKeyOptions): Promise<{ command_id: string }> {
return request<{ key_id: string }>(`/servers/${serverId}/generate-key`, { return request<{ command_id: string }>(`/servers/${serverId}/generate-key`, {
method: "POST", method: "POST",
body: JSON.stringify(opts),
}); });
}, },
getUpdateCommand(): string {
return `curl -fsSL "${window.location.origin}/update" | bash`;
},
// Keys // Keys
listKeys(): Promise<Key[]> { listKeys(): Promise<Key[]> {
return request<Key[]>("/keys"); return request<Key[]>("/keys");
+4
View File
@@ -18,6 +18,10 @@ const nextConfig: NextConfig = {
source: "/install", source: "/install",
destination: `${apiUrl}/install`, destination: `${apiUrl}/install`,
}, },
{
source: "/update",
destination: `${apiUrl}/update`,
},
]; ];
}, },
}; };