first commit
Agent Release / build (push) Has been cancelled
Server Deploy / deploy (push) Has been cancelled

This commit is contained in:
domrichardson
2026-06-15 13:58:45 +01:00
commit c9868b2108
55 changed files with 11076 additions and 0 deletions
+351
View File
@@ -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: "<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`
```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=<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.
```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