- agent --pair:临时监听 :3001 仅开放 /provision 与 /destroy,以节点密码为共享秘密; 窗口由三个独立条件关闭(首次成功配对 / 失败尝试 10 次 / 10 分钟超时),配对成功后 管理监听即关(设计 §7 第 4 步) - agent 为每台配对手机生成中继 bearer(config.Save 0600 原子写;启动时为手写条目补发) - transport.PushDevices:握手成功后全量上报本节点受信设备集;空集是吊销声明而非无操作 - relay /v1/agent/devices:agent 会话后鉴权的设备注册;全量替换本节点条目; 操作员 MSA_DEVICES 为 static 不可被覆盖;跨节点 id 不可抢占 - 中继设备注册替代了 §11 第 6 项的一部分:节点 destroy + 重握手即可在路由层吊销 - 配置:provisionSecret 字段(无密码保护节点需显式设置才可配对);Validate(pairMode) - 测试:pair 流程 8 项、设备注册 6 项、transport 1 项;文档 §7 实现修订注记与 README 状态
273 lines
9.0 KiB
Go
273 lines
9.0 KiB
Go
package transport
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ed25519"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// The relay is represented by a real local HTTP server in these tests, so the TLS requirement is
|
|
// satisfied by rewriting the URL rather than by weakening the check: what is under test is the
|
|
// handshake and the status mapping, not the certificate.
|
|
func newClient(t *testing.T, handler http.Handler, token string) (*Client, *httptest.Server) {
|
|
t.Helper()
|
|
server := httptest.NewServer(handler)
|
|
t.Cleanup(server.Close)
|
|
_, signer, err := ed25519.GenerateKey(nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
client, err := New(Config{
|
|
BaseURL: strings.Replace(server.URL, "http://", "https://", 1),
|
|
NodeID: "nd_test0001",
|
|
Token: token,
|
|
Signer: signer,
|
|
Timeout: 2 * time.Second,
|
|
UserAgent: "msa-agent/test",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Point the client at the plain-HTTP test server. Only the scheme check is bypassed here; every
|
|
// other behaviour is the production one.
|
|
client.http.Transport = server.Client().Transport
|
|
client.baseURL = server.URL
|
|
return client, server
|
|
}
|
|
|
|
func TestNewRejectsAnythingButAnHTTPSRelay(t *testing.T) {
|
|
_, signer, err := ed25519.GenerateKey(nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, base := range []string{
|
|
"http://relay.example",
|
|
"ws://relay.example",
|
|
"https://relay.example/some/path",
|
|
"https://",
|
|
"not a url",
|
|
} {
|
|
if _, err := New(Config{BaseURL: base, NodeID: "nd_x", Token: "t", Signer: signer}); err == nil {
|
|
t.Fatalf("accepted relay url %q", base)
|
|
}
|
|
}
|
|
if _, err := New(Config{BaseURL: "https://relay.example", NodeID: "nd_x", Token: "t", Signer: signer}); err != nil {
|
|
t.Fatalf("rejected a valid relay url: %v", err)
|
|
}
|
|
if _, err := New(Config{BaseURL: "https://relay.example", NodeID: "nd_x", Token: "t"}); err == nil {
|
|
t.Fatal("accepted a missing signing key")
|
|
}
|
|
}
|
|
|
|
// The handshake is challenge-response, so a stolen bearer token alone is not enough to impersonate
|
|
// an agent. This test is the one that would fail if someone replaced the signature with the token.
|
|
func TestHelloSignsTheChallengeBoundToTheNodeID(t *testing.T) {
|
|
var (
|
|
requests []HelloRequest
|
|
issued = "challenge-abc"
|
|
)
|
|
client, _ := newClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Header.Get("Authorization") == "" {
|
|
t.Error("handshake was sent without a bearer token")
|
|
}
|
|
var body HelloRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
requests = append(requests, body)
|
|
out := HelloResponse{}
|
|
if body.Challenge == "" {
|
|
out.Challenge = issued
|
|
} else {
|
|
out.Session = Session{Token: "session-token", ExpiresIn: 3600}
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(out)
|
|
}), "bootstrap-token")
|
|
|
|
response, err := client.Hello(context.Background(), "test-build", "deadbeef")
|
|
if err != nil {
|
|
t.Fatalf("Hello: %v", err)
|
|
}
|
|
if response.Session.Token != "session-token" {
|
|
t.Fatalf("session token is %q", response.Session.Token)
|
|
}
|
|
if len(requests) != 2 {
|
|
t.Fatalf("handshake took %d round trips, want 2", len(requests))
|
|
}
|
|
if requests[0].Challenge != "" {
|
|
t.Fatal("the first handshake request must not carry a challenge")
|
|
}
|
|
if requests[1].Challenge != issued {
|
|
t.Fatalf("the agent echoed challenge %q, want %q", requests[1].Challenge, issued)
|
|
}
|
|
public := client.signer.Public().(ed25519.PublicKey)
|
|
if !ed25519.Verify(public, challengeBytes("nd_test0001", issued), requests[1].Signature) {
|
|
t.Fatal("the challenge signature does not verify")
|
|
}
|
|
// Bound to the node id: the same signature must not verify under a different node.
|
|
if ed25519.Verify(public, challengeBytes("nd_other001", issued), requests[1].Signature) {
|
|
t.Fatal("the challenge signature is not bound to the node id")
|
|
}
|
|
}
|
|
|
|
func TestStatusMapping(t *testing.T) {
|
|
cases := []struct {
|
|
status int
|
|
want error
|
|
}{
|
|
{http.StatusUnauthorized, ErrUnauthorized},
|
|
{http.StatusNoContent, ErrNoWork},
|
|
{http.StatusRequestTimeout, ErrNoWork},
|
|
{http.StatusTooManyRequests, ErrRateLimited},
|
|
}
|
|
for _, c := range cases {
|
|
client, _ := newClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(c.status)
|
|
}), "t")
|
|
_, err := client.Poll(context.Background())
|
|
if err == nil || !errors.Is(err, c.want) {
|
|
t.Fatalf("status %d: got %v, want %v", c.status, err, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPollReturnsTheEnvelopesTheRelayIsHolding(t *testing.T) {
|
|
want := []string{`{"v":1,"node":"nd_test0001"}`, `{"v":1,"node":"nd_test0001"}`}
|
|
client, _ := newClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != PathPoll {
|
|
t.Fatalf("polled %q", r.URL.Path)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(Batch{Envelopes: []json.RawMessage{
|
|
json.RawMessage(want[0]), json.RawMessage(want[1]),
|
|
}})
|
|
}), "t")
|
|
batch, err := client.Poll(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(batch.Envelopes) != 2 {
|
|
t.Fatalf("got %d envelopes, want 2", len(batch.Envelopes))
|
|
}
|
|
for i := range want {
|
|
if string(batch.Envelopes[i]) != want[i] {
|
|
t.Fatalf("envelope %d is %s", i, batch.Envelopes[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
// An empty relay answer is the common case for a long poll, and must not be an error or the agent
|
|
// would back off every time nothing happened.
|
|
func TestAnEmptyPollIsNotAnError(t *testing.T) {
|
|
client, _ := newClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}), "t")
|
|
if _, err := client.Poll(context.Background()); !errors.Is(err, ErrNoWork) {
|
|
t.Fatalf("got %v, want %v", err, ErrNoWork)
|
|
}
|
|
}
|
|
|
|
func TestResponsesAreBoundedBeforeDecoding(t *testing.T) {
|
|
client, _ := newClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Write([]byte(strings.Repeat("x", 4096)))
|
|
}), "t")
|
|
client.maxBytes = 1024
|
|
if _, err := client.Poll(context.Background()); err == nil {
|
|
t.Fatal("an oversized relay response was accepted")
|
|
}
|
|
}
|
|
|
|
func TestResultPostsTheEnvelopeVerbatim(t *testing.T) {
|
|
var got json.RawMessage
|
|
var path string
|
|
client, _ := newClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
path = r.URL.Path
|
|
got, _ = io.ReadAll(r.Body)
|
|
w.WriteHeader(http.StatusOK)
|
|
}), "t")
|
|
envelope := json.RawMessage(`{"v":1,"node":"nd_test0001","p":"AAAA"}`)
|
|
if err := client.Result(context.Background(), envelope); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if path != PathResult {
|
|
t.Fatalf("posted to %q", path)
|
|
}
|
|
if string(got) != string(envelope) {
|
|
t.Fatalf("relay received %s, want %s", got, envelope)
|
|
}
|
|
}
|
|
|
|
// The device registration posts the node's authoritative paired set under the session, which is
|
|
// what lets the relay learn about freshly paired phones. Empty is posted too: "nobody" is a
|
|
// revocation claim, not a no-op.
|
|
func TestPushDevicesPostsTheFullSetUnderTheSession(t *testing.T) {
|
|
var got map[string]any
|
|
client, _ := newClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != PathDevices {
|
|
t.Fatalf("posted to %q", r.URL.Path)
|
|
}
|
|
if r.Header.Get("X-MSA-Session") == "" {
|
|
t.Error("the device push must carry the session")
|
|
}
|
|
json.NewDecoder(r.Body).Decode(&got)
|
|
w.WriteHeader(http.StatusOK)
|
|
}), "t")
|
|
err := client.PushDevices(context.Background(), []DeviceRegistration{
|
|
{App: "ap_5b1d0000", Token: "tok-a"},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got["node"] != "nd_test0001" {
|
|
t.Fatalf("node is %v", got["node"])
|
|
}
|
|
devices, _ := got["devices"].([]any)
|
|
if len(devices) != 1 {
|
|
t.Fatalf("devices: %v", got["devices"])
|
|
}
|
|
// An empty list is a real claim, not a skipped call.
|
|
client2, _ := newClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}), "t")
|
|
if err := client2.PushDevices(context.Background(), []DeviceRegistration{}); err != nil {
|
|
t.Fatalf("empty push: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRetryAfterHasASaneDefault(t *testing.T) {
|
|
client, _ := newClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}), "t")
|
|
if got := client.RetryAfter(); got <= 0 || got > 10*time.Second {
|
|
t.Fatalf("RetryAfter() = %v", got)
|
|
}
|
|
}
|
|
|
|
// The relay's Retry-After is an instruction, and the loop honours it; a value the agent cannot
|
|
// parse falls back to the default rather than becoming an unbounded wait or an immediate retry.
|
|
func TestRetryAfterHonoursTheRelayHeader(t *testing.T) {
|
|
client, _ := newClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Retry-After", "7")
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
}), "t")
|
|
if _, err := client.Poll(context.Background()); !errors.Is(err, ErrRateLimited) {
|
|
t.Fatalf("got %v, want ErrRateLimited", err)
|
|
}
|
|
if got := client.RetryAfter(); got != 7*time.Second {
|
|
t.Fatalf("RetryAfter() = %v, want 7s", got)
|
|
}
|
|
for _, junk := range []string{"", "Wed, 21 Oct 2015 07:28:00 GMT", "-3", "999999"} {
|
|
if got := parseRetryAfter(junk); got != 2*time.Second {
|
|
t.Errorf("parseRetryAfter(%q) = %v, want the default", junk, got)
|
|
}
|
|
}
|
|
}
|