节点侧现在有两条命令覆盖整个生命周期,中继侧有了一条让节点不必被人工登记就能加入 的通道。 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 项通过。
165 lines
6.3 KiB
Go
165 lines
6.3 KiB
Go
// 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/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
|
|
}
|
|
|
|
// 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.
|
|
func Generate(opts Options) (Result, error) {
|
|
if opts.RelayURL == "" {
|
|
return Result{}, fmt.Errorf("enroll: relay URL is required")
|
|
}
|
|
if opts.LocalPort <= 0 || opts.LocalPort > 65535 {
|
|
return Result{}, fmt.Errorf("enroll: local port %d is out of range", opts.LocalPort)
|
|
}
|
|
if opts.AuditPath == "" {
|
|
return Result{}, fmt.Errorf("enroll: audit path is required")
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
config := map[string]any{
|
|
"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,
|
|
// 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{},
|
|
}
|
|
// Absent rather than empty when unset, so a config that has not been told about a password does not
|
|
// claim the node has none.
|
|
if opts.NodePassword != "" {
|
|
config["nodePassword"] = opts.NodePassword
|
|
}
|
|
if opts.RelayCAFile != "" {
|
|
config["relayCAFile"] = opts.RelayCAFile
|
|
}
|
|
|
|
encoded, err := json.MarshalIndent(config, "", " ")
|
|
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)
|
|
}
|