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