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() {
- 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()} - - + +