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:
chick
2026-09-17 23:24:57 +08:00
parent 7d174a0261
commit 3473816167
9 changed files with 331 additions and 10 deletions
+10
View File
@@ -0,0 +1,10 @@
# Cross-compiled binaries. The build is reproducible from the Makefile, so committing multi-megabyte
# ELF files would only make the repository grow with every rebuild.
dist/
# The vendored copy is produced by `make vendor` from contracts/agent-policy.json.
internal/policy/agent-policy.json.tmp
internal/install/msa-agent.service.tmp
# A binary left behind by `go build ./...` run in this directory.
msa-agent
*.tmp
.DS_Store
+16 -6
View File
@@ -1,21 +1,31 @@
POLICY_SRC := ../contracts/agent-policy.json
# This repository owns the authorisation surface: the agent is what enforces it, so it is not a copy of
# a file that lives somewhere else. `contracts/agent-policy.json` is embedded in the binary and is the
# source of truth; the relay vendors a copy and the two are compared at runtime, by the policy digest the
# agent reports in its handshake.
POLICY_SRC := contracts/agent-policy.json
POLICY_DST := internal/policy/agent-policy.json
# The systemd unit is embedded in the binary so that `msa-agent install` needs no route to anything but
# the relay, and so the unit a node runs is the one its agent was built with. Vendored mechanically for
# the same reason the policy is: an operator editing the copy under internal/ would be editing a file
# the build overwrites, and TestVendoredUnitMatchesSystemd makes that loud.
# the relay, and so the unit a node runs is the one its agent was built with. Vendored mechanically: an
# operator editing the copy under internal/ would be editing a file the build overwrites, and
# TestVendoredUnitMatchesSystemd makes that loud.
UNIT_SRC := systemd/msa-agent.service
UNIT_DST := internal/install/msa-agent.service
# The version stamped into the binary, and the tag scripts/release.sh publishes under. It is the
# agent's own version rather than the app's: the two are released together but a node can be running an
# older agent than the phone, and the handshake's `agentBuild` field is what makes that visible.
VERSION ?= 0.3.0
VERSION ?= 1.0.0
LDFLAGS := -s -w -X main.version=$(VERSION)
.PHONY: all vendor test vet build build-arm64 build-armv7 clean
.PHONY: all vendor test vet fmt vectors build build-arm64 build-armv7 clean
all: vet test build
# Regenerates contracts/test-vectors.json. It is the Go implementation that generates them, and the
# relay's Node reference implementation and the app's Swift tests recompute them from the result — three
# implementations with no shared code, so agreement is evidence rather than a tautology.
vectors:
@go run ./cmd/msa-vectors contracts/test-vectors.json
# The policy is embedded, so the vendored copy is part of the binary. Keep the copy mechanical:
# an operator editing internal/policy/agent-policy.json by hand would be editing a file the build
# overwrites, and the drift test makes that mistake loud rather than quiet.
+122
View File
@@ -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.
+3 -1
View File
@@ -144,7 +144,9 @@ func main() {
check(err)
encoded = append(encoded, '\n')
target := filepath.Join("..", "..", "contracts", "test-vectors.json")
// This repository owns the vectors: the Go implementation generates them, and the Node and Swift
// implementations recompute them from a vendored copy.
target := filepath.Join("contracts", "test-vectors.json")
if len(os.Args) > 1 {
target = os.Args[1]
}
+42
View File
@@ -0,0 +1,42 @@
{
"schemaVersion": 1,
"name": "msa-agent-policy",
"comment": "Authorisation surface for relay-issued commands. Deliberately narrower than the web console proxy policy: this is what the iPhone app can actually ask for, nothing more.",
"egress": {
"mode": "loopback-only",
"portOnly": true,
"comment": "The agent config carries a port, never a host. Outbound connections are dialed to 127.0.0.1/::1 by the transport itself, so there is no host field to misconfigure and no DNS-rebinding path.",
"dialedHosts": ["127.0.0.1", "::1"]
},
"authPaths": {
"pattern": "^/api/(?:auth(?:/|$)|login(?:/|$)|logout(?:/|$))",
"proxied": false,
"comment": "The agent logs into the local SimAdmin itself and holds the credential. Login material never traverses the relay in either direction."
},
"read": [
"/api/health",
"/api/device",
"/api/sim",
"/api/network",
"/api/network/signal-strength",
"/api/stats",
"/api/stats/cpu",
"/api/connectivity",
"/api/sms/list"
],
"write": {
"/api/sms/send": ["POST"],
"/api/sms/batch-delete": ["POST"]
},
"dangerous": ["/api/sms/send", "/api/sms/batch-delete"],
"limits": {
"dangerousWritesPerMinute": 6,
"agentUpstreamTimeoutSeconds": 20,
"maxRequestBytes": 262144,
"maxResponseBytes": 262144,
"requestTtlSeconds": 30,
"clockSkewSeconds": 120,
"replayCacheSize": 4096,
"mailboxDepth": 8
}
}
+129
View File
@@ -0,0 +1,129 @@
{
"generatedBy": "agent/cmd/msa-vectors",
"note": "Deterministic cross-language vectors. The Node relay (test/wire.test.js) and the Swift app recompute these from this file; a mismatch means the envelope encoding drifted, not that one side is broken.",
"suite": "X25519-ED25519-AES256GCM-HKDFSHA256",
"version": 1,
"canonical": {
"comment": "Length-prefixed canonical bytes, then the deterministic Ed25519 signature over them. ek and p are raw decoded bytes, never their base64 text.",
"input": {
"v": 1,
"node": "nd_7f3a9c2e",
"req": "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
"app": "ap_5b1d",
"ts": 1727289600,
"ek": "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo=",
"alg": "X25519-ED25519-AES256GCM-HKDFSHA256",
"p": "AAEC/v8="
},
"bytes": {
"request": "6d73612d72656c61792d76312d726571000000000800000000000000010000000b6e645f37663361396332650000002433663235303465302d346638392d343164332d396130632d3033303565383263333330310000000761705f35623164000000080000000066f45900000000208520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a00000005000102feff",
"response": "6d73612d72656c61792d76312d72657370000000000800000000000000010000000b6e645f37663361396332650000002433663235303465302d346638392d343164332d396130632d303330356538326333333031000000080000000066f45900000000208520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a00000005000102feff"
},
"signature": {
"request": "f91fc4a40c4cb19643515894dba14a1baae602d0b307feafcf441a2d64690b94ac10e20982f8f3e8d2b48c74db11d250344256af92f7c22fa1b2c2fb6975fb0f",
"response": "3977dc4ad544ed83d9921be9929c49f5b38cd618583d99d10d096d3997237c02110da17ffebd45656453d94cd07044d2b1348e0c45381fa193bf5fb08b30b50e"
},
"publicKey": "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",
"responsePublicKey": "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c"
},
"hkdf": {
"comment": "RFC 5869 test case 1, pinning the KDF itself rather than only its callers.",
"secret": "0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b",
"salt": "000102030405060708090a0b0c",
"info": "f0f1f2f3f4f5f6f7f8f9",
"length": 42,
"output": "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865"
},
"x25519": {
"comment": "RFC 7748 section 6.1, then the derived per-direction key and IV for the envelope above. The IV is bound to the request id and the direction, so a key that is ever reused still cannot reuse a nonce.",
"appPrivate": "77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a",
"agentPublic": "de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f",
"sharedSecret": "4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742",
"requestKey": "14f4f1823bd68aabccf326e5a4ee69891428491477bfc6949a25dd69bff1b1e9",
"requestIV": "e5be943a5b8ea89e34bbcb02",
"responseKey": "e350511cf24be4045c770180276146ec647283695394dec608f1695b5f07524e",
"responseIV": "44c5c0499337c5da62b463ad"
},
"aead": {
"comment": "AES-256-GCM under the request key above; the 16-byte tag is appended.",
"key": "14f4f1823bd68aabccf326e5a4ee69891428491477bfc6949a25dd69bff1b1e9",
"iv": "e5be943a5b8ea89e34bbcb02",
"plaintext": "7b226d223a22474554222c2270223a222f6170692f6865616c7468227d",
"ciphertext": "6601340c304905a8308894ab9d050d5e2ea083ee7ad43df01b9c3e79daead5321099e7203abd5985cf2ef12114"
},
"intent": {
"comment": "sha256(method | path | canonical body). The agent recomputes it from the request it is about to forward, so a relay cannot edit a confirmed write.",
"method": "POST",
"path": "/api/sms/send",
"body": "{\"phone_number\":\"+8613800000000\",\"content\":\"hello\"}",
"digest": "d0f584d844cf6e25d5b233a65e86fd80b82a396836cb80522e528b0f6ea5ac00"
},
"canonicalJson": [
{
"input": "{\"b\":1,\"a\":2}",
"output": "{\"a\":2,\"b\":1}"
},
{
"input": "[3,1,2]",
"output": "[3,1,2]"
},
{
"input": " null ",
"output": "null"
},
{
"input": "true",
"output": "true"
},
{
"input": "\"\u003c\u0026\u003e\"",
"output": "\"\u003c\u0026\u003e\""
},
{
"input": "{\"x\":100}",
"output": "{\"x\":100}"
},
{
"input": "{\"a\":{\"c\":[1,{\"b\":2,\"a\":1}],\"b\":0},\"A\":9}",
"output": "{\"A\":9,\"a\":{\"b\":0,\"c\":[1,{\"a\":1,\"b\":2}]}}"
},
{
"input": "{\"phone_number\":\"+8613800000000\",\"content\":\"hello\"}",
"output": "{\"content\":\"hello\",\"phone_number\":\"+8613800000000\"}"
},
{
"input": "{\"content\":\"a\u2028b\"}",
"output": "{\"content\":\"a\u2028b\"}"
},
{
"input": "{\"content\":\"a\u2029b\"}",
"output": "{\"content\":\"a\u2029b\"}"
},
{
"input": "{\"content\":\"😀\"}",
"output": "{\"content\":\"😀\"}"
},
{
"input": "{\"content\":\"\\b\\f\\u0001\"}",
"output": "{\"content\":\"\\b\\f\\u0001\"}"
},
{
"input": "{\"content\":\"\\\\u2028\"}",
"output": "{\"content\":\"\\\\u2028\"}"
},
{
"input": "{\"é\":1,\"z\":2}",
"output": "{\"z\":2,\"é\":1}"
},
{
"input": "{\"𐀀\":1,\"�\":2}",
"output": "{\"�\":2,\"𐀀\":1}"
}
],
"canonicalJsonRejected": [
"{\"content\":\"\\ud800\"}",
"{\"content\":\"\\udc00\"}",
"{\"content\":\"\\ud800\\ud800\"}",
"{\"content\":\"\\ud800x\"}"
]
}
+1 -1
View File
@@ -1,6 +1,6 @@
[Unit]
Description=MultiSimAdmin relay agent
Documentation=https://gitea.chickliu.fun/Hermes/simadmin-relay
Documentation=https://gitea.chickliu.fun/Hermes/msa-agent
After=network-online.target
Wants=network-online.target
+7 -1
View File
@@ -11,8 +11,14 @@ import (
// TestVendoredCopyMatchesContracts fails loudly when the embedded policy and contracts/ drift apart.
// Without this the shipped binary can enforce something other than what the tests in test/ checked,
// which is exactly the silent-divergence case embedding was supposed to avoid.
//
// The file this reads is the one this repository owns. The relay keeps its own copy — it derives its
// limits from the same numbers — and the two are compared at runtime by the policy digest the agent
// reports in its handshake, which the relay surfaces per node. A build-time comparison across two
// repositories is not something either side can make, so the guard is the digest and the drift is
// visible in /v1/status rather than in a test.
func TestVendoredCopyMatchesContracts(t *testing.T) {
upstream, err := os.ReadFile(filepath.Join("..", "..", "..", "contracts", "agent-policy.json"))
upstream, err := os.ReadFile(filepath.Join("..", "..", "contracts", "agent-policy.json"))
if err != nil {
t.Fatalf("read contracts/agent-policy.json: %v", err)
}
+1 -1
View File
@@ -1,6 +1,6 @@
[Unit]
Description=MultiSimAdmin relay agent
Documentation=https://gitea.chickliu.fun/Hermes/simadmin-relay
Documentation=https://gitea.chickliu.fun/Hermes/msa-agent
After=network-online.target
Wants=network-online.target