- 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 状态
233 lines
6.7 KiB
Go
233 lines
6.7 KiB
Go
// Command msa-agent runs on a SimAdmin node and lets a paired iPhone reach it through a public
|
|
// relay without any inbound path.
|
|
//
|
|
// It opens no listening socket. Every network action it takes is either an outbound HTTPS request to
|
|
// the relay or a loopback request to the SimAdmin on the same machine, which is the property that
|
|
// makes "no port mapping, no VPN" true rather than merely advertised.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ed25519"
|
|
"encoding/base64"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"time"
|
|
|
|
"fun.chick/msa-agent/internal/agent"
|
|
"fun.chick/msa-agent/internal/config"
|
|
"fun.chick/msa-agent/internal/policy"
|
|
"fun.chick/msa-agent/internal/runtime"
|
|
"fun.chick/msa-agent/internal/transport"
|
|
)
|
|
|
|
// version is stamped in by the Makefile.
|
|
var version = "dev"
|
|
|
|
// Backoff bounds. The lower bound is what makes a relay outage survivable for the relay's operator,
|
|
// and the ceiling is what keeps a long outage from turning into a 30-second-per-retry queue.
|
|
const (
|
|
minBackoff = time.Second
|
|
maxBackoff = 60 * time.Second
|
|
)
|
|
|
|
func main() {
|
|
var (
|
|
configPath = flag.String("config", "/etc/msa-agent/config.json", "path to the agent config file")
|
|
check = flag.Bool("check", false, "validate the config and policy, then exit")
|
|
once = flag.Bool("once", false, "run a single poll cycle and exit")
|
|
)
|
|
flag.Parse()
|
|
|
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
|
slog.SetDefault(logger)
|
|
|
|
if err := run(*configPath, *check, *once); err != nil {
|
|
// One line, then exit non-zero. systemd restarts it, and the journal keeps the reason.
|
|
logger.Error("msa-agent exiting", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func run(configPath string, checkOnly, once bool) error {
|
|
cfg, err := config.Load(configPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pol, err := policy.Load()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sign, err := decodeKey(cfg.SignKey, ed25519.PrivateKeySize, "signKey")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
kx, err := decodeKey(cfg.KxKey, 32, "kxKey")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
devices, err := deviceSet(cfg.Devices)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// The policy digest is logged at every startup and sent in the handshake. An operator comparing
|
|
// one line across a fleet learns whether every node enforces the same surface; without it the
|
|
// only way to know is to log into each box.
|
|
slog.Info("msa-agent starting",
|
|
"version", version,
|
|
"node", cfg.NodeID,
|
|
"localPort", cfg.LocalPort,
|
|
"policy", pol.SHA256(),
|
|
"devices", devices.Len())
|
|
|
|
if checkOnly {
|
|
slog.Info("configuration is valid", "relay", cfg.RelayURL)
|
|
return nil
|
|
}
|
|
|
|
audit, err := runtime.OpenAudit(cfg.AuditPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer audit.Close()
|
|
|
|
exec, err := runtime.NewExecutor(pol, runtime.Config{
|
|
Port: cfg.LocalPort,
|
|
Audit: audit,
|
|
NodePassword: cfg.NodePassword,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
node, err := agent.New(cfg.NodeID, agent.Keys{Sign: sign, Kx: kx}, devices, pol, exec)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
client, err := transport.New(transport.Config{
|
|
BaseURL: cfg.RelayURL,
|
|
NodeID: cfg.NodeID,
|
|
Token: cfg.AgentToken,
|
|
Signer: sign,
|
|
UserAgent: "msa-agent/" + version,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
if once {
|
|
return cycle(ctx, node, client, pol)
|
|
}
|
|
return loop(ctx, node, client, pol)
|
|
}
|
|
|
|
// loop is the agent's whole life: handshake, then poll forever, re-handshaking whenever the relay
|
|
// says the session is gone and backing off when the relay is unreachable.
|
|
//
|
|
// Errors are classified here rather than handled per call site, because the three situations look
|
|
// identical from inside a function and need three different responses: retry now, re-authenticate,
|
|
// or wait.
|
|
func loop(ctx context.Context, node *agent.Agent, client *transport.Client, pol *policy.Policy) error {
|
|
backoff := minBackoff
|
|
for {
|
|
if ctx.Err() != nil {
|
|
return nil
|
|
}
|
|
err := handshakeAndPoll(ctx, node, client, pol)
|
|
switch {
|
|
case err == nil:
|
|
backoff = minBackoff
|
|
case errors.Is(err, transport.ErrNoWork):
|
|
backoff = minBackoff
|
|
case errors.Is(err, transport.ErrUnauthorized), errors.Is(err, transport.ErrSessionExpired):
|
|
// Re-handshake on the next iteration. The wait is what stops a bad token from becoming
|
|
// a tight loop against the relay.
|
|
slog.Warn("relay rejected the agent session, re-authenticating", "error", err)
|
|
backoff = minBackoff * 5
|
|
case errors.Is(err, transport.ErrRateLimited):
|
|
// The relay said how long to wait; doubling its own backoff on top of that would be
|
|
// the agent overriding an explicit instruction.
|
|
backoff = client.RetryAfter()
|
|
default:
|
|
slog.Warn("poll cycle failed", "error", err, "retryIn", backoff)
|
|
backoff = next(backoff)
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil
|
|
case <-time.After(backoff):
|
|
}
|
|
}
|
|
}
|
|
|
|
func next(current time.Duration) time.Duration {
|
|
doubled := current * 2
|
|
if doubled > maxBackoff {
|
|
return maxBackoff
|
|
}
|
|
return doubled
|
|
}
|
|
|
|
func handshakeAndPoll(ctx context.Context, node *agent.Agent, client *transport.Client, pol *policy.Policy) error {
|
|
if _, err := client.Hello(ctx, version, pol.SHA256()); err != nil {
|
|
return err
|
|
}
|
|
return cycle(ctx, node, client, pol)
|
|
}
|
|
|
|
// cycle drains one batch. Each request is handled and answered before the next is fetched, so a
|
|
// slow upstream request delays the queue rather than buffering it in memory.
|
|
func cycle(ctx context.Context, node *agent.Agent, client *transport.Client, pol *policy.Policy) error {
|
|
batch, err := client.Poll(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(batch.Envelopes) == 0 {
|
|
return transport.ErrNoWork
|
|
}
|
|
for _, raw := range batch.Envelopes {
|
|
response, err := node.Handle(ctx, raw)
|
|
if err != nil {
|
|
// Addressed to another node, or malformed beyond answering. Logged, not fatal: one bad
|
|
// envelope must not stop the batch.
|
|
slog.Warn("envelope dropped", "error", err)
|
|
continue
|
|
}
|
|
if err := client.Result(ctx, response); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func decodeKey(value string, size int, field string) ([]byte, error) {
|
|
raw, err := base64.StdEncoding.Strict().DecodeString(value)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("config: %s is not valid base64: %w", field, err)
|
|
}
|
|
if len(raw) != size {
|
|
return nil, fmt.Errorf("config: %s must be %d bytes, got %d", field, size, len(raw))
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
func deviceSet(devices []config.Device) (agent.StaticDevices, error) {
|
|
set := make(agent.StaticDevices, len(devices))
|
|
for _, device := range devices {
|
|
public, err := decodeKey(device.SignKey, ed25519.PublicKeySize, fmt.Sprintf("device %s signKey", device.ID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
set[device.ID] = ed25519.PublicKey(public)
|
|
}
|
|
return set, nil
|
|
}
|