From 4ea7f369f108d9a2124077667f0497453bcfc665 Mon Sep 17 00:00:00 2001 From: domrichardson <100129001+domrichardson@users.noreply.github.com> Date: Tue, 16 Jun 2026 10:28:46 +0100 Subject: [PATCH] updates --- agent/internal/grpc/pb/keymanager.pb.go | 6 +- agent/internal/keys/keys.go | 31 +++- agent/internal/sync/sync.go | 13 +- proto/keymanager/v1/keymanager.proto | 6 +- server/internal/api/handlers.go | 14 +- server/internal/grpc/pb/keymanager.pb.go | 6 +- server/internal/services/dispatch.go | 21 ++- web/app/servers/[id]/page.tsx | 177 ++++++++++++++++++++++- web/lib/api.ts | 13 +- 9 files changed, 263 insertions(+), 24 deletions(-) diff --git a/agent/internal/grpc/pb/keymanager.pb.go b/agent/internal/grpc/pb/keymanager.pb.go index 79aecbd..748535a 100644 --- a/agent/internal/grpc/pb/keymanager.pb.go +++ b/agent/internal/grpc/pb/keymanager.pb.go @@ -50,7 +50,11 @@ type ServerCommand 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 { diff --git a/agent/internal/keys/keys.go b/agent/internal/keys/keys.go index e9a8cc4..00bff38 100644 --- a/agent/internal/keys/keys.go +++ b/agent/internal/keys/keys.go @@ -93,19 +93,36 @@ func fingerprint(pubKey string) string { 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. -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 { return "", err } - args := []string{ - "-t", "ed25519", - "-f", keyPath, - "-N", "", - "-C", comment, + keyType := opts.KeyType + if keyType == "" { + keyType = "ed25519" } + + 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...) out, err := cmd.CombinedOutput() if err != nil { diff --git a/agent/internal/sync/sync.go b/agent/internal/sync/sync.go index 2f6df2d..85cccff 100644 --- a/agent/internal/sync/sync.go +++ b/agent/internal/sync/sync.go @@ -166,10 +166,17 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error { } 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, " ", "_")) - 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 { log.Printf("key generation failed (cmd=%s): %v", cmd.CommandId, err) return @@ -214,7 +221,7 @@ func GenerateAndUpload(cfg *config.Config, label string) error { defer client.Close() 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 { return err } diff --git a/proto/keymanager/v1/keymanager.proto b/proto/keymanager/v1/keymanager.proto index 296c802..754b93d 100644 --- a/proto/keymanager/v1/keymanager.proto +++ b/proto/keymanager/v1/keymanager.proto @@ -71,5 +71,9 @@ message ServerCommand { } 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 } diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index beda91b..e8c3552 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -126,7 +126,11 @@ func generateKey(c *gin.Context) { id := c.Param("id") 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) if body.Label == "" { @@ -139,7 +143,13 @@ func generateKey(c *gin.Context) { 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 { c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) return diff --git a/server/internal/grpc/pb/keymanager.pb.go b/server/internal/grpc/pb/keymanager.pb.go index 56e3cb3..93b94fd 100644 --- a/server/internal/grpc/pb/keymanager.pb.go +++ b/server/internal/grpc/pb/keymanager.pb.go @@ -53,7 +53,11 @@ type ServerCommand 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 { diff --git a/server/internal/services/dispatch.go b/server/internal/services/dispatch.go index bb83db8..033e7ae 100644 --- a/server/internal/services/dispatch.go +++ b/server/internal/services/dispatch.go @@ -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. // 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) { return "", fmt.Errorf("agent is not connected to the command stream") } cmdID := uuid.New().String() cmd := &pb.ServerCommand{ - CommandId: cmdID, - GenerateKey: &pb.GenerateKeyCmd{Label: label}, + CommandId: cmdID, + 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 { return "", err diff --git a/web/app/servers/[id]/page.tsx b/web/app/servers/[id]/page.tsx index 28b74d9..87ab1e0 100644 --- a/web/app/servers/[id]/page.tsx +++ b/web/app/servers/[id]/page.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useParams, useRouter } from "next/navigation"; 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 { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui"; @@ -20,12 +20,173 @@ function formatDate(dateStr: string) { return new Date(dateStr).toLocaleString(); } +const KEY_SIZES: Record = { + rsa: [2048, 3072, 4096], + ecdsa: [256, 384, 521], +}; + +const DEFAULT_SIZE: Record = { + 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(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 ( +
+
+
+
+

Generate SSH Key

+ +
+ +
+
+ + 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" + /> +
+ +
+ +
+ {(["ed25519", "rsa", "ecdsa"] as const).map(t => ( + + ))} +
+ {keyType === "ed25519" && ( +

Modern, fast, and secure. Recommended for new keys.

+ )} + {keyType === "rsa" && ( +

Widely compatible with older systems.

+ )} + {keyType === "ecdsa" && ( +

Elliptic curve — shorter keys, good compatibility.

+ )} +
+ + {sizes && ( +
+ + +
+ )} + +
+ + 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" + /> +
+ +
+ + 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" + /> +
+ +
+ + +
+
+
+
+ ); +} + export default function ServerDetailPage() { const params = useParams(); const router = useRouter(); const queryClient = useQueryClient(); const serverId = params.id as string; const [confirmDelete, setConfirmDelete] = useState(false); + const [showGenerateModal, setShowGenerateModal] = useState(false); const [copiedUpdate, setCopiedUpdate] = useState(false); const { data: server, isLoading, error } = useQuery({ @@ -35,8 +196,9 @@ export default function ServerDetailPage() { }); const { mutate: generateKey, isPending: isGenerating } = useMutation({ - mutationFn: () => api.generateKeyForServer(serverId), + mutationFn: (opts: GenerateKeyOptions) => api.generateKeyForServer(serverId, opts), onSuccess: () => { + setShowGenerateModal(false); queryClient.invalidateQueries({ queryKey: ["servers", serverId] }); queryClient.invalidateQueries({ queryKey: ["keys"] }); }, @@ -70,6 +232,14 @@ export default function ServerDetailPage() { return (
+ {showGenerateModal && ( + setShowGenerateModal(false)} + onSubmit={opts => generateKey(opts)} + isPending={isGenerating} + /> + )} +
@@ -86,8 +256,7 @@ export default function ServerDetailPage() {