Initial bootapi: NetBox-driven PXE/kickstart boot service
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful

bootapi replaces Cobbler's PXE/kickstart side. It resolves a PXE-booting host
from NetBox (by MAC or hostname), renders an iPXE boot script and a kickstart
from Go text/templates, and serves them over HTTP. The ENC half already moved to
encapi; this covers the provisioning/boot half.

What's here:
- cmd/bootapi + internal/{config,model,netbox,render,server}; embedded default
  templates under templates/ (AlmaLinux 9 + Fedora kickstarts, iPXE boot +
  unknown-MAC fallbacks) ported from Cobbler's boot/bootstrap contract.
- NetBox client (v4.x API) behind a Resolver interface with a short-TTL cache;
  tested against httptest fixtures using real NetBox JSON shapes.
- chi HTTP server: /ipxe/{mac}, /boot/ipxe?mac=, /ks/{ident}, healthz/readyz,
  Prometheus /metrics. Unknown MAC -> safe fallback iPXE (200), unknown KS -> 404.
- Secrets (root pw hash, ssh keys) injected at render time from env/Vault, never
  NetBox. Config is env-based per estate convention.
- Makefile (build/test/lint/docker + patch/minor/major), Dockerfile (distroless),
  .woodpecker (pre-commit, golangci-lint v2 + go test -race, docker build on PR;
  image push + Gitea binary release on v* tag), docs/ and example config.

