feat: 独立成仓库,自己拥有契约文件
agent 从 simadmin-relay 拆出来,作为公开仓库:安装命令要由节点执行,而节点没有凭据,所以 二进制必须放在匿名可读的地方,而 relay 源码与 IPA 继续私有。 拆出来之后有一处必须改,不是搬文件:`contracts/agent-policy.json` 原先由 relay 仓库拥有、 agent 侧是 vendored 副本(Makefile 从 `../contracts/` 拷、policy_test.go 断言两份相等)。 现在反过来——**agent 拥有策略**,因为 agent 是执行它的那一方,让它去拷贝一份住在别处的文件 本来就是别扭的。relay 保留 vendored 副本(它的限额从同一组数字推导),而两侧的一致性不再 靠构建期比较(跨仓库的构建期比较谁也做不了),改由运行时保证:agent 在握手时上报策略摘要, relay 拿自己那份比对,在 `/v1/status` 里逐节点给出 `policyMatches`。这比原来的测试更强, 因为它比的是**实际部署的那两份**,而不是两个 checkout 里的文件。 `contracts/test-vectors.json` 同理:Go 实现生成它(`make vectors`),relay 的 Node 参考实现 与 app 的 Swift 测试从 vendored 副本重算。实测重新生成的结果与 relay 那份逐字节相同。 systemd 单元的 `Documentation=` 指向本仓库。`VERSION` 提到 1.0.0。 20 个提交的历史完整保留(filter-branch 按子目录抽取)。
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
# 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.
|
||||
|
||||
## 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 |
|
||||
| `--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.
|
||||
Reference in New Issue
Block a user