Files
msa-agent/README.md
T
chick 9949cfe5b0 fix(install): 刚装完的服务会失败,单元要说清楚而不是每 5 秒重启一次
我在真内核上把安装序列完整走了一遍(容器里补了 useradd/systemctl 的替身,其余全是真的),
暴露出一个只有真跑才看得见的缺陷:

装完 → `install` 启动服务 → 但那时**还没有配对的手机**,而守护进程**故意**拒绝空的设备列表
(一个什么都不能授权的 agent 不该坐在那里显得健康)。于是 `Restart=always` + `RestartSec=5`
变成每 5 秒重启一次,永远,journal 里刷同一行。实测:exit 1,"devices is empty, so no phone
could ever be authorised"。

配对发生在安装**之后**(运维在节点上跑 `--pair`,然后在手机上配对),所以这不是异常状态,
而是流程的必经一步。修法两条:

- 单元加 `StartLimitIntervalSec=300` / `StartLimitBurst=10`:十次之后 systemd 放弃,单元进入
  `failed`——一个运维看得见、能处理的状态,而不是无限重启。
- `install` 的结束语改成说清这一步:服务会退出并停住,下一步是 `--pair`,用
  `systemctl status` 看状态。此前它说"installed and running",而那是假的。

同一轮演练确认了此前所有修复在真内核上成立:服务用户建了、配置与日志目录交给它了、配置对
该用户可读、单元来自二进制且内容一致、卸载按序拆干净(用户/目录/单元全无残留)。

Go 全包通过(新增一项守着"单元必须有启动上限,且 install 必须说出下一步")。
2026-09-18 01:09:15 +08:00

138 lines
7.1 KiB
Markdown

# 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.
```text
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.
```sh
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.
```sh
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
```sh
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.