四处"校验在错误的层"的缺陷,都属于同一类:每个组件单独看都对,接缝错了。 1. `msa-agent enroll --relay http://relay.example` / `relay.example` / `https://relay.example/v1` 都会写出配置文件,install 报成功,守护进程在**下一次启动**才 拒绝,日志里只有一行 "msa-agent exiting"。规则原本散在三处(config 只查非空、transport 查真实规则、enroll 的注册客户端另有一份),现在收进 `internal/relayurl` 一份,三处都调它。 `config.File.Validate` 因此也补上了 https/host/无路径/无 query 的检查。 2. `enroll` 不再自己手写字段检查,改为构造真正的 `config.File` 并调用真正的 `Validate`—— 一份"什么是可用配置"的定义,结构上无法再漂移。`config.Marshal` 让 enroll 与 Save 用同一个 编码器。 3. `install --config /srv/msa/config.json` 会给该文件改属主、交给服务用户,然后写一个 ExecStart 指向 `/etc/msa-agent/config.json` 的单元——服务起来找不到配置就退出,而它需要的 文件就在别处、属主正确、从未被读。`enroll --audit /srv/audit/audit.log` 同理:单元的 `ReadWritePaths` 只覆盖 `/var/log/msa-agent`,在 `ProtectSystem=strict` 下 agent 的 `MkdirAll` 报 EROFS,进程启动即退出,报错既不提那个 flag 也不提那条指令。现在单元是模板, `RenderedUnit` 按实际布局渲染,`--print-unit` 打印的就是会被写入的那一份;默认布局渲染结果 与仓库里被 review 过的文件逐字节相同,所以 vendored 副本的漂移检查仍然有意义。 测试:新增 `internal/relayurl`,`config`/`enroll`/`install` 各补回归测试。每条都做了变异验证 (把修复回退,对应测试必须失败)。
139 lines
5.1 KiB
Go
139 lines
5.1 KiB
Go
package enroll
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"fun.chick/msa-agent/internal/relayurl"
|
|
)
|
|
|
|
// 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 {
|
|
// The same rule the daemon applies, from the same package. This check is not redundant with the
|
|
// config validation in Generate: registration happens *before* the config is written, so a URL this
|
|
// rejected late would already have been sent to a host the operator did not mean to reach.
|
|
if err := relayurl.Validate(opts.RelayURL); err != nil {
|
|
return fmt.Errorf("enroll: %w", err)
|
|
}
|
|
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))
|
|
}
|