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:
2026-05-01 00:24:40 +01:00
parent 595546afb9
commit c275f4ff4c
28 changed files with 1952 additions and 13 deletions
+143
View File
@@ -0,0 +1,143 @@
package api
import (
"encoding/json"
"testing"
"time"
)
func TestEnvelopeRoundTrip(t *testing.T) {
t.Parallel()
hello := HelloPayload{
ProtocolVersion: CurrentProtocolVersion,
AgentVersion: "0.1.0",
ResticVersion: "0.17.1",
Hostname: "test-host",
OS: OSLinux,
Arch: ArchAmd64,
}
env, err := Marshal(MsgHello, "", hello)
if err != nil {
t.Fatalf("marshal: %v", err)
}
wire, err := json.Marshal(env)
if err != nil {
t.Fatalf("marshal envelope: %v", err)
}
var decoded Envelope
if err := json.Unmarshal(wire, &decoded); err != nil {
t.Fatalf("unmarshal envelope: %v", err)
}
if decoded.Type != MsgHello {
t.Errorf("type: got %q want %q", decoded.Type, MsgHello)
}
var got HelloPayload
if err := decoded.UnmarshalPayload(&got); err != nil {
t.Fatalf("unmarshal payload: %v", err)
}
if got != hello {
t.Errorf("round-trip mismatch: %+v != %+v", got, hello)
}
}
func TestEnvelopeNilPayload(t *testing.T) {
t.Parallel()
env, err := Marshal(MsgHeartbeat, "", nil)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if len(env.Payload) != 0 {
t.Errorf("nil payload should encode as empty, got %q", env.Payload)
}
// Unmarshalling nothing into anything must not error.
var hb HeartbeatPayload
if err := env.UnmarshalPayload(&hb); err != nil {
t.Errorf("unmarshal empty payload: %v", err)
}
}
func TestEnvelopeRPCCorrelation(t *testing.T) {
t.Parallel()
cmd := CommandRunPayload{JobID: "01HJ8K7", Kind: JobBackup}
env, err := Marshal(MsgCommandRun, "req-1", cmd)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if env.ID != "req-1" {
t.Errorf("id not preserved: %q", env.ID)
}
res := CommandResultPayload{JobID: "01HJ8K7", Accepted: true}
resEnv, err := Marshal(MsgCommandResult, env.ID, res)
if err != nil {
t.Fatalf("marshal result: %v", err)
}
if resEnv.ID != env.ID {
t.Errorf("rpc id mismatch: req=%q res=%q", env.ID, resEnv.ID)
}
}
func TestErrorPayload(t *testing.T) {
t.Parallel()
ep := ErrorPayload{
Code: ErrProtocolTooOld,
Message: "agent protocol_version 0 below minimum 1",
HelpURL: "https://example.com/upgrade",
}
env, err := Marshal(MsgError, "", ep)
if err != nil {
t.Fatalf("marshal: %v", err)
}
wire, _ := json.Marshal(env)
var decoded Envelope
if err := json.Unmarshal(wire, &decoded); err != nil {
t.Fatalf("unmarshal: %v", err)
}
var got ErrorPayload
if err := decoded.UnmarshalPayload(&got); err != nil {
t.Fatalf("unmarshal payload: %v", err)
}
if got.Code != ErrProtocolTooOld {
t.Errorf("code: got %q want %q", got.Code, ErrProtocolTooOld)
}
}
func TestProtocolVersionConstants(t *testing.T) {
t.Parallel()
if CurrentProtocolVersion < 1 {
t.Errorf("CurrentProtocolVersion must be >= 1, got %d", CurrentProtocolVersion)
}
if MinAgentProtocolVersion > CurrentProtocolVersion {
t.Errorf("min %d > current %d — server would refuse all agents",
MinAgentProtocolVersion, CurrentProtocolVersion)
}
}
func TestJobProgressShapeStable(t *testing.T) {
t.Parallel()
// Locks the JSON field names from spec.md §6.2 so a rename here
// breaks tests instead of silently breaking the agent.
p := JobProgressPayload{
JobID: "j", PercentDone: 0.5, FilesDone: 1, TotalFiles: 2,
BytesDone: 100, TotalBytes: 200, ETASeconds: 30, ThroughputBps: 1000,
}
raw, _ := json.Marshal(p)
want := `{"job_id":"j","percent_done":0.5,"files_done":1,"total_files":2,"bytes_done":100,"total_bytes":200,"eta_seconds":30,"throughput_bps":1000}`
if string(raw) != want {
t.Errorf("wire shape drifted:\n got %s\n want %s", raw, want)
}
}
// touch time so the import is used by other tests in this file when
// they grow over time.
var _ = time.Now