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