chick 20070b90f7 fix: enroll 写出守护进程会拒绝的配置,install 写出与自身路径不符的单元
四处"校验在错误的层"的缺陷,都属于同一类:每个组件单独看都对,接缝错了。

1. `msa-agent enroll --relay http://relay.example` / `relay.example` /
   `https://relay.example/v1` 都会写出配置文件,install 报成功,守护进程在**下一次启动**才
   拒绝,日志里只有一行 "msa-agent exiting"。规则原本散在三处(config 只查非空、transport
   查真实规则、enroll 的注册客户端另有一份),现在收进 `internal/relayurl` 一份,三处都调它。
   `config.File.Validate` 因此也补上了 https/host/无路径/无 query 的检查。

2. `enroll` 不再自己手写字段检查,改为构造真正的 `config.File` 并调用真正的 `Validate`——
   一份"什么是可用配置"的定义,结构上无法再漂移。`config.Marshal` 让 enroll 与 Save 用同一个
   编码器。

3. `install --config /srv/msa/config.json` 会给该文件改属主、交给服务用户,然后写一个
   ExecStart 指向 `/etc/msa-agent/config.json` 的单元——服务起来找不到配置就退出,而它需要的
   文件就在别处、属主正确、从未被读。`enroll --audit /srv/audit/audit.log` 同理:单元的
   `ReadWritePaths` 只覆盖 `/var/log/msa-agent`,在 `ProtectSystem=strict` 下 agent 的
   `MkdirAll` 报 EROFS,进程启动即退出,报错既不提那个 flag 也不提那条指令。现在单元是模板,
   `RenderedUnit` 按实际布局渲染,`--print-unit` 打印的就是会被写入的那一份;默认布局渲染结果
   与仓库里被 review 过的文件逐字节相同,所以 vendored 副本的漂移检查仍然有意义。

测试:新增 `internal/relayurl`,`config`/`enroll`/`install` 各补回归测试。每条都做了变异验证
(把修复回退,对应测试必须失败)。
2026-09-18 12:38:56 +08:00

msa-agent

Runs on a SimAdmin node and lets a paired iPhone reach it through a public relay, without a VPN and without any inbound path into the internal network.

It opens no listening socket in normal operation. Every network action it takes is either an outbound HTTPS long poll to the relay or a loopback request to the SimAdmin on the same machine, which is what makes "no port mapping, no VPN" true rather than merely advertised.

iPhone ──HTTPS(signed, encrypted envelope)──> relay <──outbound long poll── msa-agent ──HTTP──> 127.0.0.1:<port>

The relay is a dumb pipe: it cannot read a request, cannot forge an answer and cannot send a command. The phone signs each request with its own private key and encrypts it to this agent's public key; this agent verifies the signature before touching the payload and re-checks the policy before dialing anything.

Install on a node

The agent is a static binary with no runtime dependencies and no third-party modules. enroll mints the node's keys and writes its config; install puts the systemd service around it.

sudo mkdir -p /opt/msa-agent /etc/msa-agent
sudo curl -fsSL https://gitea.chickliu.fun/Hermes/msa-agent/releases/latest/download/msa-agent-linux-arm64 \
  -o /opt/msa-agent/msa-agent
sudo chmod 755 /opt/msa-agent/msa-agent
sudo /opt/msa-agent/msa-agent enroll --relay https://your-relay --port 3000 \
  --code <one-time code> --write /etc/msa-agent/config.json
sudo /opt/msa-agent/msa-agent install

armv7 is the same build for 32-bit ARM. SHA256SUMS covers both.

With --code the node registers itself with the relay and does not need an entry written into the relay's agents.json by hand. Without it, enroll prints the entry to add.

Removing it again is sudo msa-agent uninstall; --dry-run shows the plan first, and --keep-logs keeps the audit trail.

After installing, pair

The daemon refuses to start with no paired devices, on purpose: an agent that could authorise nothing must not sit there looking healthy. Pairing happens after install, so a freshly installed node's service will fail and stop — the unit's start limit makes systemd give up rather than restart every five seconds forever, and systemctl status msa-agent shows it as failed rather than flapping.

sudo /opt/msa-agent/msa-agent --pair     # opens the LAN window; then pair from the app

