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
+260
View File
@@ -0,0 +1,260 @@
package runtime
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"sync"
"testing"
"time"
"fun.chick/msa-agent/internal/egress"
)
// NodeAuth is tested against a real local server because the property under test is the dialogue
// with a real SimAdmin: status probe, login, cookie attach, 401 invalidation, and the cooldown
// that keeps a wrong password from becoming a lockout.
type authServer struct {
mu sync.Mutex
requests []string
protected bool
password string
logins int
}
func (s *authServer) handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
s.requests = append(s.requests, r.Method+" "+r.URL.Path)
protected := s.protected
password := s.password
loggedIn := r.Header.Get("Cookie") == "simadmin_session=agent-session"
if r.URL.Path == "/api/auth/login" {
s.logins++
}
s.mu.Unlock()
switch {
case r.URL.Path == "/api/auth/status":
w.Header().Set("Content-Type", "application/json")
if !protected {
w.Write([]byte(`{"data":{"authenticated":true}}`))
} else if loggedIn {
w.Write([]byte(`{"data":{"authenticated":true}}`))
} else {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"data":{"authenticated":false,"settings":{"password_protection_enabled":true}}}`))
}
case r.URL.Path == "/api/auth/login":
var body struct {
Password string `json:"password"`
}
json.NewDecoder(r.Body).Decode(&body)
if protected && body.Password == password {
w.Header().Set("Set-Cookie", "simadmin_session=agent-session; Path=/; HttpOnly")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok":true}`))
} else {
w.WriteHeader(http.StatusUnauthorized)
}
case r.URL.Path == "/api/health":
if protected && !loggedIn {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok":true}`))
default:
w.WriteHeader(http.StatusNotFound)
}
})
}
func (s *authServer) count(path string) int {
s.mu.Lock()
defer s.mu.Unlock()
n := 0
for _, r := range s.requests {
if r == "GET "+path || r == "POST "+path {
n++
}
}
return n
}
func (s *authServer) loginCount() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.logins
}
func startAuthServer(t *testing.T, protected bool, password string) (*authServer, int) {
t.Helper()
srv := &authServer{protected: protected, password: password}
server := httptest.NewServer(srv.handler())
t.Cleanup(server.Close)
parsed, err := url.Parse(server.URL)
if err != nil {
t.Fatal(err)
}
port, err := strconv.Atoi(parsed.Port())
if err != nil {
t.Fatal(err)
}
return srv, port
}
func testAuth(t *testing.T, port int, password string, clock func() time.Time) (*NodeAuth, *http.Client) {
t.Helper()
target, err := egress.NewTarget(port)
if err != nil {
t.Fatal(err)
}
client, err := egress.NewClient(target, 2*time.Second)
if err != nil {
t.Fatal(err)
}
auth, err := NewNodeAuth(client, target, AuthConfig{Password: password, Clock: clock})
if err != nil {
t.Fatal(err)
}
return auth, client
}
func TestUnprotectedNodeNeedsNoLogin(t *testing.T) {
srv, port := startAuthServer(t, false, "")
auth, _ := testAuth(t, port, "", nil)
if err := auth.Ensure(context.Background()); err != nil {
t.Fatalf("Ensure: %v", err)
}
if auth.Cookie() != "" {
t.Fatalf("cookie %q on an open node", auth.Cookie())
}
if srv.loginCount() != 0 {
t.Fatal("an unprotected node was logged into")
}
}
func TestProtectedNodeLogsInAndPresentsTheCookie(t *testing.T) {
srv, port := startAuthServer(t, true, "hunter2")
auth, client := testAuth(t, port, "hunter2", nil)
if err := auth.Ensure(context.Background()); err != nil {
t.Fatalf("Ensure: %v", err)
}
if auth.Cookie() == "" {
t.Fatal("no cookie after a successful login")
}
request, _ := http.NewRequest(http.MethodGet, "http://127.0.0.1:"+strconv.Itoa(port)+"/api/health", nil)
request.Header.Set("Cookie", auth.Cookie())
response, err := client.Do(request)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
if response.StatusCode != 200 {
t.Fatalf("health with the agent cookie: %d", response.StatusCode)
}
if srv.count("/api/auth/status") != 1 {
t.Fatalf("status probed %d times, want 1 (steady state is free)", srv.count("/api/auth/status"))
}
// Ensure again must not re-probe or re-login.
if err := auth.Ensure(context.Background()); err != nil {
t.Fatal(err)
}
if srv.count("/api/auth/status") != 1 {
t.Fatal("a ready auth re-probed the status endpoint")
}
}
func TestAProtectedNodeWithoutAPasswordFailsClosed(t *testing.T) {
srv, port := startAuthServer(t, true, "hunter2")
auth, _ := testAuth(t, port, "", nil)
if err := auth.Ensure(context.Background()); err != ErrAuthNoPassword {
t.Fatalf("got %v, want %v", err, ErrAuthNoPassword)
}
if srv.loginCount() != 0 {
t.Fatal("a login was attempted without a configured password")
}
}
// The cooldown is the property: a wrong password must not turn every command into a fresh login
// attempt, because some devices lock accounts after repeated failures.
func TestAWrongPasswordEntersTheCooldown(t *testing.T) {
now := time.Unix(1_800_000_000, 0).UTC()
clock := func() time.Time { return now }
srv, port := startAuthServer(t, true, "hunter2")
auth, _ := testAuth(t, port, "wrong", clock)
if err := auth.Ensure(context.Background()); err == nil {
t.Fatal("Ensure accepted a rejected login")
}
// Repeated Ensures inside the window must not re-attempt the login.
for i := 0; i < 5; i++ {
if err := auth.Ensure(context.Background()); err != ErrAuthRejected {
t.Fatalf("attempt %d: got %v, want %v", i, err, ErrAuthRejected)
}
}
if srv.loginCount() != 1 {
t.Fatalf("login attempted %d times inside the cooldown, want 1", srv.loginCount())
}
// Past the window it tries once more, so a fixed password recovers without a restart.
now = now.Add(DefaultLoginCooldown + time.Second)
if err := auth.Ensure(context.Background()); err == nil {
t.Fatal("Ensure accepted a rejected login after the cooldown")
}
if srv.loginCount() != 2 {
t.Fatalf("login attempted %d times after the window, want 2", srv.loginCount())
}
}
// A SimAdmin restart drops its sessions. Without invalidation every later command 401s until the
// agent restarts too, which is indistinguishable to the user from a broken pairing.
func TestA401FromTheNodeInvalidatesTheSession(t *testing.T) {
srv, port := startAuthServer(t, true, "hunter2")
auth, _ := testAuth(t, port, "hunter2", nil)
if err := auth.Ensure(context.Background()); err != nil {
t.Fatal(err)
}
// Simulate the node losing its session store: logins still work, old cookies do not.
srv.mu.Lock()
srv.password = "hunter2" // unchanged; the cookie check is what fails now
srv.mu.Unlock()
auth.Invalidate()
if err := auth.Ensure(context.Background()); err != nil {
t.Fatalf("Ensure after invalidation: %v", err)
}
if auth.Cookie() == "" {
t.Fatal("the session was not re-established")
}
if srv.loginCount() != 2 {
t.Fatalf("logins: %d, want 2", srv.loginCount())
}
}
// Executor-level: a command to a protected node succeeds end to end, and the phone never sees the
// login traffic — it is not on the allowlist and policy rejects it before any dial.
func TestExecutorLogsInBeforeTheFirstCommand(t *testing.T) {
srv, port := startAuthServer(t, true, "hunter2")
pol := testPolicy(t)
executor, err := NewExecutor(pol, Config{
Port: port, Timeout: 2 * time.Second, Audit: NullAudit(), NodePassword: "hunter2",
})
if err != nil {
t.Fatal(err)
}
result, refusal := executor.Execute(context.Background(),
envelopeAt("nd_test0001", "f1f1f1f1-f1f1-41f1-81f1-f1f1f1f1f1f1", uint64(time.Now().Unix())),
request("GET", "/api/health", nil))
if refusal != nil {
t.Fatalf("refused: %v", refusal)
}
if result.Status != 200 {
t.Fatalf("status %d", result.Status)
}
if srv.loginCount() != 1 {
t.Fatalf("logins: %d", srv.loginCount())
}
}