Files
domrichardson c9868b2108
Agent Release / build (push) Has been cancelled
Server Deploy / deploy (push) Has been cancelled
first commit
2026-06-15 13:58:45 +01:00

11 KiB

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

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

{
    "_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: pendingactive on first Register(), offline if last_seen exceeds threshold

keys

{
    "_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

{
    "_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

server_url: "keymanager.yourdomain.com:9090"
server_id: "<uuid>"
pre_reg_token: "<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

[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:
    curl -fsSL https://keymanager.yourdomain.com/install | \
      bash -s -- --server-id=<id> --token=<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.

on:
    push:
        tags:
            - "agent/v*"

Build command:

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:

cd /opt/keymanager && docker compose pull && docker compose up -d --remove-orphans

Tagging convention

# 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 revocationrevoked_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