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