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