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:
chick
2026-09-17 13:28:32 +08:00
parent 5feb5bfe70
commit 62b322af39
8 changed files with 1172 additions and 90 deletions
+164
View File
@@ -0,0 +1,164 @@
// 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)
}
+144
View File
@@ -0,0 +1,144 @@
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")
}
}
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)
}
}
}
+140
View File
@@ -0,0 +1,140 @@
package enroll
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
)
// 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 {
parsed, err := url.Parse(opts.RelayURL)
if err != nil {
return fmt.Errorf("enroll: relay url: %w", err)
}
if parsed.Scheme != "https" {
// https is not negotiable here for the same reason the agent refuses it: a registration sent in
// the clear can be replayed by anyone who sees it, and the code is single-use.
return fmt.Errorf("enroll: relay url must be https, got %q", parsed.Scheme)
}
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))
}