From aff6736b18a40636a5032d81dd1a3976fb314585 Mon Sep 17 00:00:00 2001 From: domrichardson <100129001+domrichardson@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:33:17 +0100 Subject: [PATCH] feat: update agent button on server page --- agent/cmd/main.go | 2 +- agent/internal/grpc/client.go | 7 +- agent/internal/grpc/pb/keymanager.pb.go | 11 ++- agent/internal/sync/sync.go | 113 ++++++++++++++++++++++- proto/keymanager/v1/keymanager.proto | 11 ++- server/internal/api/handlers.go | 31 +++++++ server/internal/grpc/pb/keymanager.pb.go | 11 ++- server/internal/grpc/server.go | 4 +- server/internal/models/server.go | 21 +++-- server/internal/services/dispatch.go | 67 ++++++++++++++ server/internal/services/servers.go | 8 +- web/app/servers/[id]/page.tsx | 79 ++++++++++++---- web/lib/api.ts | 11 +++ 13 files changed, 331 insertions(+), 45 deletions(-) diff --git a/agent/cmd/main.go b/agent/cmd/main.go index 865f601..f49ff27 100644 --- a/agent/cmd/main.go +++ b/agent/cmd/main.go @@ -33,7 +33,7 @@ func main() { defer stop() log.Printf("keymanager-agent %s starting (server=%s, poll=%s)", Version, cfg.ServerURL, cfg.PollInterval) - if err := agentsync.Run(ctx, cfg); err != nil { + if err := agentsync.Run(ctx, cfg, Version); err != nil { log.Fatalf("agent error: %v", err) } } diff --git a/agent/internal/grpc/client.go b/agent/internal/grpc/client.go index c3d8199..7a44926 100644 --- a/agent/internal/grpc/client.go +++ b/agent/internal/grpc/client.go @@ -73,13 +73,14 @@ func (c *Client) Register(serverID, preRegToken, hostname, ipAddress, osInfo str return resp.AgentToken, nil } -func (c *Client) SyncKeys(serverID, agentToken string) ([]string, error) { +func (c *Client) SyncKeys(serverID, agentToken, version string) ([]string, error) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() resp, err := c.client.SyncKeys(ctx, &pb.SyncRequest{ - ServerId: serverID, - AgentToken: agentToken, + ServerId: serverID, + AgentToken: agentToken, + AgentVersion: version, }) if err != nil { return nil, err diff --git a/agent/internal/grpc/pb/keymanager.pb.go b/agent/internal/grpc/pb/keymanager.pb.go index 514c8e9..da52687 100644 --- a/agent/internal/grpc/pb/keymanager.pb.go +++ b/agent/internal/grpc/pb/keymanager.pb.go @@ -23,8 +23,9 @@ type RegisterResponse struct { } type SyncRequest struct { - ServerId string `json:"server_id"` - AgentToken string `json:"agent_token"` + ServerId string `json:"server_id"` + AgentToken string `json:"agent_token"` + AgentVersion string `json:"agent_version,omitempty"` } type SyncResponse struct { @@ -49,12 +50,18 @@ type ServerCommand struct { CommandId string `json:"command_id"` GenerateKey *GenerateKeyCmd `json:"generate_key,omitempty"` DeleteKey *DeleteKeyCmd `json:"delete_key,omitempty"` + UpdateAgent *UpdateAgentCmd `json:"update_agent,omitempty"` } type DeleteKeyCmd struct { Label string `json:"label"` } +type UpdateAgentCmd struct { + Version string `json:"version"` + GiteaBaseURL string `json:"gitea_base_url"` +} + type GenerateKeyCmd struct { Label string `json:"label"` KeyType string `json:"key_type,omitempty"` diff --git a/agent/internal/sync/sync.go b/agent/internal/sync/sync.go index c815245..8985891 100644 --- a/agent/internal/sync/sync.go +++ b/agent/internal/sync/sync.go @@ -2,10 +2,15 @@ package agentsync import ( "context" + "crypto/sha256" + "encoding/hex" "fmt" + "io" "log" "net" + "net/http" "os" + "os/exec" "runtime" "strings" "time" @@ -16,7 +21,7 @@ import ( "github.com/mrhid6/keymanager/agent/internal/keys" ) -func Run(ctx context.Context, cfg *config.Config) error { +func Run(ctx context.Context, cfg *config.Config, version string) error { client, err := grpcclient.New(cfg.ServerURL, cfg.TLS) if err != nil { return fmt.Errorf("dial grpc: %w", err) @@ -60,7 +65,7 @@ func Run(ctx context.Context, cfg *config.Config) error { defer ticker.Stop() // Run immediately on startup - if err := poll(client, cfg); err != nil { + if err := poll(client, cfg, version); err != nil { log.Printf("poll error: %v", err) } @@ -69,15 +74,15 @@ func Run(ctx context.Context, cfg *config.Config) error { case <-ctx.Done(): return nil case <-ticker.C: - if err := poll(client, cfg); err != nil { + if err := poll(client, cfg, version); err != nil { log.Printf("poll error: %v", err) } } } } -func poll(client *grpcclient.Client, cfg *config.Config) error { - desired, err := client.SyncKeys(cfg.ServerID, cfg.AgentToken) +func poll(client *grpcclient.Client, cfg *config.Config, version string) error { + desired, err := client.SyncKeys(cfg.ServerID, cfg.AgentToken, version) if err != nil { return fmt.Errorf("SyncKeys: %w", err) } @@ -165,6 +170,9 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error { if cmd.DeleteKey != nil { go handleDeleteKey(cmd) } + if cmd.UpdateAgent != nil { + go handleUpdateAgent(cmd) + } } } @@ -184,6 +192,101 @@ func handleDeleteKey(cmd *pb.ServerCommand) { log.Printf("deleted local key files for %q (cmd=%s)", label, cmd.CommandId) } +func handleUpdateAgent(cmd *pb.ServerCommand) { + u := cmd.UpdateAgent + arch := runtime.GOARCH // "amd64" or "arm64" + tag := "agent%2Fv" + u.Version + binaryURL := fmt.Sprintf("%s/mrhid6/keymanager/releases/download/%s/keymanager-agent-linux-%s", u.GiteaBaseURL, tag, arch) + checksumURL := fmt.Sprintf("%s/mrhid6/keymanager/releases/download/%s/checksums.txt", u.GiteaBaseURL, tag) + + log.Printf("updating agent to v%s from %s (cmd=%s)", u.Version, u.GiteaBaseURL, cmd.CommandId) + + // Download binary + tmpBin := "/tmp/keymanager-agent-update" + if err := downloadFile(binaryURL, tmpBin); err != nil { + log.Printf("update download failed (cmd=%s): %v", cmd.CommandId, err) + return + } + + // Download and verify checksum + checksumData, err := httpGetBytes(checksumURL) + if err != nil { + log.Printf("update checksum fetch failed (cmd=%s): %v", cmd.CommandId, err) + return + } + if err := verifyChecksum(tmpBin, fmt.Sprintf("keymanager-agent-linux-%s", arch), checksumData); err != nil { + log.Printf("update checksum mismatch (cmd=%s): %v", cmd.CommandId, err) + os.Remove(tmpBin) + return + } + + if err := os.Chmod(tmpBin, 0755); err != nil { + log.Printf("update chmod failed (cmd=%s): %v", cmd.CommandId, err) + return + } + if err := os.Rename(tmpBin, "/usr/local/bin/keymanager-agent"); err != nil { + log.Printf("update replace binary failed (cmd=%s): %v", cmd.CommandId, err) + return + } + + log.Printf("agent binary replaced, restarting service (cmd=%s)", cmd.CommandId) + exec.Command("systemctl", "restart", "keymanager-agent").Run() +} + +func downloadFile(url, dest string) error { + resp, err := http.Get(url) //nolint:gosec + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d from %s", resp.StatusCode, url) + } + f, err := os.Create(dest) + if err != nil { + return err + } + defer f.Close() + _, err = io.Copy(f, resp.Body) + return err +} + +func httpGetBytes(url string) ([]byte, error) { + resp, err := http.Get(url) //nolint:gosec + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d from %s", resp.StatusCode, url) + } + return io.ReadAll(resp.Body) +} + +func verifyChecksum(filePath, filename string, checksumData []byte) error { + f, err := os.Open(filePath) + if err != nil { + return err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return err + } + actual := hex.EncodeToString(h.Sum(nil)) + + for _, line := range strings.Split(string(checksumData), "\n") { + fields := strings.Fields(line) + if len(fields) == 2 && fields[1] == filename { + if fields[0] != actual { + return fmt.Errorf("expected %s got %s", fields[0], actual) + } + return nil + } + } + return fmt.Errorf("no checksum entry found for %s", filename) +} + func handleGenerateKey(cfg *config.Config, cmd *pb.ServerCommand) { g := cmd.GenerateKey label := g.Label diff --git a/proto/keymanager/v1/keymanager.proto b/proto/keymanager/v1/keymanager.proto index a456a8b..0182edf 100644 --- a/proto/keymanager/v1/keymanager.proto +++ b/proto/keymanager/v1/keymanager.proto @@ -25,8 +25,9 @@ message RegisterResponse { } message SyncRequest { - string server_id = 1; - string agent_token = 2; + string server_id = 1; + string agent_token = 2; + string agent_version = 3; } message SyncResponse { @@ -69,6 +70,7 @@ message ServerCommand { oneof command { GenerateKeyCmd generate_key = 2; DeleteKeyCmd delete_key = 3; + UpdateAgentCmd update_agent = 4; } } @@ -76,6 +78,11 @@ message DeleteKeyCmd { string label = 1; } +message UpdateAgentCmd { + string version = 1; // e.g. "1.2.3" + string gitea_base_url = 2; // e.g. "https://gitea.example.com" +} + message GenerateKeyCmd { string label = 1; string key_type = 2; // ed25519 | rsa | ecdsa (default: ed25519) diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index cf00115..bc1e434 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -32,6 +32,9 @@ func RegisterRoutes(r *gin.Engine) { apiGroup.GET("/servers/:id", getServer) apiGroup.DELETE("/servers/:id", deleteServer) apiGroup.POST("/servers/:id/generate-key", generateKey) + apiGroup.POST("/servers/:id/update-agent", updateAgent) + + apiGroup.GET("/agent/latest-version", getLatestAgentVersion) apiGroup.GET("/keys", listKeys) apiGroup.POST("/keys", createKey) @@ -259,6 +262,34 @@ func revokeAssignment(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"revoked": true}) } +func getLatestAgentVersion(c *gin.Context) { + version, err := services.GetLatestAgentVersion() + if err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"version": version}) +} + +func updateAgent(c *gin.Context) { + id := c.Param("id") + s, err := services.GetServer(id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "server not found"}) + return + } + + version, err := services.DispatchUpdateAgent(s.ServerID) + if err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusAccepted, gin.H{ + "message": "update command sent to agent", + "version": version, + }) +} + func handleUpdateScript(c *gin.Context) { giteaHost := os.Getenv("GITEA_HOST") if giteaHost == "" { diff --git a/server/internal/grpc/pb/keymanager.pb.go b/server/internal/grpc/pb/keymanager.pb.go index 35dcba1..d4eac95 100644 --- a/server/internal/grpc/pb/keymanager.pb.go +++ b/server/internal/grpc/pb/keymanager.pb.go @@ -26,8 +26,9 @@ type RegisterResponse struct { } type SyncRequest struct { - ServerId string `json:"server_id"` - AgentToken string `json:"agent_token"` + ServerId string `json:"server_id"` + AgentToken string `json:"agent_token"` + AgentVersion string `json:"agent_version,omitempty"` } type SyncResponse struct { @@ -52,12 +53,18 @@ type ServerCommand struct { CommandId string `json:"command_id"` GenerateKey *GenerateKeyCmd `json:"generate_key,omitempty"` DeleteKey *DeleteKeyCmd `json:"delete_key,omitempty"` + UpdateAgent *UpdateAgentCmd `json:"update_agent,omitempty"` } type DeleteKeyCmd struct { Label string `json:"label"` } +type UpdateAgentCmd struct { + Version string `json:"version"` + GiteaBaseURL string `json:"gitea_base_url"` +} + type GenerateKeyCmd struct { Label string `json:"label"` KeyType string `json:"key_type,omitempty"` diff --git a/server/internal/grpc/server.go b/server/internal/grpc/server.go index 73e24ba..c6e679e 100644 --- a/server/internal/grpc/server.go +++ b/server/internal/grpc/server.go @@ -36,7 +36,7 @@ func (s *keyManagerServer) SyncKeys(ctx context.Context, req *pb.SyncRequest) (* return nil, status.Errorf(codes.Unauthenticated, "invalid agent token") } - if err := services.UpdateServerLastSeen(srv.ServerID); err != nil { + if err := services.UpdateServerLastSeen(srv.ServerID, req.AgentVersion); err != nil { log.Printf("failed to update last seen for %s: %v", srv.ServerID, err) } @@ -79,7 +79,7 @@ func (s *keyManagerServer) CommandStream(stream pb.KeyManager_CommandStreamServe return status.Errorf(codes.Unauthenticated, "invalid agent token") } - if err := services.UpdateServerLastSeen(srv.ServerID); err != nil { + if err := services.UpdateServerLastSeen(srv.ServerID, ""); err != nil { log.Printf("update last seen %s: %v", srv.ServerID, err) } diff --git a/server/internal/models/server.go b/server/internal/models/server.go index a97c632..330e79b 100644 --- a/server/internal/models/server.go +++ b/server/internal/models/server.go @@ -8,14 +8,15 @@ import ( type Server struct { ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` - ServerID string `bson:"server_id" json:"server_id"` - Hostname string `bson:"hostname" json:"hostname"` - IPAddress string `bson:"ip_address" json:"ip_address"` - OSInfo string `bson:"os_info" json:"os_info"` - PreRegToken string `bson:"pre_reg_token,omitempty" json:"pre_reg_token,omitempty"` - PreRegExpires *time.Time `bson:"pre_reg_expires,omitempty" json:"pre_reg_expires,omitempty"` - AgentTokenHash string `bson:"agent_token_hash,omitempty" json:"-"` - Status string `bson:"status" json:"status"` - LastSeen *time.Time `bson:"last_seen,omitempty" json:"last_seen,omitempty"` - CreatedAt time.Time `bson:"created_at" json:"created_at"` + ServerID string `bson:"server_id" json:"server_id"` + Hostname string `bson:"hostname" json:"hostname"` + IPAddress string `bson:"ip_address" json:"ip_address"` + OSInfo string `bson:"os_info" json:"os_info"` + PreRegToken string `bson:"pre_reg_token,omitempty" json:"pre_reg_token,omitempty"` + PreRegExpires *time.Time `bson:"pre_reg_expires,omitempty" json:"pre_reg_expires,omitempty"` + AgentTokenHash string `bson:"agent_token_hash,omitempty" json:"-"` + Status string `bson:"status" json:"status"` + AgentVersion string `bson:"agent_version,omitempty" json:"agent_version,omitempty"` + LastSeen *time.Time `bson:"last_seen,omitempty" json:"last_seen,omitempty"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` } diff --git a/server/internal/services/dispatch.go b/server/internal/services/dispatch.go index 5a63a26..f01f898 100644 --- a/server/internal/services/dispatch.go +++ b/server/internal/services/dispatch.go @@ -1,7 +1,11 @@ package services import ( + "encoding/json" "fmt" + "net/http" + "os" + "strings" "sync" "github.com/google/uuid" @@ -67,6 +71,69 @@ type KeyGenParams struct { Comment string } +// GetLatestAgentVersion queries the Gitea API for the latest agent/v* release tag +// and returns just the version number (e.g. "1.2.3"). +func GetLatestAgentVersion() (string, error) { + giteaHost := os.Getenv("GITEA_HOST") + if giteaHost == "" { + giteaHost = "gitea.example.com" + } + url := fmt.Sprintf("https://%s/api/v1/repos/mrhid6/keymanager/releases?limit=20", giteaHost) + resp, err := http.Get(url) //nolint:gosec + if err != nil { + return "", fmt.Errorf("fetch releases: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("Gitea API returned HTTP %d", resp.StatusCode) + } + + var releases []struct { + TagName string `json:"tag_name"` + } + if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil { + return "", fmt.Errorf("decode releases: %w", err) + } + + for _, r := range releases { + if strings.HasPrefix(r.TagName, "agent/v") { + return strings.TrimPrefix(r.TagName, "agent/v"), nil + } + } + return "", fmt.Errorf("no agent release found") +} + +// DispatchUpdateAgent sends an update command to the named server's agent. +// It fetches the latest version from Gitea and includes the download base URL. +func DispatchUpdateAgent(serverID string) (string, error) { + if !Dispatcher.IsConnected(serverID) { + return "", fmt.Errorf("agent is not connected to the command stream") + } + + version, err := GetLatestAgentVersion() + if err != nil { + return "", fmt.Errorf("get latest version: %w", err) + } + + giteaHost := os.Getenv("GITEA_HOST") + if giteaHost == "" { + giteaHost = "gitea.example.com" + } + + cmdID := uuid.New().String() + cmd := &pb.ServerCommand{ + CommandId: cmdID, + UpdateAgent: &pb.UpdateAgentCmd{ + Version: version, + GiteaBaseURL: "https://" + giteaHost, + }, + } + if err := Dispatcher.dispatch(serverID, cmd); err != nil { + return "", err + } + return version, nil +} + // DispatchDeleteKey sends a delete-key command to the named server's agent. // It is best-effort: if the agent is offline the local files will remain until next connection. func DispatchDeleteKey(serverID, label string) { diff --git a/server/internal/services/servers.go b/server/internal/services/servers.go index 93d9088..2e9d471 100644 --- a/server/internal/services/servers.go +++ b/server/internal/services/servers.go @@ -134,14 +134,18 @@ func ValidateAgentToken(serverID, agentToken string) (*models.Server, error) { return &s, nil } -func UpdateServerLastSeen(serverID string) error { +func UpdateServerLastSeen(serverID, agentVersion string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() now := time.Now() + fields := bson.M{"last_seen": now, "status": "active"} + if agentVersion != "" { + fields["agent_version"] = agentVersion + } _, err := db.Col("servers").UpdateOne(ctx, bson.M{"server_id": serverID}, - bson.M{"$set": bson.M{"last_seen": now, "status": "active"}}, + bson.M{"$set": fields}, ) return err } diff --git a/web/app/servers/[id]/page.tsx b/web/app/servers/[id]/page.tsx index 87ab1e0..bf94f6f 100644 --- a/web/app/servers/[id]/page.tsx +++ b/web/app/servers/[id]/page.tsx @@ -188,6 +188,7 @@ export default function ServerDetailPage() { const [confirmDelete, setConfirmDelete] = useState(false); const [showGenerateModal, setShowGenerateModal] = useState(false); const [copiedUpdate, setCopiedUpdate] = useState(false); + const [updateSuccess, setUpdateSuccess] = useState(false); const { data: server, isLoading, error } = useQuery({ queryKey: ["servers", serverId], @@ -204,6 +205,20 @@ export default function ServerDetailPage() { }, }); + const { data: latestVersion } = useQuery({ + queryKey: ["agent-latest-version"], + queryFn: () => api.getLatestAgentVersion(), + staleTime: 5 * 60_000, + }); + + const { mutate: triggerUpdate, isPending: isUpdating } = useMutation({ + mutationFn: () => api.updateAgent(serverId), + onSuccess: () => { + setUpdateSuccess(true); + setTimeout(() => setUpdateSuccess(false), 4000); + }, + }); + const { mutate: deleteServer, isPending: isDeleting } = useMutation({ mutationFn: () => api.deleteServer(serverId), onSuccess: () => { @@ -290,24 +305,50 @@ export default function ServerDetailPage() { Update Agent -

- Run this command on the server as root to update the agent to the latest version: -

-
-
+          
+
+ Installed: + + {server.agent_version ? `v${server.agent_version}` : "unknown"} + +
+
+ Latest: + + {latestVersion ? `v${latestVersion.version}` : "—"} + +
+ {latestVersion && server.agent_version && server.agent_version !== latestVersion.version && ( + update available + )} + {latestVersion && server.agent_version && server.agent_version === latestVersion.version && ( + up to date + )} +
+
+ +
${" "} {api.getUpdateCommand()} -
- + +
@@ -326,6 +367,12 @@ export default function ServerDetailPage() {
OS
{server.os_info}
+
+
Agent Version
+
+ {server.agent_version ? `v${server.agent_version}` : "unknown"} +
+
Last Seen
{server.last_seen ? formatDate(server.last_seen) : "Never"}
diff --git a/web/lib/api.ts b/web/lib/api.ts index 1ec86a1..509f25f 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -8,6 +8,7 @@ export interface Server { ip_address: string; os_info: string; status: ServerStatus; + agent_version?: string; last_seen: string; created_at: string; } @@ -116,6 +117,16 @@ export const api = { return `curl -fsSL "${window.location.origin}/update" | bash`; }, + getLatestAgentVersion(): Promise<{ version: string }> { + return request<{ version: string }>("/agent/latest-version"); + }, + + updateAgent(serverId: string): Promise<{ message: string; version: string }> { + return request<{ message: string; version: string }>(`/servers/${serverId}/update-agent`, { + method: "POST", + }); + }, + // Keys listKeys(): Promise { return request("/keys");