go build/vet clean, go test -race green, golangci-lint v2 clean, pre-commit clean.

Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
This commit is contained in:
2026-07-28 17:20:06 +10:00
parent 98e69d2fcb
commit 274c480b09
37 changed files with 3290 additions and 1 deletions
+82
View File
@@ -0,0 +1,82 @@
package server
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
)
// cacheStats is the read side of the NetBox cache the collector publishes.
type cacheStats interface {
Hits() int64
Misses() int64
}
// metrics holds bootapi's Prometheus instruments, registered on a private
// registry so tests can construct isolated servers.
type metrics struct {
reg *prometheus.Registry
httpRequests *prometheus.CounterVec // by endpoint,status
renders *prometheus.CounterVec // by kind,result
netboxLookups *prometheus.CounterVec // by field,result
netboxDuration *prometheus.HistogramVec
}
func newMetrics(cache cacheStats) *metrics {
reg := prometheus.NewRegistry()
m := &metrics{
reg: reg,
httpRequests: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "bootapi_http_requests_total",
Help: "HTTP requests handled, by endpoint and status class.",
}, []string{"endpoint", "status"}),
renders: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "bootapi_render_total",
Help: "Template renders, by kind (kickstart|ipxe) and result (ok|error).",
}, []string{"kind", "result"}),
netboxLookups: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "bootapi_netbox_lookups_total",
Help: "NetBox host resolutions, by field (mac|name) and result (ok|notfound|error).",
}, []string{"field", "result"}),
netboxDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "bootapi_netbox_lookup_duration_seconds",
Help: "Latency of NetBox host resolutions.",
Buckets: prometheus.DefBuckets,
}, []string{"field"}),
}
reg.MustRegister(m.httpRequests, m.renders, m.netboxLookups, m.netboxDuration)
if cache != nil {
reg.MustRegister(newCacheCollector(cache))
}
reg.MustRegister(
collectors.NewGoCollector(),
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
)
return m
}
// cacheCollector publishes the NetBox cache hit/miss counters, which live on
// the Cache itself (atomic ints) rather than in a CounterVec.
type cacheCollector struct {
stats cacheStats
hits *prometheus.Desc
miss *prometheus.Desc
}
func newCacheCollector(s cacheStats) *cacheCollector {
return &cacheCollector{
stats: s,
hits: prometheus.NewDesc("bootapi_netbox_cache_hits_total", "NetBox cache hits.", nil, nil),
miss: prometheus.NewDesc("bootapi_netbox_cache_misses_total", "NetBox cache misses.", nil, nil),
}
}
func (c *cacheCollector) Describe(ch chan<- *prometheus.Desc) {
ch <- c.hits
ch <- c.miss
}
func (c *cacheCollector) Collect(ch chan<- prometheus.Metric) {
ch <- prometheus.MustNewConstMetric(c.hits, prometheus.CounterValue, float64(c.stats.Hits()))
ch <- prometheus.MustNewConstMetric(c.miss, prometheus.CounterValue, float64(c.stats.Misses()))
}
+278
View File
@@ -0,0 +1,278 @@
// 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"
"errors"
"log/slog"
"net/http"
"strings"
"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 resolver and template engine into HTTP handlers.
type Server struct {
resolver netbox.Resolver
engine *render.Engine
metrics *metrics
// fallback is the unknown-MAC iPXE behavior: "local" (safe default) or
// "shell" (debug).
fallback string
}
// Options configures a Server.
type Options struct {
Resolver netbox.Resolver
Engine *render.Engine
// Cache, when non-nil, has its hit/miss counters published as metrics.
Cache cacheStats
UnknownMACFallback string
}
// New builds a Server.
func New(o Options) *Server {
fb := o.UnknownMACFallback
if fb == "" {
fb = "local"
}
return &Server{
resolver: o.Resolver,
engine: o.Engine,
metrics: newMetrics(o.Cache),
fallback: fb,
}
}
// 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)
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
}
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")
}
// 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.resolver.HostByMAC(ctx, value)
} else {
host, err = s.resolver.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 HTTP server until ctx is cancelled.
func (s *Server) ListenAndServe(ctx context.Context, addr string) error {
srv := &http.Server{
Addr: addr,
Handler: s.Router(),
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
}()
slog.Info("bootapi listening", "addr", addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}
// 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)
})
}
+200
View File
@@ -0,0 +1,200 @@
package server
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.unkin.net/unkin/bootapi/internal/model"
"git.unkin.net/unkin/bootapi/internal/netbox"
"git.unkin.net/unkin/bootapi/internal/render"
"git.unkin.net/unkin/bootapi/templates"
)
// fakeResolver is a canned netbox.Resolver for handler tests.
type fakeResolver struct {
byMAC map[string]*model.Host
byName map[string]*model.Host
err error
}
func (f *fakeResolver) HostByMAC(_ context.Context, mac string) (*model.Host, error) {
if f.err != nil {
return nil, f.err
}
// The real NetBox client normalizes MAC case/separators before matching;
// mirror that here so case-insensitive lookups behave like production.
if h, ok := f.byMAC[strings.ToLower(mac)]; ok {
return h, nil
}
return nil, netbox.ErrNotFound
}
func (f *fakeResolver) HostByName(_ context.Context, name string) (*model.Host, error) {
if f.err != nil {
return nil, f.err
}
if h, ok := f.byName[name]; ok {
return h, nil
}
return nil, netbox.ErrNotFound
}
func testHost() *model.Host {
return &model.Host{
Hostname: "web01", Domain: "syd1.au.unkin.net", FQDN: "web01.syd1.au.unkin.net",
Platform: "almalinux9", OSFamily: "almalinux", OSVersion: "9", Arch: "x86_64",
PrimaryIP: "10.0.1.20",
Interfaces: []model.Interface{
{Name: "eth0", MAC: "aa:bb:cc:00:11:22", IP: "10.0.1.20", PrefixLen: 24, Netmask: "255.255.255.0", Gateway: "10.0.1.254", Primary: true},
},
}
}
func newTestServer(t *testing.T, res netbox.Resolver, fallback string) *Server {
t.Helper()
eng, err := render.NewEngine(templates.FS, "", render.RenderConfig{
PuppetServer: "puppet.query.consul", PuppetCAServer: "puppetca.query.consul",
BaseURL: "http://bootapi.example.net", BootBaseURL: "http://mirror.example.net/almalinux/9",
DefaultDomain: "main.unkin.net", DefaultTemplate: "almalinux9",
RootPasswordHash: "$6$abc$def",
})
if err != nil {
t.Fatal(err)
}
return New(Options{Resolver: res, Engine: eng, UnknownMACFallback: fallback})
}
func do(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
return rec
}
func TestIPXEKnownMAC(t *testing.T) {
res := &fakeResolver{byMAC: map[string]*model.Host{"aa:bb:cc:00:11:22": testHost()}}
h := newTestServer(t, res, "local").Router()
rec := do(t, h, "/ipxe/aa:bb:cc:00:11:22")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "inst.ks=http://bootapi.example.net/ks/web01") {
t.Errorf("ipxe body missing inst.ks:\n%s", body)
}
}
func TestIPXEUnknownMACServesFallback200(t *testing.T) {
h := newTestServer(t, &fakeResolver{}, "local").Router()
rec := do(t, h, "/ipxe/de:ad:be:ef:00:00")
// Unknown MAC must NOT 404 — iPXE needs a valid script. Safe local-boot.
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 with fallback", rec.Code)
}
if !strings.Contains(rec.Body.String(), "sanboot") {
t.Errorf("expected local-boot fallback, got:\n%s", rec.Body.String())
}
}
func TestIPXEUnknownMACShellFallback(t *testing.T) {
h := newTestServer(t, &fakeResolver{}, "shell").Router()
rec := do(t, h, "/ipxe/de:ad:be:ef:00:00")
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "shell") {
t.Fatalf("shell fallback not served: %d\n%s", rec.Code, rec.Body.String())
}
}
func TestIPXEQueryAlias(t *testing.T) {
res := &fakeResolver{byMAC: map[string]*model.Host{"aa:bb:cc:00:11:22": testHost()}}
h := newTestServer(t, res, "local").Router()
rec := do(t, h, "/boot/ipxe?mac=AA:BB:CC:00:11:22")
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "inst.ks=") {
t.Fatalf("query-style ipxe failed: %d\n%s", rec.Code, rec.Body.String())
}
}
func TestKickstartByMAC(t *testing.T) {
res := &fakeResolver{byMAC: map[string]*model.Host{"aa:bb:cc:00:11:22": testHost()}}
h := newTestServer(t, res, "local").Router()
rec := do(t, h, "/ks/aa:bb:cc:00:11:22")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
t.Errorf("content-type = %q", ct)
}
if !strings.Contains(rec.Body.String(), "rootpw --iscrypted") {
t.Errorf("kickstart body missing rootpw:\n%s", rec.Body.String())
}
}
func TestKickstartByHostname(t *testing.T) {
res := &fakeResolver{byName: map[string]*model.Host{"web01": testHost()}}
h := newTestServer(t, res, "local").Router()
rec := do(t, h, "/ks/web01.cfg") // .cfg suffix must be stripped
if rec.Code != http.StatusOK {
t.Fatalf("status = %d\n%s", rec.Code, rec.Body.String())
}
}
func TestKickstartUnknownIs404(t *testing.T) {
h := newTestServer(t, &fakeResolver{}, "local").Router()
rec := do(t, h, "/ks/nosuchhost")
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 (kickstart must fail loudly)", rec.Code)
}
}
func TestHealthAndReady(t *testing.T) {
h := newTestServer(t, &fakeResolver{}, "local").Router()
if rec := do(t, h, "/healthz"); rec.Code != http.StatusOK {
t.Errorf("healthz = %d", rec.Code)
}
if rec := do(t, h, "/readyz"); rec.Code != http.StatusOK {
t.Errorf("readyz = %d", rec.Code)
}
}
func TestMetricsEndpoint(t *testing.T) {
res := &fakeResolver{byMAC: map[string]*model.Host{"aa:bb:cc:00:11:22": testHost()}}
srv := newTestServer(t, res, "local")
h := srv.Router()
do(t, h, "/ipxe/aa:bb:cc:00:11:22") // ok render + netbox ok
do(t, h, "/ipxe/de:ad:be:ef:00:00") // notfound + fallback
do(t, h, "/ks/aa:bb:cc:00:11:22") // kickstart render
rec := do(t, h, "/metrics")
if rec.Code != http.StatusOK {
t.Fatalf("metrics status = %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
`bootapi_render_total{kind="ipxe",result="ok"} 1`,
`bootapi_render_total{kind="kickstart",result="ok"} 1`,
`bootapi_netbox_lookups_total{field="mac",result="notfound"} 1`,
"bootapi_http_requests_total",
} {
if !strings.Contains(body, want) {
t.Errorf("metrics missing %q", want)
}
}
}
func TestLooksLikeMAC(t *testing.T) {
yes := []string{"aa:bb:cc:00:11:22", "aa-bb-cc-00-11-22", "aabbcc001122", "aabb.cc00.1122"}
no := []string{"web01", "web01.example.net", "aa:bb:cc", "zz:bb:cc:00:11:22"}
for _, s := range yes {
if !looksLikeMAC(s) {
t.Errorf("looksLikeMAC(%q) = false, want true", s)
}
}
for _, s := range no {
if looksLikeMAC(s) {
t.Errorf("looksLikeMAC(%q) = true, want false", s)
}
}
}