internal/agent/enrollclient/enrollclient.go
Ref: Size: 3.7 KiB History
// Package enrollclient speaks the control plane's enrollment endpoint. It owns
// the single HTTP exchange an agent makes before it has an identity: POST the
// join facts, receive the host credential. The request and response shapes are
// typed here — mirroring the server's contract in internal/server/api — so the
// wire format lives in one checkable place on this side of the trust boundary
// rather than as a hand-built map at the call site.
package enrollclient
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
)
// Request is the enrollment payload. Its JSON tags match the server's
// enrollRequest; the agent is the only producer of this shape.
type Request struct {
Token string `json:"token"`
Name string `json:"name"`
OS string `json:"os"`
Arch string `json:"arch"`
Provisioner string `json:"provisioner"`
// BridgeCIDR is this host's proposal, if it has one. Nil means "no
// opinion" and takes the fleet's suggestion; a non-nil "" means "none of my
// own", which is what a host whose OS owns the guest network says.
BridgeCIDR *string `json:"bridge_cidr,omitempty"`
}
// Response carries the fields the agent consumes from a successful enroll. The
// server also returns server_cert_sha256, which the agent deliberately ignores:
// the fingerprint pinned in the join blob is the sole trust root, so it is
// omitted here rather than decoded and discarded.
type Response struct {
HostID string `json:"host_id"`
Credential string `json:"credential"`
BridgeCIDR string `json:"bridge_cidr"`
}
// ErrTokenRejected reports that the control plane refused the token — it was
// already redeemed or has expired (HTTP 403). It is a distinct sentinel so the
// caller can surface the one actionable recovery ("mint a new join token")
// separately from transport or server faults.
var ErrTokenRejected = errors.New("enroll token rejected: already used or expired")
// Client posts enrollment requests to a control-plane HTTP origin.
type Client struct {
baseURL string
http *http.Client
}
// New returns a Client targeting baseURL (the control plane's HTTP origin, e.g.
// https://host:port). Its timeout bounds the one enroll call so a wrong or dead
// address fails fast instead of hanging the join.
func New(baseURL string) *Client {
return &Client{baseURL: baseURL, http: &http.Client{Timeout: 30 * time.Second}}
}
// Enroll redeems req against POST {baseURL}/api/v1/enroll. It returns
// ErrTokenRejected on 403, a descriptive error on any other non-201 status or on
// a transport/decode failure, and the decoded credential on success.
func (c *Client) Enroll(ctx context.Context, req Request) (Response, error) {
body, err := json.Marshal(req)
if err != nil {
return Response{}, fmt.Errorf("marshal enroll request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/enroll", bytes.NewReader(body))
if err != nil {
return Response{}, fmt.Errorf("build enroll request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(httpReq)
if err != nil {
return Response{}, fmt.Errorf("enroll request: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return Response{}, fmt.Errorf("read enroll response: %w", err)
}
switch resp.StatusCode {
case http.StatusCreated:
var out Response
if err := json.Unmarshal(respBody, &out); err != nil {
return Response{}, fmt.Errorf("parse enroll response: %w", err)
}
return out, nil
case http.StatusForbidden:
return Response{}, ErrTokenRejected
default:
return Response{}, fmt.Errorf("enroll failed (HTTP %d): %s", resp.StatusCode, respBody)
}
}