8f356346eb
Implements the six review comments on PR #1: - Per-host PXE-enable gate: read NetBox pxe_enabled custom field; a known host with it false gets the safe local-boot script (Cobbler netboot_enabled). Add a token-guarded POST /provisioned/{ident} callback that clears pxe_enabled in NetBox, plus a %post snippet in the default kickstarts that calls it. - Templates from a git repo: bootapi clones a templates repo and re-pulls every BOOTAPI_TEMPLATE_GIT_INTERVAL (default 3m), atomically swapping the template set (last-good kept on parse failure; embedded defaults are the startup fallback). Metrics for syncs/failures/generation. - Distro catalog (catalog/*.yaml): NetBox host -> boot images/kickstart, so adding an OS is a YAML + template change. Ships almalinux + fedora entries (artifactapi remotes); debian/talos path documented. - Boot images from the artifactapi almalinux/fedora remotes via the catalog. - Bind resolvers, puppet server/CA and PUPPETCA_URL env file now target the k8s services (198.18.200.7; puppet(ca).k8s.syd1.au.unkin.net). - Boot path served over plain HTTP (installers lack CA trust) with an optional parallel HTTPS listener; docs say do not 301 the boot endpoints. New packages: internal/catalog, internal/gitsync. NetBox client gains a pxe_enabled write (token needs that scope - noted in docs). `bootapi validate` subcommand validates a template/catalog set for the templates-repo CI. go build/vet clean, go test -race green, golangci-lint v2 clean, pre-commit clean. Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
385 lines
12 KiB
Go
385 lines
12 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 (
|
|
"context"
|
|
"crypto/subtle"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"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; empty disables the endpoint.
|
|
provisionToken string
|
|
|
|
// 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
|
|
TLSAddr string
|
|
TLSCertFile string
|
|
TLSKeyFile string
|
|
}
|
|
|
|
// New builds a Server.
|
|
func New(o Options) *Server {
|
|
fb := o.UnknownMACFallback
|
|
if fb == "" {
|
|
fb = "local"
|
|
}
|
|
return &Server{
|
|
nb: o.NetBox,
|
|
engine: o.Engine,
|
|
metrics: newMetrics(o.Cache, o.GitStats),
|
|
fallback: fb,
|
|
provisionToken: o.ProvisionToken,
|
|
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)
|
|
|
|
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)
|
|
}
|
|
|
|
// 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)
|
|
})
|
|
}
|