Files
msa-agent/internal/runtime/auth.go
T
chick 443af20e0a 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 状态
2026-09-16 09:15:25 +08:00

281 lines
9.4 KiB
Go

package runtime
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"sync"
"time"
"fun.chick/msa-agent/internal/egress"
)
// NodeAuth holds the agent's own session against the local SimAdmin.
//
// This exists because the phone must never hold a node password: the agent logs in itself and the
// credential never traverses the relay in either direction. The login endpoints are therefore
// reachable by this code and permanently unreachable through the policy (authPaths.proxied=false
// plus the auth-pattern rejection in policy.Decision), so the only way to trigger a login attempt
// against the node is for the agent itself to need one.
//
// The semantics mirror server/core.js ensureAuthenticated in the console, including its failure
// modes: a stale password must not turn every command into a fresh login attempt, because some
// devices punish repeated failures with lockouts.
type NodeAuth struct {
client *http.Client
origin string
password string
cooldown time.Duration
clock func() time.Time
mu sync.Mutex
state authState
cookie string
// retryAt is the wall time after which a failed login may be attempted again.
retryAt time.Time
}
type authState int
const (
// authUnknown is the startup state: nothing has been probed yet.
authUnknown authState = iota
// authOpen means the node answers without a session (protection disabled, or the auth status
// endpoint does not exist on an old build). Commands forward without a cookie.
authOpen
// authReady means cookie holds a live session.
authReady
// authFailed means a login was attempted and rejected; no command may trigger another until
// retryAt passes. This is the cooldown that keeps a stale password from becoming a lockout.
authFailed
// authNoPassword means the node demands authentication but the agent was configured without a
// password. Retrying cannot help; the operator has to fix the config.
authNoPassword
)
var (
// ErrAuthNoPassword is returned when a protected node was given an agent with no password.
ErrAuthNoPassword = errors.New("runtime: node requires authentication but the agent has no password configured")
// ErrAuthRejected is returned while a failed login is inside its cooldown.
ErrAuthRejected = errors.New("runtime: node rejected the agent credential (in login cooldown)")
// ErrAuthUnavailable is returned when the local SimAdmin could not be reached during a probe.
ErrAuthUnavailable = errors.New("runtime: local SimAdmin unreachable during authentication")
sessionCookieRe = regexp.MustCompile(`simadmin_session=([^;\s,]+)`)
)
// DefaultLoginCooldown matches the console's 60s auto-login cooldown. Two knobs in two languages
// would drift; this one lives next to the code that enforces it.
const DefaultLoginCooldown = 60 * time.Second
// AuthConfig configures NodeAuth. Password may be empty: an agent pointed at an unprotected node
// never logs in, and an agent pointed at a protected one fails with ErrAuthNoPassword rather than
// looping.
type AuthConfig struct {
Password string
Cooldown time.Duration
Clock func() time.Time
}
// NewNodeAuth builds the auth holder over the same loopback client the executor forwards with, so
// the login path cannot dial anywhere a command path could not.
func NewNodeAuth(client *http.Client, target egress.Target, cfg AuthConfig) (*NodeAuth, error) {
if client == nil {
return nil, errors.New("runtime: auth client is nil")
}
clock := cfg.Clock
if clock == nil {
clock = time.Now
}
cooldown := cfg.Cooldown
if cooldown <= 0 {
cooldown = DefaultLoginCooldown
}
return &NodeAuth{
client: client,
origin: target.Origin(),
password: cfg.Password,
cooldown: cooldown,
clock: clock,
}, nil
}
// Cookie returns the current session cookie value, or "" when the node needs none. Callers on the
// forwarding path call Ensure first, which is what performs the probe and login.
func (a *NodeAuth) Cookie() string {
a.mu.Lock()
defer a.mu.Unlock()
return a.cookie
}
// Ensure brings the agent to a usable authentication state, at most one probe/login per call.
//
// It is called before forwarding. A ready or open state returns without touching the network, so
// steady-state commands pay nothing; only an unknown or expired state triggers traffic.
func (a *NodeAuth) Ensure(ctx context.Context) error {
a.mu.Lock()
switch a.state {
case authOpen, authReady:
a.mu.Unlock()
return nil
case authNoPassword:
a.mu.Unlock()
return ErrAuthNoPassword
case authFailed:
if a.clock().Before(a.retryAt) {
a.mu.Unlock()
return ErrAuthRejected
}
// Cooldown elapsed: fall through to a fresh probe. A node whose password was fixed, or
// whose lockout expired, recovers without a restart.
}
a.mu.Unlock()
authenticated, loginRequired, err := a.probeStatus(ctx)
if err != nil {
return err
}
if authenticated || !loginRequired {
a.mu.Lock()
a.state = authOpen
a.cookie = ""
a.mu.Unlock()
return nil
}
return a.login(ctx)
}
// Invalidate marks the session unusable after a forwarded request came back unauthenticated, so
// the next Ensure re-probes and (if needed) re-logs in. An agent that never invalidates turns every
// SimAdmin restart into permanent 401s until the agent restarts too.
func (a *NodeAuth) Invalidate() {
a.mu.Lock()
defer a.mu.Unlock()
if a.state == authReady {
a.state = authUnknown
a.cookie = ""
}
}
// probeStatus answers "does this request need a session, and does ours already work".
//
// The parse mirrors core.js: the payload nests under data on some builds and not others, the
// protection flag is snake_case on some and camelCase on others. The default when the shape is not
// recognised is *unprotected*, and that is deliberate in both directions. A legacy or odd build
// that answers 200 with something this parser cannot read is better served without a cookie, since
// the alternative is the agent demanding a password the node never asked for. Login is triggered
// only when the node explicitly says it wants authentication.
func (a *NodeAuth) probeStatus(ctx context.Context) (authenticated, loginRequired bool, err error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, a.origin+"/api/auth/status", nil)
if err != nil {
return false, false, err
}
if a.Cookie() != "" {
request.Header.Set("cookie", a.Cookie())
}
response, err := a.client.Do(request)
if err != nil {
return false, false, fmt.Errorf("%w: %v", ErrAuthUnavailable, err)
}
defer drain(response)
// 401/403 means the node is protected and our cookie (if any) did not authenticate it.
if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden {
return false, true, nil
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
// 404 and friends: an old SimAdmin with no auth endpoints at all is an open node.
return false, false, nil
}
payload, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if err != nil {
return false, false, fmt.Errorf("%w: reading auth status: %v", ErrAuthUnavailable, err)
}
var doc map[string]any
if err := json.Unmarshal(payload, &doc); err != nil {
return false, false, nil
}
auth, _ := doc["data"].(map[string]any)
if auth == nil {
auth = doc
}
if v, ok := auth["authenticated"].(bool); ok {
return v, !v, nil
}
if v, ok := auth["configured"].(bool); ok && !v {
return false, false, nil
}
settings, _ := auth["settings"].(map[string]any)
if settings != nil {
if v, ok := settings["password_protection_enabled"].(bool); ok {
return false, v, nil
}
if v, ok := settings["passwordProtectionEnabled"].(bool); ok {
return false, v, nil
}
}
return false, false, nil
}
// login performs POST /api/auth/login and stores the session cookie. Failures enter the cooldown;
// the cooldown is the reason a wrong password can be fixed in place rather than requiring a
// reinstall.
func (a *NodeAuth) login(ctx context.Context) error {
if a.password == "" {
a.mu.Lock()
a.state = authNoPassword
a.mu.Unlock()
return ErrAuthNoPassword
}
body := strings.NewReader(`{"password":` + jsonQuote(a.password) + `}`)
request, err := http.NewRequestWithContext(ctx, http.MethodPost, a.origin+"/api/auth/login", body)
if err != nil {
return err
}
request.Header.Set("content-type", "application/json")
request.Header.Set("accept", "application/json")
response, err := a.client.Do(request)
if err != nil {
return fmt.Errorf("%w: %v", ErrAuthUnavailable, err)
}
defer drain(response)
if response.StatusCode < 200 || response.StatusCode >= 300 {
a.mu.Lock()
a.state = authFailed
a.cookie = ""
a.retryAt = a.clock().Add(a.cooldown)
a.mu.Unlock()
return fmt.Errorf("runtime: local SimAdmin rejected the agent password (status %d)", response.StatusCode)
}
match := sessionCookieRe.FindStringSubmatch(response.Header.Get("Set-Cookie"))
if match == nil {
// A 2xx without the session cookie is its own failure mode (a proxy that answered on the
// node's behalf, say). Treat it as a rejection rather than forwarding cookie-less.
a.mu.Lock()
a.state = authFailed
a.retryAt = a.clock().Add(a.cooldown)
a.mu.Unlock()
return errors.New("runtime: login succeeded but set no session cookie")
}
a.mu.Lock()
a.state = authReady
a.cookie = match[0]
a.mu.Unlock()
return nil
}
func drain(response *http.Response) {
io.Copy(io.Discard, response.Body)
response.Body.Close()
}
// jsonQuote encodes a string as a JSON string literal without pulling in an encoder for one field.
func jsonQuote(s string) string {
b, _ := json.Marshal(s)
return string(b)
}