phase 1 foundations: api types, store, crypto, auth
Lands the bottom three layers of Phase 1: P1-08 internal/api: protocol_version + envelope + every WS message shape from spec.md §6.2 (Hello, Heartbeat, Job*, Schedule*, etc). Wire-format tests pin the JSON shape so a rename here breaks tests instead of silently breaking the agent. P1-02 + P1-03 internal/store: SQLite via modernc.org/sqlite, embed.FS + a tiny version table for hand-rolled migrations. 0001_initial.sql covers every table from spec.md §5 plus enrollment_tokens and host_schedule_version. Typed accessors for users / sessions / enrollment / audit. WAL + foreign_keys + busy_timeout on by default. P1-06 internal/crypto: XChaCha20-Poly1305 AEAD wrapper with per-message random nonce. Key file lifecycle (generate + refuse-to-overwrite, load with size validation). Optional additionalData binds ciphertext to the row that owns it. P1-04 internal/auth (partial — passwords + tokens; sessions middleware lands with the HTTP handlers): argon2id following RFC 9106 (64 MiB / t=3 / p=4 / 32B), constant-time verify. HashToken stores SHA-256 of session/agent/enrollment tokens so a stolen DB doesn't hand over credentials. Build floor moves to Go 1.25 (modernc.org/sqlite v1.50+ requires it); CI + Dockerfile + README updated. Markdown lint diagnostics on tasks.md cleared. All packages tested. ~70 new tests pass in <1s. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
-- 0001_initial.sql
|
||||
--
|
||||
-- Initial schema for restic-manager. Mirrors the domain model in
|
||||
-- spec.md §5. We use TEXT primary keys (ULIDs) throughout: sortable,
|
||||
-- URL-safe, no autoincrement contention. JSON blobs are stored as
|
||||
-- TEXT; SQLite's json1 extension is available but we read/write
|
||||
-- raw and parse in Go for portability.
|
||||
--
|
||||
-- All timestamps are stored as RFC 3339 TEXT (UTC). SQLite's INTEGER
|
||||
-- (unix epoch) would be cheaper but text is human-readable in dumps
|
||||
-- and the storage cost is negligible at this scale.
|
||||
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('admin','operator','viewer')),
|
||||
created_at TEXT NOT NULL,
|
||||
last_login_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY, -- session token (high-entropy)
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
ip TEXT,
|
||||
ua TEXT
|
||||
);
|
||||
CREATE INDEX sessions_user_id ON sessions(user_id);
|
||||
CREATE INDEX sessions_expires_at ON sessions(expires_at);
|
||||
|
||||
CREATE TABLE credentials (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL, -- 'rest','s3','local'
|
||||
username TEXT,
|
||||
-- secret_ref is the AEAD ciphertext (nonce || ciphertext, base64).
|
||||
-- The plaintext never lands on disk.
|
||||
secret_ref TEXT NOT NULL,
|
||||
rotated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE repos (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('rest','s3','local')),
|
||||
credential_id TEXT REFERENCES credentials(id) ON DELETE RESTRICT,
|
||||
password_secret_id TEXT REFERENCES credentials(id) ON DELETE RESTRICT,
|
||||
-- Cached projection from `restic stats` + lock-file inspection.
|
||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
snapshot_count INTEGER NOT NULL DEFAULT 0,
|
||||
dedup_ratio REAL NOT NULL DEFAULT 0,
|
||||
last_check_at TEXT,
|
||||
last_check_status TEXT,
|
||||
lock_state TEXT NOT NULL DEFAULT 'unlocked'
|
||||
CHECK (lock_state IN ('locked','unlocked')),
|
||||
append_only INTEGER NOT NULL DEFAULT 1, -- bool
|
||||
credential_rotated_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE hosts (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
os TEXT NOT NULL,
|
||||
arch TEXT NOT NULL,
|
||||
agent_version TEXT NOT NULL DEFAULT '',
|
||||
restic_version TEXT NOT NULL DEFAULT '',
|
||||
protocol_version INTEGER NOT NULL DEFAULT 0,
|
||||
enrolled_at TEXT NOT NULL,
|
||||
last_seen_at TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'offline'
|
||||
CHECK (status IN ('online','offline','degraded')),
|
||||
repo_id TEXT REFERENCES repos(id) ON DELETE SET NULL,
|
||||
tags TEXT NOT NULL DEFAULT '[]', -- json array
|
||||
current_job_id TEXT,
|
||||
-- Denormalised projections (refreshed on job.finished etc).
|
||||
last_backup_at TEXT,
|
||||
last_backup_status TEXT
|
||||
CHECK (last_backup_status IN
|
||||
('succeeded','failed','cancelled') OR
|
||||
last_backup_status IS NULL),
|
||||
repo_size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
snapshot_count INTEGER NOT NULL DEFAULT 0,
|
||||
open_alert_count INTEGER NOT NULL DEFAULT 0,
|
||||
applied_schedule_version INTEGER NOT NULL DEFAULT 0,
|
||||
-- Server-issued credentials for the agent ↔ server WS.
|
||||
agent_token_hash TEXT NOT NULL DEFAULT '',
|
||||
cert_pin_sha256 TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX hosts_status ON hosts(status);
|
||||
CREATE INDEX hosts_last_seen_at ON hosts(last_seen_at);
|
||||
|
||||
-- Pending one-time enrollment tokens (TTL'd, single-use).
|
||||
CREATE TABLE enrollment_tokens (
|
||||
token_hash TEXT PRIMARY KEY, -- argon2id of token
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
consumed_at TEXT,
|
||||
consumed_host TEXT REFERENCES hosts(id) ON DELETE SET NULL
|
||||
);
|
||||
CREATE INDEX enrollment_tokens_expires_at ON enrollment_tokens(expires_at);
|
||||
|
||||
CREATE TABLE schedules (
|
||||
id TEXT PRIMARY KEY,
|
||||
host_id TEXT NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('backup','forget','prune','check')),
|
||||
cron_expr TEXT NOT NULL,
|
||||
paths TEXT NOT NULL DEFAULT '[]', -- json array
|
||||
excludes TEXT NOT NULL DEFAULT '[]',
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
retention_policy TEXT NOT NULL DEFAULT '{}', -- json object
|
||||
options TEXT NOT NULL DEFAULT '{}', -- json object (bandwidth)
|
||||
-- Hooks are encrypted at rest (AEAD ciphertext). Constraint enforced
|
||||
-- in application code: hooks must be empty unless kind='backup'.
|
||||
pre_hook TEXT NOT NULL DEFAULT '',
|
||||
post_hook TEXT NOT NULL DEFAULT '',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX schedules_host_id ON schedules(host_id);
|
||||
|
||||
-- Per-host monotonic schedule version. Bumped on any schedules INSERT/
|
||||
-- UPDATE/DELETE for that host. Pushed to the agent in schedule.set;
|
||||
-- the agent acks back the same version in schedule.ack.
|
||||
CREATE TABLE host_schedule_version (
|
||||
host_id TEXT PRIMARY KEY REFERENCES hosts(id) ON DELETE CASCADE,
|
||||
version INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
host_id TEXT NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('backup','forget','prune','check','unlock')),
|
||||
status TEXT NOT NULL CHECK (status IN ('queued','running','succeeded','failed','cancelled')),
|
||||
scheduled_id TEXT REFERENCES schedules(id) ON DELETE SET NULL,
|
||||
actor_kind TEXT NOT NULL CHECK (actor_kind IN ('user','schedule','system')),
|
||||
actor_id TEXT, -- user id, schedule id, or null
|
||||
started_at TEXT,
|
||||
finished_at TEXT,
|
||||
exit_code INTEGER,
|
||||
stats TEXT, -- json blob from restic
|
||||
error TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX jobs_host_id ON jobs(host_id);
|
||||
CREATE INDEX jobs_status ON jobs(status);
|
||||
CREATE INDEX jobs_created_at ON jobs(created_at);
|
||||
|
||||
CREATE TABLE job_logs (
|
||||
job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
|
||||
seq INTEGER NOT NULL,
|
||||
ts TEXT NOT NULL,
|
||||
stream TEXT NOT NULL CHECK (stream IN ('stdout','stderr','event')),
|
||||
payload TEXT NOT NULL,
|
||||
PRIMARY KEY (job_id, seq)
|
||||
);
|
||||
|
||||
CREATE TABLE snapshots (
|
||||
id TEXT PRIMARY KEY, -- restic snapshot id
|
||||
host_id TEXT NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
|
||||
repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
|
||||
time TEXT NOT NULL,
|
||||
hostname TEXT NOT NULL,
|
||||
paths TEXT NOT NULL DEFAULT '[]',
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
file_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX snapshots_host_id ON snapshots(host_id);
|
||||
CREATE INDEX snapshots_time ON snapshots(time);
|
||||
|
||||
CREATE TABLE alerts (
|
||||
id TEXT PRIMARY KEY,
|
||||
host_id TEXT REFERENCES hosts(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL,
|
||||
severity TEXT NOT NULL CHECK (severity IN ('info','warning','critical')),
|
||||
message TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
acknowledged_at TEXT,
|
||||
acknowledged_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
resolved_at TEXT
|
||||
);
|
||||
CREATE INDEX alerts_host_id ON alerts(host_id);
|
||||
CREATE INDEX alerts_open ON alerts(host_id) WHERE resolved_at IS NULL;
|
||||
|
||||
CREATE TABLE audit_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
actor TEXT NOT NULL CHECK (actor IN ('user','agent','system')),
|
||||
action TEXT NOT NULL,
|
||||
target_kind TEXT,
|
||||
target_id TEXT,
|
||||
ts TEXT NOT NULL,
|
||||
payload TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
CREATE INDEX audit_log_ts ON audit_log(ts);
|
||||
CREATE INDEX audit_log_user ON audit_log(user_id);
|
||||
Reference in New Issue
Block a user