# 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