Files
steve fd87218b3f server: P2-18b pending WS + admin accept/reject
GET /ws/agent/pending?pending_id=… runs an Ed25519 nonce-sign
handshake against the row's stored public key, then holds the
connection open. POST /api/pending-hosts/{id}/accept (admin)
mints a real Host row + bearer + AEAD-encrypted repo creds, pushes
the bearer down the open WS, deletes the pending row, and writes
a host.accept_pending audit entry. POST /api/pending-hosts/{id}/reject
closes the socket with code 4001 and audit-logs host.reject_pending.

In-memory pendingHub keyed by pending_id wires accept/reject to
their live socket.
2026-05-04 11:07:32 +01:00

204 lines
6.2 KiB
Go

// pending_ws_test.go — end-to-end test of the announce → pending WS
// → admin accept → bearer push round trip (P2-18b/c).
package http
import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/json"
stdhttp "net/http"
"net/url"
"strings"
"testing"
"time"
"github.com/coder/websocket"
"github.com/oklog/ulid/v2"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)
// TestPendingWSNonceSignAcceptFlow: simulate an agent. Announce →
// open pending WS → sign nonce → admin accept (with repo creds) →
// expect 'enrolled' message with bearer.
func TestPendingWSNonceSignAcceptFlow(t *testing.T) {
t.Parallel()
srv, ts, st := rawTestServer(t)
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("ed25519: %v", err)
}
// Pre-seed pending row directly (bypass the announce HTTP path
// since announce coverage lives in announce_test.go).
pendingID := ulid.Make().String()
if err := st.CreatePendingHost(context.Background(), &store.PendingHost{
ID: pendingID, Hostname: "ann-host", OS: "linux", Arch: "amd64",
AgentVersion: "1.0", ResticVersion: "0.17",
PublicKey: pub, Fingerprint: store.FingerprintForKey(pub),
AnnouncedFromIP: "127.0.0.1",
FirstSeenAt: time.Now().UTC(),
LastSeenAt: time.Now().UTC(),
ExpiresAt: time.Now().UTC().Add(time.Hour),
}); err != nil {
t.Fatalf("seed: %v", err)
}
// Open the pending WS.
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws/agent/pending?pending_id=" + pendingID
dialCtx, dialCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer dialCancel()
c, res, err := websocket.Dial(dialCtx, wsURL, nil)
if err != nil {
t.Fatalf("dial pending ws: %v", err)
}
if res != nil && res.Body != nil {
_ = res.Body.Close()
}
t.Cleanup(func() { _ = c.CloseNow() })
// Read nonce.
rctx, rcancel := context.WithTimeout(context.Background(), 3*time.Second)
_, raw, err := c.Read(rctx)
rcancel()
if err != nil {
t.Fatalf("read nonce: %v", err)
}
var nm nonceMessage
if err := json.Unmarshal(raw, &nm); err != nil {
t.Fatalf("unmarshal nonce: %v", err)
}
nonce, _ := base64.StdEncoding.DecodeString(nm.Nonce)
// Sign + reply.
sig := ed25519.Sign(priv, nonce)
reply, _ := json.Marshal(signedNonceMessage{
Type: "signed_nonce", Signature: base64.StdEncoding.EncodeToString(sig),
})
wctx, wcancel := context.WithTimeout(context.Background(), 3*time.Second)
if err := c.Write(wctx, websocket.MessageText, reply); err != nil {
wcancel()
t.Fatalf("write signed nonce: %v", err)
}
wcancel()
// Wait briefly so the server's hub.register completes before we
// fire accept.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if srv.pendingHub.get(pendingID) != nil {
break
}
time.Sleep(20 * time.Millisecond)
}
// Admin POST accept (form-encoded, with cookie).
cookie := loginAsAdmin(t, st)
form := url.Values{
"repo_url": {"rest:http://r/x"},
"repo_username": {"u"},
"repo_password": {"p"},
}
req, _ := stdhttp.NewRequest("POST",
ts.URL+"/api/pending-hosts/"+pendingID+"/accept",
strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
resAccept, err := stdhttp.DefaultClient.Do(req)
if err != nil {
t.Fatalf("accept: %v", err)
}
defer resAccept.Body.Close()
if resAccept.StatusCode != stdhttp.StatusOK {
t.Fatalf("accept status: %d", resAccept.StatusCode)
}
// Expect 'enrolled' message + close.
rctx2, rcancel2 := context.WithTimeout(context.Background(), 3*time.Second)
_, raw2, err := c.Read(rctx2)
rcancel2()
if err != nil {
t.Fatalf("read enrolled: %v", err)
}
var em enrolledMessage
if err := json.Unmarshal(raw2, &em); err != nil {
t.Fatalf("unmarshal enrolled: %v", err)
}
if em.Type != "enrolled" || em.Bearer == "" || em.HostID == "" {
t.Fatalf("enrolled payload bad: %+v", em)
}
// Pending row should be gone.
if _, err := st.GetPendingHost(context.Background(), pendingID); err == nil {
t.Error("pending row should have been deleted on accept")
}
// Real host row should exist.
if _, err := st.GetHost(context.Background(), em.HostID); err != nil {
t.Errorf("host row not created: %v", err)
}
}
// TestPendingWSBadSignatureClosed: server closes the WS when the
// signature does not verify against the row's public key.
func TestPendingWSBadSignatureClosed(t *testing.T) {
t.Parallel()
srv, ts, st := rawTestServer(t)
_ = srv
// Two distinct keypairs — agent signs with the wrong one.
pubReal, _, _ := ed25519.GenerateKey(rand.Reader)
_, privAttacker, _ := ed25519.GenerateKey(rand.Reader)
pendingID := ulid.Make().String()
if err := st.CreatePendingHost(context.Background(), &store.PendingHost{
ID: pendingID, Hostname: "bad-host", OS: "linux", Arch: "amd64",
PublicKey: pubReal, Fingerprint: store.FingerprintForKey(pubReal),
AnnouncedFromIP: "127.0.0.1",
FirstSeenAt: time.Now().UTC(),
LastSeenAt: time.Now().UTC(),
ExpiresAt: time.Now().UTC().Add(time.Hour),
}); err != nil {
t.Fatalf("seed: %v", err)
}
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws/agent/pending?pending_id=" + pendingID
dialCtx, dialCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer dialCancel()
c, res, err := websocket.Dial(dialCtx, wsURL, nil)
if err != nil {
t.Fatalf("dial: %v", err)
}
if res != nil && res.Body != nil {
_ = res.Body.Close()
}
defer func() { _ = c.CloseNow() }()
// Read nonce.
rctx, rcancel := context.WithTimeout(context.Background(), 3*time.Second)
_, raw, _ := c.Read(rctx)
rcancel()
var nm nonceMessage
_ = json.Unmarshal(raw, &nm)
nonce, _ := base64.StdEncoding.DecodeString(nm.Nonce)
// Sign with the wrong key.
sig := ed25519.Sign(privAttacker, nonce)
reply, _ := json.Marshal(signedNonceMessage{
Type: "signed_nonce", Signature: base64.StdEncoding.EncodeToString(sig),
})
wctx, wcancel := context.WithTimeout(context.Background(), 3*time.Second)
_ = c.Write(wctx, websocket.MessageText, reply)
wcancel()
// Server should close. Read until error.
rctx2, rcancel2 := context.WithTimeout(context.Background(), 3*time.Second)
_, _, err = c.Read(rctx2)
rcancel2()
if err == nil {
t.Fatal("expected ws to close on bad signature")
}
}