Files
msa-agent/internal/runtime/executor.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

308 lines
11 KiB
Go

package runtime
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
"fun.chick/msa-agent/internal/egress"
"fun.chick/msa-agent/internal/policy"
"fun.chick/msa-agent/internal/wire"
)
// Refusal is classified, and the classification is part of the contract with the operator. A node
// with a drifted clock and a node under attack produce the same outward symptom; collapsing both
// into "denied" guarantees the operator will respond to the wrong one.
type Refusal struct {
Class string
Path string
Err error
}
func (r Refusal) Error() string { return fmt.Sprintf("%s: %v", r.Class, r.Err) }
// Refusal classes, mirrored by the relay's ProblemDetails codes so one string is searchable across
// the agent log, the relay log and the phone.
const (
ClassBadEnvelope = "bad_envelope"
ClassSignature = "bad_signature"
ClassExpired = "expired"
ClassReplay = "replay"
ClassPolicy = "policy_denied"
ClassNoConfirm = "confirmation_missing"
ClassBadConfirm = "confirmation_mismatch"
ClassRateLimited = "rate_limited"
ClassTooLarge = "payload_too_large"
ClassUpstream = "upstream_error"
ClassLoopback = "egress_violation"
// ClassNodeAuth is reported when the agent cannot establish its own session against the local
// SimAdmin. It is distinct from ClassUpstream because the phone's remedy differs: "the node is
// misconfigured" calls for fixing the agent's password, while "the node errored" calls for
// looking at SimAdmin itself.
ClassNodeAuth = "node_auth_failed"
)
// Executor turns a decrypted InnerRequest into one loopback HTTP call.
//
// It holds the only HTTP client in the process, built by egress.NewClient, so there is no path from
// a request body to a socket that is not the loopback one.
type Executor struct {
client *http.Client
target egress.Target
pol *policy.Policy
replay *ReplayCache
audit *Audit
clock func() time.Time
limiter *rateLimiter
auth *NodeAuth
}
// Config is what the agent needs to build an Executor. Port is a number and nothing else: no host,
// by the same reasoning as egress.Target.
type Config struct {
Port int
Timeout time.Duration
Audit *Audit
Clock func() time.Time
DangerousPerMin int
// NodePassword is the credential the agent uses to log into the local SimAdmin itself. Empty
// means the node is expected to be unprotected; a protected node then surfaces as
// ClassNodeAuth rather than forwarding anonymous requests that will fail.
NodePassword string
}
// NewExecutor wires the policy, the loopback client and the replay cache together.
//
// It fails closed: an unusable policy or an unbuildable client is a startup error, because an agent
// that starts with a permissive fallback would be indistinguishable from one that is working.
func NewExecutor(pol *policy.Policy, cfg Config) (*Executor, error) {
if pol == nil {
return nil, errors.New("runtime: policy is nil")
}
target, err := egress.NewTarget(cfg.Port)
if err != nil {
return nil, err
}
timeout := cfg.Timeout
if timeout <= 0 {
timeout = 20 * time.Second
}
client, err := egress.NewClient(target, timeout)
if err != nil {
return nil, err
}
// The cache ttl is the skew window, not an independent knob. An id older than the widest
// acceptable timestamp can never be accepted again, so keeping it costs memory and buys nothing.
replay, err := NewReplayCache(pol.Limits.ReplayCacheSize,
time.Duration(pol.Limits.ClockSkewSeconds)*time.Second)
if err != nil {
return nil, err
}
clock := cfg.Clock
if clock == nil {
clock = time.Now
}
perMinute := cfg.DangerousPerMin
if perMinute <= 0 {
perMinute = pol.Limits.DangerousWritesPerMinute
}
auth, err := NewNodeAuth(client, target, AuthConfig{Password: cfg.NodePassword, Clock: clock})
if err != nil {
return nil, err
}
return &Executor{
client: client,
target: target,
pol: pol,
replay: replay,
audit: cfg.Audit,
clock: clock,
limiter: newRateLimiter(perMinute, time.Minute),
auth: auth,
}, nil
}
// Result is what comes back from the local SimAdmin, or why it did not.
type Result struct {
Status int
Body []byte
}
// Execute validates, then performs, exactly one request.
//
// Order matters and is not cosmetic. The replay and freshness gates run before the policy decision
// so a denied request still burns its request id: otherwise an attacker could probe the allowlist
// by resubmitting one signed request with a dozen paths. The intent is checked after the policy
// decision because the expected digest depends on the path that survived it.
func (e *Executor) Execute(ctx context.Context, envelope wire.Envelope, inner wire.InnerRequest) (Result, *Refusal) {
now := e.clock()
if err := Freshness(envelope.TS, uint64(now.Unix()), e.pol.Limits.ClockSkewSeconds); err != nil {
return Result{}, e.refuse(envelope, inner, ClassExpired, err)
}
if e.replay.Seen(envelope.Req, now) {
return Result{}, e.refuse(envelope, inner, ClassReplay, fmt.Errorf("%w: %s", ErrReplay, envelope.Req))
}
method := strings.ToUpper(strings.TrimSpace(inner.Method))
allowed, dangerous, reason := e.pol.Decision(method, inner.Path)
if !allowed {
return Result{}, e.refuse(envelope, inner, ClassPolicy, errors.New(reason))
}
if len(inner.Body) > e.pol.Limits.MaxRequestBytes {
return Result{}, e.refuse(envelope, inner, ClassTooLarge,
fmt.Errorf("body is %d bytes, limit is %d", len(inner.Body), e.pol.Limits.MaxRequestBytes))
}
if dangerous {
// Ordering inside the dangerous path: rate limit first, then intent. A caller who can send
// unlimited attempts can also brute-force a 32-byte digest in principle, and the limit is
// the cheaper of the two checks to apply.
if !e.limiter.Allow(now) {
return Result{}, e.refuse(envelope, inner, ClassRateLimited, errors.New("too many dangerous writes"))
}
want, err := wire.IntentDigest(method, inner.Path, inner.Body)
if err != nil {
return Result{}, e.refuse(envelope, inner, ClassBadConfirm, err)
}
if len(inner.Intent) == 0 {
return Result{}, e.refuse(envelope, inner, ClassNoConfirm, errors.New("dangerous write carries no confirmation intent"))
}
if !policy.RequiresConfirm(inner.Intent, want) {
return Result{}, e.refuse(envelope, inner, ClassBadConfirm, errors.New("confirmation intent does not match the request being sent"))
}
}
result, err := e.forward(ctx, method, inner.Path, inner.Body)
if err != nil {
class := ClassUpstream
switch {
case errors.Is(err, ErrResponseTooLarge):
class = ClassTooLarge
case errors.Is(err, ErrAuthNoPassword), errors.Is(err, ErrAuthRejected), errors.Is(err, ErrAuthUnavailable):
class = ClassNodeAuth
}
return Result{}, e.refuse(envelope, inner, class, err)
}
e.record(envelope, method, inner.Path, result.Status, "ok")
return result, nil
}
// forward performs the loopback call. The origin comes from egress.Target, which has no host field,
// and the path has already survived policy.Decision, so nothing here is assembled from parts that
// a caller could steer off the machine.
//
// Authentication is established here, after every phone-facing gate: probing /api/auth/status on a
// request the policy will reject anyway would let a relay-fed caller use rejected commands as a
// login-attempt amplifier against a local lockout.
func (e *Executor) forward(ctx context.Context, method, path string, body []byte) (Result, error) {
if !strings.HasPrefix(path, "/api/") {
return Result{}, ClassLoopbackErr
}
if err := e.auth.Ensure(ctx); err != nil {
return Result{}, err
}
var reader io.Reader
if len(body) > 0 {
reader = strings.NewReader(string(body))
}
request, err := http.NewRequestWithContext(ctx, method, e.target.Origin()+path, reader)
if err != nil {
return Result{}, fmt.Errorf("%w: %v", ClassLoopbackErr, err)
}
if len(body) > 0 {
request.Header.Set("Content-Type", "application/json")
}
// The agent's own session, held from a login the phone cannot trigger or see. A node password
// never traverses the relay in either direction.
if cookie := e.auth.Cookie(); cookie != "" {
request.Header.Set("Cookie", cookie)
}
response, err := e.client.Do(request)
if err != nil {
return Result{}, fmt.Errorf("local SimAdmin unreachable: %w", err)
}
defer response.Body.Close()
if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden {
// The session died between Ensure and this call (SimAdmin restarted, cookie expired).
// Drop it so the next command re-probes and re-logs in rather than 401ing until restart.
// This command still answers with the node's refusal; retrying it here would turn every
// genuine 401 from a node endpoint into a login attempt on the phone's behalf.
e.auth.Invalidate()
}
limited := io.LimitReader(response.Body, int64(e.pol.Limits.MaxResponseBytes)+1)
payload, err := io.ReadAll(limited)
if err != nil {
return Result{}, fmt.Errorf("reading local response: %w", err)
}
if len(payload) > e.pol.Limits.MaxResponseBytes {
return Result{}, fmt.Errorf("%w: %s answered with more than %d bytes",
ErrResponseTooLarge, path, e.pol.Limits.MaxResponseBytes)
}
return Result{Status: response.StatusCode, Body: payload}, nil
}
// ClassLoopbackErr is the named error used when a request could not even be constructed.
var ClassLoopbackErr = errors.New("request could not be built for the loopback target")
// ErrResponseTooLarge is returned when the local SimAdmin answers with more than the policy allows.
//
// The response is dropped rather than truncated. A truncated body is not a smaller answer, it is a
// broken one: JSON cut off at an arbitrary byte offset parses as nothing, and the phone would show
// a decoding failure that points at the app instead of at the node that answered with 3 MB.
var ErrResponseTooLarge = errors.New("local response exceeds the policy limit")
func (e *Executor) refuse(envelope wire.Envelope, inner wire.InnerRequest, class string, err error) *Refusal {
e.record(envelope, strings.ToUpper(strings.TrimSpace(inner.Method)), inner.Path, 0, class)
return &Refusal{Class: class, Path: inner.Path, Err: err}
}
func (e *Executor) record(envelope wire.Envelope, method, path string, status int, outcome string) {
if e.audit == nil {
return
}
e.audit.Write(Entry{
At: e.clock(),
Node: envelope.Node,
Req: envelope.Req,
App: envelope.App,
Method: method,
Path: path,
Status: status,
Outcome: outcome,
})
}
// rateLimiter is a fixed-window counter. Deliberately not a token bucket: the property wanted is
// "at most N dangerous writes in any 60 seconds", and a bucket's refill rate makes that harder to
// state and to test.
type rateLimiter struct {
mu sync.Mutex
limit int
window time.Duration
start time.Time
consumed int
}
func newRateLimiter(limit int, window time.Duration) *rateLimiter {
return &rateLimiter{limit: limit, window: window}
}
func (r *rateLimiter) Allow(now time.Time) bool {
r.mu.Lock()
defer r.mu.Unlock()
if r.start.IsZero() || now.Sub(r.start) >= r.window {
r.start = now
r.consumed = 0
}
if r.consumed >= r.limit {
return false
}
r.consumed++
return true
}