feat: agent 本机 SimAdmin 登录与会话维持;中继设备侧限流与 agent Retry-After

- runtime.NodeAuth:agent 自查 /api/auth/status,需要时自行 POST /api/auth/login 并持
  simadmin_session Cookie 转发;错误密码进入 60s 冷却(对齐控制台 ensureAuthenticated),
  节点回 401/403 即失效重探,SimAdmin 重启无需重启 agent
- 节点密码进 agent 配置(nodePassword),永不经中继、永不下发到手机;登录路径仍被策略
  挡在电话可达面之外(TestAuthPathsAreUnreachable)
- 新拒绝分类 node_auth_failed:与 upstream_error 区分,手机可提示'该节点需重新配置'
- 中继:/v1/request 每设备固定窗口限流(默认 30/分钟),未鉴权调用不消耗预算
- transport:解析 Retry-After 并入退避;main loop 对 429 直接遵从中继指示
- contracts/envelope.schema.json 补 InnerResponse.outcome 字段
- 设计文档与 README 同步:WSS 改为长轮询的实现修订、响应方向已临时×临时、P2/P3 状态
This commit is contained in:
chick
2026-09-16 09:15:25 +08:00
parent 54d9696a4d
commit 443af20e0a
9 changed files with 645 additions and 12 deletions
+17 -7
View File
@@ -172,7 +172,12 @@ func TestAClockedRequestIsClassifiedAsExpiredNotReplay(t *testing.T) {
func TestAuthPathsAreUnreachable(t *testing.T) {
f := newFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("the login endpoint must never be dialed on behalf of a phone")
// The agent's own status probe is allowed here (and answers "unprotected", so no login
// happens); what must never be dialed is a login on behalf of a phone.
if r.URL.Path != "/api/auth/status" {
t.Error("the login endpoint must never be dialed on behalf of a phone")
}
w.WriteHeader(200)
}))
for index, path := range []string{"/api/auth/login", "/api/login", "/api/logout"} {
_, refusal := f.executor.Execute(context.Background(),
@@ -189,7 +194,10 @@ func TestAuthPathsAreUnreachable(t *testing.T) {
func TestADangerousWriteNeedsAMatchingIntent(t *testing.T) {
calls := 0
f := newFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
// The auth probe reaches the local service too; only the intended path is counted.
if r.URL.Path == "/api/sms/send" {
calls++
}
w.WriteHeader(200)
w.Write([]byte(`{"sent":true}`))
}))
@@ -328,10 +336,12 @@ func TestAResponseExactlyAtTheLimitIsForwarded(t *testing.T) {
}
}
func TestAnUnreachableLocalServiceIsAnUpstreamError(t *testing.T) {
func TestAnUnreachableLocalServiceIsANodeAuthError(t *testing.T) {
pol := testPolicy(t)
// A port with nothing on it: the loopback dial fails, which is a node problem rather than a
// policy problem, and the refusal class must say so.
// A port with nothing on it: the authentication probe fails first, which is a node problem
// rather than a policy problem. It is classified as node_auth_failed rather than
// upstream_error because nothing of the phone's command was ever attempted, and the phone's
// remedy ("check that SimAdmin is running on this node") is the operator's, not the user's.
executor, err := NewExecutor(pol, Config{Port: 1, Timeout: 500 * time.Millisecond, Audit: NullAudit()})
if err != nil {
t.Fatal(err)
@@ -339,8 +349,8 @@ func TestAnUnreachableLocalServiceIsAnUpstreamError(t *testing.T) {
_, refusal := executor.Execute(context.Background(),
envelopeAt("nd_test0001", "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", uint64(time.Now().Unix())),
request("GET", "/api/health", nil))
if refusal == nil || refusal.Class != ClassUpstream {
t.Fatalf("got %v, want %s", refusal, ClassUpstream)
if refusal == nil || refusal.Class != ClassNodeAuth {
t.Fatalf("got %v, want %s", refusal, ClassNodeAuth)
}
}