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_tokenis cleared after the agent successfully callsRegister()agent_token_hashstores SHA-256 of the token — never plaintextstatustransitions:pending→activeon firstRegister(),offlineif 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-keygenviaexec.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
- Click Add Server in the UI
- Backend generates a short-lived pre-registration token (TTL: 1 hour) and a
server_id - UI displays a one-liner install command with copy button:
curl -fsSL https://keymanager.yourdomain.com/install | \ bash -s -- --server-id=<id> --token=<token> - 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
- Detects arch (
- On first
SyncKeyscall, server marks status asactive
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_keyswritten as0600, owned by root- Pre-registration tokens are short-lived (1 hour) and single-use
- Agent runs as
root(required for/root/.ssh/authorized_keyswrites)
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-amd64keymanager-agent-linux-arm64checksums.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_keysrewrite — write to.tmpthenos.Rename()prevents partial writes - Fingerprint diffing before write — avoids unnecessary disk writes on unchanged state
- Soft revocation —
revoked_attimestamp rather than hard deletes; preserves audit history - root only — manages
/root/.ssh/authorized_keysonly; 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