Files
bootapi/internal/server/server.go
T
unkin-agent 3c77895788
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
add POST /logs installer log relay to VictoriaLogs
A host being PXE-discovered or installed is not in Kubernetes, so vlagent
cannot collect its logs and a failed install leaves no record; the installer
environment has no internal-CA trust or credentials for the HTTPS log ingest,
and bootapi is already the plain-HTTP broker it can reach.

- add POST /logs, token-guarded like POST /provisioned, relaying ndjson to
  vlinsert's jsonline endpoint keyed on serial+phase
- stamp observed source IP and resolved NetBox device name into extra_fields
- return 202 on a sink failure so logs never block an install
- add BOOTAPI_VLINSERT_URL/_TIMEOUT and bootapi_log_relay metrics
2026-10-03 19:44:27 +10:00

497 lines
17 KiB
Go

// Package server exposes bootapi over HTTP: iPXE boot scripts and rendered
// kickstarts for PXE-booting hosts, plus health and metrics endpoints.
package server
import (
"bytes"
"context"
"crypto/subtle"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/prometheus/client_golang/prometheus/promhttp"
"git.unkin.net/unkin/bootapi/internal/model"
"git.unkin.net/unkin/bootapi/internal/netbox"
"git.unkin.net/unkin/bootapi/internal/render"
)
// Server wires the NetBox API and template engine into HTTP handlers.
type Server struct {
nb netbox.API
engine *render.Engine
metrics *metrics
// fallback is the unknown-MAC iPXE behavior: "local" (safe default) or
// "shell" (debug).
fallback string
// provisionToken guards POST /provisioned and POST /logs; empty disables
// both endpoints.
provisionToken string
// vlinsertURL is the VictoriaLogs vlinsert base POST /logs relays to;
// empty disables the endpoint. vlClient bounds the single forward attempt.
vlinsertURL string
vlClient *http.Client
// TLS listener (optional); the plain-HTTP listener is always on.
tlsAddr string
tlsCert string
tlsKey string
}
// Options configures a Server.
type Options struct {
NetBox netbox.API
Engine *render.Engine
// Cache/GitStats, when non-nil, have their counters published as metrics.
Cache cacheStats
GitStats gitStats
UnknownMACFallback string
ProvisionToken string
VLInsertURL string
VLInsertTimeout time.Duration
TLSAddr string
TLSCertFile string
TLSKeyFile string
}
// New builds a Server.
func New(o Options) *Server {
fb := o.UnknownMACFallback
if fb == "" {
fb = "local"
}
timeout := o.VLInsertTimeout
if timeout <= 0 {
timeout = 5 * time.Second
}
return &Server{
nb: o.NetBox,
engine: o.Engine,
metrics: newMetrics(o.Cache, o.GitStats),
fallback: fb,
provisionToken: o.ProvisionToken,
vlinsertURL: strings.TrimRight(o.VLInsertURL, "/"),
vlClient: &http.Client{Timeout: timeout},
tlsAddr: o.TLSAddr,
tlsCert: o.TLSCertFile,
tlsKey: o.TLSKeyFile,
}
}
// Router returns the fully-wired HTTP handler.
func (s *Server) Router() http.Handler {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.Recoverer)
r.Use(s.logRequests)
r.Get("/healthz", s.handleHealthz)
r.Get("/readyz", s.handleReadyz)
r.Handle("/metrics", promhttp.HandlerFor(s.metrics.reg, promhttp.HandlerOpts{}))
// iPXE boot script: primary path-style, plus a query-style alias.
r.Get("/ipxe/{mac}", s.handleIPXE)
r.Get("/boot/ipxe", s.handleIPXEQuery)
// Rendered kickstart, keyed by MAC or hostname.
r.Get("/ks/{ident}", s.handleKickstart)
// End-of-kickstart callback: flips pxe_enabled off in NetBox. Token-guarded.
r.Post("/provisioned/{ident}", s.handleProvisioned)
// Installer log relay: a host being installed is not in Kubernetes, so
// vlagent cannot collect its logs. Token-guarded like /provisioned.
r.Post("/logs", s.handleLogs)
return r
}
func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
s.ok(w, http.StatusOK, "text/plain", []byte("ok\n"), "healthz")
}
// handleReadyz is ready once templates parsed (engine present). NetBox is a
// soft dependency — the iPXE fallback works without it — so readiness does not
// probe NetBox.
func (s *Server) handleReadyz(w http.ResponseWriter, _ *http.Request) {
if s.engine == nil {
s.ok(w, http.StatusServiceUnavailable, "text/plain", []byte("no template engine\n"), "readyz")
return
}
s.ok(w, http.StatusOK, "text/plain", []byte("ready\n"), "readyz")
}
// handleIPXE serves the per-MAC iPXE script. An unknown MAC (or a NetBox error)
// yields a SAFE fallback script with HTTP 200 — never a 404 — so the booting
// firmware always receives a valid iPXE script instead of failing the chain.
func (s *Server) handleIPXE(w http.ResponseWriter, r *http.Request) {
s.serveIPXE(w, r, chi.URLParam(r, "mac"))
}
func (s *Server) handleIPXEQuery(w http.ResponseWriter, r *http.Request) {
s.serveIPXE(w, r, r.URL.Query().Get("mac"))
}
func (s *Server) serveIPXE(w http.ResponseWriter, r *http.Request, mac string) {
const ct = "text/plain" // iPXE scripts are served as text/plain
mac = strings.TrimSuffix(mac, ".ipxe")
if mac == "" {
s.renderFallback(w, "ipxe", "missing MAC")
return
}
host, err := s.lookup(r.Context(), "mac", mac)
if err != nil {
if errors.Is(err, netbox.ErrNotFound) {
slog.Info("ipxe unknown MAC; serving fallback", "mac", mac, "fallback", s.fallback)
} else {
slog.Error("ipxe netbox lookup failed; serving safe fallback", "mac", mac, "err", err)
}
s.renderFallback(w, "ipxe", "unknown or unresolvable MAC")
return
}
// Per-host PXE-enable gate (Cobbler's netboot_enabled): a KNOWN host whose
// pxe_enabled is false must NOT re-install. Serve the safe local-boot script
// so an already-provisioned machine just boots its disk.
if !host.ShouldPXEInstall() {
s.metrics.ipxeGated.Inc()
slog.Info("ipxe gated: pxe_enabled=false; serving local boot", "host", host.Hostname)
s.renderFallback(w, "ipxe", "pxe disabled for host")
return
}
body, err := s.engine.RenderIPXE(host)
if err != nil {
s.metrics.renders.WithLabelValues("ipxe", "error").Inc()
slog.Error("render ipxe", "host", host.Hostname, "err", err)
s.renderFallback(w, "ipxe", "render error")
return
}
s.metrics.renders.WithLabelValues("ipxe", "ok").Inc()
s.ok(w, http.StatusOK, ct, body, "ipxe")
}
// renderFallback emits the configured unknown-MAC iPXE script (still HTTP 200).
func (s *Server) renderFallback(w http.ResponseWriter, endpoint, _ string) {
body, err := s.engine.RenderFallback(s.fallback)
if err != nil {
// Last-resort inline script so the firmware still gets something valid.
body = []byte("#!ipxe\necho bootapi: fallback render failed; booting local disk\nsanboot --no-describe --drive 0x80 || exit\n")
}
s.ok(w, http.StatusOK, "text/plain", body, endpoint)
}
// handleKickstart serves the rendered kickstart for a host identified by MAC or
// hostname. Unlike iPXE, an unknown host here is a hard 404: the installer has
// already committed to installing and a wrong/empty kickstart is worse than a
// clear failure.
func (s *Server) handleKickstart(w http.ResponseWriter, r *http.Request) {
ident := chi.URLParam(r, "ident")
for _, suf := range []string{".ks", ".cfg"} {
ident = strings.TrimSuffix(ident, suf)
}
if ident == "" {
http.Error(w, "missing host identifier", http.StatusBadRequest)
s.metrics.httpRequests.WithLabelValues("ks", "4xx").Inc()
return
}
field := "name"
if looksLikeMAC(ident) {
field = "mac"
}
host, err := s.lookup(r.Context(), field, ident)
if err != nil {
if errors.Is(err, netbox.ErrNotFound) {
http.Error(w, "no host in NetBox for "+ident, http.StatusNotFound)
s.metrics.httpRequests.WithLabelValues("ks", "4xx").Inc()
return
}
http.Error(w, "netbox lookup failed", http.StatusBadGateway)
s.metrics.httpRequests.WithLabelValues("ks", "5xx").Inc()
return
}
body, name, err := s.engine.RenderKickstart(host)
if err != nil {
s.metrics.renders.WithLabelValues("kickstart", "error").Inc()
slog.Error("render kickstart", "host", host.Hostname, "err", err)
http.Error(w, "kickstart render failed", http.StatusInternalServerError)
s.metrics.httpRequests.WithLabelValues("ks", "5xx").Inc()
return
}
s.metrics.renders.WithLabelValues("kickstart", "ok").Inc()
slog.Info("served kickstart", "host", host.Hostname, "template", name)
s.ok(w, http.StatusOK, "text/plain", body, "ks")
}
// handleProvisioned is the end-of-kickstart callback. The %post posts here with
// the shared provision token when the install finishes; bootapi flips the host's
// pxe_enabled custom field to false in NetBox so the next PXE boots local disk
// instead of re-installing. This is bootapi's only NetBox write.
func (s *Server) handleProvisioned(w http.ResponseWriter, r *http.Request) {
if s.provisionToken == "" {
http.Error(w, "provisioned callback disabled: no token configured", http.StatusServiceUnavailable)
s.metrics.provisioned.WithLabelValues("disabled").Inc()
return
}
if subtle.ConstantTimeCompare([]byte(bearer(r)), []byte(s.provisionToken)) != 1 {
http.Error(w, "invalid or missing provision token", http.StatusUnauthorized)
s.metrics.provisioned.WithLabelValues("unauthorized").Inc()
return
}
ident := chi.URLParam(r, "ident")
field := "name"
if looksLikeMAC(ident) {
field = "mac"
}
host, err := s.lookup(r.Context(), field, ident)
if err != nil {
if errors.Is(err, netbox.ErrNotFound) {
http.Error(w, "no host in NetBox for "+ident, http.StatusNotFound)
s.metrics.provisioned.WithLabelValues("notfound").Inc()
return
}
http.Error(w, "netbox lookup failed", http.StatusBadGateway)
s.metrics.provisioned.WithLabelValues("error").Inc()
return
}
if err := s.nb.SetPXEEnabled(r.Context(), host.DeviceID, false); err != nil {
slog.Error("provisioned: failed to clear pxe_enabled", "host", host.Hostname, "err", err)
http.Error(w, "failed to update NetBox", http.StatusBadGateway)
s.metrics.provisioned.WithLabelValues("error").Inc()
return
}
s.metrics.provisioned.WithLabelValues("ok").Inc()
slog.Info("host provisioned; pxe_enabled cleared", "host", host.Hostname)
w.WriteHeader(http.StatusNoContent)
}
// maxLogBody caps a relayed batch. An install's log stream is a handful of
// lines per phase; bootapi is a relay, not a log buffer.
const maxLogBody = 1 << 20 // 1 MiB
// handleLogs relays newline-delimited JSON log records from a host that is
// PXE-discovering or installing into VictoriaLogs. Such a host is not in
// Kubernetes (no vlagent) and has no internal-CA trust or credentials for the
// HTTPS log ingest, so bootapi — the plain-HTTP broker it can already reach —
// forwards them. Best-effort: a dead log sink must never block an install, so
// every outcome after authentication is 202.
func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) {
if s.vlinsertURL == "" || s.provisionToken == "" {
http.Error(w, "log relay disabled: no vlinsert URL or no token configured", http.StatusServiceUnavailable)
s.metrics.logRelay.WithLabelValues("disabled").Inc()
return
}
if subtle.ConstantTimeCompare([]byte(bearer(r)), []byte(s.provisionToken)) != 1 {
http.Error(w, "invalid or missing provision token", http.StatusUnauthorized)
s.metrics.logRelay.WithLabelValues("unauthorized").Inc()
return
}
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxLogBody))
if err != nil {
http.Error(w, "log batch unreadable or larger than 1MiB", http.StatusBadRequest)
s.metrics.logRelay.WithLabelValues("error").Inc()
return
}
if len(bytes.TrimSpace(body)) == 0 {
http.Error(w, "empty log batch", http.StatusBadRequest)
s.metrics.logRelay.WithLabelValues("error").Inc()
return
}
lines := bytes.Count(bytes.TrimRight(body, "\n"), []byte("\n")) + 1
if err := s.relayLogs(r.Context(), body, s.observedFields(r)); err != nil {
slog.Error("log relay to vlinsert failed; dropping batch", "lines", lines, "err", err)
s.metrics.logRelay.WithLabelValues("error").Inc()
} else {
s.metrics.logRelay.WithLabelValues("ok").Inc()
s.metrics.logLines.Add(float64(lines))
}
s.ok(w, http.StatusAccepted, "text/plain", []byte("accepted\n"), "logs")
}
// relayLogs makes one short-timeout POST to vlinsert's jsonline endpoint.
// serial and phase are constant for a run and low-cardinality, so they key the
// stream; MAC is per-NIC and rides in extra_fields so it stays searchable
// without multiplying streams.
func (s *Server) relayLogs(ctx context.Context, body []byte, extra string) error {
q := url.Values{
"_stream_fields": {"serial,phase"},
"_msg_field": {"msg"},
"_time_field": {"time"},
}
if extra != "" {
q.Set("extra_fields", extra)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.vlinsertURL+"/insert/jsonline?"+q.Encode(), bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-ndjson")
resp, err := s.vlClient.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
_, _ = io.Copy(io.Discard, resp.Body)
if resp.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("vlinsert: %s", resp.Status)
}
return nil
}
// observedFields builds the extra_fields value from what bootapi observes
// rather than what the client claims: the source IP, plus the NetBox device
// name when ?mac= resolves. Resolution is optional — a miss just omits it.
func (s *Server) observedFields(r *http.Request) string {
fields := []string{}
if ip, _, err := net.SplitHostPort(r.RemoteAddr); err == nil && ip != "" {
fields = append(fields, "src_ip="+ip)
}
if mac := r.URL.Query().Get("mac"); looksLikeMAC(mac) {
if host, err := s.lookup(r.Context(), "mac", mac); err == nil {
fields = append(fields, "device="+host.Hostname)
}
}
return strings.Join(fields, ",")
}
// bearer extracts a token from "Authorization: Bearer <t>" or a bare "token"
// header.
func bearer(r *http.Request) string {
if h := r.Header.Get("Authorization"); h != "" {
if after, ok := strings.CutPrefix(h, "Bearer "); ok {
return after
}
}
return r.Header.Get("token")
}
// lookup resolves a host by field ("mac" or "name"), recording metrics.
func (s *Server) lookup(ctx context.Context, field, value string) (*model.Host, error) {
start := time.Now()
var host *model.Host
var err error
if field == "mac" {
host, err = s.nb.HostByMAC(ctx, value)
} else {
host, err = s.nb.HostByName(ctx, value)
}
s.metrics.netboxDuration.WithLabelValues(field).Observe(time.Since(start).Seconds())
switch {
case err == nil:
s.metrics.netboxLookups.WithLabelValues(field, "ok").Inc()
case errors.Is(err, netbox.ErrNotFound):
s.metrics.netboxLookups.WithLabelValues(field, "notfound").Inc()
default:
s.metrics.netboxLookups.WithLabelValues(field, "error").Inc()
}
return host, err
}
func (s *Server) ok(w http.ResponseWriter, status int, contentType string, body []byte, endpoint string) {
w.Header().Set("Content-Type", contentType)
w.WriteHeader(status)
_, _ = w.Write(body)
s.metrics.httpRequests.WithLabelValues(endpoint, statusClass(status)).Inc()
}
// ListenAndServe runs the plain-HTTP server (always) plus, when a TLS listener
// is configured, an HTTPS server sharing the same handler — both until ctx is
// cancelled. The boot path works over plain HTTP because PXE installers have no
// internal CA trust; HTTPS is offered in parallel for clients that do.
func (s *Server) ListenAndServe(ctx context.Context, addr string) error {
h := s.Router()
var wg sync.WaitGroup
errc := make(chan error, 2)
serve := func(name string, srv *http.Server, tls bool) {
defer wg.Done()
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
}()
slog.Info("bootapi listening", "listener", name, "addr", srv.Addr)
var err error
if tls {
err = srv.ListenAndServeTLS(s.tlsCert, s.tlsKey)
} else {
err = srv.ListenAndServe()
}
if err != nil && !errors.Is(err, http.ErrServerClosed) {
errc <- fmt.Errorf("%s listener: %w", name, err)
}
}
wg.Add(1)
go serve("http", &http.Server{Addr: addr, Handler: h, ReadHeaderTimeout: 10 * time.Second}, false)
if s.tlsAddr != "" && s.tlsCert != "" && s.tlsKey != "" {
wg.Add(1)
go serve("https", &http.Server{Addr: s.tlsAddr, Handler: h, ReadHeaderTimeout: 10 * time.Second}, true)
}
wg.Wait()
close(errc)
return <-errc // first error, or nil (channel closed empty)
}
// looksLikeMAC reports whether s is plausibly a MAC (12 hex nibbles, ignoring
// common separators). Used to pick the NetBox lookup field for /ks/{ident}.
func looksLikeMAC(s string) bool {
n := 0
for _, r := range strings.ToLower(s) {
switch {
case r >= '0' && r <= '9', r >= 'a' && r <= 'f':
n++
case r == ':' || r == '-' || r == '.':
// separator, ignore
default:
return false
}
}
return n == 12
}
func statusClass(code int) string {
switch {
case code < 300:
return "2xx"
case code < 400:
return "3xx"
case code < 500:
return "4xx"
default:
return "5xx"
}
}
func (s *Server) logRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
defer func() {
slog.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", ww.Status(),
"duration_ms", time.Since(start).Milliseconds(),
"remote", r.RemoteAddr,
"request_id", middleware.GetReqID(r.Context()),
)
}()
next.ServeHTTP(ww, r)
})
}