# 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: ``` 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 --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.