chick b31b7fe25a fix(release): 发布前确认工作树干净且提交已推送,并显式指定 tag 指向的提交
实测的缺陷:v1.0.2 的二进制是对的(校验和与 b060f86 干净树上的本地构建逐字节一致),但
**tag 落在 1.0.1 那个提交上**——因为脚本不自己建 tag,而是让 Gitea 建,而 Gitea 把 release 的
tag 建在"创建那一刻远端默认分支的 HEAD"上。当时 b060f86 还没 push,origin/main 还是 f499d02,
于是 v1.0.1 和 v1.0.2 指向同一个树,去读该 release 源码的人拿到的是修复前的版本。

两处修正:

1. 创建 release 时显式给 `target_commitish`,不再让服务器决定。
2. 发布前要求工作树干净、且 HEAD 已在某个远端分支上。前者不只是洁癖:Go 默认把 VCS 信息打进
   二进制,工作树脏时构建产物带 "modified" 标记——实测同一个提交,干净树是 9c9c09dd…,脏树是
   1d9bf3a1…,所以"tag 必须指向二进制所来自的那个提交"这句话在有未提交改动时根本不成立。
2026-09-18 14:05:40 +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%