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 项通过。
This commit is contained in:
+24
-90
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user