567561a6a3
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.
204 lines
6.2 KiB
Go
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")
|
|
}
|
|
}
|