Then systemctl start msa-agent (or just wait — it is enabled) and check journalctl -u msa-agent -n 20 for the policy digest and the device count.

Subcommands

Command What it does
(none) The daemon: handshake once, then poll forever
enroll Mints keys and identifiers, writes the config, optionally registers with the relay
install Creates the service user, gives it the config and logs, writes the unit, starts the service
uninstall Tells the relay first, then removes the service, unit, config, logs and user
version Prints the version stamped into the binary
--pair Opens the temporary LAN window a phone uses to pair; closes itself
--pair-bind The address that window binds to (an IP literal; the default is all interfaces)
--check Validates the config and policy, then exits
--once Runs a single poll cycle and exits

install --print-unit prints the systemd unit before installing it. The unit is embedded in the binary rather than fetched, so a node cannot end up running a unit from a different release than its agent.

Layout

Path Contents
contracts/agent-policy.json The authorisation surface: read/write allowlists, loopback enforcement, limits. Embedded in the binary; this repository owns it
contracts/test-vectors.json Deterministic vectors shared with the relay (Node) and the app (Swift), generated by cmd/msa-vectors
internal/wire/ The envelope: what is signed, what is encrypted, and in what order
internal/policy/ Loads and enforces the policy
internal/runtime/ Turns a verified envelope into one loopback call, or refuses it
internal/egress/ The only place a socket is opened toward SimAdmin
internal/agent/ The control loop: verify, decrypt, decide, execute, answer
internal/pair/ The temporary pairing window
internal/enroll/ Key and identifier generation, and self-registration with the relay
internal/install/ The service lifecycle: user, ownership, unit, and its reverse
internal/transport/ The outbound long-poll client
systemd/ The unit file, vendored into the binary by make vendor

Tests

make test          # go test ./... (vendors first)
make vet
go test ./internal/agent -fuzz FuzzHandleDoesNotPanicOnArbitraryEnvelopes

The agent is the one component whose input comes from a party the design assumes is hostile — the relay — so Handle is fuzzed as well as table-tested. A panic there is not a bad request: the agent is a single process holding one poll, so an unrecovered panic ends it and the node goes offline until systemd restarts it.

Three properties are worth stating because they are what the tests are about, and each one was a real defect at some point:

  • Ordering. Nothing that depends on the payload happens before the signature verifies, and nothing that touches SimAdmin happens before the policy says yes. Every refusal is classified, because a node with a drifted clock and a node under attack produce the same outward symptom.
  • The limiters are sliding windows. "At most N dangerous writes in any 60 seconds" is the property; a fixed window admits 2N across its boundary, and a token bucket admits a burst after any quiet period. The relay's per-device budget is the same algorithm, and the two are kept in step because they bound the same operation from two sides.
  • A request the phone has given up on is not executed. The relay answers 504 with "it may or may not have executed it", and that is only honest while it stays true. A batch is processed serially, so the last envelope of a full mailbox can begin well after its deadline; executing it then turns an honest unknown into a duplicate SMS.

Cross-repository contracts

This repository owns the two contract files. The relay keeps vendored copies:

  • agent-policy.json — the relay derives its envelope ceiling, mailbox depth, request deadline and long-poll window from these numbers, so the two cannot drift into disagreeing about what a node will accept. scripts/sync-contracts.sh in the relay repository pulls the copy.
  • test-vectors.json — generated here by make vectors (or go run ./cmd/msa-vectors), recomputed by the relay's Node reference implementation and by the app's Swift tests.

Drift in the policy is visible at runtime rather than only in a test: the agent sends its policy digest in the handshake, the relay compares it with its own copy and reports policyMatches per node in /v1/status.

License

No license file yet: until one is added, the default is all rights reserved. The source is public so the binaries can be fetched by a node that has no credentials — the install command downloads from this repository's releases — not as an invitation to redistribute.

S
Description
Outbound-only node agent for SimAdmin nodes: long-polls a relay, verifies signed envelopes, and forwards to the local SimAdmin on loopback. Static Go binary, no third-party modules.
Readme
268 KiB
2026-09-18 12:39:58 +08:00
Languages
Go 96.8%
Shell 2.4%
Makefile 0.8%