store+server: P2-18a announce-and-approve schema + endpoint

migration 0011 adds pending_hosts table (id, hostname, public_key,
fingerprint, expiry). store/pending_hosts.go covers full CRUD plus
hostname-collision count + expired-row sweeper.

POST /api/agents/announce takes {hostname, os, arch, agent_version,
restic_version, public_key (base64)}, returns {pending_id,
fingerprint, hostname_collision}. Per-source-IP token-bucket
rate limit (10/min) + global cap of 100 in-flight rows. Public
key must be exactly 32 bytes (Ed25519).
This commit is contained in:
2026-05-04 11:03:41 +01:00
parent a5a2cb91d0
commit cd80be3b13
5 changed files with 654 additions and 1 deletions
@@ -0,0 +1,39 @@
-- 0011_pending_hosts.sql
--
-- P2-18: announce-and-approve enrolment.
--
-- Agents that don't have an enrolment token announce themselves
-- with `POST /api/agents/announce`, persisting one row here. The
-- admin sees them in the dashboard's Pending hosts panel and can
-- accept (mints a real Host row + bearer) or reject (deletes the
-- row + closes the agent's pending WS).
--
-- public_key is the agent's Ed25519 public key (32 raw bytes).
-- fingerprint = "SHA256:" + hex(sha256(public_key)) — printed by
-- the install script on the endpoint terminal so the operator can
-- compare the two before clicking accept. This comparison is the
-- load-bearing security gate for this flow.
--
-- expires_at is set to first_seen_at + 1h on insert; a sweeper
-- goroutine (P2-18b) deletes rows past their expiry. Hostname
-- collisions with existing or other pending rows are *not*
-- prevented at the DB level — multiple announces with the same
-- hostname are flagged in the UI so admin can pick the right one.
CREATE TABLE pending_hosts (
id TEXT PRIMARY KEY,
hostname TEXT NOT NULL,
os TEXT NOT NULL,
arch TEXT NOT NULL,
agent_version TEXT NOT NULL,
restic_version TEXT NOT NULL,
public_key BLOB NOT NULL, -- 32-byte Ed25519
fingerprint TEXT NOT NULL, -- "SHA256:hex(...)"
announced_from_ip TEXT NOT NULL,
first_seen_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
expires_at TEXT NOT NULL
);
CREATE INDEX pending_hosts_expires ON pending_hosts(expires_at);
CREATE INDEX pending_hosts_fingerprint ON pending_hosts(fingerprint);
CREATE INDEX pending_hosts_hostname ON pending_hosts(hostname);