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)) }