commit c9868b21083087c6da42bb3f097a691c68900e09 Author: domrichardson <100129001+domrichardson@users.noreply.github.com> Date: Mon Jun 15 13:58:45 2026 +0100 first commit diff --git a/.gitea/workflows/agent-release.yml b/.gitea/workflows/agent-release.yml new file mode 100644 index 0000000..2a18602 --- /dev/null +++ b/.gitea/workflows/agent-release.yml @@ -0,0 +1,50 @@ +name: Agent Release + +on: + push: + tags: + - "agent/v*" + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.23" + cache: true + cache-dependency-path: agent/go.sum + + - name: Extract version + id: version + run: echo "VERSION=${GITHUB_REF_NAME#agent/}" >> $GITHUB_OUTPUT + + - name: Build + working-directory: agent + env: + VERSION: ${{ steps.version.outputs.VERSION }} + run: | + mkdir -p dist + GOOS=linux GOARCH=amd64 go build \ + -ldflags="-s -w -X main.Version=${VERSION}" \ + -o dist/keymanager-agent-linux-amd64 ./cmd + GOOS=linux GOARCH=arm64 go build \ + -ldflags="-s -w -X main.Version=${VERSION}" \ + -o dist/keymanager-agent-linux-arm64 ./cmd + + - name: Checksums + working-directory: agent/dist + run: sha256sum keymanager-agent-linux-amd64 keymanager-agent-linux-arm64 > checksums.txt + + - name: Create release + uses: https://gitea.com/actions/gitea-release-action@v1 + with: + token: ${{ secrets.RELEASE_TOKEN }} + files: | + agent/dist/keymanager-agent-linux-amd64 + agent/dist/keymanager-agent-linux-arm64 + agent/dist/checksums.txt diff --git a/.gitea/workflows/server-deploy.yml b/.gitea/workflows/server-deploy.yml new file mode 100644 index 0000000..9fbfec6 --- /dev/null +++ b/.gitea/workflows/server-deploy.yml @@ -0,0 +1,50 @@ +name: Server Deploy + +on: + push: + branches: + - main + paths: + - "server/**" + - "web/**" + - "proto/**" + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Log in to registry + run: | + echo "${{ secrets.REGISTRY_PASSWORD }}" | \ + docker login ${{ vars.GITEA_HOST }} \ + -u "${{ secrets.REGISTRY_USER }}" --password-stdin + + - name: Build and push server image + run: | + IMAGE="${{ vars.GITEA_HOST }}/${{ github.repository_owner }}/keymanager/server:latest" + docker build -t "$IMAGE" -f server/Dockerfile server/ + docker push "$IMAGE" + + - name: Build and push web image + run: | + IMAGE="${{ vars.GITEA_HOST }}/${{ github.repository_owner }}/keymanager/web:latest" + docker build \ + --build-arg NEXT_PUBLIC_API_URL="https://${{ vars.GITEA_HOST }}" \ + -t "$IMAGE" \ + -f web/Dockerfile web/ + docker push "$IMAGE" + + - name: Deploy via SSH + uses: https://github.com/appleboy/ssh-action@v1 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + script: | + cd /opt/keymanager + docker compose pull + docker compose up -d --remove-orphans + docker image prune -f diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7056799 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +build +.env \ No newline at end of file diff --git a/agent/cmd/main.go b/agent/cmd/main.go new file mode 100644 index 0000000..0bda460 --- /dev/null +++ b/agent/cmd/main.go @@ -0,0 +1,33 @@ +package main + +import ( + "flag" + "log" + + "github.com/mrhid6/keymanager/agent/internal/config" + agentsync "github.com/mrhid6/keymanager/agent/internal/sync" +) + +var Version = "dev" + +func main() { + genKey := flag.String("generate-key", "", "Generate SSH keypair and upload with this label") + flag.Parse() + + cfg, err := config.Load() + if err != nil { + log.Fatalf("failed to load config: %v", err) + } + + if *genKey != "" { + if err := agentsync.GenerateAndUpload(cfg, *genKey); err != nil { + log.Fatalf("key generation failed: %v", err) + } + return + } + + log.Printf("keymanager-agent %s starting (server=%s, poll=%s)", Version, cfg.ServerURL, cfg.PollInterval) + if err := agentsync.Run(cfg); err != nil { + log.Fatalf("agent error: %v", err) + } +} diff --git a/agent/go.mod b/agent/go.mod new file mode 100644 index 0000000..321268f --- /dev/null +++ b/agent/go.mod @@ -0,0 +1,16 @@ +module github.com/mrhid6/keymanager/agent + +go 1.26 + +require ( + google.golang.org/grpc v1.64.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + golang.org/x/net v0.25.0 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/text v0.15.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e // indirect + google.golang.org/protobuf v1.34.1 // indirect +) diff --git a/agent/go.sum b/agent/go.sum new file mode 100644 index 0000000..4c66072 --- /dev/null +++ b/agent/go.sum @@ -0,0 +1,18 @@ +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e h1:Elxv5MwEkCI9f5SkoL6afed6NTdxaGoAo39eANBwHL8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0= +google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY= +google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/agent/internal/config/config.go b/agent/internal/config/config.go new file mode 100644 index 0000000..47d0ba8 --- /dev/null +++ b/agent/internal/config/config.go @@ -0,0 +1,45 @@ +package config + +import ( + "os" + "time" + + "gopkg.in/yaml.v3" +) + +const ConfigPath = "/etc/keymanager/config.yaml" + +type Config struct { + ServerURL string `yaml:"server_url"` + ServerID string `yaml:"server_id"` + PreRegToken string `yaml:"pre_reg_token"` + AgentToken string `yaml:"agent_token"` + PollInterval time.Duration `yaml:"poll_interval"` + TLS bool `yaml:"tls"` +} + +func Load() (*Config, error) { + data, err := os.ReadFile(ConfigPath) + if err != nil { + return nil, err + } + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, err + } + if cfg.PollInterval == 0 { + cfg.PollInterval = 30 * time.Second + } + return &cfg, nil +} + +func Save(cfg *Config) error { + data, err := yaml.Marshal(cfg) + if err != nil { + return err + } + if err := os.MkdirAll("/etc/keymanager", 0700); err != nil { + return err + } + return os.WriteFile(ConfigPath, data, 0600) +} diff --git a/agent/internal/grpc/client.go b/agent/internal/grpc/client.go new file mode 100644 index 0000000..e6ee16c --- /dev/null +++ b/agent/internal/grpc/client.go @@ -0,0 +1,100 @@ +package grpcclient + +import ( + "context" + "crypto/tls" + "time" + + "github.com/mrhid6/keymanager/agent/internal/grpc/pb" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/encoding" +) + +func init() { + encoding.RegisterCodec(JSONCodec{}) +} + +type Client struct { + conn *grpc.ClientConn + client pb.KeyManagerClient +} + +func New(serverURL string, useTLS bool) (*Client, error) { + var dialOpts []grpc.DialOption + + if useTLS { + tlsCfg := &tls.Config{ + InsecureSkipVerify: false, + } + creds := credentials.NewTLS(tlsCfg) + dialOpts = append(dialOpts, grpc.WithTransportCredentials(creds)) + } else { + dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials())) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + conn, err := grpc.DialContext(ctx, serverURL, dialOpts...) + if err != nil { + return nil, err + } + + return &Client{ + conn: conn, + client: pb.NewKeyManagerClient(conn), + }, nil +} + +func (c *Client) Close() error { + return c.conn.Close() +} + +func (c *Client) Register(serverID, preRegToken, hostname, ipAddress, osInfo string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + resp, err := c.client.Register(ctx, &pb.RegisterRequest{ + ServerId: serverID, + PreRegToken: preRegToken, + Hostname: hostname, + IpAddress: ipAddress, + OsInfo: osInfo, + }) + if err != nil { + return "", err + } + return resp.AgentToken, nil +} + +func (c *Client) SyncKeys(serverID, agentToken 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, + }) + if err != nil { + return nil, err + } + return resp.PublicKeys, nil +} + +func (c *Client) UploadGeneratedKey(serverID, agentToken, publicKey, label string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + resp, err := c.client.UploadGeneratedKey(ctx, &pb.UploadKeyRequest{ + ServerId: serverID, + AgentToken: agentToken, + PublicKey: publicKey, + Label: label, + }) + if err != nil { + return "", err + } + return resp.KeyId, nil +} diff --git a/agent/internal/grpc/codec.go b/agent/internal/grpc/codec.go new file mode 100644 index 0000000..478474b --- /dev/null +++ b/agent/internal/grpc/codec.go @@ -0,0 +1,17 @@ +package grpcclient + +import "encoding/json" + +type JSONCodec struct{} + +func (JSONCodec) Marshal(v interface{}) ([]byte, error) { + return json.Marshal(v) +} + +func (JSONCodec) Unmarshal(data []byte, v interface{}) error { + return json.Unmarshal(data, v) +} + +func (JSONCodec) Name() string { + return "proto" +} diff --git a/agent/internal/grpc/pb/keymanager.pb.go b/agent/internal/grpc/pb/keymanager.pb.go new file mode 100644 index 0000000..3a209af --- /dev/null +++ b/agent/internal/grpc/pb/keymanager.pb.go @@ -0,0 +1,93 @@ +// Hand-written gRPC bindings for keymanager.proto (agent side, JSON codec). + +package pb + +import ( + "context" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type RegisterRequest struct { + ServerId string `json:"server_id"` + PreRegToken string `json:"pre_reg_token"` + Hostname string `json:"hostname"` + IpAddress string `json:"ip_address"` + OsInfo string `json:"os_info"` +} + +type RegisterResponse struct { + AgentToken string `json:"agent_token"` +} + +type SyncRequest struct { + ServerId string `json:"server_id"` + AgentToken string `json:"agent_token"` +} + +type SyncResponse struct { + PublicKeys []string `json:"public_keys"` +} + +type UploadKeyRequest struct { + ServerId string `json:"server_id"` + AgentToken string `json:"agent_token"` + PublicKey string `json:"public_key"` + Label string `json:"label"` +} + +type UploadKeyResponse struct { + KeyId string `json:"key_id"` +} + +type KeyManagerClient interface { + Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error) + SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error) + UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error) +} + +type UnimplementedKeyManagerServer struct{} + +func (UnimplementedKeyManagerServer) Register(context.Context, *RegisterRequest) (*RegisterResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "not implemented") +} +func (UnimplementedKeyManagerServer) SyncKeys(context.Context, *SyncRequest) (*SyncResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "not implemented") +} +func (UnimplementedKeyManagerServer) UploadGeneratedKey(context.Context, *UploadKeyRequest) (*UploadKeyResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "not implemented") +} + +type keyManagerClient struct { + cc grpc.ClientConnInterface +} + +func NewKeyManagerClient(cc grpc.ClientConnInterface) KeyManagerClient { + return &keyManagerClient{cc} +} + +func (c *keyManagerClient) Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error) { + out := new(RegisterResponse) + if err := c.cc.Invoke(ctx, "/keymanager.v1.KeyManager/Register", in, out, opts...); err != nil { + return nil, err + } + return out, nil +} + +func (c *keyManagerClient) SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error) { + out := new(SyncResponse) + if err := c.cc.Invoke(ctx, "/keymanager.v1.KeyManager/SyncKeys", in, out, opts...); err != nil { + return nil, err + } + return out, nil +} + +func (c *keyManagerClient) UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error) { + out := new(UploadKeyResponse) + if err := c.cc.Invoke(ctx, "/keymanager.v1.KeyManager/UploadGeneratedKey", in, out, opts...); err != nil { + return nil, err + } + return out, nil +} diff --git a/agent/internal/keys/keys.go b/agent/internal/keys/keys.go new file mode 100644 index 0000000..e9a8cc4 --- /dev/null +++ b/agent/internal/keys/keys.go @@ -0,0 +1,120 @@ +package keys + +import ( + "crypto/md5" + "encoding/base64" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +const authorizedKeysPath = "/root/.ssh/authorized_keys" + +func ReadAuthorizedKeys() ([]string, error) { + data, err := os.ReadFile(authorizedKeysPath) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + + var lines []string + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line != "" && !strings.HasPrefix(line, "#") { + lines = append(lines, line) + } + } + return lines, nil +} + +func WriteAuthorizedKeys(keys []string) error { + dir := filepath.Dir(authorizedKeysPath) + if err := os.MkdirAll(dir, 0700); err != nil { + return fmt.Errorf("mkdir %s: %w", dir, err) + } + + content := strings.Join(keys, "\n") + if len(keys) > 0 { + content += "\n" + } + + tmpPath := authorizedKeysPath + ".tmp" + if err := os.WriteFile(tmpPath, []byte(content), 0600); err != nil { + return fmt.Errorf("write tmp: %w", err) + } + + if err := os.Rename(tmpPath, authorizedKeysPath); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("rename: %w", err) + } + + return os.Chmod(authorizedKeysPath, 0600) +} + +func FingerprintLines(lines []string) map[string]bool { + fp := make(map[string]bool, len(lines)) + for _, line := range lines { + fp[fingerprint(line)] = true + } + return fp +} + +func StateChanged(current, desired []string) bool { + if len(current) != len(desired) { + return true + } + cur := FingerprintLines(current) + for _, line := range desired { + if !cur[fingerprint(line)] { + return true + } + } + return false +} + +func fingerprint(pubKey string) string { + parts := strings.Fields(pubKey) + if len(parts) < 2 { + return pubKey + } + raw, err := base64.StdEncoding.DecodeString(parts[1]) + if err != nil { + return pubKey + } + sum := md5.Sum(raw) + var pairs []string + for _, b := range sum { + pairs = append(pairs, fmt.Sprintf("%02x", b)) + } + return "MD5:" + strings.Join(pairs, ":") +} + +// GenerateKeyPair generates an ed25519 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) { + if err := os.MkdirAll(filepath.Dir(keyPath), 0700); err != nil { + return "", err + } + + args := []string{ + "-t", "ed25519", + "-f", keyPath, + "-N", "", + "-C", comment, + } + cmd := exec.Command("ssh-keygen", args...) + out, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("ssh-keygen: %w: %s", err, out) + } + + pubData, err := os.ReadFile(keyPath + ".pub") + if err != nil { + return "", fmt.Errorf("read pubkey: %w", err) + } + return strings.TrimSpace(string(pubData)), nil +} diff --git a/agent/internal/sync/sync.go b/agent/internal/sync/sync.go new file mode 100644 index 0000000..9402318 --- /dev/null +++ b/agent/internal/sync/sync.go @@ -0,0 +1,129 @@ +package agentsync + +import ( + "fmt" + "log" + "net" + "os" + "runtime" + "strings" + "time" + + "github.com/mrhid6/keymanager/agent/internal/config" + grpcclient "github.com/mrhid6/keymanager/agent/internal/grpc" + "github.com/mrhid6/keymanager/agent/internal/keys" +) + +func Run(cfg *config.Config) error { + client, err := grpcclient.New(cfg.ServerURL, cfg.TLS) + if err != nil { + return fmt.Errorf("dial grpc: %w", err) + } + defer client.Close() + + // Register if we have a pre-reg token + if cfg.PreRegToken != "" { + log.Println("registering with server...") + hostname, _ := os.Hostname() + ipAddress := localIP() + osInfo := fmt.Sprintf("%s %s", runtime.GOOS, runtime.GOARCH) + + agentToken, err := client.Register(cfg.ServerID, cfg.PreRegToken, hostname, ipAddress, osInfo) + if err != nil { + return fmt.Errorf("registration failed: %w", err) + } + + cfg.AgentToken = agentToken + cfg.PreRegToken = "" + if err := config.Save(cfg); err != nil { + return fmt.Errorf("save config: %w", err) + } + log.Println("registration successful") + + // Reconnect with potentially updated state + client.Close() + client, err = grpcclient.New(cfg.ServerURL, cfg.TLS) + if err != nil { + return fmt.Errorf("reconnect: %w", err) + } + } + + if cfg.AgentToken == "" { + return fmt.Errorf("no agent token available — registration required") + } + + ticker := time.NewTicker(cfg.PollInterval) + defer ticker.Stop() + + // Run immediately on startup + if err := poll(client, cfg); err != nil { + log.Printf("poll error: %v", err) + } + + for range ticker.C { + if err := poll(client, cfg); err != nil { + log.Printf("poll error: %v", err) + } + } + return nil +} + +func poll(client *grpcclient.Client, cfg *config.Config) error { + desired, err := client.SyncKeys(cfg.ServerID, cfg.AgentToken) + if err != nil { + return fmt.Errorf("SyncKeys: %w", err) + } + + current, err := keys.ReadAuthorizedKeys() + if err != nil { + return fmt.Errorf("read authorized_keys: %w", err) + } + + if !keys.StateChanged(current, desired) { + log.Println("authorized_keys unchanged, skipping write") + return nil + } + + if err := keys.WriteAuthorizedKeys(desired); err != nil { + return fmt.Errorf("write authorized_keys: %w", err) + } + log.Printf("authorized_keys updated (%d keys)", len(desired)) + return nil +} + +func localIP() string { + addrs, err := net.InterfaceAddrs() + if err != nil { + return "" + } + for _, addr := range addrs { + if ipNet, ok := addr.(*net.IPNet); ok && !ipNet.IP.IsLoopback() { + if ipNet.IP.To4() != nil { + return ipNet.IP.String() + } + } + } + return "" +} + +// GenerateAndUpload generates an SSH keypair and uploads the public key to the server. +func GenerateAndUpload(cfg *config.Config, label string) error { + client, err := grpcclient.New(cfg.ServerURL, cfg.TLS) + if err != nil { + return err + } + defer client.Close() + + keyPath := fmt.Sprintf("/root/.ssh/keymanager_%s", strings.ReplaceAll(label, " ", "_")) + pubKey, err := keys.GenerateKeyPair(keyPath, label) + if err != nil { + return err + } + + keyID, err := client.UploadGeneratedKey(cfg.ServerID, cfg.AgentToken, pubKey, label) + if err != nil { + return err + } + log.Printf("uploaded generated key %s (key_id=%s)", label, keyID) + return nil +} diff --git a/claude.md b/claude.md new file mode 100644 index 0000000..ce4eb70 --- /dev/null +++ b/claude.md @@ -0,0 +1,351 @@ +# KeyManager + +A self-hosted SSH key management system. A central server (Go + Next.js + MongoDB) manages public key assignments across servers. A lightweight Go agent runs on each managed server, polls the central server via gRPC, and atomically rewrites `/root/.ssh/authorized_keys` to match the desired state. + +--- + +## Architecture Overview + +``` +┌─────────────────────────────────┐ +│ Next.js Frontend │ +│ - Upload/manage keys │ +│ - Add servers (install script) │ +│ - Assign/revoke per server │ +└────────────┬────────────────────┘ + │ REST +┌────────────▼────────────────────┐ +│ Go Backend │ +│ - REST API for frontend │ +│ - gRPC server for agents │ +│ - MongoDB │ +└────────────┬────────────────────┘ + │ gRPC (TLS) +┌────────────▼────────────────────┐ +│ Go Agent (per server) │ +│ - Polls every 30s │ +│ - Rewrites authorized_keys │ +│ - Can generate SSH keypairs │ +└─────────────────────────────────┘ +``` + +--- + +## Repository Structure + +``` +keymanager/ +├── agent/ +│ ├── cmd/main.go +│ └── internal/ +│ ├── config/ +│ ├── grpc/ +│ ├── keys/ +│ └── sync/ +├── server/ +│ ├── cmd/main.go +│ └── internal/ +│ ├── api/ # REST handlers for Next.js +│ ├── grpc/ # gRPC server implementation +│ ├── models/ # MongoDB models +│ └── services/ +│ ├── keys.go +│ ├── servers.go +│ └── sync.go # builds desired state per server +├── web/ +│ ├── app/ +│ └── components/ +├── proto/ +│ └── keymanager/v1/keymanager.proto +├── deploy/ +│ ├── docker-compose.yml +│ └── agent.service +└── .gitea/ + └── workflows/ + ├── agent-release.yml + └── server-deploy.yml +``` + +--- + +## gRPC API + +```protobuf +syntax = "proto3"; +package keymanager.v1; + +service KeyManager { + rpc Register(RegisterRequest) returns (RegisterResponse); + rpc SyncKeys(SyncRequest) returns (SyncResponse); + rpc UploadGeneratedKey(UploadKeyRequest) returns (UploadKeyResponse); +} + +message RegisterRequest { + string server_id = 1; + string pre_reg_token = 2; + string hostname = 3; + string ip_address = 4; + string os_info = 5; +} +message RegisterResponse { + string agent_token = 1; +} + +message SyncRequest { + string server_id = 1; + string agent_token = 2; +} +message SyncResponse { + repeated string public_keys = 1; // full authorized_keys lines +} + +message UploadKeyRequest { + string server_id = 1; + string agent_token = 2; + string public_key = 3; + string label = 4; +} +message UploadKeyResponse { + string key_id = 1; +} +``` + +No streaming — polling only. Poll interval: **30 seconds**. + +--- + +## MongoDB Collections + +### `servers` + +```json +{ + "_id": "ObjectId", + "server_id": "uuid", + "hostname": "proxmox-node-1", + "ip_address": "10.10.10.5", + "os_info": "Ubuntu 24.04", + "pre_reg_token": "abc123", + "pre_reg_expires": "ISODate", + "agent_token_hash": "sha256...", + "status": "pending|active|offline", + "last_seen": "ISODate", + "created_at": "ISODate" +} +``` + +- `pre_reg_token` is cleared after the agent successfully calls `Register()` +- `agent_token_hash` stores SHA-256 of the token — never plaintext +- `status` transitions: `pending` → `active` on first `Register()`, `offline` if last_seen exceeds threshold + +### `keys` + +```json +{ + "_id": "ObjectId", + "key_id": "uuid", + "label": "dom-macbook", + "public_key": "ssh-ed25519 AAAA...", + "fingerprint": "SHA256:...", + "source": "uploaded|generated", + "generated_by_server_id": "uuid", + "created_at": "ISODate" +} +``` + +### `assignments` + +```json +{ + "_id": "ObjectId", + "key_id": "uuid", + "server_id": "uuid", + "assigned_at": "ISODate", + "revoked_at": "ISODate | null" +} +``` + +- `revoked_at: null` = key is active on that server +- Revocation is soft — set `revoked_at`, agent picks it up on next poll + +--- + +## Agent Lifecycle + +### Config file — `/etc/keymanager/config.yaml` + +```yaml +server_url: "keymanager.yourdomain.com:9090" +server_id: "" +pre_reg_token: "" # removed after first successful Register() +agent_token: "" # written by agent after Register() +poll_interval: 30s +tls: true +``` + +Config file permissions: `0600`. Config directory: `0700`. + +### Startup flow + +``` +1. Load config +2. If pre_reg_token present: + → call Register(server_id, pre_reg_token, hostname, ip, os_info) + → save returned agent_token to config + → delete pre_reg_token from config +3. Enter poll loop +``` + +### Poll loop (every 30s) + +``` +1. Call SyncKeys(server_id, agent_token) +2. Receive []public_keys +3. Compute fingerprints of current /root/.ssh/authorized_keys +4. If state unchanged → skip write +5. If changed: + → write to /root/.ssh/authorized_keys.tmp + → os.Rename() to /root/.ssh/authorized_keys (atomic) + → chmod 0600 +``` + +### Key generation (on demand) + +- Triggered by a flag or API call from the server +- Runs `ssh-keygen` via `exec.Command` +- Uploads public key via `UploadGeneratedKey()` +- Private key stays local on the machine + +### Systemd unit — `/etc/systemd/system/keymanager-agent.service` + +```ini +[Unit] +Description=KeyManager Agent +After=network.target + +[Service] +ExecStart=/usr/local/bin/keymanager-agent +Restart=always +RestartSec=10 +User=root + +[Install] +WantedBy=multi-user.target +``` + +--- + +## Server Registration Flow + +1. Click **Add Server** in the UI +2. Backend generates a short-lived pre-registration token (TTL: 1 hour) and a `server_id` +3. UI displays a one-liner install command with copy button: + ```bash + curl -fsSL https://keymanager.yourdomain.com/install | \ + bash -s -- --server-id= --token= + ``` +4. Install script: + - Detects arch (`amd64` / `arm64`) + - Downloads agent binary from Gitea release + - Verifies SHA-256 checksum + - Writes `/etc/keymanager/config.yaml` + - Installs and starts systemd unit +5. On first `SyncKeys` call, server marks status as `active` + +The backend serves `/install` dynamically, injecting the latest agent version by querying the Gitea API for the most recent `agent/v*` release tag. + +--- + +## Security + +- gRPC over TLS (Let's Encrypt or self-signed with cert pinning on the agent) +- Agent authenticates with a per-server token stored at `/etc/keymanager/config.yaml` (`0600`) +- Server stores `SHA-256(agent_token)` — never the plaintext token +- Private keys generated by agents are encrypted at rest in MongoDB (AES-256) +- `authorized_keys` written as `0600`, owned by root +- Pre-registration tokens are short-lived (1 hour) and single-use +- Agent runs as `root` (required for `/root/.ssh/authorized_keys` writes) + +--- + +## Frontend Routes + +| Route | Purpose | +| --------------- | -------------------------------------------------------------------- | +| `/servers` | List all servers, online/offline status badge, last seen timestamp | +| `/servers/new` | Displays the one-liner install script with copy button | +| `/servers/[id]` | Keys installed on this server, trigger key generation, remove server | +| `/keys` | All keys — label, fingerprint, source, assigned count | +| `/keys/[id]` | Assign key to servers, revoke per server | + +--- + +## CI/CD — Gitea Actions + +### Agent release — `.gitea/workflows/agent-release.yml` + +Triggered by a `agent/v*` tag. Cross-compiles for `linux/amd64` and `linux/arm64`, creates a Gitea release with binaries and checksums. + +```yaml +on: + push: + tags: + - "agent/v*" +``` + +Build command: + +```bash +GOOS=linux GOARCH=amd64 go build \ + -ldflags="-s -w -X main.Version=${VERSION}" \ + -o dist/keymanager-agent-linux-amd64 ./cmd +``` + +Release assets: + +- `keymanager-agent-linux-amd64` +- `keymanager-agent-linux-arm64` +- `checksums.txt` + +### Server deploy — `.gitea/workflows/server-deploy.yml` + +Triggered on pushes to `main` touching `server/**`, `web/**`, or `proto/**`. Builds and pushes Docker images to the Gitea container registry, then deploys via SSH: + +```bash +cd /opt/keymanager && docker compose pull && docker compose up -d --remove-orphans +``` + +### Tagging convention + +```bash +# Release a new agent version +git tag agent/v1.0.0 && git push origin agent/v1.0.0 + +# Server + web deploy automatically on push to main +git push origin main +``` + +### Required Gitea secrets / variables + +| Name | Type | Value | +| ------------------- | -------- | ------------------------------------------ | +| `RELEASE_TOKEN` | Secret | Gitea API token with `write:release` scope | +| `REGISTRY_USER` | Secret | Gitea username | +| `REGISTRY_PASSWORD` | Secret | Gitea token with `write:packages` scope | +| `DEPLOY_HOST` | Secret | IP/hostname of the server VM | +| `DEPLOY_USER` | Secret | SSH user for deploy | +| `DEPLOY_SSH_KEY` | Secret | Private key for deploy SSH | +| `GITEA_HOST` | Variable | `gitea.hostxtra.co.uk` | + +--- + +## Design Decisions + +- **gRPC over REST for agent communication** — strong typing, easy versioning, bi-directional streaming available later if push-based updates are needed +- **Poll-only, no streaming** — 30s interval is sufficient for a homelab; simplifies agent implementation +- **Outbound-only agent connections** — no inbound firewall holes required on managed servers +- **Atomic `authorized_keys` rewrite** — write to `.tmp` then `os.Rename()` prevents partial writes +- **Fingerprint diffing before write** — avoids unnecessary disk writes on unchanged state +- **Soft revocation** — `revoked_at` timestamp rather than hard deletes; preserves audit history +- **root only** — manages `/root/.ssh/authorized_keys` only; no per-user key management +- **Gitea releases for agent binaries** — slots into existing act_runner CI pipeline; install script queries Gitea API for latest version at serve time diff --git a/deploy/agent.service b/deploy/agent.service new file mode 100644 index 0000000..08eaed8 --- /dev/null +++ b/deploy/agent.service @@ -0,0 +1,23 @@ +[Unit] +Description=KeyManager Agent +Documentation=https://github.com/your-org/keymanager +After=network.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/keymanager-agent +Restart=always +RestartSec=10 +User=root +StandardOutput=journal +StandardError=journal +SyslogIdentifier=keymanager-agent + +# Security hardening +NoNewPrivileges=true +ProtectSystem=false +ProtectHome=false + +[Install] +WantedBy=multi-user.target diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..64a7a56 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,45 @@ +services: + mongo: + image: mongo:8 + restart: unless-stopped + volumes: + - mongo_data:/data/db + healthcheck: + test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + + server: + build: + context: ../server + dockerfile: Dockerfile + restart: unless-stopped + ports: + - "8080:8080" + - "9090:9090" + environment: + MONGO_URI: mongodb://mongo:27017/keymanager + GITEA_HOST: ${GITEA_HOST} + PUBLIC_HOST: ${PUBLIC_HOST} + GRPC_PORT: "9090" + HTTP_PORT: "8080" + depends_on: + mongo: + condition: service_healthy + + web: + build: + context: ../web + dockerfile: Dockerfile + args: + NEXT_PUBLIC_API_URL: http://server:8080 + restart: unless-stopped + ports: + - "3000:3000" + depends_on: + - server + +volumes: + mongo_data: diff --git a/proto/keymanager/v1/keymanager.proto b/proto/keymanager/v1/keymanager.proto new file mode 100644 index 0000000..3ac5ad3 --- /dev/null +++ b/proto/keymanager/v1/keymanager.proto @@ -0,0 +1,43 @@ +syntax = "proto3"; + +package keymanager.v1; + +option go_package = "github.com/mrhid6/keymanager/server/internal/grpc/pb"; + +service KeyManager { + rpc Register(RegisterRequest) returns (RegisterResponse); + rpc SyncKeys(SyncRequest) returns (SyncResponse); + rpc UploadGeneratedKey(UploadKeyRequest) returns (UploadKeyResponse); +} + +message RegisterRequest { + string server_id = 1; + string pre_reg_token = 2; + string hostname = 3; + string ip_address = 4; + string os_info = 5; +} + +message RegisterResponse { + string agent_token = 1; +} + +message SyncRequest { + string server_id = 1; + string agent_token = 2; +} + +message SyncResponse { + repeated string public_keys = 1; +} + +message UploadKeyRequest { + string server_id = 1; + string agent_token = 2; + string public_key = 3; + string label = 4; +} + +message UploadKeyResponse { + string key_id = 1; +} diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..3b207dc --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,24 @@ +# Build stage +FROM golang:1.26 AS builder + +WORKDIR /app + +# Download dependencies first (layer cache) +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source and build +COPY . . + +ARG VERSION=dev +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.Version=${VERSION}" -o /keymanager-server ./cmd + +# Runtime stage +FROM scratch + +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ +COPY --from=builder /keymanager-server /keymanager-server + +EXPOSE 8080 9090 + +ENTRYPOINT ["/keymanager-server"] diff --git a/server/cmd/main.go b/server/cmd/main.go new file mode 100644 index 0000000..a2e12d9 --- /dev/null +++ b/server/cmd/main.go @@ -0,0 +1,71 @@ +package main + +import ( + "log" + "os" + "time" + + "github.com/gin-gonic/gin" + "github.com/mrhid6/keymanager/server/internal/api" + "github.com/mrhid6/keymanager/server/internal/db" + grpcserver "github.com/mrhid6/keymanager/server/internal/grpc" + "github.com/mrhid6/keymanager/server/internal/services" +) + +func main() { + mongoURI := getEnv("MONGO_URI", "mongodb://localhost:27017") + dbName := getEnv("MONGO_DB", "keymanager") + + if err := db.Connect(mongoURI, dbName); err != nil { + log.Fatalf("failed to connect to MongoDB: %v", err) + } + log.Println("connected to MongoDB") + + // Background goroutine to mark offline servers + go func() { + ticker := time.NewTicker(2 * time.Minute) + defer ticker.Stop() + for range ticker.C { + if err := services.MarkOfflineServers(5 * time.Minute); err != nil { + log.Printf("mark offline error: %v", err) + } + } + }() + + // Start gRPC server + go func() { + if err := grpcserver.StartGRPC(9090); err != nil { + log.Fatalf("gRPC server error: %v", err) + } + }() + + // Start REST server + r := gin.Default() + r.Use(corsMiddleware()) + api.RegisterRoutes(r) + + log.Println("REST server listening on :8080") + if err := r.Run(":8080"); err != nil { + log.Fatalf("REST server error: %v", err) + } +} + +func corsMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("Access-Control-Allow-Origin", "*") + c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization") + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(204) + return + } + c.Next() + } +} + +func getEnv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..d126f4a --- /dev/null +++ b/server/go.mod @@ -0,0 +1,47 @@ +module github.com/mrhid6/keymanager/server + +go 1.26 + +require ( + github.com/gin-gonic/gin v1.10.0 + github.com/google/uuid v1.6.0 + go.mongodb.org/mongo-driver/v2 v2.2.2 + google.golang.org/grpc v1.64.0 +) + +require ( + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/golang/snappy v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.16.7 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.1.2 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/crypto v0.33.0 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sync v0.11.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e // indirect + google.golang.org/protobuf v1.34.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/server/go.sum b/server/go.sum new file mode 100644 index 0000000..685f143 --- /dev/null +++ b/server/go.sum @@ -0,0 +1,133 @@ +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= +github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= +github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mongodb.org/mongo-driver/v2 v2.2.2 h1:9cYuS3fl1Xhqwpfazso10V7BHQD58kCgtzhfAmJYz9c= +go.mongodb.org/mongo-driver/v2 v2.2.2/go.mod h1:qQkDMhCGWl3FN509DfdPd4GRBLU/41zqF/k8eTRceps= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e h1:Elxv5MwEkCI9f5SkoL6afed6NTdxaGoAo39eANBwHL8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0= +google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY= +google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go new file mode 100644 index 0000000..7ae0dad --- /dev/null +++ b/server/internal/api/handlers.go @@ -0,0 +1,293 @@ +package api + +import ( + "fmt" + "net/http" + "os" + + "github.com/gin-gonic/gin" + "github.com/mrhid6/keymanager/server/internal/models" + "github.com/mrhid6/keymanager/server/internal/services" +) + +func RegisterRoutes(r *gin.Engine) { + r.GET("/install", handleInstallScript) + + api := r.Group("/api") + { + api.GET("/servers", listServers) + api.POST("/servers", createServer) + api.GET("/servers/new", newServer) + api.POST("/servers/new", newServer) + api.GET("/servers/:id", getServer) + api.DELETE("/servers/:id", deleteServer) + api.POST("/servers/:id/generate-key", generateKey) + + api.GET("/keys", listKeys) + api.POST("/keys", createKey) + api.GET("/keys/:id", getKey) + api.POST("/keys/:id/assign", assignKey) + api.DELETE("/keys/:id/assign/:serverId", revokeAssignment) + } +} + +func listServers(c *gin.Context) { + servers, err := services.ListServers() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, servers) +} + +func createServer(c *gin.Context) { + s, token, err := services.CreateServer() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, gin.H{ + "server": s, + "token": token, + "server_id": s.ServerID, + }) +} + +func newServer(c *gin.Context) { + s, token, err := services.CreateServer() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + giteaHost := os.Getenv("GITEA_HOST") + if giteaHost == "" { + giteaHost = "gitea.example.com" + } + host := os.Getenv("PUBLIC_HOST") + if host == "" { + host = "keymanager.example.com" + } + + installCmd := fmt.Sprintf( + `curl -fsSL "https://%s/install?server_id=%s&token=%s" | bash`, + host, s.ServerID, token, + ) + + c.JSON(http.StatusOK, gin.H{ + "server_id": s.ServerID, + "pre_reg_token": token, + "install_command": installCmd, + }) +} + +func getServer(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 + } + + assignments, _ := services.GetAssignmentsWithKeysForServer(id) + + // Build response matching ServerWithKeys shape expected by frontend + type serverResponse struct { + *models.Server + Keys interface{} `json:"keys"` + } + c.JSON(http.StatusOK, serverResponse{ + Server: s, + Keys: assignments, + }) +} + +func deleteServer(c *gin.Context) { + id := c.Param("id") + if err := services.DeleteServer(id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"deleted": true}) +} + +func generateKey(c *gin.Context) { + // The agent triggers key generation itself; this endpoint signals + // the intent by returning the server so the caller knows to wait + // for the agent to upload via gRPC UploadGeneratedKey. + id := c.Param("id") + s, err := services.GetServer(id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "server not found"}) + return + } + c.JSON(http.StatusOK, gin.H{ + "message": "agent will generate and upload key on next poll", + "server_id": s.ServerID, + }) +} + +func listKeys(c *gin.Context) { + keys, err := services.ListKeys() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, keys) +} + +func createKey(c *gin.Context) { + var body struct { + Label string `json:"label" binding:"required"` + PublicKey string `json:"public_key" binding:"required"` + } + 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", "") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, key) +} + +func getKey(c *gin.Context) { + id := c.Param("id") + key, err := services.GetKey(id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "key not found"}) + return + } + + assignments, _ := services.GetAssignmentsWithServers(id) + c.JSON(http.StatusOK, gin.H{ + "key": key, + "assignments": assignments, + }) +} + +func assignKey(c *gin.Context) { + keyID := c.Param("id") + var body struct { + ServerID string `json:"server_id" binding:"required"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + a, err := services.AssignKey(keyID, body.ServerID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, a) +} + +func revokeAssignment(c *gin.Context) { + keyID := c.Param("id") + serverID := c.Param("serverId") + + if err := services.RevokeAssignment(keyID, serverID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"revoked": true}) +} + +func handleInstallScript(c *gin.Context) { + serverID := c.Query("server_id") + token := c.Query("token") + + giteaHost := os.Getenv("GITEA_HOST") + if giteaHost == "" { + giteaHost = "gitea.example.com" + } + publicHost := os.Getenv("PUBLIC_HOST") + if publicHost == "" { + publicHost = "keymanager.example.com" + } + + script := fmt.Sprintf(`#!/usr/bin/env bash +set -euo pipefail + +SERVER_ID="%s" +TOKEN="%s" +GITEA_HOST="%s" +KM_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/}" +BINARY_URL="https://${GITEA_HOST}/mrhid6/keymanager/releases/download/${LATEST}/keymanager-agent-linux-${ARCH}" +CHECKSUM_URL="https://${GITEA_HOST}/mrhid6/keymanager/releases/download/${LATEST}/checksums.txt" + +echo "Installing keymanager-agent ${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 + +install -m 0755 /tmp/keymanager-agent /usr/local/bin/keymanager-agent + +mkdir -p /etc/keymanager +chmod 0700 /etc/keymanager + +cat > /etc/keymanager/config.yaml < /etc/systemd/system/keymanager-agent.service < void; +}) { + const queryClient = useQueryClient(); + const [selectedServer, setSelectedServer] = useState(""); + + const { data: servers } = useQuery({ + queryKey: ["servers"], + queryFn: api.listServers, + }); + + const { mutate: assign, isPending, error } = useMutation({ + mutationFn: () => api.assignKey(keyId, selectedServer), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["keys", keyId] }); + queryClient.invalidateQueries({ queryKey: ["servers"] }); + onClose(); + }, + }); + + const availableServers = servers?.filter( + (s: Server) => !assignedServerIds.includes(s.server_id) + ); + + return ( +
+
+

Assign Key to Server

+ + {error && ( +
+ {(error as Error).message} +
+ )} + +
+ + {!availableServers || availableServers.length === 0 ? ( +

+ All servers already have this key assigned. +

+ ) : ( + + )} +
+ +
+ + +
+
+
+ ); +} + +export default function KeyDetailPage() { + const params = useParams(); + const router = useRouter(); + const queryClient = useQueryClient(); + const keyId = params.id as string; + const [showAssign, setShowAssign] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); + const [copiedKey, setCopiedKey] = useState(false); + + const { data: key, isLoading, error } = useQuery({ + queryKey: ["keys", keyId], + queryFn: () => api.getKey(keyId), + }); + + const { mutate: revokeKey } = useMutation({ + mutationFn: (serverId: string) => api.revokeKey(keyId, serverId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["keys", keyId] }); + queryClient.invalidateQueries({ queryKey: ["servers"] }); + }, + }); + + const { mutate: deleteKey, isPending: isDeleting } = useMutation({ + mutationFn: () => api.deleteKey(keyId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["keys"] }); + router.push("/keys"); + }, + }); + + const handleCopyKey = async () => { + if (!key?.public_key) return; + await navigator.clipboard.writeText(key.public_key); + setCopiedKey(true); + setTimeout(() => setCopiedKey(false), 2000); + }; + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (error || !key) { + return ( +
+
+ Key not found or failed to load. +
+
+ ); + } + + const activeAssignments = key.assignments?.filter((a) => !a.revoked_at) ?? []; + const assignedServerIds = activeAssignments.map((a) => a.server_id); + + return ( +
+ {showAssign && ( + setShowAssign(false)} + /> + )} + +
+
+ + ← SSH Keys + +
+

