我在真内核上把安装序列完整走了一遍(容器里补了 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 必须说出下一步")。
138 lines
7.1 KiB
Markdown
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.
|