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:
@@ -40,6 +40,11 @@ const (
|
||||
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.
|
||||
@@ -54,6 +59,7 @@ type Executor struct {
|
||||
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,
|
||||
@@ -64,6 +70,10 @@ type Config struct {
|
||||
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.
|
||||
@@ -101,6 +111,10 @@ func NewExecutor(pol *policy.Policy, cfg Config) (*Executor, error) {
|
||||
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,
|
||||
@@ -109,6 +123,7 @@ func NewExecutor(pol *policy.Policy, cfg Config) (*Executor, error) {
|
||||
audit: cfg.Audit,
|
||||
clock: clock,
|
||||
limiter: newRateLimiter(perMinute, time.Minute),
|
||||
auth: auth,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -165,8 +180,11 @@ func (e *Executor) Execute(ctx context.Context, envelope wire.Envelope, inner wi
|
||||
result, err := e.forward(ctx, method, inner.Path, inner.Body)
|
||||
if err != nil {
|
||||
class := ClassUpstream
|
||||
if errors.Is(err, ErrResponseTooLarge) {
|
||||
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)
|
||||
}
|
||||
@@ -177,10 +195,17 @@ func (e *Executor) Execute(ctx context.Context, envelope wire.Envelope, inner wi
|
||||
// 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))
|
||||
@@ -192,13 +217,23 @@ func (e *Executor) forward(ctx context.Context, method, path string, body []byte
|
||||
if len(body) > 0 {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
// No credential is attached here. The agent logs into the local SimAdmin itself on startup and
|
||||
// holds the session; a phone cannot use this path to present a node password it does not have.
|
||||
// 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 {
|
||||
|
||||
Reference in New Issue
Block a user