{key.label}

+ + {key.source} + +
+

{key.fingerprint}

+
+
+ + {!confirmDelete ? ( + + ) : ( +
+ Delete permanently? + + +
+ )} +
+
+ +
+
+ + + Details + +
+
+
Key ID
+
{key.key_id}
+
+
+
Source
+
{key.source}
+
+ {key.generated_by_server_id && ( +
+
Generated By
+
+ + {key.generated_by_server_id} + +
+
+ )} +
+
Active Assignments
+
{activeAssignments.length}
+
+
+
Created
+
+ {new Date(key.created_at).toLocaleString()} +
+
+
+
+ + + + Public Key + + +
+
+                {key.public_key}
+              
+
+
+
+ +
+ +
+

+ Server Assignments + + {activeAssignments.length} active + +

+
+ + {!key.assignments || key.assignments.length === 0 ? ( +
+

Not assigned to any servers.

+ +
+ ) : ( + + + + + + + + + + + + {key.assignments.map((assignment) => ( + + + + + + + + + ))} + +
ServerIP AddressStatusAssignedRevoked +
+ + {assignment.server?.hostname ?? assignment.server_id} + + + + {assignment.server?.ip_address ?? "—"} + + + + {assignment.revoked_at ? "revoked" : "active"} + + + + {new Date(assignment.assigned_at).toLocaleDateString()} + + + + {assignment.revoked_at + ? new Date(assignment.revoked_at).toLocaleDateString() + : "—"} + + + {!assignment.revoked_at && ( + + )} +
+ )} +
+
+
+
+ ); +} diff --git a/web/app/keys/page.tsx b/web/app/keys/page.tsx new file mode 100644 index 0000000..1238035 --- /dev/null +++ b/web/app/keys/page.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import Link from "next/link"; +import { api, Key } from "@/lib/api"; +import { Badge, Button, Card, CardHeader, CardTitle } from "@/components/ui"; +import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui"; + +function UploadKeyModal({ onClose }: { onClose: () => void }) { + const queryClient = useQueryClient(); + const [label, setLabel] = useState(""); + const [publicKey, setPublicKey] = useState(""); + + const { mutate: upload, isPending, error } = useMutation({ + mutationFn: () => api.uploadKey(label.trim(), publicKey.trim()), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["keys"] }); + onClose(); + }, + }); + + return ( +
+
+

Upload SSH Key

+ + {error && ( +
+ {(error as Error).message} +
+ )} + +
+
+ + setLabel(e.target.value)} + placeholder="e.g. dom-macbook" + className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder-text-secondary/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent" + /> +
+
+ +