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:
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user