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:
@@ -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, ®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
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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, ®); 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, ®); 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// Package install removes the agent from a node, in the one order that leaves no dangling state.
|
||||
//
|
||||
// The order is the whole point, and every step of it was chosen against a specific failure:
|
||||
//
|
||||
// 1. Clear the paired-device list in the config and force one handshake, so the relay retires this
|
||||
// node's device bearers. Removing the node first would leave the relay holding routing for a node
|
||||
// that no longer exists — and, worse, leave a phone that thinks it is paired pointing at nothing.
|
||||
// 2. Stop and disable the service. After this the agent cannot be reached by anything, including the
|
||||
// relay, so the remaining steps are purely local.
|
||||
// 3. Remove the unit file and reload systemd. Leaving a unit behind means a later `systemctl start`
|
||||
// resurrects a node whose config is gone.
|
||||
// 4. Remove the config directory. This holds the agent's private keys, the relay bearer and — on a
|
||||
// protected node — the SimAdmin password. It is the step that actually matters for security, and
|
||||
// it is why "uninstall" cannot be a shell one-liner the operator writes from memory.
|
||||
// 5. Remove the log directory, unless asked to keep it. Audit entries are the only record of what
|
||||
// this node was asked to do, so deleting them is a decision rather than a default.
|
||||
// 6. Remove the system user. Last, because every step above ran as that user.
|
||||
//
|
||||
// Nothing here is reversible, so a dry run prints the plan and stops.
|
||||
package install
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Paths is where the install put things. The defaults match agent/systemd/msa-agent.service and the
|
||||
// deployment document; they are fields so a test can point them at a temporary directory.
|
||||
type Paths struct {
|
||||
ServiceName string
|
||||
UnitPath string
|
||||
ConfigPath string
|
||||
LogDir string
|
||||
BinaryPath string
|
||||
User string
|
||||
}
|
||||
|
||||
// DefaultPaths is the layout the deployment document describes.
|
||||
func DefaultPaths() Paths {
|
||||
return Paths{
|
||||
ServiceName: "msa-agent",
|
||||
UnitPath: "/etc/systemd/system/msa-agent.service",
|
||||
ConfigPath: "/etc/msa-agent/config.json",
|
||||
LogDir: "/var/log/msa-agent",
|
||||
BinaryPath: "/opt/msa-agent/msa-agent",
|
||||
User: "msa-agent",
|
||||
}
|
||||
}
|
||||
|
||||
// Runner runs system commands. It is an interface so the sequence can be tested without systemd.
|
||||
type Runner interface {
|
||||
Run(name string, args ...string) error
|
||||
}
|
||||
|
||||
// ExecRunner runs commands for real.
|
||||
type ExecRunner struct {
|
||||
// Stdout and Stderr receive the output of commands that have any. systemctl is quiet on success.
|
||||
Stdout io.Writer
|
||||
Stderr io.Writer
|
||||
}
|
||||
|
||||
func (r ExecRunner) Run(name string, args ...string) error {
|
||||
command := exec.Command(name, args...)
|
||||
command.Stdout = r.Stdout
|
||||
command.Stderr = r.Stderr
|
||||
return command.Run()
|
||||
}
|
||||
|
||||
// Options configures an uninstall.
|
||||
type Options struct {
|
||||
Paths Paths
|
||||
// KeepLogs leaves the audit log in place. Deleting it is the default because the node is being
|
||||
// removed, but an operator retiring a node after an incident may want the record.
|
||||
KeepLogs bool
|
||||
// DryRun prints what would happen and removes nothing.
|
||||
DryRun bool
|
||||
// NotifyRelay retires this node's device bearers at the relay, and is called before anything is
|
||||
// removed. Nil skips it, which is right for a node that was never paired.
|
||||
NotifyRelay func() error
|
||||
Runner Runner
|
||||
Log func(format string, args ...any)
|
||||
}
|
||||
|
||||
// Uninstall performs the sequence. It returns an error only for a failure that leaves the node in a
|
||||
// state the operator must know about; a missing file or an already-stopped service is not one.
|
||||
func Uninstall(opts Options) error {
|
||||
if opts.Runner == nil {
|
||||
opts.Runner = ExecRunner{}
|
||||
}
|
||||
if opts.Log == nil {
|
||||
opts.Log = func(string, ...any) {}
|
||||
}
|
||||
paths := opts.Paths
|
||||
|
||||
// 1. The relay has to be told before the node stops existing.
|
||||
if opts.NotifyRelay != nil {
|
||||
if opts.DryRun {
|
||||
opts.Log("would clear the paired-device list and tell the relay to retire this node's bearers")
|
||||
} else if err := opts.NotifyRelay(); err != nil {
|
||||
// Not fatal, but the operator has to know: the relay will keep routing to a node that is
|
||||
// gone until its own restart or until the agent entry is removed from agents.json.
|
||||
opts.Log("WARNING: could not tell the relay to retire this node: %v", err)
|
||||
opts.Log(" remove this node's entry from the relay's agents.json by hand")
|
||||
} else {
|
||||
opts.Log("told the relay to retire this node's device bearers")
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Stop and disable. `disable --now` is one call so a failure cannot leave the unit enabled with
|
||||
// the process still running.
|
||||
//
|
||||
// Every step below is gated on step()'s answer, and that is load-bearing rather than tidy: a dry
|
||||
// run that stops the service is not a dry run. The first version of this file logged the plan and
|
||||
// ran the commands anyway, which on a Linux box would have taken the node offline while telling the
|
||||
// operator nothing had been touched.
|
||||
if step(opts, "stopping and disabling %s", paths.ServiceName) {
|
||||
if err := opts.Runner.Run("systemctl", "disable", "--now", paths.ServiceName); err != nil {
|
||||
// A unit that was never enabled, or a node where systemd never started it, lands here. It is
|
||||
// not a reason to abort: the remaining steps are what make the node clean.
|
||||
opts.Log(" systemctl disable --now %s: %v (continuing)", paths.ServiceName, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. The unit file, then a reload so systemd forgets it.
|
||||
if step(opts, "removing %s", paths.UnitPath) {
|
||||
removeIfPresent(paths.UnitPath, opts)
|
||||
}
|
||||
if step(opts, "reloading systemd") {
|
||||
if err := opts.Runner.Run("systemctl", "daemon-reload"); err != nil {
|
||||
opts.Log(" systemctl daemon-reload: %v (continuing)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. The config: private keys, relay bearer, and the node password when the node is protected.
|
||||
if step(opts, "removing %s (private keys, relay bearer, node password)", paths.ConfigPath) {
|
||||
removeIfPresent(paths.ConfigPath, opts)
|
||||
// The directory holds relay-ca.pem and anything else the operator put beside the config.
|
||||
removeIfPresent(filepath.Dir(paths.ConfigPath), opts)
|
||||
}
|
||||
|
||||
// 5. Logs.
|
||||
if opts.KeepLogs {
|
||||
step(opts, "keeping %s (--keep-logs)", paths.LogDir)
|
||||
} else if step(opts, "removing %s", paths.LogDir) {
|
||||
removeIfPresent(paths.LogDir, opts)
|
||||
}
|
||||
|
||||
// 6. The binary, then the user. The user goes last because the steps above ran as that user.
|
||||
if step(opts, "removing %s", paths.BinaryPath) {
|
||||
removeIfPresent(paths.BinaryPath, opts)
|
||||
removeIfPresent(filepath.Dir(paths.BinaryPath), opts)
|
||||
}
|
||||
if step(opts, "removing the %s system user", paths.User) {
|
||||
if err := opts.Runner.Run("userdel", paths.User); err != nil {
|
||||
opts.Log(" userdel %s: %v (continuing)", paths.User, err)
|
||||
}
|
||||
}
|
||||
|
||||
if opts.DryRun {
|
||||
opts.Log("dry run: nothing was removed")
|
||||
} else {
|
||||
opts.Log("msa-agent removed")
|
||||
opts.Log("the relay's agents.json still lists this node; remove that entry unless you are reinstalling")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// step reports one line of the plan, and says whether the action should be performed.
|
||||
func step(opts Options, format string, args ...any) bool {
|
||||
if opts.DryRun {
|
||||
opts.Log("would "+format, args...)
|
||||
return false
|
||||
}
|
||||
opts.Log(format, args...)
|
||||
return true
|
||||
}
|
||||
|
||||
// removeIfPresent deletes a path, and treats "it is not there" as success.
|
||||
//
|
||||
// A missing path is the normal case on a partially installed node, and reporting it as an error would
|
||||
// train the operator to ignore the errors that matter.
|
||||
func removeIfPresent(path string, opts Options) {
|
||||
if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) {
|
||||
opts.Log(" %s is not present", path)
|
||||
return
|
||||
}
|
||||
if err := os.RemoveAll(path); err != nil {
|
||||
opts.Log(" WARNING: could not remove %s: %v", path, err)
|
||||
return
|
||||
}
|
||||
opts.Log(" removed %s", path)
|
||||
}
|
||||
|
||||
// ErrNotRoot is returned when an uninstall is attempted without the privileges the steps need.
|
||||
var ErrNotRoot = errors.New("install: uninstalling needs root (it removes a systemd unit and a system user)")
|
||||
|
||||
// RequireRoot is what the command calls before doing anything.
|
||||
func RequireRoot() error {
|
||||
if os.Geteuid() != 0 {
|
||||
return ErrNotRoot
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// String renders the plan for a dry run, so an operator can see it without reading this file.
|
||||
func (p Paths) String() string {
|
||||
return fmt.Sprintf("service=%s unit=%s config=%s logs=%s binary=%s user=%s",
|
||||
p.ServiceName, p.UnitPath, p.ConfigPath, p.LogDir, p.BinaryPath, p.User)
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package install
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// recorder stands in for systemctl and userdel, so the sequence can be tested without systemd.
|
||||
type recorder struct {
|
||||
calls []string
|
||||
// failOn makes a named command fail, for the cases where a missing unit or an absent user must not
|
||||
// abort the uninstall.
|
||||
failOn string
|
||||
}
|
||||
|
||||
func (r *recorder) Run(name string, args ...string) error {
|
||||
call := name + " " + strings.Join(args, " ")
|
||||
r.calls = append(r.calls, call)
|
||||
if r.failOn != "" && strings.HasPrefix(call, r.failOn) {
|
||||
return fmt.Errorf("simulated failure")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// installed creates the layout an install leaves behind, so the test removes real files.
|
||||
func installed(t *testing.T) Paths {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
paths := Paths{
|
||||
ServiceName: "msa-agent",
|
||||
UnitPath: filepath.Join(root, "etc/systemd/system/msa-agent.service"),
|
||||
ConfigPath: filepath.Join(root, "etc/msa-agent/config.json"),
|
||||
LogDir: filepath.Join(root, "var/log/msa-agent"),
|
||||
BinaryPath: filepath.Join(root, "opt/msa-agent/msa-agent"),
|
||||
User: "msa-agent",
|
||||
}
|
||||
for _, path := range []string{paths.UnitPath, paths.ConfigPath, filepath.Join(paths.LogDir, "audit.log"), paths.BinaryPath} {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func TestUninstallRemovesEverythingTheInstallPlaced(t *testing.T) {
|
||||
paths := installed(t)
|
||||
runner := &recorder{}
|
||||
var lines []string
|
||||
err := Uninstall(Options{
|
||||
Paths: paths,
|
||||
Runner: runner,
|
||||
Log: func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) },
|
||||
NotifyRelay: func() error {
|
||||
lines = append(lines, "relay notified")
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Uninstall: %v", err)
|
||||
}
|
||||
for _, path := range []string{paths.UnitPath, paths.ConfigPath, paths.LogDir, paths.BinaryPath} {
|
||||
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("%s survived the uninstall", path)
|
||||
}
|
||||
}
|
||||
// The config directory goes with the config: it holds relay-ca.pem and anything else the operator
|
||||
// put beside it.
|
||||
if _, err := os.Stat(filepath.Dir(paths.ConfigPath)); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatal("the config directory survived; it may still hold pinned CA material")
|
||||
}
|
||||
joined := strings.Join(runner.calls, "\n")
|
||||
for _, want := range []string{"systemctl disable --now msa-agent", "systemctl daemon-reload", "userdel msa-agent"} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Fatalf("expected %q among the commands, got:\n%s", want, joined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The order is the point: the relay has to be told before the node stops existing, or it keeps routing
|
||||
// to a node that is gone and a phone that believes it is paired points at nothing.
|
||||
func TestUninstallTellsTheRelayBeforeItRemovesAnything(t *testing.T) {
|
||||
paths := installed(t)
|
||||
configStillThere := false
|
||||
err := Uninstall(Options{
|
||||
Paths: paths,
|
||||
Runner: &recorder{},
|
||||
Log: func(string, ...any) {},
|
||||
NotifyRelay: func() error {
|
||||
// At the moment the relay is told, the config must still exist: the notification reads it.
|
||||
_, statErr := os.Stat(paths.ConfigPath)
|
||||
configStillThere = statErr == nil
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Uninstall: %v", err)
|
||||
}
|
||||
if !configStillThere {
|
||||
t.Fatal("the config was removed before the relay was told; the device list could not be read")
|
||||
}
|
||||
}
|
||||
|
||||
// A relay that cannot be reached must not stop an uninstall. The operator is removing the node either
|
||||
// way, and the alternative — aborting half way — leaves the machine in a state nobody described.
|
||||
func TestUninstallCompletesWhenTheRelayIsUnreachable(t *testing.T) {
|
||||
paths := installed(t)
|
||||
var lines []string
|
||||
err := Uninstall(Options{
|
||||
Paths: paths,
|
||||
Runner: &recorder{},
|
||||
Log: func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) },
|
||||
NotifyRelay: func() error { return fmt.Errorf("relay unreachable") },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Uninstall: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(paths.ConfigPath); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatal("the uninstall did not finish")
|
||||
}
|
||||
// And the operator is told what is left to do by hand, because the relay still lists this node.
|
||||
output := strings.Join(lines, "\n")
|
||||
if !strings.Contains(output, "WARNING") || !strings.Contains(output, "agents.json") {
|
||||
t.Fatalf("a failed relay notification was not explained:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
// A dry run removes nothing, and that includes not stopping the service: an operator asking what an
|
||||
// uninstall would do must not lose the node while they read the answer.
|
||||
func TestDryRunRemovesNothingAndRunsNothing(t *testing.T) {
|
||||
paths := installed(t)
|
||||
runner := &recorder{}
|
||||
var lines []string
|
||||
err := Uninstall(Options{
|
||||
Paths: paths,
|
||||
DryRun: true,
|
||||
Runner: runner,
|
||||
Log: func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Uninstall: %v", err)
|
||||
}
|
||||
if len(runner.calls) != 0 {
|
||||
t.Fatalf("a dry run ran commands: %v", runner.calls)
|
||||
}
|
||||
for _, path := range []string{paths.UnitPath, paths.ConfigPath, paths.BinaryPath} {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("a dry run removed %s", path)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(strings.Join(lines, "\n"), "dry run: nothing was removed") {
|
||||
t.Fatal("a dry run did not say so")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeepLogsLeavesTheAuditLog(t *testing.T) {
|
||||
paths := installed(t)
|
||||
audit := filepath.Join(paths.LogDir, "audit.log")
|
||||
err := Uninstall(Options{
|
||||
Paths: paths,
|
||||
KeepLogs: true,
|
||||
Runner: &recorder{},
|
||||
Log: func(string, ...any) {},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Uninstall: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(audit); err != nil {
|
||||
t.Fatalf("--keep-logs removed the audit log: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A missing unit or an absent user is the normal case on a partially installed node, and it must not
|
||||
// abort: reporting it as an error would train the operator to ignore the errors that matter.
|
||||
func TestMissingPiecesDoNotAbortTheUninstall(t *testing.T) {
|
||||
paths := installed(t)
|
||||
// Remove the unit first, then uninstall.
|
||||
if err := os.Remove(paths.UnitPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runner := &recorder{failOn: "systemctl disable"}
|
||||
err := Uninstall(Options{
|
||||
Paths: paths,
|
||||
Runner: runner,
|
||||
Log: func(string, ...any) {},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("a missing unit aborted the uninstall: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(paths.ConfigPath); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatal("the config survived")
|
||||
}
|
||||
}
|
||||
|
||||
// The plan names every path, so an operator can check it before running anything.
|
||||
func TestThePlanNamesEveryPath(t *testing.T) {
|
||||
plan := DefaultPaths().String()
|
||||
for _, want := range []string{"/etc/systemd/system/msa-agent.service", "/etc/msa-agent/config.json",
|
||||
"/var/log/msa-agent", "/opt/msa-agent/msa-agent", "msa-agent"} {
|
||||
if !strings.Contains(plan, want) {
|
||||
t.Fatalf("the plan does not mention %s:\n%s", want, plan)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user