9cc0caff1e
Lands the protocol layer end-to-end: an agent can be enrolled through the operator UI, store credentials, dial back to the server over WS, complete the protocol_version handshake, and stay connected with periodic heartbeats. Server side: - P1-09 ws.Hub: one Conn per host_id, last-write-wins eviction, json envelope writer with a write mutex, reader, error envelopes. - P1-09 ws.AgentHandler: bearer-auth, accept upgrade, hello-stage (10s deadline, protocol_version checked against api.MinAgentProtocolVersion → ErrProtocolTooOld with help URL on reject), main read loop, defer hub register/unregister. - P1-10 POST /api/agents/enroll consumes a one-time token, mints a persistent agent bearer (sha-256 stored), creates a host row. - P1-10 POST /api/enrollment-tokens (operator, session-auth) issues a 1h one-time token. - P1-11 hello upserts agent_version + restic_version + protocol_version on the host row, flips status to online. - P1-12 heartbeat touches last_seen_at; background sweeper marks hosts offline after 90s without one. - store: hosts table accessors, host_schedule_version, enrollment_tokens FK on consumed_host dropped (audit-only field; the token gets burned before the host row exists). Agent side: - P1-13 internal/agent/config: yaml at /etc/restic-manager/agent.yaml, atomic Save (tmp+fsync+rename), Enrolled() helper. - P1-15 internal/agent/wsclient: dial with bearer + optional TLS cert pinning (sha-256 of leaf), exponential backoff with jitter (1s → 60s cap), heartbeat goroutine, fatal handling for ErrProtocolTooOld. - P1-15 wsclient.Enroll: HTTP POST /api/agents/enroll with sysinfo. - P1-17 internal/agent/sysinfo: hostname/OS/arch/restic-version collection. restic detected by `restic version` parse; absent restic doesn't block startup. - cmd/agent: -enroll-server / -enroll-token flags drive first-run enrollment then exit (so the install script can hand off to systemd to run the persistent service). End-to-end smoke verified: bootstrap → login → issue token → enroll → run agent → server logs `ws agent connected` with the right host_id and protocol_version 1. All tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
119 lines
3.4 KiB
Go
119 lines
3.4 KiB
Go
package http
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
stdhttp "net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
|
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/auth"
|
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/crypto"
|
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/config"
|
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ws"
|
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
|
)
|
|
|
|
// newTestServerWithHub mirrors newTestServer but plugs in a real
|
|
// ws.Hub so /ws/agent is available.
|
|
func newTestServerWithHub(t *testing.T) (*Server, string, *store.Store) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
st, err := store.Open(context.Background(), filepath.Join(dir, "rm.db"))
|
|
if err != nil {
|
|
t.Fatalf("store: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = st.Close() })
|
|
|
|
keyPath := filepath.Join(dir, "secret.key")
|
|
_ = crypto.GenerateKeyFile(keyPath)
|
|
key, _ := crypto.LoadKeyFromFile(keyPath)
|
|
aead, _ := crypto.NewAEAD(key)
|
|
|
|
deps := Deps{
|
|
Cfg: config.Config{Listen: ":0", DataDir: dir, SecretKeyFile: keyPath},
|
|
Store: st,
|
|
AEAD: aead,
|
|
Hub: ws.NewHub(),
|
|
}
|
|
s := New(deps)
|
|
ts := httptest.NewServer(s.srv.Handler)
|
|
t.Cleanup(ts.Close)
|
|
return s, ts.URL, st
|
|
}
|
|
|
|
func TestEnrollmentBadToken(t *testing.T) {
|
|
t.Parallel()
|
|
_, url, _ := newTestServerWithHub(t)
|
|
|
|
body, _ := json.Marshal(enrollRequest{
|
|
Token: "no-such-token", HostName: "host1",
|
|
OS: api.OSLinux, Arch: api.ArchAmd64,
|
|
AgentVersion: "0.1", ResticVersion: "0.17",
|
|
})
|
|
res, err := stdhttp.Post(url+"/api/agents/enroll", "application/json", bytes.NewReader(body))
|
|
if err != nil {
|
|
t.Fatalf("post: %v", err)
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != stdhttp.StatusUnauthorized {
|
|
t.Errorf("status: %d", res.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestEnrollmentHappyPath(t *testing.T) {
|
|
t.Parallel()
|
|
_, url, st := newTestServerWithHub(t)
|
|
|
|
// Issue a token directly via the store (skipping the operator UI).
|
|
rawToken, _ := auth.NewToken()
|
|
if err := st.CreateEnrollmentToken(context.Background(),
|
|
auth.HashToken(rawToken), 5*time.Minute); err != nil {
|
|
t.Fatalf("issue: %v", err)
|
|
}
|
|
|
|
body, _ := json.Marshal(enrollRequest{
|
|
Token: rawToken, HostName: "test-host",
|
|
OS: api.OSLinux, Arch: api.ArchAmd64,
|
|
AgentVersion: "0.1", ResticVersion: "0.17",
|
|
})
|
|
res, err := stdhttp.Post(url+"/api/agents/enroll", "application/json", bytes.NewReader(body))
|
|
if err != nil {
|
|
t.Fatalf("post: %v", err)
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != stdhttp.StatusCreated {
|
|
buf, _ := io.ReadAll(res.Body)
|
|
t.Fatalf("status %d: %s", res.StatusCode, buf)
|
|
}
|
|
|
|
var er enrollResponse
|
|
if err := json.NewDecoder(res.Body).Decode(&er); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if er.HostID == "" || er.AgentToken == "" {
|
|
t.Errorf("missing fields in response: %+v", er)
|
|
}
|
|
|
|
// Token must not be reusable.
|
|
res2, _ := stdhttp.Post(url+"/api/agents/enroll", "application/json", bytes.NewReader(body))
|
|
defer res2.Body.Close()
|
|
if res2.StatusCode != stdhttp.StatusUnauthorized {
|
|
t.Errorf("re-enrollment with same token should fail, got %d", res2.StatusCode)
|
|
}
|
|
|
|
// Host row exists with matching agent_token_hash.
|
|
got, err := st.LookupHostByAgentToken(context.Background(), auth.HashToken(er.AgentToken))
|
|
if err != nil {
|
|
t.Fatalf("lookup by token: %v", err)
|
|
}
|
|
if got.Name != "test-host" || got.OS != "linux" {
|
|
t.Errorf("host fields: %+v", got)
|
|
}
|
|
}
|