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:
+64
-28
@@ -9,6 +9,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"flag"
|
||||
@@ -177,12 +178,17 @@ func run(configPath string, checkOnly, once bool) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
roots, err := relayRoots(cfg.RelayCAFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := transport.New(transport.Config{
|
||||
BaseURL: cfg.RelayURL,
|
||||
NodeID: cfg.NodeID,
|
||||
Token: cfg.AgentToken,
|
||||
Signer: sign,
|
||||
UserAgent: "msa-agent/" + version,
|
||||
RootCAs: roots,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -205,35 +211,80 @@ func registrationsFrom(cfg config.File) []transport.DeviceRegistration {
|
||||
return out
|
||||
}
|
||||
|
||||
// loop is the agent's whole life: handshake, then poll forever, re-handshaking whenever the relay
|
||||
// says the session is gone and backing off when the relay is unreachable.
|
||||
// relayRoots loads a PEM bundle to trust instead of the system store, or returns nil to keep the
|
||||
// system store. An unreadable or empty bundle is a startup error rather than a silent fallback: an
|
||||
// operator who pinned a CA and got a system-store verification instead would be running a trust
|
||||
// decision they did not choose, and the failure would look like a network problem.
|
||||
func relayRoots(path string) (*x509.CertPool, error) {
|
||||
if path == "" {
|
||||
return nil, nil
|
||||
}
|
||||
pem, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("relayCAFile: %w", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(pem) {
|
||||
return nil, fmt.Errorf("relayCAFile %s contains no PEM certificates", path)
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
// loop is the agent's whole life: handshake once, then poll forever, re-handshaking only when the
|
||||
// relay says the session is gone.
|
||||
//
|
||||
// Errors are classified here rather than handled per call site, because the three situations look
|
||||
// identical from inside a function and need three different responses: retry now, re-authenticate,
|
||||
// or wait.
|
||||
// The handshake is *not* part of the per-cycle work, and that distinction is load-bearing rather
|
||||
// than an optimisation. The relay's registry holds one session per node: a second handshake replaces
|
||||
// the first, and replacing it drops whatever poll was parked. So an agent that re-handshook on every
|
||||
// cycle would tear down its own parked poll each time, and any request that arrived in the window
|
||||
// between the two handshakes would be delivered into a session that no longer exists and time out
|
||||
// with "agent offline" — while the agent was connected the whole time. An earlier version of this
|
||||
// loop did exactly that, and only the end-to-end test caught it, because each component was behaving
|
||||
// correctly on its own.
|
||||
//
|
||||
// Errors are classified here rather than handled per call site, because the situations look identical
|
||||
// from inside a function and need different responses: retry now, re-authenticate, or wait.
|
||||
func loop(ctx context.Context, node *agent.Agent, client *transport.Client, pol *policy.Policy, devices []transport.DeviceRegistration) error {
|
||||
backoff := minBackoff
|
||||
authenticated := false
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
err := handshakeAndPoll(ctx, node, client, pol, devices)
|
||||
var err error
|
||||
if !authenticated {
|
||||
if _, err = client.Hello(ctx, version, pol.SHA256()); err == nil {
|
||||
// The relay holds no device state across restarts, and it must never trust a device
|
||||
// claim it cannot tie to an authenticated agent. Reporting the full set right after
|
||||
// the handshake is what makes that true without a second channel: the node's paired
|
||||
// set, including any revocation, is re-asserted every time a session is established.
|
||||
// Empty is pushed too, because "nobody" is exactly the claim revocation needs.
|
||||
err = client.PushDevices(ctx, devices)
|
||||
authenticated = err == nil
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
err = cycle(ctx, node, client, pol)
|
||||
}
|
||||
switch {
|
||||
case err == nil:
|
||||
backoff = minBackoff
|
||||
case errors.Is(err, transport.ErrNoWork):
|
||||
case err == nil, errors.Is(err, transport.ErrNoWork):
|
||||
backoff = minBackoff
|
||||
case errors.Is(err, transport.ErrUnauthorized), errors.Is(err, transport.ErrSessionExpired):
|
||||
// Re-handshake on the next iteration. The wait is what stops a bad token from becoming
|
||||
// a tight loop against the relay.
|
||||
// Re-handshake on the next iteration. The wait is what stops a bad token from becoming a
|
||||
// tight loop against the relay.
|
||||
slog.Warn("relay rejected the agent session, re-authenticating", "error", err)
|
||||
authenticated = false
|
||||
backoff = minBackoff * 5
|
||||
case errors.Is(err, transport.ErrRateLimited):
|
||||
// The relay said how long to wait; doubling its own backoff on top of that would be
|
||||
// the agent overriding an explicit instruction.
|
||||
// The relay said how long to wait; doubling its own backoff on top of that would be the
|
||||
// agent overriding an explicit instruction.
|
||||
backoff = client.RetryAfter()
|
||||
default:
|
||||
// A transport failure leaves the session's fate unknown — the relay may still hold the
|
||||
// parked poll, or may have dropped it. Re-handshaking is the safe reading, because a
|
||||
// stale session fails every subsequent request.
|
||||
slog.Warn("poll cycle failed", "error", err, "retryIn", backoff)
|
||||
authenticated = false
|
||||
backoff = next(backoff)
|
||||
}
|
||||
select {
|
||||
@@ -252,21 +303,6 @@ func next(current time.Duration) time.Duration {
|
||||
return doubled
|
||||
}
|
||||
|
||||
func handshakeAndPoll(ctx context.Context, node *agent.Agent, client *transport.Client, pol *policy.Policy, devices []transport.DeviceRegistration) error {
|
||||
if _, err := client.Hello(ctx, version, pol.SHA256()); err != nil {
|
||||
return err
|
||||
}
|
||||
// The relay holds no device state across restarts, and it must never trust a device claim it
|
||||
// cannot tie to an authenticated agent. Reporting the full set after every handshake is what
|
||||
// makes that true without a second channel: the node's paired-device set, including any
|
||||
// revocations from a destroy, is re-asserted each time the session is established. Empty is
|
||||
// pushed too, because "empty" is exactly the claim that revocation needs.
|
||||
if err := client.PushDevices(ctx, devices); err != nil {
|
||||
return err
|
||||
}
|
||||
return cycle(ctx, node, client, pol)
|
||||
}
|
||||
|
||||
// cycle drains one batch. Each request is handled and answered before the next is fetched, so a
|
||||
// slow upstream request delays the queue rather than buffering it in memory.
|
||||
func cycle(ctx context.Context, node *agent.Agent, client *transport.Client, pol *policy.Policy) error {
|
||||
|
||||
Reference in New Issue
Block a user