15 Commits
Author SHA1 Message Date
chick b060f8664c chore: 1.0.2(enroll/install 的接缝修复) 2026-09-18 12:39:58 +08:00
chick 20070b90f7 fix: enroll 写出守护进程会拒绝的配置,install 写出与自身路径不符的单元
四处"校验在错误的层"的缺陷,都属于同一类:每个组件单独看都对,接缝错了。

1. `msa-agent enroll --relay http://relay.example` / `relay.example` /
   `https://relay.example/v1` 都会写出配置文件,install 报成功,守护进程在**下一次启动**才
   拒绝,日志里只有一行 "msa-agent exiting"。规则原本散在三处(config 只查非空、transport
   查真实规则、enroll 的注册客户端另有一份),现在收进 `internal/relayurl` 一份,三处都调它。
   `config.File.Validate` 因此也补上了 https/host/无路径/无 query 的检查。

2. `enroll` 不再自己手写字段检查,改为构造真正的 `config.File` 并调用真正的 `Validate`——
   一份"什么是可用配置"的定义,结构上无法再漂移。`config.Marshal` 让 enroll 与 Save 用同一个
   编码器。

3. `install --config /srv/msa/config.json` 会给该文件改属主、交给服务用户,然后写一个
   ExecStart 指向 `/etc/msa-agent/config.json` 的单元——服务起来找不到配置就退出,而它需要的
   文件就在别处、属主正确、从未被读。`enroll --audit /srv/audit/audit.log` 同理:单元的
   `ReadWritePaths` 只覆盖 `/var/log/msa-agent`,在 `ProtectSystem=strict` 下 agent 的
   `MkdirAll` 报 EROFS,进程启动即退出,报错既不提那个 flag 也不提那条指令。现在单元是模板,
   `RenderedUnit` 按实际布局渲染,`--print-unit` 打印的就是会被写入的那一份;默认布局渲染结果
   与仓库里被 review 过的文件逐字节相同,所以 vendored 副本的漂移检查仍然有意义。

测试:新增 `internal/relayurl`,`config`/`enroll`/`install` 各补回归测试。每条都做了变异验证
(把修复回退,对应测试必须失败)。
2026-09-18 12:38:56 +08:00
chick f499d02ca0 chore: 1.0.1(安装后的启动上限与下一步提示) 2026-09-18 01:10:58 +08:00
chick 9949cfe5b0 fix(install): 刚装完的服务会失败,单元要说清楚而不是每 5 秒重启一次
我在真内核上把安装序列完整走了一遍(容器里补了 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 必须说出下一步")。
2026-09-18 01:09:15 +08:00
chick fc7a77c500 feat: 本仓库自己发布二进制
发布逻辑搬到这里,因为二进制从这里构建、这里拥有契约。simadmin-relay 的那个脚本改成
转发(一条命令,不是两份实现——两处知道怎么发同一批产物,就是两处要在注册表 API 变化时
一起改,这个仓库已经犯过一次)。

与本仓库的一条硬约束绑定:**必须公开可读**。安装命令由节点执行、节点没有凭据,而 Gitea
对私有仓库返回 404,所以节点上看到的失败长得像 URL 写错。脚本现在会检查并**拒绝发布**,
而不是发出一批拿不到的东西。

VERSION 提到 1.0.0,release body 里的安装片段更新为 enroll + install 两步。
2026-09-17 23:51:35 +08:00
chick 3473816167 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 按子目录抽取)。
2026-09-17 23:24:57 +08:00
chick 7d174a0261 fix(agent): 卸载时指出该去哪删节点,而不是一律说 agents.json
agent 不持有运维令牌——节点不是运维,一个能删掉邻居的被控节点比它解决的问题更糟——所以
它不能自己从中继上摘掉自己。此前卸载的结束语对所有节点都写"the relay's agents.json still
lists this node",而自注册的节点根本不在那里:它在中继的状态文件里,要从手机上删。运维照着
那句话去翻一个没有这条记录的文件。

现在节点在登记时把来历写进配置(`selfEnrolled`,带 `--code` 为真),卸载结束语据此给出
正确的那一句:

- 自注册的 → "在中继的登记状态里,去 App 的「中继节点」移除"
- 手工登记的 → "在 agents.json 里,删掉那条并重启中继"
- 配置读不出来 → 两句都提,因为读不到不是让卸载失败的理由

测试两项:卸载打印的是它被告知的那一句(且不再对自注册节点说 agents.json)、登记时正确
记录来历(带码为真、不带码不写这个字段)。

Go 全包、Node 141 项、Swift 121 项通过。
2026-09-17 19:12:32 +08:00
chick 934760f8e2 fix(agent): 补上安装的服务那一半——建用户、改所有权、装单元
上一轮那段"一键安装"是坏的,而且只在节点上坏。实测三段里漏了三件:

1. systemd 单元写着 `User=msa-agent`,而命令从没创建这个用户;
2. `enroll` 是 sudo 跑的,配置是 root 所有 0600,服务以另一个用户运行——读不到自己的私钥;
3. 单元从 `msa-agent.service` 这个仓库里的路径拷,而节点上没有仓库。

三段都"看起来对",因为它们各自是一行合理的 shell;错的是它们与单元之间的接缝。这正
是这个项目里反复出现的那一类缺陷。

现在有 `msa-agent install`:

- 建 `--system --no-create-home --shell /usr/sbin/nologin` 的用户(已存在则跳过);
- 建日志目录,并把配置、配置目录、日志目录 chown 给该用户;
- 把 systemd 单元写进去。单元**嵌在二进制里**(`go:embed`),不是从仓库路径拷的,也不是
  从 URL 下的——一个能发 unit 的服务器就能给节点塞一个后门 unit,那与"中继不可信"是同一
  条理由。`--print-unit` 可以在装之前把它打出来看;
- `daemon-reload` 并 enable/start。

幂等:每步先看已经是什么状态,所以升级或上次失败后重跑都安全。`--dry-run` 打印计划且什么
都不改,并且在缺二进制或配置时**不拒绝**——"还没装"正是会去跑 dry-run 的状态,它只把缺的
东西指出来。chown 走 Runner 而不是 os.Chown,这样整条序列都能在无 root 的情况下测。

install 与 uninstall 共用同一份 `Paths`:装了什么没卸掉会在机器上留一份凭据,卸了什么没装
过会在新节点上失败。单元也一样,`TestVendoredUnitMatchesSystemd` 守着嵌入副本与
`systemd/msa-agent.service` 不漂移。

手机上的安装命令相应简化:`install` 那一步取代了原先手工的 useradd/install/systemctl 三行,
卸载那段也先给 `--dry-run`。

测试新增五项:单元不漂移、install 满足单元的全部要求(用户、所有权、单元来自二进制而非仓库
路径)、install dry run 什么都不改、缺二进制或配置时拒绝且一条命令都不跑、dry run 在全新
节点上不拒绝而是指出缺什么。变异验证:去掉 chown、去掉建用户、让 dry run 执行命令——三处都
让对应测试失败。

Go 全包、Node 134 项、Swift 116 项通过。
2026-09-17 17:55:57 +08:00
chick a4439331e3 feat(release): 把 agent 二进制发到 Gitea Releases
安装命令要成立,节点就得有个地方取二进制——节点上没有 Go 工具链,此前唯一的办法是在
另一台机器上编译再 scp,而这一步是安装命令表达不了的。

scripts/release.sh:编译两个 ARM 目标、生成 SHA256SUMS、建 release、上传三个附件。
令牌从 git 的 credential helper 读,所以进程表和 shell 历史里都没有它;也可以用
MSA_GITEA_TOKEN 覆盖。重跑会复用已存在的 release 并替换同名附件——修完东西重发时,
否则会静默留着上一个二进制。--dry-run 只编译和算校验值。

Makefile 的 VERSION 从 0.1.0 提到 0.3.0,并且它现在是二进制里真实带着的版本
(`msa-agent version` 与启动日志都会报),也是 release 的 tag。

已发布 v0.3.0:下载回来验过,sha256 与 SHA256SUMS 一致,是 ARM aarch64 静态 ELF,
且带着新的 enroll/uninstall 子命令。

Go 全包、Node 134 项、Swift 107 项通过。
2026-09-17 13:37:24 +08:00
chick 62b322af39 feat(agent,relay): 节点自注册与一键卸载
节点侧现在有两条命令覆盖整个生命周期,中继侧有了一条让节点不必被人工登记就能加入
的通道。

agent 新增两个子命令:

- `msa-agent enroll --relay <url> [--code <code>]` 铸造密钥与标识并写配置。它和
  `msa-keygen` 共用 internal/enroll,所以两者不会对"配置长什么样"产生分歧——区别只在
  运行位置:keygen 需要 Go 工具链,而节点上只有交叉编译好的二进制。带 --code 时它自己
  向中继登记,顺序是先登记再落盘:为"中继还不认识的节点"写下的配置会永远重试 401,而
  运维无法把这种情况和网络故障区分开。
- `msa-agent uninstall [--dry-run] [--keep-logs]` 按唯一一个不会留下悬挂状态的顺序拆除:
  先清设备并告诉中继,再停服务、删单元、删配置目录、删日志、删用户。顺序是重点——先删
  节点会让中继继续向一台已经不存在的节点投递,而"一键卸载"此前根本不存在,尽管安装会
  往宿主机放六样东西,其中配置里还有节点密码。

  写这个时踩到一个值得记的坑:第一版把每个动作都写成"先 log 再执行",于是 --dry-run
  会真的停掉服务——一个不是 dry run 的 dry run。现在每个副作用都 gate 在 step() 的
  返回值上,并有测试断言 dry run 一条命令都不跑。

中继新增自注册,默认关闭:

- `POST /v1/enroll/code`(运维令牌)铸造一次性码,15 分钟、最多 10 个未使用。
- `POST /v1/enroll` 节点用码登记自己。码先兑换再校验登记体,所以用垃圾请求探测码是否
  有效的尝试会烧掉它。
- 登记只授权"加入"这一件事。中继仍然读不到内容、伪造不了应答、下发不了命令——最坏情况
  是让一个不该出现的节点出现,也就是拒绝服务,正是设计已经接受的边界。
- agents.json 的条目优先于自注册状态,所以运维永远能用一个 diff 覆盖或吊销自注册的节点;
  反过来,节点不能覆盖运维条目(被拒绝,400)。
- 自注册状态要落盘,这是中继第一次有它必须记住的东西。中继此前无状态是它的一条性质,
  这里明确让掉了:忘记已登记的节点会让它们全部 401,而症状看起来像 token 坏了。状态文件
  原子写、损坏时拒绝启动(而不是静默变成空机群),systemd 单元用 StateDirectory 给它一块
  可写目录,其余文件系统仍是只读。

测试:internal/enroll 五项(两份产物必须描述同一个节点、密码只在给出时写入、给定标识
不被重新生成、两次铸造不同、缺输入被拒),internal/install 七项(删干净、顺序、中继不可
达仍完成、dry run 不动任何东西、保留日志、缺件不中止、计划列出每条路径),
test/enrollment.test.js 十四项(流程、码单次、垃圾请求烧码、默认关闭、只有运维能铸造、
不能覆盖运维条目、可重复登记、重启后仍在、运维优先、损坏状态拒绝启动、码过期与清扫、
铸造循环不涨内存、登记体校验、状态文件不含私钥)。变异验证:去掉码的单次性、去掉运维条目
保护、让 dry run 执行命令、先删配置再通知中继——四处都让对应测试失败。

Go 全包、Node 134 项、Swift 107 项通过。
2026-09-17 13:28:32 +08:00
chick 5feb5bfe70 feat(agent): 配对尝试记录来源 IP
"十次失败"说明窗口关了,"十次来自 10.0.0.7 的失败"说明有人在试——后者才是可行动
的那句话,而它是尝试次数上限无法提供的信息。每次尝试与每次成功现在都带 peer。

只记 IP 不记端口:端口每次都是新的临时号,带上会让同一台机器的两次尝试看起来像
两台,而"有几台机器在试"正是要回答的问题。

测试断言失败尝试带上了 peer,且是去掉端口后的主机。变异验证:去掉 peer,测试失败。

Go 全包、Node 120 项、Swift 107 项通过。
2026-09-17 07:58:34 +08:00
chick b5f548f1ad feat(agent): 配对窗口可显式绑定地址,默认仍是内网可达
配对窗口是 agent 唯一持有的监听端口,而它授予的是设备信任:谁能带节点密码打到
/provision,谁就拿到一把 agent 会听命的密钥。此前它固定绑所有接口,且没有任何办法
收窄——一台本身能从公网路由到的节点(公网 VPS 上的 SimAdmin、放在非可信网络里的
设备)在窗口开启期间就把这个面暴露给了整个互联网。

现在加 --pair-bind,只接受 IP 字面量。不接受主机名是刻意的:绑定时解析名字意味着
监听地址取决于 DNS 当时说什么,那不是运维做过的决定。

默认值仍是绑所有接口,这个我改过一次又改回来了,原因值得记下来:我先把默认设成
127.0.0.1 并写了理由,然后发现配对按设计走内网、手机要直接打到节点,回环监听从
另一台机器根本连不上——那不是一个加固措施,而是一个永远不工作的流程,用户对这个
功能的第一次体验会是"连接被拒"且没有解释。所以默认保持内网可达,把"节点能从公网
路由到时请收窄"写进部署文档,并在 --pair-bind 上给出显式入口。

测试断言默认值同时满足两件事:是 IP 字面量(不是名字),且不是回环(手机要能连)。

Go 全包、Node 120 项、Swift 107 项通过。
2026-09-17 07:47:25 +08:00
chick 0f983f046a fix(agent): 读上限要容得下整批轮询结果
同一类缺陷的第四处,也是最难发现的一处,因为它在最繁忙那条接缝的另一侧。agent
限制自己从中继读多少,而那个上限必须容得下中继**合法能发**的最大东西:一整批
轮询结果,即每个邮箱槽一个信封。原值 1 MiB 是"给宽一点"拍出来的,没有任何推导;
而按策略上限算,满邮箱的一批约 4 MiB。于是 agent 会拒掉一个中继答得好好的轮询,
然后重试进同一个拒绝——表现为持续的连接抖动,日志说"relay response exceeds
1048576 bytes",而中继那边一切正常。

现在取 8 MiB,并把推导写清楚:中继信封上限 × 邮箱深度,留一档余量。两侧的关系由
test/limits.test.js 钉住(常量从 Go 源码读出),因为这是跨语言、跨进程的一对数字,
任何一侧单独都看不到。

变异验证:改回 1 MiB,测试失败。

Node 119 项、Go 全包、Swift 101 项通过。
2026-09-17 07:21:29 +08:00
chick 274e25961a fix(agent): 手机已经放弃的请求不再执行
中继答 504 并说"可能已经执行了,重试前请先确认"——这句话诚实的前提是它保持为真。
批次是串行处理的,所以满邮箱里第八个信封可以在它被签名之后 8 个上游超时才轮到;
那时手机早已被告知失败、用户可能已经重试。再执行它,就把一次诚实的"未知"变成了
一条重复发出的短信。

现在 agent 在动任何东西之前先比对自己的截止时间(策略里的 requestTtlSeconds),
超龄一律以 expired 拒绝并落审计。判定放在新鲜度检查之后、策略判定之前:它比签名
更便宜,而且一个过期请求无论路径是否在白名单里都不该执行。

为了让这条规则**可达**,策略新增一条断言:TTL 不得大于时钟偏移窗口。否则新鲜度
检查会先触发,这条规则永远不会被执行——而"永不触发的规则"是出事时才会被发现坏了
的那种东西。

单个上游调用的超时也移进了策略(agentUpstreamTimeoutSeconds)。"中继期限必须长于
一次上游调用"这个比较,与二进制里的常量是比不了的;现在两侧从同一个文件读,策略
加载时就能拒绝一组自相矛盾的数值。原来那个 20s 常量保留为默认值的来源,不再硬编码。

测试两项:一个请求在期限内被转发、一个超龄请求被拒且**没有被转发**(计数按路径,
否则首次转发前的 /api/auth/status 探测会让断言以错误的理由通过);另一项断言策略
本身满足上述两个关系。变异验证:关掉判定,第一项失败。

Go 全包、Node 119 项、Swift 101 项通过。
2026-09-17 07:12:44 +08:00
chick 9608a7ebc0 fix(agent): 审计写入失败不再静默
审计日志是证据而不是控制手段:写失败不能让请求失败,否则磁盘写满会变成一次远程
拒绝服务——已经执行成功的指令会因为日志追加不上而开始报错,那是坏错了地方。

但它也不能消失。静默丢审计条目是那种"运维以为自己有记录、其实没有"的失败,而这份
日志的全部意义就是事后能回答"谁在什么时候要求了什么"。现在写失败会进 agent 自己的
日志,磁盘写满表现为一行反复出现,而不是一片空白。

顺带把 Close 之后的语义写清楚:Close 会丢掉 sink,此后的写入返回 nil——这是刻意的,
因为一个正在关闭的进程与在途请求赛跑时,为每一个恰好到达的请求报一次失败是噪声
而不是信息。这条差别在 agent 日志里看得见。

测试用真实失败路径(写一个已关闭的文件)而不是填满文件系统,并断言 exec.record 把
它报了出来。

