a73x

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