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