feat: 端到端测试(真 agent + 真中继 + TLS),并修三处只有它能发现的缺陷
新增 test/e2e.test.js:编译并运行 Go agent,经 TLS 接到真中继,由模拟手机发起 签名请求,再断言节点实际收到的内容(含 audit 日志不含 body/密码、危险写 body 逐字节一致)。缺 go/openssl 则跳过并说明原因。 它一次抓出三个单元测试各自都通过、合起来却坏了的问题: 1. transport.Client 把会话 token 写进 c.token,而 c.token 是 bearer——Poll/Result 因此用会话 token 当 bearer 发出,中继一律 401。现象是 agent 连上后无限重连, 日志说'凭据被拒'。现在两个凭据分成两个字段,并加测试断言握手带 bearer 不带会话、 轮询带会话。 2. main.loop 每个周期都重新握手。中继每个节点只保留一个会话,新握手会顶掉旧的并 丢弃其挂起的轮询,于是两次握手之间到达的请求被投进已不存在的会话、以'agent 离线' 超时——而 agent 全程在线。改为只在会话失效/传输失败时重新握手。 3. auth.registerDevices 把'已存在'一律跳过,随后又被'本轮未声明'的回收循环删掉, 所以节点的第二次握手会把自己注册的设备全部注销。改为本节点已持有即确认(幂等)。 另:transport 支持 relayCAFile(PEM 钉 CA,替代系统信任库,不是关闭校验), 测试用自签证书 + 钉 CA 走真实 TLS 校验路径。
This commit is contained in:
@@ -14,6 +14,8 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -75,10 +77,17 @@ type Batch struct {
|
||||
}
|
||||
|
||||
// Client talks to one relay.
|
||||
//
|
||||
// The two credentials are kept apart on purpose, and keeping them apart is not cosmetic. The bearer
|
||||
// token identifies the node to the relay's authentication layer; the session token is what the relay
|
||||
// issued after the challenge signature proved possession of the node's key. The relay checks both on
|
||||
// every poll, and using one where the other belongs fails as "relay rejected the agent credential" —
|
||||
// which reads like a bad token rather than like a mixed-up header.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
nodeID string
|
||||
token string
|
||||
session string
|
||||
signer ed25519.PrivateKey
|
||||
http *http.Client
|
||||
userAgent string
|
||||
@@ -95,6 +104,14 @@ type Config struct {
|
||||
Timeout time.Duration
|
||||
UserAgent string
|
||||
MaxBytes int64
|
||||
// RootCAs replaces the system trust store when non-nil. It exists for one deployment shape: a
|
||||
// relay behind a private CA, where installing the CA into the node's system store is either not
|
||||
// possible or not desirable. The zero value keeps the system store, which is what an operator
|
||||
// using a public certificate wants and what the agent does by default.
|
||||
//
|
||||
// It is a *CertPool and not an "insecure" flag on purpose: the way to trust a private relay is to
|
||||
// pin the CA that signed it, never to stop verifying.
|
||||
RootCAs *x509.CertPool
|
||||
}
|
||||
|
||||
// New validates the relay URL before any traffic is sent.
|
||||
@@ -127,6 +144,15 @@ func New(cfg Config) (*Client, error) {
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = 1 << 20
|
||||
}
|
||||
client := &http.Client{Timeout: timeout}
|
||||
if cfg.RootCAs != nil {
|
||||
// A fresh transport rather than a mutated DefaultTransport: mutating the shared one would
|
||||
// change trust for every other client in the process, which is a surprising thing for a
|
||||
// constructor to do.
|
||||
client.Transport = &http.Transport{
|
||||
TLSClientConfig: &tls.Config{RootCAs: cfg.RootCAs, MinVersion: tls.VersionTLS12},
|
||||
}
|
||||
}
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(cfg.BaseURL, "/"),
|
||||
nodeID: cfg.NodeID,
|
||||
@@ -134,7 +160,7 @@ func New(cfg Config) (*Client, error) {
|
||||
signer: cfg.Signer,
|
||||
userAgent: cfg.UserAgent,
|
||||
maxBytes: maxBytes,
|
||||
http: &http.Client{Timeout: timeout},
|
||||
http: client,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -163,7 +189,7 @@ func (c *Client) Hello(ctx context.Context, build, policySHA string) (HelloRespo
|
||||
if err := c.post(ctx, PathHello, signed, &completed, "", false); err != nil {
|
||||
return completed, err
|
||||
}
|
||||
c.token = completed.Session.Token
|
||||
c.session = completed.Session.Token
|
||||
return completed, nil
|
||||
}
|
||||
|
||||
@@ -179,7 +205,7 @@ func (c *Client) Poll(ctx context.Context) (Batch, error) {
|
||||
var batch Batch
|
||||
// A long poll that times out answers with an empty body, and that is the normal case rather than
|
||||
// a failure, so an empty body means ErrNoWork here and only here.
|
||||
err := c.post(ctx, PathPoll, map[string]string{"node": c.nodeID}, &batch, c.session(), true)
|
||||
err := c.post(ctx, PathPoll, map[string]string{"node": c.nodeID}, &batch, c.sessionToken(), true)
|
||||
if err != nil {
|
||||
return Batch{}, err
|
||||
}
|
||||
@@ -192,10 +218,12 @@ func (c *Client) Poll(ctx context.Context) (Batch, error) {
|
||||
// must not share that reading of an empty body, or a confirmed delivery would be treated as a
|
||||
// reason to back off.
|
||||
func (c *Client) Result(ctx context.Context, envelope json.RawMessage) error {
|
||||
return c.post(ctx, PathResult, envelope, &json.RawMessage{}, c.session(), false)
|
||||
return c.post(ctx, PathResult, envelope, &json.RawMessage{}, c.sessionToken(), false)
|
||||
}
|
||||
|
||||
func (c *Client) session() string { return c.token }
|
||||
// session returns the token the relay issued for this session. It is empty before the handshake, and
|
||||
// an empty value makes the relay answer 401, which the loop treats as "re-authenticate".
|
||||
func (c *Client) sessionToken() string { return c.session }
|
||||
|
||||
// DeviceRegistration is one paired phone as the relay needs to know it: the bearer token the phone
|
||||
// presents, minted by the agent at pairing time and never seen by the relay before that moment.
|
||||
@@ -213,7 +241,7 @@ type DeviceRegistration struct {
|
||||
func (c *Client) PushDevices(ctx context.Context, devices []DeviceRegistration) error {
|
||||
return c.post(ctx, PathDevices, map[string]any{
|
||||
"node": c.nodeID, "devices": devices,
|
||||
}, &json.RawMessage{}, c.session(), false)
|
||||
}, &json.RawMessage{}, c.sessionToken(), false)
|
||||
}
|
||||
|
||||
// post is the single place a relay request is made, so authentication, size limits, status mapping
|
||||
|
||||
Reference in New Issue
Block a user