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
+197
View File
@@ -0,0 +1,197 @@
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{
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) }
}
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
}
+80
View File
@@ -25,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"
@@ -42,6 +43,22 @@ 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 "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")
@@ -379,3 +396,66 @@ 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
}
+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) {