Go 全包、Node 112 项、Swift 101 项通过。
2026-09-17 01:52:59 +08:00
32 changed files with 3082 additions and 143 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
+26 -4
View File
@@ -1,20 +1,42 @@
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
VERSION ?= 0.1.0
# 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: 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 ?= 1.0.2
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.
vendor: $(POLICY_DST)
vendor: $(POLICY_DST) $(UNIT_DST)
$(POLICY_DST): $(POLICY_SRC)
@cp $(POLICY_SRC) $(POLICY_DST)
$(UNIT_DST): $(UNIT_SRC)
@cp $(UNIT_SRC) $(UNIT_DST)
test: vendor
@go test ./...
+137
View File
@@ -0,0 +1,137 @@
# 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.
+222
View File
@@ -0,0 +1,222 @@
package main
import (
"context"
"crypto/ed25519"
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"time"
"fun.chick/msa-agent/internal/config"
"fun.chick/msa-agent/internal/enroll"
"fun.chick/msa-agent/internal/install"
"fun.chick/msa-agent/internal/policy"
"fun.chick/msa-agent/internal/transport"
)
// EnrollOptions is what `msa-agent enroll` was asked to do.
type EnrollOptions struct {
ConfigPath string
RelayURL string
LocalPort int
AuditPath string
NodeID string
AgentToken string
ProvisionSecret string
RelayCAFile string
NodePassword string
// Code is a one-time enrollment code. When set, the node registers itself with the relay and does
// not print a registration entry for anyone to copy.
Code string
// Force overwrites an existing config. Without it, an existing file is a refusal rather than an
// overwrite, because the file holds the node's private keys: replacing it silently would unpair
// every phone and orphan the node at the relay.
Force bool
// PrintOnly mints and prints without writing anything.
PrintOnly bool
}
// runEnroll mints this node's identity and writes its config, optionally registering with the relay.
//
// It is the on-node counterpart of `msa-keygen`, and both call internal/enroll, so the two cannot
// disagree about what a config looks like. The difference is where they run: msa-keygen needs a Go
// toolchain, which a node with a cross-compiled binary does not have.
func runEnroll(ctx context.Context, opts EnrollOptions) error {
if _, err := os.Stat(opts.ConfigPath); err == nil && !opts.Force && !opts.PrintOnly {
return fmt.Errorf("%s already exists; pass --force to replace it (this discards the node's keys "+
"and unpairs every phone)", opts.ConfigPath)
}
result, err := enroll.Generate(enroll.Options{
// The flag is what records the node's own provenance: with a code the relay holds a
// self-enrolled entry for it, without one an operator wrote it into agents.json.
SelfEnrolled: opts.Code != "",
RelayURL: opts.RelayURL,
LocalPort: opts.LocalPort,
AuditPath: opts.AuditPath,
NodeID: opts.NodeID,
AgentToken: opts.AgentToken,
ProvisionSecret: opts.ProvisionSecret,
RelayCAFile: opts.RelayCAFile,
NodePassword: opts.NodePassword,
})
if err != nil {
return err
}
if opts.PrintOnly {
os.Stdout.Write(result.Config)
fmt.Fprintf(os.Stderr, "\n# relay registration\n%s\n", result.Registration)
fmt.Fprintf(os.Stderr, "# pairing secret: %s\n", result.ProvisionSecret)
return nil
}
// The registration goes first, and that order is the point of this command: a config written for a
// relay that does not know the node is a node that retries 401 forever, and an operator has no way
// to tell that apart from a network problem.
if opts.Code != "" {
var registration enroll.Registration
if err := json.Unmarshal(result.Registration, &registration); err != nil {
return fmt.Errorf("enroll: decoding the registration: %w", err)
}
if err := enroll.Register(ctx, enroll.RegisterOptions{
RelayURL: opts.RelayURL,
Code: opts.Code,
Registration: registration,
RelayCAFile: opts.RelayCAFile,
}); err != nil {
// Nothing has been written, so a retry with a fresh code is safe and the node is untouched.
return err
}
slog.Info("relay accepted the enrollment", "node", result.NodeID, "relay", opts.RelayURL)
}
if err := os.MkdirAll(filepath.Dir(opts.ConfigPath), 0o755); err != nil {
return err
}
// 0600: private keys, the relay bearer, and the node password when the node is protected.
if err := os.WriteFile(opts.ConfigPath, result.Config, 0o600); err != nil {
return err
}
fmt.Printf("node id: %s\n", result.NodeID)
fmt.Printf("wrote config: %s (mode 0600)\n", opts.ConfigPath)
if opts.Code == "" {
// Without a code the relay still has to be told by hand, so the entry is printed for copying.
fmt.Printf("\n# the relay does not know this node yet. Add this to its agents.json:\n%s\n", result.Registration)
}
fmt.Printf("\npairing secret: %s\n", result.ProvisionSecret)
fmt.Printf(" (only needed on an unprotected node; a node with nodePassword uses that instead)\n")
fmt.Printf("\nnext: systemctl enable --now msa-agent, then `msa-agent --pair` once and pair from the app\n")
return nil
}
// runUninstall removes the agent from this node.
//
// The relay is told first, through the same mechanism a revocation uses: the device list is emptied and
// the relay is told directly, so it retires this node's device bearers before the node stops existing.
// Removing the node first would leave the relay routing to a node that is gone.
func runUninstall(ctx context.Context, opts install.Options, configPath string) error {
// A dry run needs no privileges: it reads nothing and removes nothing, and an operator checking
// what an uninstall would do should not have to be root to ask.
if !opts.DryRun {
if err := install.RequireRoot(); err != nil {
return err
}
}
paths := install.DefaultPaths()
// The config path is a flag, so it can differ from the layout the defaults assume.
paths.ConfigPath = configPath
opts.Paths = paths
if opts.NotifyRelay == nil && !opts.DryRun {
opts.NotifyRelay = func() error { return retireAtRelay(ctx, configPath) }
}
if opts.RemovalHint == "" {
opts.RemovalHint = removalHint(configPath)
}
return install.Uninstall(opts)
}
// retireAtRelay empties the paired-device list and tells the relay, so it drops this node's bearers.
//
// It talks to the relay itself rather than waiting for the running agent to notice. The agent does
// notice — it re-reads its config every poll cycle — but a poll window is 24 seconds, and making an
// uninstall wait on one, with a timeout, to learn something it can say in one round trip is a worse
// trade than holding a session for two requests.
//
// The order matters: the file is cleared first, so an agent that notices mid-way pushes the same empty
// set this function does. Both converge, and neither can resurrect a device.
func retireAtRelay(ctx context.Context, configPath string) error {
cfg, err := config.LoadForPairing(configPath)
if err != nil {
return fmt.Errorf("reading %s: %w", configPath, err)
}
if len(cfg.Devices) == 0 {
return nil
}
cleared := cfg
cleared.Devices = nil
if err := config.Save(configPath, cleared); err != nil {
return fmt.Errorf("clearing the device list: %w", err)
}
sign, err := decodeKey(cfg.SignKey, ed25519.PrivateKeySize, "signKey")
if err != nil {
return err
}
roots, err := relayRoots(cfg.RelayCAFile)
if err != nil {
return err
}
client, err := transport.New(transport.Config{
BaseURL: cfg.RelayURL,
NodeID: cfg.NodeID,
Token: cfg.AgentToken,
Signer: sign,
UserAgent: "msa-agent/" + version,
RootCAs: roots,
})
if err != nil {
return err
}
pol, err := policy.Load()
if err != nil {
return err
}
// A short, bounded attempt: the uninstall must finish even when the relay is unreachable, and the
// caller prints what to do by hand in that case.
attemptCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
if _, err := client.Hello(attemptCtx, version, pol.SHA256()); err != nil {
return fmt.Errorf("handshaking with the relay: %w", err)
}
if err := client.PushDevices(attemptCtx, nil); err != nil {
return fmt.Errorf("reporting an empty device set: %w", err)
}
return nil
}
// removalHint says what is left at the relay, which depends on how this node got there.
//
// The distinction matters because the two records live in different places and are removed by different
// people. A self-enrolled node is in the relay's own state file and comes off from the app; a node an
// operator wrote into agents.json is in a file the relay only reads, and has to be edited and the relay
// restarted. Telling the operator the wrong one sends them to the wrong place.
//
// An unreadable config is not a reason to fail an uninstall, so it falls back to naming both.
func removalHint(configPath string) string {
cfg, err := config.LoadForPairing(configPath)
if err != nil {
return "the relay may still list this node; check its agents.json and, if it accepts self-enrollment, the app"
}
if cfg.SelfEnrolled {
return "the relay still lists this node in its enrollment state; remove it in the app (中继节点 → 移除)"
}
return "the relay's agents.json still lists this node; remove that entry and restart the relay"
}
+144 -4
View File
@@ -15,6 +15,7 @@ import (
"flag"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
@@ -24,6 +25,7 @@ import (
"fun.chick/msa-agent/internal/agent"
"fun.chick/msa-agent/internal/config"
"fun.chick/msa-agent/internal/install"
"fun.chick/msa-agent/internal/pair"
"fun.chick/msa-agent/internal/policy"
"fun.chick/msa-agent/internal/runtime"
@@ -41,12 +43,31 @@ const (
)
func main() {
// Subcommands are dispatched before flag.Parse, because the default invocation is a bare
// `msa-agent --config ...` from systemd and a positional argument there would be a parse error.
// The two subcommands are the whole lifecycle around that daemon: put the node on the machine, and
// take it off again.
if len(os.Args) > 1 {
switch os.Args[1] {
case "enroll":
os.Exit(runEnrollCommand(os.Args[2:]))
case "install":
os.Exit(runInstallCommand(os.Args[2:]))
case "uninstall":
os.Exit(runUninstallCommand(os.Args[2:]))
case "version":
fmt.Println(version)
return
}
}
var (
configPath = flag.String("config", "/etc/msa-agent/config.json", "path to the agent config file")
check = flag.Bool("check", false, "validate the config and policy, then exit")
once = flag.Bool("once", false, "run a single poll cycle and exit")
pairMode = flag.Bool("pair", false, "open the temporary pairing window on the LAN and exit when it closes")
pairPort = flag.Int("pair-port", pair.DefaultAdminPort, "port for the pairing window's management listener")
pairBind = flag.String("pair-bind", pair.DefaultAdminHost, "address the pairing window binds to")
)
flag.Parse()
@@ -55,7 +76,7 @@ func main() {
var err error
if *pairMode {
err = runPair(*configPath, *pairPort)
err = runPair(*configPath, *pairPort, *pairBind)
} else {
err = run(*configPath, *check, *once)
}
@@ -71,7 +92,7 @@ func main() {
// the invariant "the agent binds no socket" is a property of the daemon; the pairing window is a
// deliberate, operator-started exception with three independent shutdowns (first pairing, attempt
// ceiling, deadline). Pairing is run again by the operator for each additional phone.
func runPair(configPath string, adminPort int) error {
func runPair(configPath string, adminPort int, adminHost string) error {
cfg, err := config.LoadForPairing(configPath)
if err != nil {
return err
@@ -82,13 +103,19 @@ func runPair(configPath string, adminPort int) error {
if err != nil {
return err
}
if net.ParseIP(adminHost) == nil {
// A name here would be resolved at bind time, and the window is the one place the agent holds a
// listener: an operator who typed a hostname would get whatever that name resolved to, which is
// not a decision they made deliberately. The default is a literal for that reason.
return fmt.Errorf("pair-bind must be an IP literal, got %q", adminHost)
}
server := &http.Server{
Addr: ":" + strconv.Itoa(adminPort),
Addr: net.JoinHostPort(adminHost, strconv.Itoa(adminPort)),
Handler: handler.Routes(),
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
slog.Info("pairing window open", "port", adminPort, "node", cfg.NodeID)
slog.Info("pairing window open", "bind", adminHost, "port", adminPort, "node", cfg.NodeID)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("pairing listener failed", "error", err)
handler.Close()
@@ -371,3 +398,116 @@ func deviceSet(devices []config.Device) (agent.StaticDevices, error) {
}
return set, nil
}
// enrollFlags are the enrollment command's own flag set. It is separate from the daemon's so that
// `msa-agent enroll --help` describes only what enroll does, and so a flag added to one cannot
// silently change the other.
func runEnrollCommand(args []string) int {
flags := flag.NewFlagSet("enroll", flag.ExitOnError)
var opts EnrollOptions
flags.StringVar(&opts.ConfigPath, "write", "/etc/msa-agent/config.json", "where to write the agent config")
flags.StringVar(&opts.RelayURL, "relay", "", "relay base URL, https (required)")
flags.IntVar(&opts.LocalPort, "port", 3000, "the local SimAdmin port on 127.0.0.1")
flags.StringVar(&opts.AuditPath, "audit", "/var/log/msa-agent/audit.log", "audit log path")
flags.StringVar(&opts.NodeID, "node", "", "node id (nd_...); generated when empty")
flags.StringVar(&opts.AgentToken, "token", "", "relay bearer for this agent; generated when empty")
flags.StringVar(&opts.ProvisionSecret, "provision-secret", "", "pairing secret; generated when empty")
flags.StringVar(&opts.RelayCAFile, "relay-ca", "", "PEM bundle to pin as the relay's CA (optional)")
flags.StringVar(&opts.NodePassword, "node-password", "", "the node's SimAdmin password, if it has one")
flags.StringVar(&opts.Code, "code", "", "one-time enrollment code from the app; registers this node with the relay")
flags.BoolVar(&opts.Force, "force", false, "overwrite an existing config")
flags.BoolVar(&opts.PrintOnly, "print", false, "print the config instead of writing it")
flags.Usage = func() {
fmt.Fprintf(flags.Output(), "usage: msa-agent enroll --relay <https url> [--code <code>] [flags]\n\n")
fmt.Fprintf(flags.Output(), "Mints this node's keys and identifiers and writes its config. With --code it also\n")
fmt.Fprintf(flags.Output(), "registers itself with the relay, which is what makes the one-line install work.\n\n")
flags.PrintDefaults()
}
if err := flags.Parse(args); err != nil {
return 2
}
if opts.RelayURL == "" {
flags.Usage()
return 2
}
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
slog.SetDefault(logger)
if err := runEnroll(context.Background(), opts); err != nil {
fmt.Fprintln(os.Stderr, "msa-agent enroll:", err)
return 1
}
return 0
}
func runUninstallCommand(args []string) int {
flags := flag.NewFlagSet("uninstall", flag.ExitOnError)
var opts install.Options
configPath := flags.String("config", "/etc/msa-agent/config.json", "path to the agent config file")
flags.BoolVar(&opts.KeepLogs, "keep-logs", false, "leave the audit log in place")
flags.BoolVar(&opts.DryRun, "dry-run", false, "print what would be removed and remove nothing")
flags.Usage = func() {
fmt.Fprintf(flags.Output(), "usage: msa-agent uninstall [--dry-run] [--keep-logs]\n\n")
fmt.Fprintf(flags.Output(), "Removes the agent: the relay is told first, then the service, unit, config\n")
fmt.Fprintf(flags.Output(), "directory, logs and system user. Needs root.\n\n")
flags.PrintDefaults()
}
if err := flags.Parse(args); err != nil {
return 2
}
opts.Log = func(format string, args ...any) { fmt.Printf(format+"\n", args...) }
if err := runUninstall(context.Background(), opts, *configPath); err != nil {
fmt.Fprintln(os.Stderr, "msa-agent uninstall:", err)
return 1
}
return 0
}
// runInstallCommand makes a node ready to run the agent as a service.
//
// It is the second half of the install: `enroll` puts the keys and the config on the node, and this
// puts the service around them. Both are needed, and the first version of the install command had only
// the first — it left three things undone, each of which fails on the node: the service user was never
// created, the config stayed root-owned 0600 so the service could not read its own keys, and the unit
// was copied from a path in the repository that a node does not have.
func runInstallCommand(args []string) int {
flags := flag.NewFlagSet("install", flag.ExitOnError)
var opts install.InstallOptions
configPath := flags.String("config", "/etc/msa-agent/config.json", "path to the agent config file")
flags.BoolVar(&opts.DryRun, "dry-run", false, "print what would be done and change nothing")
printUnit := flags.Bool("print-unit", false, "print the systemd unit this binary installs, then exit")
flags.Usage = func() {
fmt.Fprintf(flags.Output(), "usage: msa-agent install [--dry-run] [--print-unit]\n\n")
fmt.Fprintf(flags.Output(), "Creates the service user, gives it the config and the log directory, writes the\n")
fmt.Fprintf(flags.Output(), "systemd unit from this binary, and starts the service. Run `enroll` first: this\n")
fmt.Fprintf(flags.Output(), "needs the config that enroll writes. Needs root.\n\n")
flags.PrintDefaults()
}
if err := flags.Parse(args); err != nil {
return 2
}
paths := install.DefaultPaths()
paths.ConfigPath = *configPath
opts.Paths = paths
if *printUnit {
// The unit is embedded and rendered for this layout, so this is how an operator reads what will be
// installed — which is the point of embedding rather than fetching: the file that will be installed
// is readable first, including the parts that follow --config.
if err := install.PrintUnit(os.Stdout, paths); err != nil {
fmt.Fprintln(os.Stderr, "msa-agent install:", err)
return 1
}
return 0
}
if !opts.DryRun {
if err := install.RequireRoot(); err != nil {
fmt.Fprintln(os.Stderr, "msa-agent install:", err)
return 1
}
}
opts.Log = func(format string, args ...any) { fmt.Printf(format+"\n", args...) }
if err := install.Install(opts); err != nil {
fmt.Fprintln(os.Stderr, "msa-agent install:", err)
return 1
}
return 0
}
+24 -90
View File
@@ -1,29 +1,22 @@
// Command msa-keygen mints the key material and identifiers a node needs, and prints a config file
// skeleton to stdout.
// Command msa-keygen prints the key material, identifiers and secrets a node needs, as a config file
// and a relay registration entry.
//
// It exists because the agent's config cannot be written by hand: the two private keys are raw
// 32-byte values that must be base64, and a node id, an agent token and a device id all have to
// match patterns the agent and the relay both enforce. An operator generating those with openssl and
// a text editor will eventually produce a file that fails at startup with a validation error whose
// cause is a character in the wrong place.
// It is a thin wrapper over internal/enroll, which is also what `msa-agent enroll` calls. The two must
// not drift: one writes the config an operator places, the other writes it on the node, and a
// disagreement between them would show up as a node that pairs and then cannot authenticate.
//
// It generates the *agent's* keys only. The phone's keys are generated on the phone, at pairing
// time, and never leave it — a tool that could mint a device key would be a tool that can impersonate
// a phone.
// It generates the *agent's* keys only. The phone's keys are generated on the phone, at pairing time,
// and never leave it — a tool that could mint a device key would be a tool that can impersonate a
// phone.
package main
import (
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"fun.chick/msa-agent/internal/wire"
"fun.chick/msa-agent/internal/enroll"
)
func main() {
@@ -38,95 +31,36 @@ func main() {
)
flag.Parse()
// The signing key is Ed25519 for challenge-response at the relay and for signing responses.
signingPublic, signingKey, err := ed25519.GenerateKey(rand.Reader)
check(err)
// The key-agreement key is raw X25519: clamping is the curve library's job, and a stored key
// that had already been clamped would be a second, non-standard encoding to keep in sync across
// three languages.
kxKey := make([]byte, wire.KeyBytes)
_, err = rand.Read(kxKey)
check(err)
id := *nodeID
if id == "" {
id = "nd_" + randomHex(5)
}
token := *agentToken
if token == "" {
token = randomHex(32)
}
// The pairing secret is generated even though it is only needed for an unprotected node: a
// config without one cannot pair at all, and discovering that at pairing time means another trip
// to the node. When the node *is* protected, `nodePassword` takes precedence over this value, so
// carrying it is harmless.
pairingSecret := *secret
if pairingSecret == "" {
pairingSecret = randomHex(16)
}
config := map[string]any{
"nodeId": id,
"relayUrl": *relayURL,
"agentToken": token,
"localPort": *localPort,
"signKey": base64.StdEncoding.EncodeToString(signingKey),
"kxKey": base64.StdEncoding.EncodeToString(kxKey),
// The node password is intentionally absent: it is the operator's, and it is the one secret
// this tool must not invent. An unprotected node needs no value; a protected one needs the
// real password or every command fails with node_auth_failed.
"auditPath": *auditPath,
// Only used to authorise the pairing window on an unprotected node. Delete it after pairing
// if you like; nothing else reads it.
"provisionSecret": pairingSecret,
// Empty on purpose: devices are added by pairing (`msa-agent --pair`), which is the only
// path that should ever be able to introduce a trusted phone.
"devices": []any{},
}
encoded, err := json.MarshalIndent(config, "", " ")
check(err)
encoded = append(encoded, '\n')
// The relay registration carries the agent's *public* signing key as well as the bearer token.
// Both are required: the token is only an abuse-control handle, and the signature over the relay's
// challenge is what proves the caller holds the node's private key. A registration without the
// key makes the relay refuse to start rather than start and reject every node, which is the
// failure mode that shipped when the key was omitted.
registration, err := json.Marshal(map[string]string{
"nodeID": id,
"token": token,
"signKey": base64.StdEncoding.EncodeToString(signingPublic),
result, err := enroll.Generate(enroll.Options{
RelayURL: *relayURL,
LocalPort: *localPort,
AuditPath: *auditPath,
NodeID: *nodeID,
AgentToken: *agentToken,
ProvisionSecret: *secret,
})
check(err)
if *write == "" {
// The relay's own registration is printed alongside, because the two files have to agree on
// the node id and token and copying them by hand is where that agreement breaks.
// The relay's own registration is printed alongside, because the two files have to agree on the
// node id, the token and the signing key, and copying them by hand is where that agreement breaks.
fmt.Fprintf(os.Stderr, "# agent config (paste into %s, then add \"nodePassword\" if the node is protected)\n", "/etc/msa-agent/config.json")
os.Stdout.Write(encoded)
os.Stdout.Write(result.Config)
fmt.Fprintf(os.Stderr, "\n# relay registration: add this entry to MSA_AGENTS\n")
fmt.Fprintf(os.Stderr, "%s\n", registration)
fmt.Fprintf(os.Stderr, "\n# pairing secret (unprotected nodes only; protected ones use nodePassword): %s\n", pairingSecret)
fmt.Fprintf(os.Stderr, "%s\n", result.Registration)
fmt.Fprintf(os.Stderr, "\n# pairing secret (unprotected nodes only; protected ones use nodePassword): %s\n", result.ProvisionSecret)
fmt.Fprintf(os.Stderr, "# next: run `msa-agent --pair` on the node once, then pair the phone from the app\n")
return
}
if err := os.MkdirAll(filepath.Dir(*write), 0o755); err != nil {
check(err)
}
// 0600: this file holds the agent's private keys and the node's password.
if err := os.WriteFile(*write, encoded, 0o600); err != nil {
// 0600: this file holds the agent's private keys and, on a protected node, the node password.
if err := os.WriteFile(*write, result.Config, 0o600); err != nil {
check(err)
}
fmt.Printf("wrote %s (mode 0600)\n", *write)
fmt.Printf("relay registration: %s\n", registration)
}
func randomHex(bytes int) string {
raw := make([]byte, bytes)
_, err := rand.Read(raw)
check(err)
return hex.EncodeToString(raw)
fmt.Printf("relay registration: %s\n", result.Registration)
}
func check(err error) {
+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\"}"
]
}
+25 -1
View File
@@ -15,6 +15,8 @@ import (
"os"
"regexp"
"syscall"
"fun.chick/msa-agent/internal/relayurl"
)
var nodePattern = regexp.MustCompile(`^nd_[A-Za-z0-9_-]{8,64}$`)
@@ -55,6 +57,14 @@ type File struct {
// certificate. It exists for a relay behind a private CA; the default is the system store, which
// is what a public certificate needs. Setting it pins trust rather than disabling verification.
RelayCAFile string `json:"relayCAFile,omitempty"`
// SelfEnrolled records that this node registered itself with the relay using a one-time code,
// rather than being written into the relay's agents.json by an operator.
//
// It exists so `uninstall` can tell the operator which of two removals applies, because the agent
// cannot do either: it holds no admin token, by design — a node is not an operator, and giving it
// one would let a compromised node remove its neighbours. So the node records which half of the
// relay's state mentions it, and the operator is told exactly what to delete.
SelfEnrolled bool `json:"selfEnrolled,omitempty"`
// AuditPath is where the audit log is appended.
AuditPath string `json:"auditPath"`
// Devices may be empty only in pair mode (--pair). In normal operation an agent with no
@@ -76,9 +86,16 @@ func (c File) Validate(pairMode bool) error {
if !nodePattern.MatchString(c.NodeID) {
return errors.New("nodeId must look like nd_xxxxxxxx")
}
// The URL is checked for shape here, not merely for being present. It used to be checked for
// presence alone, and the transport refused the real rule at dial time — so `enroll` would write a
// config, the daemon would refuse it at the next startup, and the operator would find out after
// the install had already reported success.
if c.RelayURL == "" {
return errors.New("relayUrl is required")
}
if err := relayurl.Validate(c.RelayURL); err != nil {
return err
}
if c.AgentToken == "" {
return errors.New("agentToken is required")
}
@@ -151,6 +168,13 @@ func loadFile(path string) (File, error) {
return cfg, nil
}
// Marshal renders a File the way Save writes it, so a caller that needs the bytes without writing them
// — `enroll`, which validates before it writes — gets the same encoding rather than its own
// json.MarshalIndent that could drift from this one.
func Marshal(cfg File) ([]byte, error) {
return json.MarshalIndent(cfg, "", " ")
}
// Save writes the config back with 0600, atomically, preserving the file's owner.
//
// Atomic (temp file + rename) because the pairing flow writes the device list at moments when the
@@ -168,7 +192,7 @@ func Save(path string, cfg File) error {
if err := cfg.Validate(true); err != nil {
return fmt.Errorf("config: refusing to save an invalid file: %w", err)
}
encoded, err := json.MarshalIndent(cfg, "", " ")
encoded, err := Marshal(cfg)
if err != nil {
return err
}
+27
View File
@@ -123,6 +123,33 @@ func TestSaveRefusesAnInvalidFile(t *testing.T) {
}
}
// The relay URL used to be checked only for being non-empty, so a shape the transport refuses was
// accepted here and refused later — at dial time in the daemon, or at the next startup for a config
// `enroll` had already written and reported as success. These are the values that reached real nodes.
func TestValidateRefusesARelayURLTheDaemonCannotDial(t *testing.T) {
for _, raw := range []string{
"relay.example",
"http://relay.example",
"https://relay.example/v1",
"https://relay.example?x=1",
"https://",
} {
cfg := valid()
cfg.RelayURL = raw
if err := cfg.Validate(false); err == nil {
t.Errorf("accepted relayUrl %q", raw)
}
if err := cfg.Validate(true); err == nil {
t.Errorf("pair mode accepted relayUrl %q", raw)
}
}
cfg := valid()
cfg.RelayURL = "https://relay.example/"
if err := cfg.Validate(false); err != nil {
t.Fatalf("refused a trailing slash: %v", err)
}
}
func TestValidateNamesEveryMissingField(t *testing.T) {
cases := map[string]func(*File){
"nodeId": func(c *File) { c.NodeID = "" },
+175
View File
@@ -0,0 +1,175 @@
// Package enroll mints the key material, identifiers and secrets a node needs, and renders the two
// artefacts an operator has to place: the agent's config file and the relay's registration entry.
//
// It exists as a package rather than as a command because two callers need the same thing and must
// not drift. `msa-keygen` was the first, and it required a Go toolchain on whichever machine ran it —
// fine when that was the operator's laptop, wrong now that enrollment happens on the node itself,
// where there is a cross-compiled binary and no toolchain. `msa-agent enroll` is the second caller,
// and it is the same code path, so the two can never disagree about what a config looks like.
//
// What it deliberately does NOT do: invent the node's SimAdmin password. That is the operator's, and
// it is the one secret a tool must not make up. An unprotected node needs no value; a protected one
// needs the real one, or every command fails with node_auth_failed and the cause is not obvious.
package enroll
import (
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"fun.chick/msa-agent/internal/config"
"fun.chick/msa-agent/internal/wire"
)
// Options are what the caller knows and cannot derive.
type Options struct {
// RelayURL is the relay this node will connect to. https; the agent refuses anything else.
RelayURL string
// LocalPort is the SimAdmin port on 127.0.0.1. There is deliberately no host.
LocalPort int
// AuditPath is where the audit log is appended.
AuditPath string
// NodeID, AgentToken and ProvisionSecret are generated when empty. They are settable so an
// operator re-enrolling a node can keep the identifiers the relay already knows.
NodeID string
AgentToken string
ProvisionSecret string
// RelayCAFile pins the relay's CA instead of the system trust store. Optional.
RelayCAFile string
// NodePassword is written into the config when non-empty. Never generated.
NodePassword string
// SelfEnrolled records that this node registered itself with a one-time code, so `uninstall` can
// tell the operator which of the relay's two records mentions it.
SelfEnrolled bool
}
// Result is everything the caller has to place or show.
type Result struct {
// Config is the agent config file, ready to write 0600.
Config []byte
// Registration is the relay's agents.json entry, as compact JSON.
Registration []byte
// NodeID and ProvisionSecret are echoed because both have to be shown to the operator.
NodeID string
ProvisionSecret string
}
// Registration is the relay's view of this node: an id, a bearer token, and the public key that
// verifies its handshake signatures.
//
// The signing key is the part that cannot be omitted, and the reason is not tidiness. The relay
// refuses to start when an entry has no usable key — it did ship once with an optional verifier that
// nobody supplied, and the result was a relay that reported healthy and answered every node with 500.
type Registration struct {
NodeID string `json:"nodeID"`
Token string `json:"token"`
SignKey string `json:"signKey"`
}
// Generate mints the material and renders both artefacts.
//
// Every input rule is enforced by config.File.Validate below rather than restated here. The three
// hand-written checks that used to stand here — relay URL non-empty, port in range, audit path present
// — were a second, weaker copy of rules the daemon already had, and the relay URL one was the weakest
// of the three: it accepted anything non-empty, including values the transport refuses at dial time.
func Generate(opts Options) (Result, error) {
// The signing key is Ed25519: it answers the relay's handshake challenge and signs responses.
signingPublic, signingKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return Result{}, fmt.Errorf("enroll: generating the signing key: %w", err)
}
// The key-agreement key is raw X25519. Clamping is the curve library's job, and a stored key that
// had already been clamped would be a second, non-standard encoding to keep in sync across three
// languages.
kxKey := make([]byte, wire.KeyBytes)
if _, err := rand.Read(kxKey); err != nil {
return Result{}, fmt.Errorf("enroll: generating the key-agreement key: %w", err)
}
nodeID := opts.NodeID
if nodeID == "" {
nodeID = "nd_" + randomHex(5)
}
token := opts.AgentToken
if token == "" {
token = randomHex(32)
}
// The pairing secret is minted even though an unprotected node is the only thing that needs it: a
// config without one cannot pair at all, and discovering that at pairing time means another trip to
// the node. When the node *is* protected, nodePassword takes precedence and this value is unused.
pairingSecret := opts.ProvisionSecret
if pairingSecret == "" {
pairingSecret = randomHex(16)
}
// The config is built as the type the daemon loads, and validated by the daemon's own rules before it
// is written.
//
// This used to be a map[string]any with a handful of hand-written checks, and it produced configs the
// daemon then refused: `--relay relay.example` (no scheme), `--relay https://relay.example/v1` (a
// path), `--node node-1` (a malformed id) were all written happily, and the failure appeared later as
// "msa-agent exiting" on a node the operator had just installed. Two places that must agree about
// what a valid config is, with nothing checking the agreement — the same shape of defect as the
// install command that skipped the service user.
//
// Building the real type and calling the real Validate makes the agreement structural: there is one
// definition of a usable config and this cannot drift from it.
file := config.File{
NodeID: nodeID,
RelayURL: opts.RelayURL,
AgentToken: token,
LocalPort: opts.LocalPort,
SignKey: base64.StdEncoding.EncodeToString(signingKey),
KxKey: base64.StdEncoding.EncodeToString(kxKey),
AuditPath: opts.AuditPath,
// Only used to authorise the pairing window on an unprotected node. Deleting it after pairing is
// fine; nothing else reads it.
ProvisionSecret: pairingSecret,
NodePassword: opts.NodePassword,
RelayCAFile: opts.RelayCAFile,
SelfEnrolled: opts.SelfEnrolled,
// Empty on purpose: devices are added by pairing (`msa-agent --pair`), which is the only path
// that should ever be able to introduce a trusted phone.
Devices: []config.Device{},
}
// pairMode relaxes exactly the empty-device rule, which is the state enrollment produces: the node
// has keys and no phone yet.
if err := file.Validate(true); err != nil {
return Result{}, fmt.Errorf("enroll: refusing to write a config the agent would not load: %w", err)
}
encoded, err := config.Marshal(file)
if err != nil {
return Result{}, fmt.Errorf("enroll: encoding the config: %w", err)
}
encoded = append(encoded, '\n')
registration, err := json.Marshal(Registration{
NodeID: nodeID,
Token: token,
SignKey: base64.StdEncoding.EncodeToString(signingPublic),
})
if err != nil {
return Result{}, fmt.Errorf("enroll: encoding the registration: %w", err)
}
return Result{
Config: encoded,
Registration: registration,
NodeID: nodeID,
ProvisionSecret: pairingSecret,
}, nil
}
func randomHex(bytes int) string {
raw := make([]byte, bytes)
if _, err := rand.Read(raw); err != nil {
// rand.Read only fails if the system entropy source is gone, in which case nothing here can run
// safely: a key minted from a broken source is worse than no key.
panic(fmt.Sprintf("enroll: entropy source failed: %v", err))
}
return hex.EncodeToString(raw)
}
+187
View File
@@ -0,0 +1,187 @@
package enroll
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"strings"
"testing"
)
// The two artefacts have to agree, and that agreement is the whole reason this is a package rather
// than two commands: the config carries the private signing key, the registration carries the public
// one, and a node whose relay entry was minted from a different key pair authenticates nowhere.
func TestTheConfigAndTheRegistrationDescribeTheSameNode(t *testing.T) {
result, err := Generate(Options{
RelayURL: "https://relay.example",
LocalPort: 3000,
AuditPath: "/var/log/msa-agent/audit.log",
})
if err != nil {
t.Fatalf("Generate: %v", err)
}
var cfg struct {
NodeID string `json:"nodeId"`
RelayURL string `json:"relayUrl"`
AgentToken string `json:"agentToken"`
LocalPort int `json:"localPort"`
SignKey string `json:"signKey"`
KxKey string `json:"kxKey"`
AuditPath string `json:"auditPath"`
Devices []any `json:"devices"`
}
if err := json.Unmarshal(result.Config, &cfg); err != nil {
t.Fatalf("the config is not valid JSON: %v", err)
}
var reg Registration
if err := json.Unmarshal(result.Registration, &reg); err != nil {
t.Fatalf("the registration is not valid JSON: %v", err)
}
if cfg.NodeID != reg.NodeID || cfg.NodeID != result.NodeID {
t.Fatalf("node ids disagree: config=%q registration=%q result=%q", cfg.NodeID, reg.NodeID, result.NodeID)
}
if cfg.AgentToken != reg.Token {
t.Fatal("the config's bearer and the registration's token differ; the agent would 401")
}
if cfg.RelayURL != "https://relay.example" || cfg.LocalPort != 3000 {
t.Fatalf("relay or port lost: %+v", cfg)
}
if cfg.Devices == nil || len(cfg.Devices) != 0 {
t.Fatalf("devices must start empty, got %v", cfg.Devices)
}
// The private key is 64 bytes; its last 32 are the public key, which is what the relay verifies.
private, err := base64.StdEncoding.DecodeString(cfg.SignKey)
if err != nil || len(private) != ed25519.PrivateKeySize {
t.Fatalf("signKey is not a base64 ed25519 private key: %v (%d bytes)", err, len(private))
}
public, err := base64.StdEncoding.DecodeString(reg.SignKey)
if err != nil || len(public) != ed25519.PublicKeySize {
t.Fatalf("the registration's signKey is not a base64 ed25519 public key: %v (%d bytes)", err, len(public))
}
if string(private[ed25519.PrivateKeySize-ed25519.PublicKeySize:]) != string(public) {
t.Fatal("the relay's registration carries a key that does not belong to the config's private key")
}
}
// The node password is the one secret this tool must not invent. An unprotected node needs no value;
// a protected one needs the real one, or every command fails with node_auth_failed and the cause is
// not obvious from the phone.
func TestTheNodePasswordIsOnlyWrittenWhenGiven(t *testing.T) {
without, err := Generate(Options{RelayURL: "https://relay.example", LocalPort: 3000, AuditPath: "/a"})
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(without.Config), "nodePassword") {
t.Fatal("a config with no password claims one")
}
with, err := Generate(Options{
RelayURL: "https://relay.example", LocalPort: 3000, AuditPath: "/a", NodePassword: "hunter2",
})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(with.Config), `"nodePassword": "hunter2"`) {
t.Fatal("the given password was not written")
}
}
// Re-enrolling a node has to be able to keep the identifiers the relay already knows, or the operator
// has two edits to make instead of one.
func TestGivenIdentifiersAreKept(t *testing.T) {
result, err := Generate(Options{
RelayURL: "https://relay.example", LocalPort: 3000, AuditPath: "/a",
NodeID: "nd_keepme0001", AgentToken: strings.Repeat("a", 64), ProvisionSecret: "kept-secret",
})
if err != nil {
t.Fatal(err)
}
var reg Registration
if err := json.Unmarshal(result.Registration, &reg); err != nil {
t.Fatal(err)
}
if reg.NodeID != "nd_keepme0001" || reg.Token != strings.Repeat("a", 64) {
t.Fatalf("identifiers were regenerated: %+v", reg)
}
if result.ProvisionSecret != "kept-secret" {
t.Fatal("the pairing secret was regenerated")
}
}
// Two enrollments must not produce the same keys or ids. A generator that repeated a key would make
// two nodes able to impersonate each other, and it is the kind of bug a seeded RNG introduces once.
func TestTwoEnrollmentsDiffer(t *testing.T) {
first, err := Generate(Options{RelayURL: "https://relay.example", LocalPort: 3000, AuditPath: "/a"})
if err != nil {
t.Fatal(err)
}
second, err := Generate(Options{RelayURL: "https://relay.example", LocalPort: 3000, AuditPath: "/a"})
if err != nil {
t.Fatal(err)
}
if first.NodeID == second.NodeID {
t.Fatal("two enrollments produced the same node id")
}
if string(first.Config) == string(second.Config) {
t.Fatal("two enrollments produced the same config, so the same keys")
}
}
// Enrollment is where a bad relay URL is cheapest to catch and where it used to be missed entirely.
// It built a map[string]any with a handful of hand-written checks, so `--relay relay.example` and
// `--relay https://relay.example/v1` both produced a config file, the install reported success, and
// the daemon refused the file at the next startup. The values below are the ones that were measured
// being written.
func TestARelayURLTheDaemonWouldRefuseIsNeverWritten(t *testing.T) {
for _, raw := range []string{
"relay.example",
"http://relay.example",
"https://relay.example/v1",
"https://relay.example?x=1",
"https://",
} {
if _, err := Generate(Options{RelayURL: raw, LocalPort: 3000, AuditPath: "/a"}); err == nil {
t.Errorf("wrote a config for relay %q", raw)
}
}
}
func TestMissingInputIsRefused(t *testing.T) {
cases := map[string]Options{
"no relay": {LocalPort: 3000, AuditPath: "/a"},
"bad port": {RelayURL: "https://relay.example", LocalPort: 0, AuditPath: "/a"},
"huge port": {RelayURL: "https://relay.example", LocalPort: 70000, AuditPath: "/a"},
"no audit": {RelayURL: "https://relay.example", LocalPort: 3000},
}
for name, opts := range cases {
if _, err := Generate(opts); err == nil {
t.Fatalf("%s was accepted", name)
}
}
}
// The node records how it got into the relay, because `uninstall` cannot work it out any other way: the
// agent holds no admin token, so it cannot remove its own entry, and the operator has to be told which
// of the relay's two records mentions this node.
func TestEnrollmentRecordsItsOwnProvenance(t *testing.T) {
withCode, err := Generate(Options{
RelayURL: "https://relay.example", LocalPort: 3000, AuditPath: "/a", SelfEnrolled: true,
})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(withCode.Config), `"selfEnrolled": true`) {
t.Fatal("a self-enrolled node does not say so")
}
// And a node an operator wrote into agents.json does not claim to have enrolled itself, or the
// uninstall would send the operator to the app to remove an entry that is not there.
manual, err := Generate(Options{RelayURL: "https://relay.example", LocalPort: 3000, AuditPath: "/a"})
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(manual.Config), "selfEnrolled") {
t.Fatal("a manually registered node claims to be self-enrolled")
}
}
+138
View File
@@ -0,0 +1,138 @@
package enroll
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"fun.chick/msa-agent/internal/relayurl"
)
// Register announces this node to the relay, using a one-time code an operator generated in the app.
//
// This is the step that lets a node join without anyone editing the relay's agents.json, and it is the
// only place in this system where the relay learns about a node it did not already know. What the code
// buys is bounded and worth stating: it authorises *registration*, nothing else. The relay still cannot
// read a request, cannot forge an answer, and cannot command anything — the phone's signature and the
// agent's pinned keys are unchanged. The worst a stolen code can do is make a node appear, which is a
// denial of service, the outcome the design already accepts for a compromised relay.
type RegisterOptions struct {
// RelayURL is the relay's base URL, https.
RelayURL string
// Code is the one-time enrollment code.
Code string
// Registration is the entry to install: node id, bearer token, signing public key.
Registration Registration
// RelayCAFile pins a CA instead of the system trust store. Same meaning as the agent's own
// relayCAFile: it replaces the trust root rather than disabling verification.
RelayCAFile string
// Timeout bounds the whole exchange. A node with no route to the relay should fail in seconds, not
// hang the install.
Timeout time.Duration
}
// Register posts the registration and returns nil when the relay accepted it.
//
// The failure vocabulary is deliberately small and each case names the next action, because this runs
// during an install and the operator is looking at one line of output.
func Register(ctx context.Context, opts RegisterOptions) error {
// The same rule the daemon applies, from the same package. This check is not redundant with the
// config validation in Generate: registration happens *before* the config is written, so a URL this
// rejected late would already have been sent to a host the operator did not mean to reach.
if err := relayurl.Validate(opts.RelayURL); err != nil {
return fmt.Errorf("enroll: %w", err)
}
if strings.TrimSpace(opts.Code) == "" {
return fmt.Errorf("enroll: the enrollment code is empty")
}
body, err := json.Marshal(struct {
Code string `json:"code"`
Registration
}{Code: opts.Code, Registration: opts.Registration})
if err != nil {
return fmt.Errorf("enroll: encoding the registration: %w", err)
}
timeout := opts.Timeout
if timeout <= 0 {
timeout = 20 * time.Second
}
client := &http.Client{Timeout: timeout}
if opts.RelayCAFile != "" {
pem, err := os.ReadFile(opts.RelayCAFile)
if err != nil {
return fmt.Errorf("enroll: relayCAFile: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return fmt.Errorf("enroll: relayCAFile %s contains no PEM certificates", opts.RelayCAFile)
}
// A fresh transport rather than a mutated DefaultTransport, so this does not change trust for
// anything else in the process.
client.Transport = &http.Transport{
TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12},
}
}
endpoint := strings.TrimRight(opts.RelayURL, "/") + "/v1/enroll"
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("enroll: request: %w", err)
}
request.Header.Set("Content-Type", "application/json")
response, err := client.Do(request)
if err != nil {
return fmt.Errorf("enroll: cannot reach the relay at %s: %w", endpoint, err)
}
defer func() {
io.Copy(io.Discard, response.Body)
response.Body.Close()
}()
if response.StatusCode == http.StatusOK {
return nil
}
// The relay's refusals are the ones an operator has to act on, so the detail is passed through
// rather than replaced with a status code.
detail := readDetail(response.Body)
switch response.StatusCode {
case http.StatusUnauthorized, http.StatusForbidden:
return fmt.Errorf("enroll: the relay refused the code (%s). Codes are single-use and short-lived; "+
"generate a new install command in the app", detail)
case http.StatusConflict:
return fmt.Errorf("enroll: the relay already knows this node (%s)", detail)
case http.StatusNotFound:
return fmt.Errorf("enroll: this relay does not accept self-enrollment (%s). Add this node to its "+
"agents.json by hand, or enable enrollment on the relay", detail)
default:
return fmt.Errorf("enroll: the relay refused the registration: %d %s", response.StatusCode, detail)
}
}
// readDetail pulls the RFC 9457 `detail` field out of a refusal, falling back to the raw body.
func readDetail(body io.Reader) string {
raw, err := io.ReadAll(io.LimitReader(body, 4<<10))
if err != nil || len(raw) == 0 {
return "no detail"
}
var problem struct {
Detail string `json:"detail"`
Code string `json:"code"`
}
if err := json.Unmarshal(raw, &problem); err == nil && problem.Detail != "" {
if problem.Code != "" {
return problem.Code + ": " + problem.Detail
}
return problem.Detail
}
return strings.TrimSpace(string(raw))
}
+75
View File
@@ -0,0 +1,75 @@
[Unit]
Description=MultiSimAdmin relay agent
Documentation=https://gitea.chickliu.fun/Hermes/msa-agent
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/opt/msa-agent/msa-agent --config /etc/msa-agent/config.json
Restart=always
# Backing off on our own is not enough: a relay that is down for an hour should not be hammered by
# every node in the fleet the moment it comes back.
RestartSec=5
# A freshly installed node has no paired devices, and the daemon refuses to run in that state on
# purpose: an agent that could authorise nothing must not sit there looking healthy. But pairing happens
# *after* install — the operator runs `--pair` on the node and then pairs the phone — so between those
# two moments this unit would restart every five seconds, forever, filling the journal with the same
# line. Measured on a real kernel: exit 1, "devices is empty, so no phone could ever be authorised".
#
# StartLimitIntervalSec/StartLimitBurst make systemd give up instead: ten attempts inside five minutes
# and the unit enters `failed`, which is a state an operator can see and act on. The install command
# prints the next step precisely so that state is expected rather than surprising.
StartLimitIntervalSec=300
StartLimitBurst=10
TimeoutStopSec=20
KillSignal=SIGTERM
User=msa-agent
Group=msa-agent
# The agent needs almost nothing from the host. Everything below is a capability the process never
# uses, removed so that a compromise of the agent is not automatically a compromise of the box.
NoNewPrivileges=yes
PrivateDevices=yes
PrivateTmp=yes
PrivateUsers=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectClock=yes
ProtectControlGroups=yes
ProtectHostname=yes
ProtectKernelLogs=yes
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
RemoveIPC=yes
UMask=0077
# Configuration and the audit log are the only writable paths. ReadWritePaths is what keeps
# ProtectSystem=strict from making the audit log unwritable.
ReadWritePaths=/var/log/msa-agent
ConfigurationDirectory=msa-agent
LogsDirectory=msa-agent
# AF_INET and AF_INET6 only: the agent dials out to the relay and to 127.0.0.1, so it has no reason
# to reach a Unix socket, a netlink family or a packet socket.
#
# No IPAddressDeny / IPAddressAllow pair here, deliberately. It reads like the obvious way to pin the
# agent to loopback, and it is wrong: those directives filter both directions, so IPAddressDeny=any
# would also cut the outbound connection to the relay the agent exists to maintain. The loopback
# invariant is enforced in code by internal/egress, which is testable, rather than by a directive
# that silently breaks the only path out.
RestrictAddressFamilies=AF_INET AF_INET6
# No capability at all, so the process cannot even ask to bind a privileged port.
CapabilityBoundingSet=
AmbientCapabilities=
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
[Install]
WantedBy=multi-user.target
+263
View File
@@ -0,0 +1,263 @@
// Package install puts the agent on a node and takes it off again, as the two halves of one sequence.
//
// It is one package because the two have to agree about every path. An install that creates something
// the uninstall does not remove leaves a machine with a stray credential on it, and an uninstall that
// removes something the install did not create fails on a fresh node. Keeping the path list in one
// place is what makes that agreement checkable rather than remembered.
//
// The systemd unit is embedded rather than fetched. Two reasons, and the second is the one that
// decided it: a node installing the agent may have no route to anything except the relay, and a unit
// file downloaded from a URL is a second thing that can 404 at the exact moment the operator has the
// least context to debug it. Embedding also means the unit an operator runs is the one the binary was
// built with, so a node cannot end up with a unit from a different release than its agent.
package install
import (
_ "embed"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// The two literals in the embedded unit that have to follow the install's own paths. They are named
// here rather than parsed out of the file so a unit edit that removes one is a visible failure in
// RenderedUnit's tests rather than a silent no-op substitution.
const (
defaultConfigPath = "/etc/msa-agent/config.json"
defaultWritablePaths = "/var/log/msa-agent"
)
// The vendored unit. It is copied from agent/systemd/ by the Makefile, and
// TestVendoredUnitMatchesSystemd fails when the copy drifts — the same arrangement the agent's policy
// uses, and for the same reason: an embedded file that is edited in place is a file the build
// overwrites, and the mistake should be loud rather than silent.
//
//go:embed msa-agent.service
var embeddedUnit []byte
// UnitFile returns the unit this binary installs, for tests and for `install --print-unit`.
func UnitFile() []byte { return embeddedUnit }
// RenderedUnit is the unit as it will actually be written for a given layout.
//
// The embedded file is a template, and two of its literals have to follow the paths the install was
// given. Both were measured drifting:
//
// - `install --config /srv/msa/config.json` chowned that file, gave it to the service user, and then
// wrote a unit whose ExecStart named `/etc/msa-agent/config.json` — a path that on that node holds
// nothing, so the service fails with "no such file or directory" while the config it needs sits
// elsewhere, correctly owned and never read.
// - `enroll --audit /srv/audit/audit.log` puts that path in the config, and the unit's
// `ReadWritePaths=/var/log/msa-agent` does not cover it. Under `ProtectSystem=strict` the agent's
// `os.MkdirAll` for the parent directory fails with EROFS and the daemon exits at startup, with a
// permission error that names neither the flag nor the directive.
//
// The default layout renders byte-for-byte the embedded file, which is what keeps the vendored copy
// and the drift test meaningful: the template is still the thing that was reviewed.
func RenderedUnit(paths Paths, writableDirs []string) []byte {
unit := string(embeddedUnit)
// Only substituted when it differs, so the common install produces exactly the reviewed file and a
// diff of `install --print-unit` against the repository copy stays empty.
if paths.ConfigPath != defaultConfigPath {
unit = strings.Replace(unit, "--config "+defaultConfigPath, "--config "+paths.ConfigPath, 1)
}
dirs := uniqueDirs(append([]string{paths.LogDir}, writableDirs...))
if len(dirs) > 0 && strings.Join(dirs, " ") != defaultWritablePaths {
unit = strings.Replace(unit, "ReadWritePaths="+defaultWritablePaths, "ReadWritePaths="+strings.Join(dirs, " "), 1)
}
return []byte(unit)
}
// uniqueDirs keeps the first occurrence of each path, in order, so a log directory that is also an
// audit directory appears once and the rendering is stable.
func uniqueDirs(dirs []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(dirs))
for _, dir := range dirs {
dir = filepath.Clean(dir)
if dir == "" || dir == "." || dir == "/" || seen[dir] {
continue
}
seen[dir] = true
out = append(out, dir)
}
return out
}
// auditDirOf reports the directory the config's audit log needs to be writable, or "" when the config
// cannot be read.
//
// An unreadable config is not an error here: `install --dry-run` is run on nodes where `enroll` has not
// happened yet, and the real run refuses a missing config a few lines above. What it must not do is
// invent a path — a wrong ReadWritePaths entry is a sandbox hole, and a missing one is the EROFS above.
func auditDirOf(configPath string) string {
raw, err := os.ReadFile(configPath)
if err != nil {
return ""
}
var parsed struct {
AuditPath string `json:"auditPath"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
return ""
}
return filepath.Dir(parsed.AuditPath)
}
// InstallOptions configures an install.
type InstallOptions struct {
Paths Paths
// DryRun prints the plan and changes nothing.
DryRun bool
Runner Runner
Log func(format string, args ...any)
}
// Install makes a node ready to run the agent as a service, assuming the binary is already in place
// and the config has already been written by `msa-agent enroll`.
//
// It is idempotent: every step checks what is already true, so re-running it after an upgrade or a
// failed attempt is safe. That matters because the alternative — a command that only works on a clean
// node — makes every partial failure a manual cleanup.
//
// The order is the reverse of the uninstall's, and for the same reason: the user has to exist before
// anything can be owned by it, and the config has to be readable by that user before the service can
// start.
func Install(opts InstallOptions) error {
if opts.Runner == nil {
opts.Runner = ExecRunner{}
}
if opts.Log == nil {
opts.Log = func(string, ...any) {}
}
paths := opts.Paths
// The two preconditions, reported rather than enforced during a dry run.
//
// A dry run is asked before the work is done — that is the whole point of it — so refusing it for
// the very conditions it exists to reveal would make it useless on exactly the machine an operator
// runs it on. The real run refuses, because a service whose ExecStart points at nothing fails with
// "no such file or directory" in the journal, which reads like a broken build.
binaryMissing := !exists(paths.BinaryPath)
configMissing := !exists(paths.ConfigPath)
if !opts.DryRun {
if binaryMissing {
return fmt.Errorf("install: %s is not there; run this from the installed binary, not a copy in /tmp", paths.BinaryPath)
}
if configMissing {
return fmt.Errorf("install: %s is not there; run `msa-agent enroll` first", paths.ConfigPath)
}
} else {
if binaryMissing {
opts.Log("note: %s is not there yet; the real run would refuse", paths.BinaryPath)
}
if configMissing {
opts.Log("note: %s is not there yet; run `msa-agent enroll` first", paths.ConfigPath)
}
}
// 1. The system user. `--system` gives it no login shell and no home, which is what a process that
// only dials out needs; `--no-create-home` keeps the filesystem as it was.
if stepWith(opts.DryRun, opts.Log, "creating the %s system user", paths.User) {
if err := opts.Runner.Run("getent", "passwd", paths.User); err == nil {
opts.Log(" %s already exists", paths.User)
} else if err := opts.Runner.Run("useradd", "--system", "--no-create-home",
"--shell", "/usr/sbin/nologin", paths.User); err != nil {
return fmt.Errorf("install: creating the %s user: %w", paths.User, err)
}
}
// 2. The log directory, owned by that user. The unit's ProtectSystem=strict makes everything else
// read-only, so this is one of the two paths the service can write.
if stepWith(opts.DryRun, opts.Log, "creating %s", paths.LogDir) {
if err := os.MkdirAll(paths.LogDir, 0o750); err != nil {
return fmt.Errorf("install: creating the log directory: %w", err)
}
}
// 3. Ownership of the config, and of the log directory, and that step is load-bearing rather than
// tidy: `enroll` ran under sudo, so the config is root-owned 0600 and the service — which runs as
// the user above — cannot read its own private keys. The failure is a service that starts and
// immediately exits with a permission error.
if stepWith(opts.DryRun, opts.Log, "giving %s ownership of the config and logs", paths.User) {
for _, path := range []string{paths.ConfigPath, filepath.Dir(paths.ConfigPath), paths.LogDir} {
if err := chownTo(path, paths.User, opts); err != nil {
return err
}
}
}
// 4. The unit, from this binary. The audit log's directory comes from the config that `enroll` wrote,
// because the unit's ReadWritePaths is the only thing that makes it writable under
// ProtectSystem=strict — and a config pointing outside it is a daemon that exits at startup with
// EROFS, naming neither the flag nor the directive.
if stepWith(opts.DryRun, opts.Log, "writing %s", paths.UnitPath) {
if err := os.MkdirAll(filepath.Dir(paths.UnitPath), 0o755); err != nil {
return fmt.Errorf("install: creating the unit directory: %w", err)
}
unit := RenderedUnit(paths, []string{auditDirOf(paths.ConfigPath)})
if err := os.WriteFile(paths.UnitPath, unit, 0o644); err != nil {
return fmt.Errorf("install: writing the unit: %w", err)
}
}
// 5. Enable and start. `daemon-reload` first, because systemd caches unit files and would otherwise
// start whatever it read last.
if stepWith(opts.DryRun, opts.Log, "reloading systemd") {
if err := opts.Runner.Run("systemctl", "daemon-reload"); err != nil {
return fmt.Errorf("install: systemctl daemon-reload: %w", err)
}
}
if stepWith(opts.DryRun, opts.Log, "enabling and starting %s", paths.ServiceName) {
if err := opts.Runner.Run("systemctl", "enable", "--now", paths.ServiceName); err != nil {
return fmt.Errorf("install: systemctl enable --now: %w", err)
}
}
if opts.DryRun {
opts.Log("dry run: nothing was changed")
return nil
}
// The service will fail until a phone is paired, and that is expected rather than a fault: the daemon
// refuses an empty device list so it cannot sit there looking healthy while authorising nothing. The
// unit's start limit turns that into a `failed` state instead of a restart loop, and this line is
// what makes the state expected.
opts.Log("msa-agent installed and enabled")
opts.Log("next: run `msa-agent --pair` once and pair the phone; until then the service exits and stops")
opts.Log("check it with: systemctl status %s; journalctl -u %s -n 20", paths.ServiceName, paths.ServiceName)
return nil
}
// exists reports whether a path is present, without caring why it might not be.
func exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// chownTo gives a path to the service user.
//
// It shells out to chown rather than calling os.Chown, and that is a deliberate consistency rather than
// a preference: the whole install is a sequence of system commands, so routing all of them through the
// Runner is what makes the sequence testable without root. os.Chown would work and would also be the one
// step a test could not intercept.
func chownTo(path, user string, opts InstallOptions) error {
if err := opts.Runner.Run("chown", user+":"+user, path); err != nil {
return fmt.Errorf("install: giving %s to %s: %w", path, user, err)
}
opts.Log(" %s -> %s", path, user)
return nil
}
// PrintUnit writes the unit as it would be installed for a layout to w, so an operator can read what
// will be installed before it is.
//
// It renders rather than dumping the embedded bytes, because the two are not the same thing once the
// install has been given a non-default config path or an audit log outside the log directory — and the
// command's whole promise is that what it prints is what gets written.
func PrintUnit(w io.Writer, paths Paths) error {
_, err := w.Write(RenderedUnit(paths, []string{auditDirOf(paths.ConfigPath)}))
return err
}
+228
View File
@@ -0,0 +1,228 @@
// Package install removes the agent from a node, in the one order that leaves no dangling state.
//
// The order is the whole point, and every step of it was chosen against a specific failure:
//
// 1. Clear the paired-device list in the config and force one handshake, so the relay retires this
// node's device bearers. Removing the node first would leave the relay holding routing for a node
// that no longer exists — and, worse, leave a phone that thinks it is paired pointing at nothing.
// 2. Stop and disable the service. After this the agent cannot be reached by anything, including the
// relay, so the remaining steps are purely local.
// 3. Remove the unit file and reload systemd. Leaving a unit behind means a later `systemctl start`
// resurrects a node whose config is gone.
// 4. Remove the config directory. This holds the agent's private keys, the relay bearer and — on a
// protected node — the SimAdmin password. It is the step that actually matters for security, and
// it is why "uninstall" cannot be a shell one-liner the operator writes from memory.
// 5. Remove the log directory, unless asked to keep it. Audit entries are the only record of what
// this node was asked to do, so deleting them is a decision rather than a default.
// 6. Remove the system user. Last, because every step above ran as that user.
//
// Nothing here is reversible, so a dry run prints the plan and stops.
package install
import (
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
)
// Paths is where the install put things. The defaults match agent/systemd/msa-agent.service and the
// deployment document; they are fields so a test can point them at a temporary directory.
type Paths struct {
ServiceName string
UnitPath string
ConfigPath string
LogDir string
BinaryPath string
User string
}
// DefaultPaths is the layout the deployment document describes.
func DefaultPaths() Paths {
return Paths{
ServiceName: "msa-agent",
UnitPath: "/etc/systemd/system/msa-agent.service",
ConfigPath: "/etc/msa-agent/config.json",
LogDir: "/var/log/msa-agent",
BinaryPath: "/opt/msa-agent/msa-agent",
User: "msa-agent",
}
}
// Runner runs system commands. It is an interface so the sequence can be tested without systemd.
type Runner interface {
Run(name string, args ...string) error
}
// ExecRunner runs commands for real.
type ExecRunner struct {
// Stdout and Stderr receive the output of commands that have any. systemctl is quiet on success.
Stdout io.Writer
Stderr io.Writer
}
func (r ExecRunner) Run(name string, args ...string) error {
command := exec.Command(name, args...)
command.Stdout = r.Stdout
command.Stderr = r.Stderr
return command.Run()
}
// Options configures an uninstall.
type Options struct {
Paths Paths
// KeepLogs leaves the audit log in place. Deleting it is the default because the node is being
// removed, but an operator retiring a node after an incident may want the record.
KeepLogs bool
// DryRun prints what would happen and removes nothing.
DryRun bool
// NotifyRelay retires this node's device bearers at the relay, and is called before anything is
// removed. Nil skips it, which is right for a node that was never paired.
NotifyRelay func() error
// RemovalHint is the sentence printed at the end telling the operator what is left at the relay.
// It depends on how the node got there — self-enrolled or written into agents.json — and the caller
// is what knows that, from the config the node wrote at enrollment time.
RemovalHint string
Runner Runner
Log func(format string, args ...any)
}
// Uninstall performs the sequence. It returns an error only for a failure that leaves the node in a
// state the operator must know about; a missing file or an already-stopped service is not one.
func Uninstall(opts Options) error {
if opts.Runner == nil {
opts.Runner = ExecRunner{}
}
if opts.Log == nil {
opts.Log = func(string, ...any) {}
}
paths := opts.Paths
// 1. The relay has to be told before the node stops existing.
if opts.NotifyRelay != nil {
if opts.DryRun {
opts.Log("would clear the paired-device list and tell the relay to retire this node's bearers")
} else if err := opts.NotifyRelay(); err != nil {
// Not fatal, but the operator has to know: the relay will keep routing to a node that is
// gone until its own restart or until the agent entry is removed from agents.json.
opts.Log("WARNING: could not tell the relay to retire this node: %v", err)
opts.Log(" remove this node's entry from the relay's agents.json by hand")
} else {
opts.Log("told the relay to retire this node's device bearers")
}
}
// 2. Stop and disable. `disable --now` is one call so a failure cannot leave the unit enabled with
// the process still running.
//
// Every step below is gated on step()'s answer, and that is load-bearing rather than tidy: a dry
// run that stops the service is not a dry run. The first version of this file logged the plan and
// ran the commands anyway, which on a Linux box would have taken the node offline while telling the
// operator nothing had been touched.
if step(opts, "stopping and disabling %s", paths.ServiceName) {
if err := opts.Runner.Run("systemctl", "disable", "--now", paths.ServiceName); err != nil {
// A unit that was never enabled, or a node where systemd never started it, lands here. It is
// not a reason to abort: the remaining steps are what make the node clean.
opts.Log(" systemctl disable --now %s: %v (continuing)", paths.ServiceName, err)
}
}
// 3. The unit file, then a reload so systemd forgets it.
if step(opts, "removing %s", paths.UnitPath) {
removeIfPresent(paths.UnitPath, opts)
}
if step(opts, "reloading systemd") {
if err := opts.Runner.Run("systemctl", "daemon-reload"); err != nil {
opts.Log(" systemctl daemon-reload: %v (continuing)", err)
}
}
// 4. The config: private keys, relay bearer, and the node password when the node is protected.
if step(opts, "removing %s (private keys, relay bearer, node password)", paths.ConfigPath) {
removeIfPresent(paths.ConfigPath, opts)
// The directory holds relay-ca.pem and anything else the operator put beside the config.
removeIfPresent(filepath.Dir(paths.ConfigPath), opts)
}
// 5. Logs.
if opts.KeepLogs {
step(opts, "keeping %s (--keep-logs)", paths.LogDir)
} else if step(opts, "removing %s", paths.LogDir) {
removeIfPresent(paths.LogDir, opts)
}
// 6. The binary, then the user. The user goes last because the steps above ran as that user.
if step(opts, "removing %s", paths.BinaryPath) {
removeIfPresent(paths.BinaryPath, opts)
removeIfPresent(filepath.Dir(paths.BinaryPath), opts)
}
if step(opts, "removing the %s system user", paths.User) {
if err := opts.Runner.Run("userdel", paths.User); err != nil {
opts.Log(" userdel %s: %v (continuing)", paths.User, err)
}
}
if opts.DryRun {
opts.Log("dry run: nothing was removed")
} else {
opts.Log("msa-agent removed")
// The one thing this command cannot do for the operator, and it cannot by design: the agent holds
// no admin token, because a node is not an operator, and a compromised node that could remove its
// neighbours would be a worse problem than the one this hint solves.
opts.Log("%s", opts.RemovalHint)
}
return nil
}
// step reports one line of the plan, and says whether the action should be performed.
func step(opts Options, format string, args ...any) bool {
return stepWith(opts.DryRun, opts.Log, format, args...)
}
// stepWith is the same decision for the install half, whose options type is different. Taking the two
// fields rather than the struct is what keeps "a dry run changes nothing" in one place: two copies of
// this would eventually disagree, and the one that disagreed would be the one that ran a command during
// a dry run.
func stepWith(dryRun bool, log func(string, ...any), format string, args ...any) bool {
if dryRun {
log("would "+format, args...)
return false
}
log(format, args...)
return true
}
// removeIfPresent deletes a path, and treats "it is not there" as success.
//
// A missing path is the normal case on a partially installed node, and reporting it as an error would
// train the operator to ignore the errors that matter.
func removeIfPresent(path string, opts Options) {
if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) {
opts.Log(" %s is not present", path)
return
}
if err := os.RemoveAll(path); err != nil {
opts.Log(" WARNING: could not remove %s: %v", path, err)
return
}
opts.Log(" removed %s", path)
}
// ErrNotRoot is returned when an uninstall is attempted without the privileges the steps need.
var ErrNotRoot = errors.New("install: uninstalling needs root (it removes a systemd unit and a system user)")
// RequireRoot is what the command calls before doing anything.
func RequireRoot() error {
if os.Geteuid() != 0 {
return ErrNotRoot
}
return nil
}
// String renders the plan for a dry run, so an operator can see it without reading this file.
func (p Paths) String() string {
return fmt.Sprintf("service=%s unit=%s config=%s logs=%s binary=%s user=%s",
p.ServiceName, p.UnitPath, p.ConfigPath, p.LogDir, p.BinaryPath, p.User)
}
+476
View File
@@ -0,0 +1,476 @@
package install
import (
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
// recorder stands in for systemctl and userdel, so the sequence can be tested without systemd.
type recorder struct {
calls []string
// failOn makes a named command fail, for the cases where a missing unit or an absent user must not
// abort the uninstall.
failOn string
}
func (r *recorder) Run(name string, args ...string) error {
call := name + " " + strings.Join(args, " ")
r.calls = append(r.calls, call)
if r.failOn != "" && strings.HasPrefix(call, r.failOn) {
return fmt.Errorf("simulated failure")
}
return nil
}
// RunCaptured is what the install needs to resolve the service user's ids. The real ExecRunner reads
// them from id(1); here they are fixed, because what is being tested is that the install asks for them
// and applies them, not that this machine has a particular uid.
func (r *recorder) RunCaptured(name string, args ...string) (string, error) {
r.calls = append(r.calls, name+" "+strings.Join(args, " "))
return "1001", nil
}
// installed creates the layout an install leaves behind, so the test removes real files.
func installed(t *testing.T) Paths {
t.Helper()
root := t.TempDir()
paths := Paths{
ServiceName: "msa-agent",
UnitPath: filepath.Join(root, "etc/systemd/system/msa-agent.service"),
ConfigPath: filepath.Join(root, "etc/msa-agent/config.json"),
LogDir: filepath.Join(root, "var/log/msa-agent"),
BinaryPath: filepath.Join(root, "opt/msa-agent/msa-agent"),
User: "msa-agent",
}
for _, path := range []string{paths.UnitPath, paths.ConfigPath, filepath.Join(paths.LogDir, "audit.log"), paths.BinaryPath} {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
}
return paths
}
func TestUninstallRemovesEverythingTheInstallPlaced(t *testing.T) {
paths := installed(t)
runner := &recorder{}
var lines []string
err := Uninstall(Options{
Paths: paths,
Runner: runner,
Log: func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) },
NotifyRelay: func() error {
lines = append(lines, "relay notified")
return nil
},
})
if err != nil {
t.Fatalf("Uninstall: %v", err)
}
for _, path := range []string{paths.UnitPath, paths.ConfigPath, paths.LogDir, paths.BinaryPath} {
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("%s survived the uninstall", path)
}
}
// The config directory goes with the config: it holds relay-ca.pem and anything else the operator
// put beside it.
if _, err := os.Stat(filepath.Dir(paths.ConfigPath)); !errors.Is(err, os.ErrNotExist) {
t.Fatal("the config directory survived; it may still hold pinned CA material")
}
joined := strings.Join(runner.calls, "\n")
for _, want := range []string{"systemctl disable --now msa-agent", "systemctl daemon-reload", "userdel msa-agent"} {
if !strings.Contains(joined, want) {
t.Fatalf("expected %q among the commands, got:\n%s", want, joined)
}
}
}
// The order is the point: the relay has to be told before the node stops existing, or it keeps routing
// to a node that is gone and a phone that believes it is paired points at nothing.
func TestUninstallTellsTheRelayBeforeItRemovesAnything(t *testing.T) {
paths := installed(t)
configStillThere := false
err := Uninstall(Options{
Paths: paths,
Runner: &recorder{},
Log: func(string, ...any) {},
NotifyRelay: func() error {
// At the moment the relay is told, the config must still exist: the notification reads it.
_, statErr := os.Stat(paths.ConfigPath)
configStillThere = statErr == nil
return nil
},
})
if err != nil {
t.Fatalf("Uninstall: %v", err)
}
if !configStillThere {
t.Fatal("the config was removed before the relay was told; the device list could not be read")
}
}
// A relay that cannot be reached must not stop an uninstall. The operator is removing the node either
// way, and the alternative — aborting half way — leaves the machine in a state nobody described.
func TestUninstallCompletesWhenTheRelayIsUnreachable(t *testing.T) {
paths := installed(t)
var lines []string
err := Uninstall(Options{
Paths: paths,
Runner: &recorder{},
Log: func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) },
NotifyRelay: func() error { return fmt.Errorf("relay unreachable") },
})
if err != nil {
t.Fatalf("Uninstall: %v", err)
}
if _, err := os.Stat(paths.ConfigPath); !errors.Is(err, os.ErrNotExist) {
t.Fatal("the uninstall did not finish")
}
// And the operator is told what is left to do by hand, because the relay still lists this node.
output := strings.Join(lines, "\n")
if !strings.Contains(output, "WARNING") || !strings.Contains(output, "agents.json") {
t.Fatalf("a failed relay notification was not explained:\n%s", output)
}
}
// A dry run removes nothing, and that includes not stopping the service: an operator asking what an
// uninstall would do must not lose the node while they read the answer.
func TestDryRunRemovesNothingAndRunsNothing(t *testing.T) {
paths := installed(t)
runner := &recorder{}
var lines []string
err := Uninstall(Options{
Paths: paths,
DryRun: true,
Runner: runner,
Log: func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) },
})
if err != nil {
t.Fatalf("Uninstall: %v", err)
}
if len(runner.calls) != 0 {
t.Fatalf("a dry run ran commands: %v", runner.calls)
}
for _, path := range []string{paths.UnitPath, paths.ConfigPath, paths.BinaryPath} {
if _, err := os.Stat(path); err != nil {
t.Fatalf("a dry run removed %s", path)
}
}
if !strings.Contains(strings.Join(lines, "\n"), "dry run: nothing was removed") {
t.Fatal("a dry run did not say so")
}
}
func TestKeepLogsLeavesTheAuditLog(t *testing.T) {
paths := installed(t)
audit := filepath.Join(paths.LogDir, "audit.log")
err := Uninstall(Options{
Paths: paths,
KeepLogs: true,
Runner: &recorder{},
Log: func(string, ...any) {},
})
if err != nil {
t.Fatalf("Uninstall: %v", err)
}
if _, err := os.Stat(audit); err != nil {
t.Fatalf("--keep-logs removed the audit log: %v", err)
}
}
// A missing unit or an absent user is the normal case on a partially installed node, and it must not
// abort: reporting it as an error would train the operator to ignore the errors that matter.
func TestMissingPiecesDoNotAbortTheUninstall(t *testing.T) {
paths := installed(t)
// Remove the unit first, then uninstall.
if err := os.Remove(paths.UnitPath); err != nil {
t.Fatal(err)
}
runner := &recorder{failOn: "systemctl disable"}
err := Uninstall(Options{
Paths: paths,
Runner: runner,
Log: func(string, ...any) {},
})
if err != nil {
t.Fatalf("a missing unit aborted the uninstall: %v", err)
}
if _, err := os.Stat(paths.ConfigPath); !errors.Is(err, os.ErrNotExist) {
t.Fatal("the config survived")
}
}
// The plan names every path, so an operator can check it before running anything.
func TestThePlanNamesEveryPath(t *testing.T) {
plan := DefaultPaths().String()
for _, want := range []string{"/etc/systemd/system/msa-agent.service", "/etc/msa-agent/config.json",
"/var/log/msa-agent", "/opt/msa-agent/msa-agent", "msa-agent"} {
if !strings.Contains(plan, want) {
t.Fatalf("the plan does not mention %s:\n%s", want, plan)
}
}
}
// The unit is embedded, and the vendored copy is produced by the Makefile. This is the drift guard: an
// embedded file edited in place is a file the build overwrites, and the mistake should fail here rather
// than ship as a node running a unit nobody reviewed.
func TestVendoredUnitMatchesSystemd(t *testing.T) {
upstream, err := os.ReadFile(filepath.Join("..", "..", "systemd", "msa-agent.service"))
if err != nil {
t.Fatalf("read systemd/msa-agent.service: %v", err)
}
if string(upstream) != string(UnitFile()) {
t.Fatal("internal/install/msa-agent.service differs from systemd/msa-agent.service; run make -C agent vendor")
}
}
// The install has to produce what the unit requires, and the two live in different files. These are the
// three requirements the first version of the install command missed, each of which fails on the node:
//
// 1. the unit names a user, and nothing created it;
// 2. `enroll` runs under sudo, so the config is root-owned 0600 and the service cannot read its own keys;
// 3. the unit was copied from a path in the repository, which a node does not have.
func TestInstallSatisfiesWhatTheUnitRequires(t *testing.T) {
unit := string(UnitFile())
user := regexp.MustCompile(`(?m)^User=(\S+)$`).FindStringSubmatch(unit)
if user == nil {
t.Skip("the unit no longer names a user, so there is nothing to reconcile")
}
serviceUser := user[1]
root := t.TempDir()
paths := Paths{
ServiceName: "msa-agent",
UnitPath: filepath.Join(root, "etc/systemd/system/msa-agent.service"),
ConfigPath: filepath.Join(root, "etc/msa-agent/config.json"),
LogDir: filepath.Join(root, "var/log/msa-agent"),
BinaryPath: filepath.Join(root, "opt/msa-agent/msa-agent"),
User: serviceUser,
}
for _, path := range []string{paths.BinaryPath, paths.ConfigPath} {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
}
// A fresh node: the service user does not exist yet, so the existence check must fail. The recorder
// succeeds at everything by default, which would model a node where the user was already there — the
// idempotent path, and not the one this test is about.
runner := &recorder{failOn: "getent"}
var lines []string
err := Install(InstallOptions{
Paths: paths,
Runner: runner,
Log: func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) },
})
if err != nil {
t.Fatalf("Install: %v", err)
}
// 1. The user is created with the flags a service account needs.
calls := strings.Join(runner.calls, "\n")
if !strings.Contains(calls, "useradd --system --no-create-home") {
t.Fatalf("the service user is never created:\n%s", calls)
}
// 2. Ownership is moved to that user, so the service can read its own config.
if !strings.Contains(strings.Join(lines, "\n"), "giving "+serviceUser+" ownership") {
t.Fatalf("the config is left owned by root, so the service cannot read it:\n%s", strings.Join(lines, "\n"))
}
// 3. The unit is written from the binary, not copied from a repository path.
if _, err := os.Stat(paths.UnitPath); err != nil {
t.Fatalf("the unit was not written: %v", err)
}
if strings.Contains(calls, "install -m 0644") {
t.Fatal("the unit is copied from a path a node does not have")
}
// And the service is enabled and started, last.
if !strings.Contains(calls, "systemctl enable --now msa-agent") {
t.Fatalf("the service is never started:\n%s", calls)
}
}
// A dry run of the install changes nothing either, for the same reason as the uninstall's.
func TestInstallDryRunChangesNothing(t *testing.T) {
root := t.TempDir()
paths := Paths{
ServiceName: "msa-agent",
UnitPath: filepath.Join(root, "etc/systemd/system/msa-agent.service"),
ConfigPath: filepath.Join(root, "etc/msa-agent/config.json"),
LogDir: filepath.Join(root, "var/log/msa-agent"),
BinaryPath: filepath.Join(root, "opt/msa-agent/msa-agent"),
User: "msa-agent",
}
for _, path := range []string{paths.BinaryPath, paths.ConfigPath} {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
}
runner := &recorder{}
if err := Install(InstallOptions{Paths: paths, DryRun: true, Runner: runner, Log: func(string, ...any) {}}); err != nil {
t.Fatalf("Install: %v", err)
}
if len(runner.calls) != 0 {
t.Fatalf("a dry run ran commands: %v", runner.calls)
}
if _, err := os.Stat(paths.UnitPath); !errors.Is(err, os.ErrNotExist) {
t.Fatal("a dry run wrote the unit")
}
if _, err := os.Stat(paths.LogDir); !errors.Is(err, os.ErrNotExist) {
t.Fatal("a dry run created the log directory")
}
}
// An install with no config is refused before anything is changed, because `enroll` is what mints the
// node's keys: a service installed without one starts, fails to load its config, and restarts forever.
func TestInstallRefusesWithoutAConfigOrBinary(t *testing.T) {
root := t.TempDir()
paths := Paths{
ServiceName: "msa-agent",
UnitPath: filepath.Join(root, "etc/systemd/system/msa-agent.service"),
ConfigPath: filepath.Join(root, "etc/msa-agent/config.json"),
LogDir: filepath.Join(root, "var/log/msa-agent"),
BinaryPath: filepath.Join(root, "opt/msa-agent/msa-agent"),
User: "msa-agent",
}
runner := &recorder{}
log := func(string, ...any) {}
// Nothing exists at all.
if err := Install(InstallOptions{Paths: paths, Runner: runner, Log: log}); err == nil {
t.Fatal("installed without a binary")
}
// The binary but no config.
if err := os.MkdirAll(filepath.Dir(paths.BinaryPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(paths.BinaryPath, []byte("x"), 0o755); err != nil {
t.Fatal(err)
}
err := Install(InstallOptions{Paths: paths, Runner: runner, Log: log})
if err == nil || !strings.Contains(err.Error(), "enroll") {
t.Fatalf("expected a refusal that names enroll, got %v", err)
}
if len(runner.calls) != 0 {
t.Fatalf("a refused install still ran commands: %v", runner.calls)
}
}
// A dry run is asked before the work is done, so it must not refuse for the very conditions it exists
// to reveal. An operator on a node that has neither the binary nor the config — the normal state before
// an install — is exactly who runs it.
func TestInstallDryRunReportsPreconditionsInsteadOfRefusing(t *testing.T) {
root := t.TempDir()
paths := Paths{
ServiceName: "msa-agent",
UnitPath: filepath.Join(root, "etc/systemd/system/msa-agent.service"),
ConfigPath: filepath.Join(root, "etc/msa-agent/config.json"),
LogDir: filepath.Join(root, "var/log/msa-agent"),
BinaryPath: filepath.Join(root, "opt/msa-agent/msa-agent"),
User: "msa-agent",
}
var lines []string
err := Install(InstallOptions{
Paths: paths,
DryRun: true,
Runner: &recorder{},
Log: func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) },
})
if err != nil {
t.Fatalf("a dry run on a fresh node was refused: %v", err)
}
output := strings.Join(lines, "\n")
// Both missing preconditions are named, so the operator learns what the real run needs.
if !strings.Contains(output, "msa-agent is not there yet") || !strings.Contains(output, "enroll") {
t.Fatalf("the dry run did not name the missing pieces:\n%s", output)
}
// And the plan is still printed.
if !strings.Contains(output, "would writing") {
t.Fatalf("the dry run printed no plan:\n%s", output)
}
}
// The hint at the end has to name the right place, because the two records live in different places and
// are removed by different people. Telling the operator the wrong one sends them to the wrong place.
func TestTheRemovalHintIsTheOneItWasGiven(t *testing.T) {
paths := installed(t)
var lines []string
err := Uninstall(Options{
Paths: paths,
Runner: &recorder{},
Log: func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) },
RemovalHint: "the relay still lists this node in its enrollment state; remove it in the app",
})
if err != nil {
t.Fatalf("Uninstall: %v", err)
}
output := strings.Join(lines, "\n")
if !strings.Contains(output, "remove it in the app") {
t.Fatalf("the hint was not printed:\n%s", output)
}
// And the old hard-coded sentence is gone: it named agents.json for every node, including the
// self-enrolled ones that are not there.
if strings.Contains(output, "agents.json still lists this node") {
t.Fatalf("the uninstall still claims every node is in agents.json:\n%s", output)
}
}
// The install ends by starting a service that cannot run yet, and the unit has to say what to do about
// that. A freshly installed node has no paired devices — pairing happens after install — and the daemon
// refuses to run in that state on purpose, because an agent that could authorise nothing must not look
// healthy. With `Restart=always` and `RestartSec=5` and no start limit, that is a restart every five
// seconds until a phone pairs.
//
// Found by rehearsing the install on a real kernel: exit 1, "devices is empty, so no phone could ever be
// authorised", and the journal filling with the same line.
func TestTheUnitGivesUpInsteadOfRestartingForever(t *testing.T) {
unit := string(UnitFile())
if !strings.Contains(unit, "Restart=always") {
t.Skip("the unit no longer restarts, so there is nothing to bound")
}
if !strings.Contains(unit, "StartLimitIntervalSec=") || !strings.Contains(unit, "StartLimitBurst=") {
t.Fatal("the unit restarts without a start limit: an unpaired node would restart every RestartSec forever")
}
// And the install tells the operator what to do next, so the failed state is expected rather than
// surprising.
var lines []string
root := t.TempDir()
paths := Paths{
ServiceName: "msa-agent",
UnitPath: filepath.Join(root, "etc/systemd/system/msa-agent.service"),
ConfigPath: filepath.Join(root, "etc/msa-agent/config.json"),
LogDir: filepath.Join(root, "var/log/msa-agent"),
BinaryPath: filepath.Join(root, "opt/msa-agent/msa-agent"),
User: "msa-agent",
}
for _, path := range []string{paths.BinaryPath, paths.ConfigPath} {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
}
if err := Install(InstallOptions{
Paths: paths,
Runner: &recorder{failOn: "getent"},
Log: func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) },
}); err != nil {
t.Fatalf("Install: %v", err)
}
if !strings.Contains(strings.Join(lines, "\n"), "--pair") {
t.Fatalf("the install does not say to pair next:\n%s", strings.Join(lines, "\n"))
}
}
+158
View File
@@ -0,0 +1,158 @@
package install
import (
"os"
"path/filepath"
"strings"
"testing"
"fun.chick/msa-agent/internal/config"
)
// The embedded unit is a template, and the install's own paths have to reach it.
//
// Two literals were measured drifting, and both fail on the node rather than here:
//
// 1. `install --config /srv/msa/config.json` chowned that file and gave it to the service user, then
// wrote a unit whose ExecStart named the default path. The service starts, cannot find its config,
// and exits — with the file it needs sitting elsewhere, correctly owned and never read.
// 2. `enroll --audit /srv/audit/audit.log` writes that path into the config, and the unit's
// ReadWritePaths covered only /var/log/msa-agent. Under ProtectSystem=strict the agent's MkdirAll
// for the parent directory fails with EROFS and the daemon exits at startup, naming neither the
// flag nor the directive.
//
// The rendering is deliberately identity-preserving for the default layout: the file a reviewer read
// is still byte-for-byte what a default install writes, so the vendored-copy drift test keeps its
// meaning.
func TestRenderedUnitFollowsTheLayoutItWasGiven(t *testing.T) {
// (a) The default layout renders exactly the embedded file.
if got := string(RenderedUnit(DefaultPaths(), nil)); got != string(UnitFile()) {
t.Fatal("the default layout does not render the reviewed unit file")
}
// (b) A config path elsewhere reaches ExecStart.
custom := DefaultPaths()
custom.ConfigPath = "/srv/msa/config.json"
unit := string(RenderedUnit(custom, nil))
if !strings.Contains(unit, "ExecStart=/opt/msa-agent/msa-agent --config /srv/msa/config.json") {
t.Fatalf("ExecStart does not name the config the install was given:\n%s", execLines(unit))
}
if strings.Contains(unit, defaultConfigPath) {
t.Fatalf("the default config path is still in the unit:\n%s", execLines(unit))
}
// (c) An audit log outside the log directory becomes writable, and the default entry stays.
unit = string(RenderedUnit(DefaultPaths(), []string{"/srv/audit"}))
if !strings.Contains(unit, "ReadWritePaths=/var/log/msa-agent /srv/audit") {
t.Fatalf("the audit directory is not writable:\n%s", readWriteLines(unit))
}
// (d) A duplicate — an audit log that already lives in the log directory — is collapsed, so the
// rendered file does not accumulate the same path on every re-install.
unit = string(RenderedUnit(DefaultPaths(), []string{"/var/log/msa-agent", "/var/log/msa-agent/"}))
if !strings.Contains(unit, "ReadWritePaths=/var/log/msa-agent\n") {
t.Fatalf("a duplicate writable path was rendered:\n%s", readWriteLines(unit))
}
}
// The install reads the audit path out of the config `enroll` wrote, so the two agree without anyone
// passing the flag twice. An unreadable config yields nothing rather than a guess: a wrong
// ReadWritePaths entry is a sandbox hole, and a missing one is the EROFS above.
func TestTheAuditDirectoryComesFromTheConfig(t *testing.T) {
directory := t.TempDir()
path := filepath.Join(directory, "config.json")
cfg := config.File{
NodeID: "nd_7f3a9c2e", RelayURL: "https://relay.example", AgentToken: "tok",
LocalPort: 3000, SignKey: "AAAA", KxKey: "AAAA",
AuditPath: "/srv/audit/audit.log", Devices: []config.Device{},
}
if err := config.Save(path, cfg); err != nil {
t.Fatal(err)
}
if got := auditDirOf(path); got != "/srv/audit" {
t.Fatalf("auditDirOf = %q, want /srv/audit", got)
}
if got := auditDirOf(filepath.Join(directory, "absent.json")); got != "" {
t.Fatalf("auditDirOf invented %q for a config that is not there", got)
}
if err := os.WriteFile(path, []byte("not json"), 0o600); err != nil {
t.Fatal(err)
}
if got := auditDirOf(path); got != "" {
t.Fatalf("auditDirOf invented %q for an unparseable config", got)
}
}
// And the whole install writes the rendered unit, not the embedded bytes. This is the assertion that
// would fail if the two ever came apart again.
func TestInstallWritesAUnitForItsOwnPaths(t *testing.T) {
root := t.TempDir()
paths := Paths{
ServiceName: "msa-agent",
UnitPath: filepath.Join(root, "etc/systemd/system/msa-agent.service"),
ConfigPath: filepath.Join(root, "srv/msa/config.json"),
LogDir: filepath.Join(root, "var/log/msa-agent"),
BinaryPath: filepath.Join(root, "opt/msa-agent/msa-agent"),
User: "msa-agent",
}
for _, path := range []string{paths.BinaryPath, paths.ConfigPath} {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(paths.BinaryPath, []byte("binary"), 0o755); err != nil {
t.Fatal(err)
}
cfg := config.File{
NodeID: "nd_7f3a9c2e", RelayURL: "https://relay.example", AgentToken: "tok",
LocalPort: 3000, SignKey: "AAAA", KxKey: "AAAA",
AuditPath: filepath.Join(root, "srv/audit/audit.log"), Devices: []config.Device{},
}
if err := config.Save(paths.ConfigPath, cfg); err != nil {
t.Fatal(err)
}
runner := &recorder{failOn: "getent"}
if err := Install(InstallOptions{Paths: paths, Runner: runner, Log: func(string, ...any) {}}); err != nil {
t.Fatalf("Install: %v", err)
}
written, err := os.ReadFile(paths.UnitPath)
if err != nil {
t.Fatal(err)
}
unit := string(written)
if !strings.Contains(unit, "--config "+paths.ConfigPath) {
t.Fatalf("the installed unit does not name the config the install was given:\n%s", execLines(unit))
}
if !strings.Contains(unit, "ReadWritePaths="+paths.LogDir+" "+filepath.Join(root, "srv/audit")) {
t.Fatalf("the installed unit does not make the audit log writable:\n%s", readWriteLines(unit))
}
}
// PrintUnit is the operator's preview of the install, so it has to be the same rendering the install
// writes. Dumping the embedded bytes would show a default-layout unit to someone installing with
// --config, which is a preview of a different file.
func TestPrintUnitShowsWhatTheInstallWouldWrite(t *testing.T) {
paths := DefaultPaths()
paths.ConfigPath = "/srv/msa/config.json"
var printed strings.Builder
if err := PrintUnit(&printed, paths); err != nil {
t.Fatal(err)
}
if printed.String() != string(RenderedUnit(paths, []string{auditDirOf(paths.ConfigPath)})) {
t.Fatal("--print-unit and the install would write different files")
}
}
func execLines(unit string) string { return linesStartingWith(unit, "ExecStart") }
func readWriteLines(unit string) string { return linesStartingWith(unit, "ReadWritePaths") }
func linesStartingWith(unit, prefix string) string {
var out []string
for _, line := range strings.Split(unit, "\n") {
if strings.HasPrefix(line, prefix) {
out = append(out, line)
}
}
return strings.Join(out, "\n")
}
+40 -8
View File
@@ -19,6 +19,7 @@ import (
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"sync"
"time"
@@ -30,9 +31,26 @@ import (
// Limits. The attempt ceiling is small on purpose: the pairing secret is a human password, and a
// window where anyone on the LAN can try it a thousand times is a window that leaks the node.
const (
MaxAttempts = 10
DefaultWindow = 10 * time.Minute
MaxRequestBytes = 8 << 10
MaxAttempts = 10
DefaultWindow = 10 * time.Minute
MaxRequestBytes = 8 << 10
// DefaultAdminHost is a literal, not a name and not empty.
//
// The window grants device trust: whoever reaches /provision with the node password gets a key the
// agent will obey. So the bind address decides who can even attempt that, and the default has to be
// the narrowest thing that still lets the documented flow work.
//
// It cannot be 127.0.0.1. Pairing happens over the LAN by design — the phone dials the node's LAN
// address, which is what makes a QR code unnecessary — and a listener on loopback is not reachable
// from another host. A loopback default would not be a hardening measure; it would be a flow that
// never works, and the operator's first experience of this feature would be a refused connection
// with no explanation.
//
// It is a literal rather than a name for a different reason: a name is resolved at bind time, so the
// listening address would depend on what DNS said at that moment. That is not a decision the
// operator made, and on a node whose resolver is compromised it is a decision someone else made.
// Operators who want a narrower or wider bind pass --pair-bind.
DefaultAdminHost = "0.0.0.0"
DefaultAdminPort = 3001
)
@@ -162,7 +180,7 @@ func (h *Handler) provision(w http.ResponseWriter, r *http.Request) {
return
}
if !h.authorize(body.Password) {
h.log("pair_attempt", map[string]any{"outcome": "bad_secret"})
h.log("pair_attempt", map[string]any{"outcome": "bad_secret", "peer": peerOf(r)})
writeError(w, http.StatusUnauthorized, "the shared secret did not match")
return
}
@@ -178,7 +196,7 @@ func (h *Handler) provision(w http.ResponseWriter, r *http.Request) {
if err := config.Save(h.cfgPath, h.cfg); err != nil {
// The device was not added if the save failed, so a retry is safe and the phone must not
// be handed a keypair the agent will not honour.
h.log("pair_attempt", map[string]any{"outcome": "save_failed", "error": err.Error()})
h.log("pair_attempt", map[string]any{"outcome": "save_failed", "error": err.Error(), "peer": peerOf(r)})
writeError(w, http.StatusInternalServerError, "the agent could not persist the pairing")
return
}
@@ -186,7 +204,7 @@ func (h *Handler) provision(w http.ResponseWriter, r *http.Request) {
// second phone means re-running --pair, which is the right amount of friction for a socket
// that grants device trust.
h.closeLocked()
h.log("pair_provision", map[string]any{"outcome": "ok", "app": deviceID})
h.log("pair_provision", map[string]any{"outcome": "ok", "app": deviceID, "peer": peerOf(r)})
writeJSON(w, provisionResponse{
NodeID: h.nodeID,
AppID: deviceID,
@@ -213,7 +231,7 @@ func (h *Handler) destroy(w http.ResponseWriter, r *http.Request) {
return
}
if !h.authorize(body.Password) {
h.log("pair_attempt", map[string]any{"outcome": "bad_secret_destroy"})
h.log("pair_attempt", map[string]any{"outcome": "bad_secret_destroy", "peer": peerOf(r)})
writeError(w, http.StatusUnauthorized, "the shared secret did not match")
return
}
@@ -223,7 +241,7 @@ func (h *Handler) destroy(w http.ResponseWriter, r *http.Request) {
return
}
h.closeLocked()
h.log("pair_destroy", map[string]any{"outcome": "ok"})
h.log("pair_destroy", map[string]any{"outcome": "ok", "peer": peerOf(r)})
writeJSON(w, map[string]any{"destroyed": true})
}
@@ -262,6 +280,20 @@ func mustPublic(private []byte) []byte {
return key
}
// peerOf reports the caller's address, for the log only.
//
// It is the one piece of context the attempt ceiling cannot supply: "ten failed pairings" says the
// window closed, and "ten failed pairings from 10.0.0.7" says someone is trying. Only the IP is kept,
// never the port, because the port is a fresh ephemeral number on every attempt and would make two
// attempts from one host look like two hosts.
func peerOf(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
func writeJSON(w http.ResponseWriter, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
+64
View File
@@ -6,11 +6,13 @@ import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
@@ -280,3 +282,65 @@ func TestServeDeadlineClosesTheWindow(t *testing.T) {
t.Fatalf("status after the deadline: %d", response.StatusCode)
}
}
// The window's default bind has to satisfy two things at once, and they pull in opposite directions.
//
// It must be reachable from the phone: pairing happens over the LAN by design, so a loopback default
// would be a flow that never works. And it must be a literal rather than a name: a name is resolved at
// bind time, so the listening address would depend on what DNS said, which is not a decision the
// operator made.
func TestTheDefaultBindIsALiteralAndIsReachableFromTheLAN(t *testing.T) {
if net.ParseIP(DefaultAdminHost) == nil {
t.Fatalf("%q is not an IP literal; a name would be resolved at bind time", DefaultAdminHost)
}
ip := net.ParseIP(DefaultAdminHost)
if ip.IsLoopback() {
t.Fatal("a loopback default cannot be reached from the phone, and pairing is a LAN flow")
}
// Any-address is the default because the documented flow requires it; the operator narrows it with
// --pair-bind when the node is routable from somewhere it should not be.
if !ip.IsUnspecified() {
t.Fatalf("the default bind changed to %q; the documented flow and the deployment notes assume %q",
DefaultAdminHost, "0.0.0.0")
}
}
// The log has to name the caller. "Ten failed pairings" tells an operator the window closed; "ten
// failed pairings from 10.0.0.7" tells them someone is trying, which is the only actionable form of
// the same fact and the one thing the attempt ceiling cannot supply.
func TestAPairingAttemptIsLoggedWithItsPeer(t *testing.T) {
path, cfg, _ := agentConfig(t)
var mu sync.Mutex
var attempts []map[string]any
h, err := New(path, cfg, func(event string, fields map[string]any) {
if event != "pair_attempt" {
return
}
mu.Lock()
defer mu.Unlock()
attempts = append(attempts, fields)
})
if err != nil {
t.Fatalf("New: %v", err)
}
signPub, kxPub := phoneKeys(t)
request := httptest.NewRequest("POST", "/provision", provisionBody(t, "wrong", signPub, kxPub))
// A caller behind a NAT, which is the shape a real attempt has.
request.RemoteAddr = "10.0.0.7:54321"
h.Routes().ServeHTTP(httptest.NewRecorder(), request)
mu.Lock()
defer mu.Unlock()
if len(attempts) != 1 {
t.Fatalf("expected one logged attempt, got %d", len(attempts))
}
if attempts[0]["outcome"] != "bad_secret" {
t.Fatalf("outcome is %v, want bad_secret", attempts[0]["outcome"])
}
// The host without its ephemeral port: the port is a fresh number on every attempt, so keeping it
// would make two attempts from one host look like two hosts.
if attempts[0]["peer"] != "10.0.0.7" {
t.Fatalf("peer is %v, want the host alone", attempts[0]["peer"])
}
}
+1
View File
@@ -31,6 +31,7 @@
"dangerous": ["/api/sms/send", "/api/sms/batch-delete"],
"limits": {
"dangerousWritesPerMinute": 6,
"agentUpstreamTimeoutSeconds": 20,
"maxRequestBytes": 262144,
"maxResponseBytes": 262144,
"requestTtlSeconds": 30,
+32 -14
View File
@@ -39,12 +39,17 @@ type AuthPaths struct {
type Limits struct {
DangerousWritesPerMinute int `json:"dangerousWritesPerMinute"`
MaxRequestBytes int `json:"maxRequestBytes"`
MaxResponseBytes int `json:"maxResponseBytes"`
RequestTTLSeconds int `json:"requestTtlSeconds"`
ClockSkewSeconds int `json:"clockSkewSeconds"`
ReplayCacheSize int `json:"replayCacheSize"`
MailboxDepth int `json:"mailboxDepth"`
// AgentUpstreamTimeoutSeconds is how long one loopback call to SimAdmin may take. It lives in the
// policy rather than only in the agent's code because the relay's deadline has to be compared
// against it, and a comparison between a policy file and a constant in a binary is not one
// anything can check.
AgentUpstreamTimeoutSeconds int `json:"agentUpstreamTimeoutSeconds"`
MaxRequestBytes int `json:"maxRequestBytes"`
MaxResponseBytes int `json:"maxResponseBytes"`
RequestTTLSeconds int `json:"requestTtlSeconds"`
ClockSkewSeconds int `json:"clockSkewSeconds"`
ReplayCacheSize int `json:"replayCacheSize"`
MailboxDepth int `json:"mailboxDepth"`
}
type Policy struct {
@@ -148,13 +153,14 @@ func Parse(raw []byte) (*Policy, error) {
}
for name, value := range map[string]int{
"dangerousWritesPerMinute": p.Limits.DangerousWritesPerMinute,
"maxRequestBytes": p.Limits.MaxRequestBytes,
"maxResponseBytes": p.Limits.MaxResponseBytes,
"requestTtlSeconds": p.Limits.RequestTTLSeconds,
"clockSkewSeconds": p.Limits.ClockSkewSeconds,
"replayCacheSize": p.Limits.ReplayCacheSize,
"mailboxDepth": p.Limits.MailboxDepth,
"dangerousWritesPerMinute": p.Limits.DangerousWritesPerMinute,
"maxRequestBytes": p.Limits.MaxRequestBytes,
"maxResponseBytes": p.Limits.MaxResponseBytes,
"requestTtlSeconds": p.Limits.RequestTTLSeconds,
"agentUpstreamTimeoutSeconds": p.Limits.AgentUpstreamTimeoutSeconds,
"clockSkewSeconds": p.Limits.ClockSkewSeconds,
"replayCacheSize": p.Limits.ReplayCacheSize,
"mailboxDepth": p.Limits.MailboxDepth,
} {
if value <= 0 {
return nil, fmt.Errorf("policy: limits.%s must be positive, got %d", name, value)
@@ -163,8 +169,20 @@ func Parse(raw []byte) (*Policy, error) {
if p.Limits.ClockSkewSeconds > 300 {
return nil, errors.New("policy: clockSkewSeconds is too wide to bound replay")
}
// The relay's deadline has to sit inside the clock-skew window, and the reason is not tidiness.
//
// The freshness check above rejects an envelope whose timestamp is further from now than
// clockSkewSeconds. The agent's own deadline check rejects one older than requestTtlSeconds. If the
// TTL were the larger of the two, the freshness check would fire first and the deadline check would
// be unreachable — a rule that can never fire, which is the kind of thing that gets discovered
// during an incident by someone who assumed it was working.
if p.Limits.RequestTTLSeconds > p.Limits.ClockSkewSeconds {
return nil, errors.New("policy: requestTtlSeconds exceeds clockSkewSeconds and would never be enforced")
return nil, errors.New("policy: requestTtlSeconds exceeds clockSkewSeconds, so the agent's deadline check would never be reached")
}
// And the node must not be told to wait longer than the phone is willing to. The two are one
// decision — how long a request may take end to end — stated in the file that both sides read.
if p.Limits.RequestTTLSeconds*1000 <= p.Limits.AgentUpstreamTimeoutSeconds*1000 {
return nil, errors.New("policy: requestTtlSeconds must exceed the per-request upstream timeout, or the relay deadline is not the longer bound")
}
if p.Limits.ReplayCacheSize < p.Limits.MailboxDepth {
return nil, errors.New("policy: replayCacheSize is smaller than mailboxDepth, so entries can be evicted while still queued")
+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)
}
+56
View File
@@ -0,0 +1,56 @@
// Package relayurl holds the one definition of an acceptable relay URL.
//
// Three components need to agree on it — the config validator that decides whether the daemon starts
// at all, the transport that dials the relay, and the enrollment client that registers a node before
// either of them runs — and they did not. `msa-agent enroll --relay relay.example` (no scheme) and
// `--relay https://relay.example/v1` (a path) both wrote a config happily; the daemon then refused it
// at startup, on a node the operator had just finished installing, minutes later and in a different
// process. The error arrived as "msa-agent exiting" in the journal.
//
// The rule is short enough to state once and nowhere else: https, a host, and nothing after it.
package relayurl
import (
"errors"
"fmt"
"net/url"
)
// Validate reports whether raw can be used as a relay base URL.
//
// The path rule is not cosmetic. The transport builds every request URL by concatenation
// (baseURL + "/v1/hello"), so a base URL carrying a query would produce
// `https://relay.example?x=1/v1/hello` — an empty path and a query of "/v1/hello". A base URL with a
// path would silently address endpoints under it. Both fail somewhere far from the typo.
func Validate(raw string) error {
if raw == "" {
// Named separately because the empty string is what a flag that was never given a value looks
// like, and "must be https, got """ is a worse thing to read at 2am than "is required".
return errors.New("relay url is required")
}
parsed, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("relay url: %w", err)
}
if parsed.Scheme != "https" {
// Not negotiable. The relay is untrusted by design, so the channel's job is to stop a third
// party from substituting a different ciphertext; over an unauthenticated channel the
// end-to-end encryption above it is decorative.
return fmt.Errorf("relay url must be https, got %q", parsed.Scheme)
}
if parsed.Host == "" {
return errors.New("relay url has no host")
}
if parsed.Path != "" && parsed.Path != "/" {
return fmt.Errorf("relay url must not carry a path, got %q", parsed.Path)
}
if parsed.RawQuery != "" || parsed.Fragment != "" {
return errors.New("relay url must not carry a query or a fragment")
}
if parsed.User != nil {
// Credentials in the URL would be sent on every request and are not how this authenticates:
// the agent signs a challenge with its own key, and a bearer in the URL would end up in logs.
return errors.New("relay url must not carry credentials")
}
return nil
}
+37
View File
@@ -0,0 +1,37 @@
package relayurl
import "testing"
// The list is the union of every malformed value that reached a real node: the two that were written
// into a config and refused only at the next startup, and the ones the transport already refused.
func TestValidateRefusesWhatTheDaemonCannotUse(t *testing.T) {
for _, raw := range []string{
"", // the flag was never given a value
"relay.example", // no scheme: url.Parse reads this as a path
"http://relay.example", // plaintext, so the ciphertext above it is decorative
"ws://relay.example", // a scheme the transport cannot dial
"https://relay.example/v1", // a path the request builder would silently append to
"https://relay.example?x=1",
"https://relay.example#f",
"https://user:pw@relay.example",
"https://", // no host
"not a url", // a space is not a valid URL byte
} {
if err := Validate(raw); err == nil {
t.Fatalf("accepted %q", raw)
}
}
}
func TestValidateAcceptsTheFormsAnOperatorActuallyTypes(t *testing.T) {
for _, raw := range []string{
"https://relay.example",
"https://relay.example/", // a trailing slash is trimmed by the transport, not an error
"https://relay.example:8443",
"https://203.0.113.9",
} {
if err := Validate(raw); err != nil {
t.Fatalf("refused %q: %v", raw, err)
}
}
}
+11 -2
View File
@@ -59,6 +59,11 @@ func NullAudit() *Audit { return &Audit{} }
// Write appends one entry. A write failure is returned to the caller rather than panicking: the
// audit log is evidence, not a control, and a full disk must not become a remote denial of service.
//
// Returning nil after Close is deliberate and is the one case where a dropped entry is not an error:
// a shutdown path racing an in-flight request would otherwise log a failure for every request that
// happened to arrive at that moment, which is noise rather than information. The executor logs what it
// sees either way, so the difference is visible in the agent's log.
func (a *Audit) Write(entry Entry) error {
a.mu.Lock()
defer a.mu.Unlock()
@@ -75,8 +80,12 @@ func (a *Audit) Write(entry Entry) error {
return a.sink.Sync()
}
// Close releases the file. Subsequent writes are silently dropped, which is the right behaviour for
// a shutdown path: failing the request that happens to race with a clean exit buys nothing.
// Close releases the file.
//
// A write after Close is dropped, and dropped silently is the wrong word for it: the executor reports
// every write error it sees, and this returns one, so a request racing a clean shutdown shows up as a
// warning rather than as a missing line nobody notices. The alternative — failing the request — buys
// nothing, because the command it describes has already run or been refused.
func (a *Audit) Close() error {
a.mu.Lock()
defer a.mu.Unlock()
+34 -3
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"sync"
@@ -90,7 +91,10 @@ func NewExecutor(pol *policy.Policy, cfg Config) (*Executor, error) {
}
timeout := cfg.Timeout
if timeout <= 0 {
timeout = 20 * time.Second
// From the policy, not a constant here. The relay's deadline is compared against this number
// when the policy is validated, and a comparison against a constant inside a binary is not one
// anything can check.
timeout = time.Duration(pol.Limits.AgentUpstreamTimeoutSeconds) * time.Second
}
client, err := egress.NewClient(target, timeout)
if err != nil {
@@ -144,6 +148,21 @@ func (e *Executor) Execute(ctx context.Context, envelope wire.Envelope, inner wi
if err := Freshness(envelope.TS, uint64(now.Unix()), e.pol.Limits.ClockSkewSeconds); err != nil {
return Result{}, e.refuse(envelope, inner, ClassExpired, err)
}
// The phone's own deadline, checked before anything is executed.
//
// The relay answers 504 with "it may or may not have executed it; check the node before retrying",
// which is honest — but it is only honest while it stays true. A batch is processed serially, so
// the eighth envelope of a full mailbox can begin up to eight upstream timeouts after it was
// signed; by then the phone has been told the request failed and the user may already have retried.
// Executing it at that point turns an honest "unknown" into a duplicate SMS.
//
// The relay's deadline is the authority on when that happened, because the relay is the party that
// starts the clock. Its value is therefore carried in the policy and asserted to be no larger than
// the clock-skew window, which is what makes the freshness check above unable to mask this one.
if age := int64(now.Unix()) - int64(envelope.TS); age > int64(e.pol.Limits.RequestTTLSeconds) {
return Result{}, e.refuse(envelope, inner, ClassExpired,
fmt.Errorf("%w: signed %ds ago, deadline is %ds", ErrExpired, age, e.pol.Limits.RequestTTLSeconds))
}
if e.replay.Seen(envelope.Req, now) {
return Result{}, e.refuse(envelope, inner, ClassReplay, fmt.Errorf("%w: %s", ErrReplay, envelope.Req))
}
@@ -261,11 +280,21 @@ func (e *Executor) refuse(envelope wire.Envelope, inner wire.InnerRequest, class
return &Refusal{Class: class, Path: inner.Path, Err: err}
}
// record appends one audit entry, and reports a failure to the process log.
//
// The write error is not propagated to the caller: the audit log is evidence, not a control, and a
// full disk must not become a remote denial of service — a request that already succeeded would start
// failing because the log could not be appended to, which is the wrong thing to break.
//
// But it must not vanish either. Silently losing audit entries is the failure mode where an operator
// believes they have a record and does not, and the whole point of the log is to be able to answer
// "who asked for what" afterwards. So the error goes to the agent's own log, where a full disk shows
// up as a repeating line rather than as an absence.
func (e *Executor) record(envelope wire.Envelope, method, path string, status int, outcome string) {
if e.audit == nil {
return
}
e.audit.Write(Entry{
if err := e.audit.Write(Entry{
At: e.clock(),
Node: envelope.Node,
Req: envelope.Req,
@@ -274,7 +303,9 @@ func (e *Executor) record(envelope wire.Envelope, method, path string, status in
Path: path,
Status: status,
Outcome: outcome,
})
}); err != nil {
slog.Warn("audit entry could not be written", "error", err, "req", envelope.Req, "outcome", outcome)
}
}
// rateLimiter is a sliding-window counter over the dangerous writes this node has performed.
+115
View File
@@ -1,9 +1,11 @@
package runtime
import (
"bytes"
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
@@ -544,3 +546,116 @@ func TestExecutorRefusesToStartWithoutAPolicyOrPort(t *testing.T) {
t.Fatal("port 0 was accepted")
}
}
// A failed audit write must be visible somewhere. The entry cannot fail the request — the audit log is
// evidence, not a control, and a full disk must not become a remote denial of service — but silently
// losing entries is the failure mode where an operator believes they have a record and does not.
func TestAFailedAuditWriteIsReported(t *testing.T) {
directory := t.TempDir()
path := filepath.Join(directory, "audit.log")
audit, err := OpenAudit(path)
if err != nil {
t.Fatal(err)
}
// Replace the sink with one that fails, which is what a full disk looks like from here. Reaching
// into the struct is deliberate: the alternative is filling a filesystem in a test.
audit.mu.Lock()
audit.sink = nil
audit.mu.Unlock()
var captured bytes.Buffer
previous := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&captured, &slog.HandlerOptions{Level: slog.LevelDebug})))
defer slog.SetDefault(previous)
// Close() drops the sink, so a write after it returns nil rather than an error — that is the
// documented shutdown case. Force the error path by giving it a closed file instead.
file, err := os.OpenFile(filepath.Join(directory, "closed.log"), os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
t.Fatal(err)
}
if err := file.Close(); err != nil {
t.Fatal(err)
}
audit.mu.Lock()
audit.sink = file
audit.mu.Unlock()
if err := audit.Write(Entry{Node: "nd_x", Req: "r1", Outcome: "ok"}); err == nil {
t.Fatal("writing to a closed file reported success")
}
// And the executor's own path reports it rather than swallowing it.
exec, err := NewExecutor(testPolicy(t), Config{Port: 3000, Audit: audit})
if err != nil {
t.Fatal(err)
}
exec.record(wire.Envelope{Node: "nd_x", Req: "r2", App: "ap_x"}, "GET", "/api/health", 200, "ok")
if !strings.Contains(captured.String(), "audit entry could not be written") {
t.Fatalf("a failed audit write was not reported; log was: %s", captured.String())
}
}
// A request whose phone has already given up must not be executed.
//
// The relay answers 504 with "it may or may not have executed it; check the node before retrying",
// which is honest — but only while it stays true. A batch is processed serially, so the eighth
// envelope of a full mailbox can begin up to eight upstream timeouts after it was signed. By then the
// phone has been told the request failed and the user may have retried; executing it turns an honest
// "unknown" into a duplicate SMS.
func TestARequestPastItsDeadlineIsNotExecuted(t *testing.T) {
// Counted by path: the agent probes /api/auth/status before its first forward, and counting every
// request would make the first assertion pass for the wrong reason.
executed := 0
f := newFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/sms/send" {
executed++
}
w.WriteHeader(200)
}))
pol := testPolicy(t)
body := []byte(`{"phone_number":"+8613800000000","content":"hello"}`)
intent, err := wire.IntentDigest("POST", "/api/sms/send", body)
if err != nil {
t.Fatal(err)
}
inner := wire.InnerRequest{Method: "POST", Path: "/api/sms/send", Body: body, Intent: intent}
// One second inside the deadline: executed, because the phone is still waiting.
envelope := envelopeAt("nd_test0001", newReqID(1), uint64(f.now.Unix()))
if _, refusal := f.executor.Execute(context.Background(), envelope, inner); refusal != nil {
t.Fatalf("a request inside its deadline was refused: %v", refusal)
}
if executed != 1 {
t.Fatalf("expected one execution, got %d", executed)
}
// One second past it: refused, and nothing is forwarded.
f.advance(time.Duration(pol.Limits.RequestTTLSeconds+1) * time.Second)
late := envelopeAt("nd_test0001", newReqID(2), uint64(f.now.Unix()-int64(pol.Limits.RequestTTLSeconds)-1))
_, refusal := f.executor.Execute(context.Background(), late, inner)
if refusal == nil {
t.Fatal("a request past its deadline was executed; the phone was told it had failed")
}
if refusal.Class != ClassExpired {
t.Fatalf("refused as %s, want %s", refusal.Class, ClassExpired)
}
if executed != 1 {
t.Fatalf("the late request was forwarded anyway (%d executions)", executed)
}
}
// The deadline check is only reachable because the policy keeps the TTL inside the clock-skew window.
// If it were not, the freshness check would fire first and the deadline rule could never be reached —
// a rule that never fires is one nobody discovers is broken until an incident.
func TestThePolicyKeepsTheDeadlineInsideTheFreshnessWindow(t *testing.T) {
pol := testPolicy(t)
if pol.Limits.RequestTTLSeconds > pol.Limits.ClockSkewSeconds {
t.Fatalf("requestTtlSeconds (%d) exceeds clockSkewSeconds (%d); the deadline check is unreachable",
pol.Limits.RequestTTLSeconds, pol.Limits.ClockSkewSeconds)
}
// And the relay's deadline is the longer bound, or the phone gives up before the node does.
if pol.Limits.RequestTTLSeconds <= pol.Limits.AgentUpstreamTimeoutSeconds {
t.Fatalf("the relay deadline (%ds) is not longer than one upstream call (%ds)",
pol.Limits.RequestTTLSeconds, pol.Limits.AgentUpstreamTimeoutSeconds)
}
}
+23 -14
View File
@@ -21,10 +21,11 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"fun.chick/msa-agent/internal/relayurl"
)
// Paths on the relay. Versioned, because an agent and a relay will not be upgraded on the same day.
@@ -35,6 +36,21 @@ const (
PathDevices = "/v1/agent/devices"
)
// DefaultMaxBytes bounds a relay response body.
//
// It has to admit a full long-poll batch, which is the largest thing the relay can legitimately send:
// one envelope per mailbox slot. The previous 1 MiB was chosen as "generous" without being derived
// from anything, and it is smaller than what the relay is now allowed to send — a mailbox of eight
// envelopes at the policy's ceiling is about 4 MiB, so a full batch would have been refused as
// "relay response exceeds 1048576 bytes", and the agent would have retried a poll the relay answered
// correctly. That is the same class of defect as the relay's own envelope ceiling: a bound picked by
// feel, on the other side of a seam, disagreeing with the party that produces the data.
//
// The derivation is stated here rather than computed from the policy because the agent reads the
// policy for its own limits; the two numbers are pinned against each other by
// test/limits.test.js, which is where a change on either side becomes visible.
const DefaultMaxBytes int64 = 8 << 20
var (
// ErrSessionExpired means the relay no longer recognises this session. The caller re-runs the
// handshake and polls again; it is not an error worth crashing over.
@@ -120,18 +136,11 @@ type Config struct {
// stop a third party from substituting a different ciphertext; an unauthenticated channel would make
// the endpoint-to-endpoint encryption above it decorative.
func New(cfg Config) (*Client, error) {
parsed, err := url.Parse(cfg.BaseURL)
if err != nil {
return nil, fmt.Errorf("transport: relay url: %w", err)
}
if parsed.Scheme != "https" {
return nil, fmt.Errorf("transport: relay url must be https, got %q", parsed.Scheme)
}
if parsed.Host == "" {
return nil, errors.New("transport: relay url has no host")
}
if parsed.Path != "" && parsed.Path != "/" {
return nil, fmt.Errorf("transport: relay url must not carry a path, got %q", parsed.Path)
// The rule lives in internal/relayurl and is applied here, in config.File.Validate and in the
// enrollment client. Three copies of it is how `enroll` came to write configs this constructor
// refused.
if err := relayurl.Validate(cfg.BaseURL); err != nil {
return nil, fmt.Errorf("transport: %w", err)
}
if len(cfg.Signer) != ed25519.PrivateKeySize {
return nil, errors.New("transport: agent signing key is the wrong size")
@@ -142,7 +151,7 @@ func New(cfg Config) (*Client, error) {
}
maxBytes := cfg.MaxBytes
if maxBytes <= 0 {
maxBytes = 1 << 20
maxBytes = DefaultMaxBytes
}
client := &http.Client{Timeout: timeout}
if cfg.RootCAs != nil {
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env bash
# Publishes this agent's cross-compiled binaries as a Gitea release.
#
# The release is what makes the install command real: `msa-agent enroll` runs on the node, and the node
# has no Go toolchain, so the binary has to come from somewhere. Before this the only way to get one was
# to build it on another machine and scp it, which is a step the install command cannot express.
#
# The token is read from the git credential helper rather than an environment variable, so it is not in
# the process table and not in a shell history. Override with MSA_GITEA_TOKEN when running somewhere
# that has no helper.
#
# Usage:
# ./scripts/release.sh # publish the version in the Makefile
# ./scripts/release.sh --tag v1.0.0 # publish an explicit tag
# ./scripts/release.sh --dry-run # build, checksum, print, upload nothing
#
# This repository must stay publicly readable: the install command fetches the binary from a node that
# has no credentials, and Gitea answers 404 for a private repository, so the failure on the node looks
# like a wrong URL. The script checks that and refuses rather than publishing something unreachable.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
REMOTE="$(git config --get remote.origin.url)"
HOST="$(printf '%s' "$REMOTE" | sed -E 's#^https?://([^/]+)/.*#\1#')"
SLUG="$(printf '%s' "$REMOTE" | sed -E 's#^https?://[^/]+/##; s#\.git$##')"
if [[ -z "$HOST" || -z "$SLUG" ]]; then
echo "release: cannot parse the origin remote ($REMOTE)" >&2
exit 1
fi
VERSION="$(sed -n 's/^VERSION[[:space:]]*?*=[[:space:]]*//p' Makefile | head -1)"
TAG="v${VERSION}"
DRY_RUN=0
while [[ $# -gt 0 ]]; do
case "$1" in
--tag) TAG="$2"; shift 2 ;;
--dry-run) DRY_RUN=1; shift ;;
*) echo "release: unknown argument $1" >&2; exit 2 ;;
esac
done
step() { printf '\n\033[1m== %s\033[0m\n' "$1"; }
step "building ${TAG}"
# VERSION is passed through so the binary reports the tag it was published under, which is what an
# operator sees in `msa-agent version` and in the agent's startup log line.
make build-arm64 build-armv7 VERSION="${VERSION}"
step "checksums"
(
cd dist
shasum -a 256 msa-agent-linux-arm64 msa-agent-linux-armv7 > SHA256SUMS
cat SHA256SUMS
)
if [[ "$DRY_RUN" -eq 1 ]]; then
step "dry run: nothing was uploaded"
exit 0
fi
TOKEN="${MSA_GITEA_TOKEN:-}"
if [[ -z "$TOKEN" ]]; then
# The credential helper is what `git push` already uses, so there is no second secret to keep.
TOKEN="$(printf 'protocol=https\nhost=%s\n\n' "$HOST" | git credential fill 2>/dev/null | sed -n 's/^password=//p')"
fi
if [[ -z "$TOKEN" ]]; then
echo "release: no API token. Set MSA_GITEA_TOKEN, or make sure git can authenticate to $HOST." >&2
exit 1
fi
API="https://${HOST}/api/v1/repos/${SLUG}"
AUTH=(-H "Authorization: token ${TOKEN}")
step "target repository"
PRIVATE="$(curl -sf "${AUTH[@]}" "${API}" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("private"))' 2>/dev/null || echo unknown)"
echo "${SLUG} (private: ${PRIVATE})"
if [[ "$PRIVATE" != "False" ]]; then
echo "release: ${SLUG} is not publicly readable. A node fetching the binary has no credentials and" >&2
echo " would get 404, which looks like a wrong URL. Make it public before publishing." >&2
exit 1
fi
step "creating the release ${TAG}"
# A release that already exists is reused rather than refused: re-running this after a fix should
# replace the binaries, and failing here would mean the operator has to delete the release by hand.
EXISTING="$(curl -sf "${AUTH[@]}" "${API}/releases/tags/${TAG}" 2>/dev/null || true)"
if [[ -n "$EXISTING" ]]; then
RELEASE_ID="$(printf '%s' "$EXISTING" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')"
echo "reusing release ${TAG} (id ${RELEASE_ID})"
else
BODY="$(python3 - "$TAG" "$VERSION" <<'PY'
import json, sys
tag, version = sys.argv[1], sys.argv[2]
print(json.dumps({
"tag_name": tag,
"name": f"simadmin-relay agent {version}",
"body": (
"Outbound-only node agent for SimAdmin nodes, cross-compiled for the ARM Debian boxes SimAdmin "
"runs on. Static, no runtime dependencies, no third-party modules.\n\n"
"Install on a node:\n\n"
"```sh\n"
f"curl -fsSL https://HOST/SLUG/releases/download/{tag}/msa-agent-linux-arm64 \\\n"
" -o /opt/msa-agent/msa-agent && chmod 755 /opt/msa-agent/msa-agent\n"
"/opt/msa-agent/msa-agent enroll --relay https://your-relay --code <code> \\\n"
" --write /etc/msa-agent/config.json\n"
"/opt/msa-agent/msa-agent install\n"
"```\n\n"
"`armv7` is the same build for 32-bit ARM. `SHA256SUMS` covers both.\n\n"
"Removing it again is `msa-agent uninstall` on the node; `--dry-run` shows the plan first.\n"
),
"draft": False,
"prerelease": False,
}, ensure_ascii=False))
PY
)"
BODY="${BODY//HOST/$HOST}"
BODY="${BODY//SLUG/$SLUG}"
RELEASE_ID="$(curl -sf -X POST "${AUTH[@]}" -H 'Content-Type: application/json' \
"${API}/releases" -d "$BODY" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')"
echo "created release ${TAG} (id ${RELEASE_ID})"
fi
step "uploading binaries"
for asset in msa-agent-linux-arm64 msa-agent-linux-armv7 SHA256SUMS; do
# An asset with the same name cannot be replaced, so an existing one is deleted first: otherwise a
# re-run after a rebuild would silently leave the previous binary in place.
EXISTING_ASSET="$(curl -sf "${AUTH[@]}" "${API}/releases/${RELEASE_ID}/assets" \
| python3 -c "
import json, sys
name = sys.argv[1]
for asset in json.load(sys.stdin):
if asset['name'] == name:
print(asset['id'])
break
" "$asset" || true)"
if [[ -n "$EXISTING_ASSET" ]]; then
# Gitea's delete path carries the release id; GitHub's does not. Using GitHub's shape here returns
# 404 and, under `set -e`, killed the script silently after the first asset — which is how a
# re-publish left the old binary in place while looking like it had replaced it.
curl -sf -X DELETE "${AUTH[@]}" "${API}/releases/${RELEASE_ID}/assets/${EXISTING_ASSET}" >/dev/null
echo " replaced ${asset}"
fi
curl -sf -X POST "${AUTH[@]}" \
-F "attachment=@dist/${asset}" \
"${API}/releases/${RELEASE_ID}/assets?name=${asset}" >/dev/null
echo " uploaded ${asset}"
done
step "published"
printf 'release: https://%s/%s/releases/tag/%s\n' "$HOST" "$SLUG" "$TAG"
printf 'download: https://%s/%s/releases/download/%s/msa-agent-linux-arm64\n' "$HOST" "$SLUG" "$TAG"
printf '\n'
# The app renders the install command, so the URL it carries has to be this one. Printing it in the
# exact form DefaultContainer holds is what makes the two comparable by eye.
printf 'DefaultContainer.agentDownload must be:\n'
printf ' https://%s/%s/releases/download/%s/msa-agent-linux-arm64\n' "$HOST" "$SLUG" "$TAG"
printf '\nVerify it is fetchable with no credentials:\n'
printf ' curl -fsSL -o /dev/null -w "%%{http_code}\\n" %s\n' \
"https://${HOST}/${SLUG}/releases/download/${TAG}/msa-agent-linux-arm64"
+12 -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
@@ -11,6 +11,17 @@ Restart=always
# Backing off on our own is not enough: a relay that is down for an hour should not be hammered by
# every node in the fleet the moment it comes back.
RestartSec=5
# A freshly installed node has no paired devices, and the daemon refuses to run in that state on
# purpose: an agent that could authorise nothing must not sit there looking healthy. But pairing happens
# *after* install — the operator runs `--pair` on the node and then pairs the phone — so between those
# two moments this unit would restart every five seconds, forever, filling the journal with the same
# line. Measured on a real kernel: exit 1, "devices is empty, so no phone could ever be authorised".
#
# StartLimitIntervalSec/StartLimitBurst make systemd give up instead: ten attempts inside five minutes
# and the unit enters `failed`, which is a state an operator can see and act on. The install command
# prints the next step precisely so that state is expected rather than surprising.
StartLimitIntervalSec=300
StartLimitBurst=10
TimeoutStopSec=20
KillSignal=SIGTERM