节点侧现在有两条命令覆盖整个生命周期,中继侧有了一条让节点不必被人工登记就能加入 的通道。 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 项通过。
198 lines
7.1 KiB
Go
198 lines
7.1 KiB
Go
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, ®istration); 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
|
|
}
|