Compare commits

...
Author SHA1 Message Date
domrichardson aff6736b18 feat: update agent button on server page
Server Deploy / deploy (push) Successful in 1m39s
Agent Release / build (push) Successful in 2m10s
2026-06-24 14:33:17 +01:00
domrichardson e6ef9bc536 updates
Agent Release / build (push) Successful in 1m18s
Server Deploy / deploy (push) Successful in 1m33s
2026-06-24 13:57:48 +01:00
domrichardson 407a610cfb updates
Server Deploy / deploy (push) Successful in 1m25s
2026-06-16 11:07:18 +01:00
19 changed files with 754 additions and 66 deletions
+1 -1
View File
@@ -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)
}
}
+6 -4
View File
@@ -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
@@ -87,7 +88,7 @@ func (c *Client) SyncKeys(serverID, agentToken string) ([]string, error) {
return resp.PublicKeys, nil
}
func (c *Client) UploadGeneratedKey(serverID, agentToken, publicKey, label string) (string, error) {
func (c *Client) UploadGeneratedKey(serverID, agentToken, publicKey, privateKey, label string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@@ -95,6 +96,7 @@ func (c *Client) UploadGeneratedKey(serverID, agentToken, publicKey, label strin
ServerId: serverID,
AgentToken: agentToken,
PublicKey: publicKey,
PrivateKey: privateKey,
Label: label,
})
if err != nil {
+15 -2
View File
@@ -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 {
@@ -36,6 +37,7 @@ type UploadKeyRequest struct {
AgentToken string `json:"agent_token"`
PublicKey string `json:"public_key"`
Label string `json:"label"`
PrivateKey string `json:"private_key,omitempty"`
}
type UploadKeyResponse struct {
@@ -47,6 +49,17 @@ type UploadKeyResponse struct {
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 {
+91
View File
@@ -11,6 +11,9 @@ import (
)
const authorizedKeysPath = "/root/.ssh/authorized_keys"
const sshConfigPath = "/root/.ssh/config"
const managedConfigPath = "/root/.ssh/keymanager.conf"
const includeDirective = "Include /root/.ssh/keymanager.conf"
func ReadAuthorizedKeys() ([]string, error) {
data, err := os.ReadFile(authorizedKeysPath)
@@ -135,3 +138,91 @@ func GenerateKeyPair(keyPath string, opts KeyGenOptions) (string, error) {
}
return strings.TrimSpace(string(pubData)), nil
}
// AddSSHIdentity writes an IdentityFile entry for keyPath into the managed
// keymanager.conf include file, and ensures ~/.ssh/config includes it.
func AddSSHIdentity(keyPath string) error {
if err := os.MkdirAll(filepath.Dir(sshConfigPath), 0700); err != nil {
return fmt.Errorf("mkdir .ssh: %w", err)
}
if err := ensureIncludeDirective(); err != nil {
return err
}
// Read existing managed config (it may not exist yet).
var existing string
data, err := os.ReadFile(managedConfigPath)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("read %s: %w", managedConfigPath, err)
}
existing = string(data)
line := "IdentityFile " + keyPath
for _, l := range strings.Split(existing, "\n") {
if strings.TrimSpace(l) == line {
return nil // already present
}
}
if existing != "" && !strings.HasSuffix(existing, "\n") {
existing += "\n"
}
updated := existing + line + "\n"
if err := os.WriteFile(managedConfigPath, []byte(updated), 0600); err != nil {
return fmt.Errorf("write %s: %w", managedConfigPath, err)
}
return nil
}
// RemoveSSHIdentity removes the IdentityFile entry for keyPath from the managed config.
func RemoveSSHIdentity(keyPath string) error {
data, err := os.ReadFile(managedConfigPath)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return fmt.Errorf("read %s: %w", managedConfigPath, err)
}
line := "IdentityFile " + keyPath
var kept []string
for _, l := range strings.Split(strings.TrimRight(string(data), "\n"), "\n") {
if strings.TrimSpace(l) != line {
kept = append(kept, l)
}
}
content := strings.Join(kept, "\n")
if len(kept) > 0 {
content += "\n"
}
if err := os.WriteFile(managedConfigPath, []byte(content), 0600); err != nil {
return fmt.Errorf("write %s: %w", managedConfigPath, err)
}
return nil
}
// ensureIncludeDirective adds "Include /root/.ssh/keymanager.conf" to the top
// of ~/.ssh/config if it is not already present. The Include must appear before
// any Host stanzas to be effective for all connections.
func ensureIncludeDirective() error {
data, err := os.ReadFile(sshConfigPath)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("read %s: %w", sshConfigPath, err)
}
for _, l := range strings.Split(string(data), "\n") {
if strings.TrimSpace(l) == includeDirective {
return nil // already present
}
}
// Prepend the Include directive so it takes effect before any Host blocks.
updated := includeDirective + "\n" + string(data)
if err := os.WriteFile(sshConfigPath, []byte(updated), 0600); err != nil {
return fmt.Errorf("write %s: %w", sshConfigPath, err)
}
return nil
}
+148 -7
View File
@@ -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)
}
@@ -162,9 +167,126 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
if cmd.GenerateKey != nil {
go handleGenerateKey(cfg, cmd)
}
if cmd.DeleteKey != nil {
go handleDeleteKey(cmd)
}
if cmd.UpdateAgent != nil {
go handleUpdateAgent(cmd)
}
}
}
func handleDeleteKey(cmd *pb.ServerCommand) {
label := cmd.DeleteKey.Label
keyPath := fmt.Sprintf("/root/.ssh/keymanager_%s", strings.ReplaceAll(label, " ", "_"))
if err := keys.RemoveSSHIdentity(keyPath); err != nil {
log.Printf("remove ssh identity failed (cmd=%s): %v", cmd.CommandId, err)
}
for _, path := range []string{keyPath, keyPath + ".pub"} {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
log.Printf("delete key file %s (cmd=%s): %v", path, cmd.CommandId, err)
}
}
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
@@ -182,6 +304,12 @@ func handleGenerateKey(cfg *config.Config, cmd *pb.ServerCommand) {
return
}
privKeyData, err := os.ReadFile(keyPath)
if err != nil {
log.Printf("read private key failed (cmd=%s): %v", cmd.CommandId, err)
return
}
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
if err != nil {
log.Printf("dial for key upload failed (cmd=%s): %v", cmd.CommandId, err)
@@ -189,11 +317,15 @@ func handleGenerateKey(cfg *config.Config, cmd *pb.ServerCommand) {
}
defer client.Close()
keyID, err := client.UploadGeneratedKey(cfg.ServerID, cfg.AgentToken, pubKey, label)
keyID, err := client.UploadGeneratedKey(cfg.ServerID, cfg.AgentToken, pubKey, string(privKeyData), label)
if err != nil {
log.Printf("key upload failed (cmd=%s): %v", cmd.CommandId, err)
return
}
if err := keys.AddSSHIdentity(keyPath); err != nil {
log.Printf("add ssh identity failed (cmd=%s): %v", cmd.CommandId, err)
}
log.Printf("generated and uploaded key %q (key_id=%s, cmd=%s)", label, keyID, cmd.CommandId)
}
@@ -226,10 +358,19 @@ func GenerateAndUpload(cfg *config.Config, label string) error {
return err
}
keyID, err := client.UploadGeneratedKey(cfg.ServerID, cfg.AgentToken, pubKey, label)
privKeyData, err := os.ReadFile(keyPath)
if err != nil {
return fmt.Errorf("read private key: %w", err)
}
keyID, err := client.UploadGeneratedKey(cfg.ServerID, cfg.AgentToken, pubKey, string(privKeyData), label)
if err != nil {
return err
}
if err := keys.AddSSHIdentity(keyPath); err != nil {
log.Printf("add ssh identity: %v", err)
}
log.Printf("uploaded generated key %s (key_id=%s)", label, keyID)
return nil
}
+15 -2
View File
@@ -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 {
@@ -38,6 +39,7 @@ message UploadKeyRequest {
string agent_token = 2;
string public_key = 3;
string label = 4;
string private_key = 5;
}
message UploadKeyResponse {
@@ -67,9 +69,20 @@ message ServerCommand {
string command_id = 1;
oneof command {
GenerateKeyCmd generate_key = 2;
DeleteKeyCmd delete_key = 3;
UpdateAgentCmd update_agent = 4;
}
}
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)
+46 -3
View File
@@ -32,10 +32,14 @@ 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)
apiGroup.GET("/keys/:id", getKey)
apiGroup.GET("/keys/:id/private-key", getPrivateKey)
apiGroup.DELETE("/keys/:id", deleteKey)
apiGroup.POST("/keys/:id/assign", assignKey)
apiGroup.DELETE("/keys/:id/assign/:serverId", revokeAssignment)
@@ -173,15 +177,16 @@ func listKeys(c *gin.Context) {
func createKey(c *gin.Context) {
var body struct {
Label string `json:"label" binding:"required"`
PublicKey string `json:"public_key" binding:"required"`
Label string `json:"label" binding:"required"`
PublicKey string `json:"public_key" binding:"required"`
PrivateKey string `json:"private_key"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
key, err := services.CreateKey(body.Label, body.PublicKey, "uploaded", "")
key, err := services.CreateKey(body.Label, body.PublicKey, "uploaded", "", body.PrivateKey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -189,6 +194,16 @@ func createKey(c *gin.Context) {
c.JSON(http.StatusCreated, key)
}
func getPrivateKey(c *gin.Context) {
id := c.Param("id")
plaintext, err := services.GetPrivateKey(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"private_key": plaintext})
}
func getKey(c *gin.Context) {
id := c.Param("id")
key, err := services.GetKey(id)
@@ -247,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 == "" {
+15 -2
View File
@@ -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 {
@@ -39,6 +40,7 @@ type UploadKeyRequest struct {
AgentToken string `json:"agent_token"`
PublicKey string `json:"public_key"`
Label string `json:"label"`
PrivateKey string `json:"private_key,omitempty"`
}
type UploadKeyResponse struct {
@@ -50,6 +52,17 @@ type UploadKeyResponse struct {
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 {
+3 -3
View File
@@ -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)
}
@@ -54,7 +54,7 @@ func (s *keyManagerServer) UploadGeneratedKey(ctx context.Context, req *pb.Uploa
return nil, status.Errorf(codes.Unauthenticated, "invalid agent token")
}
key, err := services.CreateKey(req.Label, req.PublicKey, "generated", srv.ServerID)
key, err := services.CreateKey(req.Label, req.PublicKey, "generated", srv.ServerID, req.PrivateKey)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to store key: %v", 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)
}
+9 -7
View File
@@ -8,11 +8,13 @@ import (
type Key struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
KeyID string `bson:"key_id" json:"key_id"`
Label string `bson:"label" json:"label"`
PublicKey string `bson:"public_key" json:"public_key"`
Fingerprint string `bson:"fingerprint" json:"fingerprint"`
Source string `bson:"source" json:"source"` // uploaded | generated
GeneratedByServerID string `bson:"generated_by_server_id,omitempty" json:"generated_by_server_id,omitempty"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
KeyID string `bson:"key_id" json:"key_id"`
Label string `bson:"label" json:"label"`
PublicKey string `bson:"public_key" json:"public_key"`
Fingerprint string `bson:"fingerprint" json:"fingerprint"`
Source string `bson:"source" json:"source"` // uploaded | generated
GeneratedByServerID string `bson:"generated_by_server_id,omitempty" json:"generated_by_server_id,omitempty"`
PrivateKeyEncrypted string `bson:"private_key_enc,omitempty" json:"-"`
HasPrivateKey bool `bson:"-" json:"has_private_key"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
+11 -10
View File
@@ -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"`
}
+72
View File
@@ -0,0 +1,72 @@
package services
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"os"
)
func encryptionKey() ([]byte, error) {
raw := os.Getenv("KEY_ENCRYPTION_KEY")
if raw == "" {
return nil, fmt.Errorf("KEY_ENCRYPTION_KEY is not set")
}
key, err := hex.DecodeString(raw)
if err != nil || len(key) != 32 {
return nil, fmt.Errorf("KEY_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)")
}
return key, nil
}
func encryptPrivateKey(plaintext string) (string, error) {
key, err := encryptionKey()
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
sealed := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return hex.EncodeToString(sealed), nil
}
func decryptPrivateKey(ciphertextHex string) (string, error) {
key, err := encryptionKey()
if err != nil {
return "", err
}
data, err := hex.DecodeString(ciphertextHex)
if err != nil {
return "", fmt.Errorf("invalid ciphertext encoding")
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonceSize := gcm.NonceSize()
if len(data) < nonceSize {
return "", fmt.Errorf("ciphertext too short")
}
plaintext, err := gcm.Open(nil, data[:nonceSize], data[nonceSize:], nil)
if err != nil {
return "", fmt.Errorf("decryption failed")
}
return string(plaintext), nil
}
+83
View File
@@ -1,7 +1,11 @@
package services
import (
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"sync"
"github.com/google/uuid"
@@ -67,6 +71,85 @@ 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) {
if !Dispatcher.IsConnected(serverID) {
return
}
cmd := &pb.ServerCommand{
CommandId: uuid.New().String(),
DeleteKey: &pb.DeleteKeyCmd{Label: label},
}
if err := Dispatcher.dispatch(serverID, cmd); err != nil {
// Non-fatal: agent will clean up files on next manual intervention or reinstall.
_ = err
}
}
// 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 string, p KeyGenParams) (string, error) {
+43 -3
View File
@@ -31,7 +31,11 @@ func computeFingerprint(pubKey string) string {
return "MD5:" + strings.Join(pairs, ":")
}
func CreateKey(label, publicKey, source, generatedByServerID string) (*models.Key, error) {
func setKeyMeta(k *models.Key) {
k.HasPrivateKey = k.PrivateKeyEncrypted != ""
}
func CreateKey(label, publicKey, source, generatedByServerID, privateKey string) (*models.Key, error) {
key := &models.Key{
KeyID: uuid.NewString(),
Label: label,
@@ -41,6 +45,13 @@ func CreateKey(label, publicKey, source, generatedByServerID string) (*models.Ke
GeneratedByServerID: generatedByServerID,
CreatedAt: time.Now(),
}
if privateKey != "" {
enc, err := encryptPrivateKey(privateKey)
if err != nil {
return nil, fmt.Errorf("encrypt private key: %w", err)
}
key.PrivateKeyEncrypted = enc
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -48,6 +59,7 @@ func CreateKey(label, publicKey, source, generatedByServerID string) (*models.Ke
if err != nil {
return nil, err
}
setKeyMeta(key)
return key, nil
}
@@ -60,9 +72,24 @@ func GetKey(keyID string) (*models.Key, error) {
if err != nil {
return nil, err
}
setKeyMeta(&key)
return &key, nil
}
func GetPrivateKey(keyID string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var key models.Key
if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID}).Decode(&key); err != nil {
return "", err
}
if key.PrivateKeyEncrypted == "" {
return "", fmt.Errorf("no private key stored for this key")
}
return decryptPrivateKey(key.PrivateKeyEncrypted)
}
type KeyWithCount struct {
models.Key `bson:",inline"`
AssignedCount int `bson:"-" json:"assigned_count"`
@@ -85,6 +112,7 @@ func ListKeys() ([]KeyWithCount, error) {
result := make([]KeyWithCount, 0, len(keys))
for _, k := range keys {
setKeyMeta(&k)
count, _ := db.Col("assignments").CountDocuments(ctx, bson.M{
"key_id": k.KeyID,
"revoked_at": nil,
@@ -98,11 +126,22 @@ func DeleteKey(keyID string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var key models.Key
if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID}).Decode(&key); err != nil {
return err
}
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
if _, err := db.Col("assignments").DeleteMany(ctx, bson.M{"key_id": keyID}); err != nil {
return err
}
if key.Source == "generated" && key.GeneratedByServerID != "" {
DispatchDeleteKey(key.GeneratedByServerID, key.Label)
}
return nil
}
func AssignKey(keyID, serverID string) (*models.Assignment, error) {
@@ -219,6 +258,7 @@ func GetAssignmentsWithKeysForServer(serverID string) ([]AssignmentWithKey, erro
if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": a.KeyID}).Decode(&key); err != nil {
continue
}
setKeyMeta(&key)
result = append(result, AssignmentWithKey{Assignment: a, Key: &key})
}
return result, nil
+6 -2
View File
@@ -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
}
+93
View File
@@ -89,6 +89,97 @@ function AssignModal({
);
}
function PrivateKeyCard({ keyId }: { keyId: string }) {
const [revealed, setRevealed] = useState(false);
const [privateKey, setPrivateKey] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
async function reveal() {
setLoading(true);
setError(null);
try {
const res = await api.getPrivateKey(keyId);
setPrivateKey(res.private_key);
setRevealed(true);
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
}
}
function download() {
if (!privateKey) return;
const blob = new Blob([privateKey], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${keyId}.pem`;
a.click();
URL.revokeObjectURL(url);
}
async function copy() {
if (!privateKey) return;
await navigator.clipboard.writeText(privateKey);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<Card>
<CardHeader>
<CardTitle>Private Key</CardTitle>
{revealed && (
<div className="flex gap-2">
<button
onClick={copy}
className="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"
>
{copied ? <span className="text-success">Copied!</span> : "Copy"}
</button>
<button
onClick={download}
className="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"
>
Download .pem
</button>
</div>
)}
</CardHeader>
{error && (
<div className="mb-3 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-xs text-danger">
{error}
</div>
)}
{!revealed ? (
<div className="flex flex-col items-center gap-3 py-4">
<p className="text-center text-xs text-text-tertiary">
Stored encrypted (AES-256-GCM). Click to decrypt and display.
</p>
<Button variant="secondary" size="sm" loading={loading} onClick={reveal}>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.964-7.178z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Reveal Private Key
</Button>
</div>
) : (
<div className="rounded-lg border border-border bg-[#0a0c14] p-3">
<pre className="overflow-x-auto whitespace-pre-wrap break-all font-mono text-xs text-text-secondary leading-relaxed">
{privateKey}
</pre>
</div>
)}
</Card>
);
}
export default function KeyDetailPage() {
const params = useParams();
const router = useRouter();
@@ -252,6 +343,8 @@ export default function KeyDetailPage() {
</pre>
</div>
</Card>
{key.has_private_key && <PrivateKeyCard keyId={keyId} />}
</div>
<div className="lg:col-span-2">
+16 -2
View File
@@ -11,9 +11,10 @@ function UploadKeyModal({ onClose }: { onClose: () => void }) {
const queryClient = useQueryClient();
const [label, setLabel] = useState("");
const [publicKey, setPublicKey] = useState("");
const [privateKey, setPrivateKey] = useState("");
const { mutate: upload, isPending, error } = useMutation({
mutationFn: () => api.uploadKey(label.trim(), publicKey.trim()),
mutationFn: () => api.uploadKey(label.trim(), publicKey.trim(), privateKey.trim() || undefined),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["keys"] });
onClose();
@@ -52,7 +53,20 @@ function UploadKeyModal({ onClose }: { onClose: () => void }) {
value={publicKey}
onChange={(e) => setPublicKey(e.target.value)}
placeholder="ssh-ed25519 AAAA..."
rows={4}
rows={3}
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 font-mono text-xs text-text-primary placeholder-text-secondary/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent resize-none"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
Private Key{" "}
<span className="text-text-tertiary font-normal">(optional stored AES-256-GCM encrypted)</span>
</label>
<textarea
value={privateKey}
onChange={(e) => setPrivateKey(e.target.value)}
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
rows={3}
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 font-mono text-xs text-text-primary placeholder-text-secondary/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent resize-none"
/>
</div>
+63 -16
View File
@@ -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() {
<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">
<div className="mb-4 flex flex-wrap items-center gap-4 text-sm">
<div>
<span className="text-text-secondary">Installed: </span>
<span className="font-mono font-medium text-text-primary">
{server.agent_version ? `v${server.agent_version}` : "unknown"}
</span>
</div>
<div>
<span className="text-text-secondary">Latest: </span>
<span className="font-mono font-medium text-text-primary">
{latestVersion ? `v${latestVersion.version}` : "—"}
</span>
</div>
{latestVersion && server.agent_version && server.agent_version !== latestVersion.version && (
<Badge variant="warning">update available</Badge>
)}
{latestVersion && server.agent_version && server.agent_version === latestVersion.version && (
<Badge variant="success">up to date</Badge>
)}
</div>
<div className="flex flex-wrap gap-3">
<Button
variant="primary"
loading={isUpdating}
onClick={() => triggerUpdate()}
disabled={server.status !== "active"}
title={server.status !== "active" ? "Agent must be online to update" : undefined}
>
{updateSuccess ? "Update Sent!" : "Update Agent"}
</Button>
<div className="relative flex-1 min-w-64 rounded-lg border border-border bg-[#0a0c14] px-4 py-2.5 font-mono text-sm">
<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>
<button
onClick={async () => {
await navigator.clipboard.writeText(api.getUpdateCommand());
setCopiedUpdate(true);
setTimeout(() => setCopiedUpdate(false), 2000);
}}
className="absolute right-2 top-1.5 rounded-md border border-border bg-surface-2 px-2 py-0.5 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>
</div>
</Card>
</div>
@@ -326,6 +367,12 @@ export default function ServerDetailPage() {
<dt className="text-text-secondary">OS</dt>
<dd className="mt-0.5 text-text-primary">{server.os_info}</dd>
</div>
<div className="border-t border-border pt-3">
<dt className="text-text-secondary">Agent Version</dt>
<dd className="mt-0.5 font-mono text-text-primary">
{server.agent_version ? `v${server.agent_version}` : "unknown"}
</dd>
</div>
<div className="border-t border-border pt-3">
<dt className="text-text-secondary">Last Seen</dt>
<dd className="mt-0.5 text-text-primary">{server.last_seen ? formatDate(server.last_seen) : "Never"}</dd>
+18 -2
View File
@@ -8,6 +8,7 @@ export interface Server {
ip_address: string;
os_info: string;
status: ServerStatus;
agent_version?: string;
last_seen: string;
created_at: string;
}
@@ -20,6 +21,7 @@ export interface Key {
fingerprint: string;
source: KeySource;
generated_by_server_id?: string;
has_private_key: boolean;
created_at: string;
assigned_count?: number;
}
@@ -115,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<Key[]> {
return request<Key[]>("/keys");
@@ -124,13 +136,17 @@ export const api = {
return request<KeyWithAssignments>(`/keys/${keyId}`);
},
uploadKey(label: string, public_key: string): Promise<Key> {
uploadKey(label: string, public_key: string, private_key?: string): Promise<Key> {
return request<Key>("/keys", {
method: "POST",
body: JSON.stringify({ label, public_key }),
body: JSON.stringify({ label, public_key, private_key: private_key || undefined }),
});
},
getPrivateKey(keyId: string): Promise<{ private_key: string }> {
return request<{ private_key: string }>(`/keys/${keyId}/private-key`);
},
deleteKey(keyId: string): Promise<void> {
return request<void>(`/keys/${keyId}`, { method: "DELETE" });
},