From 274c480b0963731afb83fbe1df4e9277429ab821 Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Tue, 28 Jul 2026 17:20:06 +1000 Subject: [PATCH] Initial bootapi: NetBox-driven PXE/kickstart boot service 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 --- .gitignore | 4 + .pre-commit-config.yaml | 27 ++ .woodpecker/build.yaml | 19 ++ .woodpecker/docker.yaml | 28 ++ .woodpecker/pre-commit.yaml | 18 + .woodpecker/release.yaml | 79 +++++ .woodpecker/test.yaml | 33 ++ Dockerfile | 21 ++ Makefile | 77 +++++ README.md | 79 ++++- cmd/bootapi/main.go | 76 +++++ config.example.env | 52 +++ docs/data-model.md | 77 +++++ docs/deployment.md | 79 +++++ docs/endpoints.md | 68 ++++ docs/security.md | 49 +++ docs/template-authoring.md | 71 ++++ go.mod | 20 ++ go.sum | 36 ++ internal/config/config.go | 163 +++++++++ internal/config/config_test.go | 94 ++++++ internal/model/host.go | 100 ++++++ internal/netbox/cache.go | 89 +++++ internal/netbox/cache_test.go | 87 +++++ internal/netbox/netbox.go | 423 ++++++++++++++++++++++++ internal/netbox/netbox_test.go | 220 ++++++++++++ internal/render/render.go | 268 +++++++++++++++ internal/render/render_test.go | 161 +++++++++ internal/server/metrics.go | 82 +++++ internal/server/server.go | 278 ++++++++++++++++ internal/server/server_test.go | 200 +++++++++++ templates/embed.go | 11 + templates/ipxe/boot.ipxe.tmpl | 20 ++ templates/ipxe/fallback-local.ipxe.tmpl | 10 + templates/ipxe/fallback-shell.ipxe.tmpl | 10 + templates/kickstart/almalinux9.ks.tmpl | 96 ++++++ templates/kickstart/fedora.ks.tmpl | 66 ++++ 37 files changed, 3290 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 .woodpecker/build.yaml create mode 100644 .woodpecker/docker.yaml create mode 100644 .woodpecker/pre-commit.yaml create mode 100644 .woodpecker/release.yaml create mode 100644 .woodpecker/test.yaml create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 cmd/bootapi/main.go create mode 100644 config.example.env create mode 100644 docs/data-model.md create mode 100644 docs/deployment.md create mode 100644 docs/endpoints.md create mode 100644 docs/security.md create mode 100644 docs/template-authoring.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/model/host.go create mode 100644 internal/netbox/cache.go create mode 100644 internal/netbox/cache_test.go create mode 100644 internal/netbox/netbox.go create mode 100644 internal/netbox/netbox_test.go create mode 100644 internal/render/render.go create mode 100644 internal/render/render_test.go create mode 100644 internal/server/metrics.go create mode 100644 internal/server/server.go create mode 100644 internal/server/server_test.go create mode 100644 templates/embed.go create mode 100644 templates/ipxe/boot.ipxe.tmpl create mode 100644 templates/ipxe/fallback-local.ipxe.tmpl create mode 100644 templates/ipxe/fallback-shell.ipxe.tmpl create mode 100644 templates/kickstart/almalinux9.ks.tmpl create mode 100644 templates/kickstart/fedora.ks.tmpl diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..92a959a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/bin/ +/dist/ +*.out +*.test diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..43a7738 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,27 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + - id: check-merge-conflict + + - repo: https://github.com/dnephin/pre-commit-golang + rev: v0.5.1 + hooks: + - id: go-fmt + - id: go-mod-tidy + + # bootapi has no root-level Go files (all under cmd/, internal/, templates/), + # so the dnephin go-vet hook (which runs `go vet` at the repo root) fails with + # "no Go files". Vet the whole module instead, mirroring encapi. + - repo: local + hooks: + - id: go-vet + name: go vet + entry: go vet ./... + language: system + types: [go] + pass_filenames: false diff --git a/.woodpecker/build.yaml b/.woodpecker/build.yaml new file mode 100644 index 0000000..0b1067e --- /dev/null +++ b/.woodpecker/build.yaml @@ -0,0 +1,19 @@ +when: + - event: pull_request + +steps: + - name: docker-build + image: woodpeckerci/plugin-docker-buildx + settings: + repo: git.unkin.net/unkin/bootapi + dry_run: true + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/.woodpecker/docker.yaml b/.woodpecker/docker.yaml new file mode 100644 index 0000000..d489d60 --- /dev/null +++ b/.woodpecker/docker.yaml @@ -0,0 +1,28 @@ +when: + - event: tag + ref: refs/tags/v* + +steps: + - name: docker-bootapi + image: woodpeckerci/plugin-docker-buildx + settings: + registry: git.unkin.net + repo: git.unkin.net/unkin/bootapi + build_args: + VERSION: ${CI_COMMIT_TAG} + username: droneci + password: + from_secret: DRONECI_PASSWORD + tags: + - ${CI_COMMIT_TAG} + - latest + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/.woodpecker/pre-commit.yaml b/.woodpecker/pre-commit.yaml new file mode 100644 index 0000000..d57b508 --- /dev/null +++ b/.woodpecker/pre-commit.yaml @@ -0,0 +1,18 @@ +when: + - event: pull_request + +steps: + - name: pre-commit + image: git.unkin.net/unkin/almalinux9-gobuilder:20260606 + commands: + - uvx pre-commit run --all-files + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/.woodpecker/release.yaml b/.woodpecker/release.yaml new file mode 100644 index 0000000..72c5bf3 --- /dev/null +++ b/.woodpecker/release.yaml @@ -0,0 +1,79 @@ +when: + - event: tag + ref: refs/tags/v* + +# Cuts a Gitea release with cross-compiled bootapi binaries attached. The +# container image is built+pushed separately by docker.yaml. +steps: + - name: test + image: golang:1.25 + commands: + - go test -race ./... + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 + + - name: build + image: git.unkin.net/unkin/almalinux9-gobuilder:20260606 + commands: + - make release-binaries VERSION=${CI_COMMIT_TAG} + depends_on: [test] + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 + + - name: release + image: git.unkin.net/unkin/almalinux9-base:20260606 + environment: + RELEASER_TOKEN: + from_secret: RELEASER_TOKEN + commands: + - | + curl --output /usr/local/bin/tea https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/gitea-dl/tea/0.12.0/tea-0.12.0-linux-amd64 && chmod +x /usr/local/bin/tea + tea logins add --name gitea --url https://git.unkin.net --token "$${RELEASER_TOKEN}" --no-version-check + # $$ escapes shell vars so Woodpecker doesn't substitute them at parse + # time; ${CI_COMMIT_TAG}/${CI_REPO} are real Woodpecker vars. + CUR_SHA=$$(git rev-list -n1 "${CI_COMMIT_TAG}") + PREV_TAG="" + for t in $$(git tag --sort=-v:refname); do + [ "$$t" = "${CI_COMMIT_TAG}" ] && continue + [ "$$(git rev-list -n1 "$$t")" = "$$CUR_SHA" ] && continue + if git merge-base --is-ancestor "$$t" "${CI_COMMIT_TAG}" 2>/dev/null; then + PREV_TAG="$$t"; break + fi + done + if [ -n "$$PREV_TAG" ]; then + NOTES=$$(git log "$${PREV_TAG}..${CI_COMMIT_TAG}" --pretty=format:"- %s") + else + NOTES=$$(git log --pretty=format:"- %s") + fi + tea releases create --tag "${CI_COMMIT_TAG}" --title "${CI_COMMIT_TAG}" --note "$${NOTES}" --login gitea --repo "${CI_REPO}" + ASSETS="dist/bootapi-linux-amd64 dist/bootapi-linux-arm64 dist/bootapi-darwin-amd64 dist/bootapi-darwin-arm64" + sha256sum $$ASSETS > dist/sha256sums.txt + tea releases assets create "${CI_COMMIT_TAG}" $$ASSETS dist/sha256sums.txt \ + --login gitea --repo "${CI_REPO}" + depends_on: [build] + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 128Mi + cpu: 100m + limits: + memory: 512Mi + cpu: 500m diff --git a/.woodpecker/test.yaml b/.woodpecker/test.yaml new file mode 100644 index 0000000..9e5823b --- /dev/null +++ b/.woodpecker/test.yaml @@ -0,0 +1,33 @@ +when: + - event: pull_request + +steps: + - name: lint + image: golangci/golangci-lint:v2.5.0 + commands: + - golangci-lint run ./... + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 + + - name: test + image: golang:1.25 + commands: + - go test -race ./... + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..15be8ca --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM golang:1.25-alpine AS builder + +RUN apk add --no-cache git + +WORKDIR /build + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +ARG VERSION=dev +RUN CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION}" -o bootapi ./cmd/bootapi + +FROM gcr.io/distroless/static-debian12:nonroot + +COPY --from=builder /build/bootapi /usr/local/bin/bootapi + +EXPOSE 8000 + +ENTRYPOINT ["bootapi"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..87afa89 --- /dev/null +++ b/Makefile @@ -0,0 +1,77 @@ +.PHONY: build test test-race lint fmt vet docker run clean tidy check-go release-binaries patch minor major + +MODULE := git.unkin.net/unkin/bootapi +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "0.0.0-dev") +DIST := dist +OS ?= $(shell go env GOOS) +ARCH ?= $(shell go env GOARCH) + +GO_VERSION_REQUIRED := 1.25 +GO_VERSION_ACTUAL := $(shell go version | sed 's/go version go\([0-9]*\.[0-9]*\).*/\1/') + +check-go: + @if [ "$$(printf '%s\n%s' "$(GO_VERSION_REQUIRED)" "$(GO_VERSION_ACTUAL)" | sort -V | head -1)" != "$(GO_VERSION_REQUIRED)" ]; then \ + echo "ERROR: Go >= $(GO_VERSION_REQUIRED) required, found $(GO_VERSION_ACTUAL)"; exit 1; \ + fi + +build: check-go tidy + go build -ldflags="-s -w -X main.version=$(VERSION)" -o bin/bootapi ./cmd/bootapi + +test: check-go + go test -count=1 ./... + +test-race: check-go + go test -race -count=1 ./... + +# golangci-lint v2 runs in CI via the golangci/golangci-lint container; locally +# `make vet` is the quick check and `make lint` runs golangci-lint if present. +vet: check-go + go vet ./... + +lint: check-go + @if command -v golangci-lint >/dev/null 2>&1; then golangci-lint run ./...; else echo "golangci-lint not installed; running go vet"; go vet ./...; fi + +fmt: check-go + gofmt -w . + +docker: + docker build -t bootapi:$(VERSION) . + +run: build + ./bin/bootapi + +# Cross-compiled binaries attached to the Gitea release (mirrors node-lookup). +release-binaries: check-go + @mkdir -p $(DIST) + @for osarch in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do \ + os=$${osarch%/*}; arch=$${osarch#*/}; \ + echo "building bootapi-$$os-$$arch"; \ + CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch \ + go build -ldflags="-s -w -X main.version=$(VERSION)" \ + -o "$(DIST)/bootapi-$$os-$$arch" ./cmd/bootapi; \ + done + +clean: + rm -rf bin/ $(DIST)/ + +tidy: + go mod tidy + +# --- version bump: tag + push triggers the release pipeline --- +_LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1) +_BASE := $(if $(_LATEST),$(_LATEST),v0.0.0) +_MAJ := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f1) +_MIN := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f2) +_PAT := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f3) + +patch: + @NEW=v$(_MAJ).$(_MIN).$(shell expr $(_PAT) + 1); \ + git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW + +minor: + @NEW=v$(_MAJ).$(shell expr $(_MIN) + 1).0; \ + git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW + +major: + @NEW=v$(shell expr $(_MAJ) + 1).0.0; \ + git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW diff --git a/README.md b/README.md index ecd95f6..e2fb3c0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,80 @@ # bootapi -PXE/kickstart boot service replacing Cobbler. Renders kickstart + iPXE from NetBox data over HTTP. Go API. \ No newline at end of file +`bootapi` is a small Go service that replaces Cobbler's PXE/kickstart side. It +renders kickstart files and iPXE boot scripts from **NetBox** device data and +serves them over HTTP to PXE-booting hosts. + +The ENC half of Cobbler already moved to [encapi](https://git.unkin.net/unkin/encapi). +With bootapi, provisioning a new host is: + +1. `terraform` the host into **NetBox** (device, interfaces/MACs, IP, platform, + role) and **encapi** (classification), then +2. rack it and let it **PXE-boot** — DHCP points it at bootapi, which serves the + iPXE script and the rendered kickstart; the kickstart hands off to the + existing Puppet firstrun bootstrap. + +## How it works + +``` +DHCP next-server ─▶ iPXE ─▶ GET /ipxe/ ─▶ boot kernel+initrd, inst.ks=/ks/ + └▶ GET /ks/ ─▶ rendered kickstart ─▶ puppet firstrun +``` + +bootapi identifies the booting host by the **MAC** it booted from (NetBox +interface lookup → device → primary IP, platform, role, interfaces), renders a +Go `text/template` selected from the host's platform/role/custom-field, and +serves it. Templates are embedded defaults, overridable from a directory +(ConfigMap in k8s). + +## Endpoints (summary) + +| Path | Purpose | +|------|---------| +| `GET /ipxe/{mac}` · `GET /boot/ipxe?mac=` | iPXE boot script | +| `GET /ks/{ident}` | rendered kickstart (MAC or hostname) | +| `GET /healthz` · `/readyz` · `/metrics` | health + Prometheus | + +Unknown MAC → iPXE gets a **safe fallback** (local-disk boot, HTTP 200), never a +404. Unknown kickstart host → **404** (fail loud once installing). Full rationale +in [docs/endpoints.md](docs/endpoints.md). + +## Documentation + +- [docs/endpoints.md](docs/endpoints.md) — the PXE flow, every endpoint, error/fallback behavior, metrics. +- [docs/data-model.md](docs/data-model.md) — the exact template data model + NetBox custom fields + template selection. +- [docs/template-authoring.md](docs/template-authoring.md) — writing/overriding kickstart & iPXE templates. +- [docs/deployment.md](docs/deployment.md) — Kubernetes/argocd wiring, Vault secrets, and the DHCP cutover. +- [docs/security.md](docs/security.md) — what belongs in NetBox vs Vault; secrets in kickstarts. + +## Configuration + +Env-based (12-factor), see [`config.example.env`](config.example.env). Key vars: +`BOOTAPI_NETBOX_URL`, `BOOTAPI_NETBOX_TOKEN[_FILE]`, `BOOTAPI_BASE_URL`, +`BOOTAPI_BOOT_BASE_URL`, `BOOTAPI_ROOT_PASSWORD_HASH[_FILE]`, +`BOOTAPI_UNKNOWN_MAC_FALLBACK`. + +## Development + +```bash +make build # build ./bin/bootapi +make test-race # go test -race ./... +make lint # golangci-lint (v2) if installed, else go vet +make run # build + run +``` + +CI (Woodpecker): `pre-commit`, `golangci-lint v2` + `go test -race`, and a +docker build on PRs; on a `v*` tag, a container image push and a Gitea binary +release. Cut a release with `make patch|minor|major` (tags + pushes). + +## Layout + +``` +cmd/bootapi/ main +internal/config/ env config +internal/model/ Host/Interface data model +internal/netbox/ NetBox client (+ TTL cache), behind a Resolver interface +internal/render/ text/template engine, selection, embedded-defaults loader +internal/server/ chi HTTP handlers + Prometheus metrics +templates/ embedded default kickstart + iPXE templates +docs/ see above +``` diff --git a/cmd/bootapi/main.go b/cmd/bootapi/main.go new file mode 100644 index 0000000..004812e --- /dev/null +++ b/cmd/bootapi/main.go @@ -0,0 +1,76 @@ +// Command bootapi is the PXE/kickstart boot service: it renders kickstart files +// and iPXE boot scripts from NetBox device data and serves them to PXE-booting +// hosts, replacing Cobbler's provisioning side. +package main + +import ( + "context" + "log/slog" + "os" + "os/signal" + "syscall" + + "git.unkin.net/unkin/bootapi/internal/config" + "git.unkin.net/unkin/bootapi/internal/netbox" + "git.unkin.net/unkin/bootapi/internal/render" + "git.unkin.net/unkin/bootapi/internal/server" + "git.unkin.net/unkin/bootapi/templates" +) + +var version = "dev" + +func main() { + slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil))) + slog.Info("starting bootapi", "version", version) + + cfg, err := config.Load() + if err != nil { + slog.Error("load config", "err", err) + os.Exit(1) + } + if cfg.NetBoxURL == "" { + slog.Warn("BOOTAPI_NETBOX_URL is empty; every host lookup will fail and iPXE will serve the fallback") + } + if cfg.NetBoxToken == "" { + slog.Warn("no NetBox token set (BOOTAPI_NETBOX_TOKEN/_FILE); NetBox reads will likely be denied") + } + + engine, err := render.NewEngine(templates.FS, cfg.TemplateDir, render.RenderConfig{ + PuppetServer: cfg.PuppetServer, + PuppetCAServer: cfg.PuppetCAServer, + BaseURL: cfg.BaseURL, + BootBaseURL: cfg.BootBaseURL, + DefaultDomain: cfg.Domain, + DefaultNS: cfg.Nameservers, + RootPasswordHash: cfg.RootPasswordHash, + SSHAuthorizedKeys: cfg.SSHAuthorizedKeys, + DefaultTemplate: cfg.DefaultTemplate, + }) + if err != nil { + slog.Error("load templates", "err", err) + os.Exit(1) + } + + nb := netbox.New(netbox.Options{ + BaseURL: cfg.NetBoxURL, + Token: cfg.NetBoxToken, + Timeout: cfg.NetBoxTimeout, + Insecure: cfg.NetBoxInsecure, + }) + cache := netbox.NewCache(nb, cfg.CacheTTL) + + srv := server.New(server.Options{ + Resolver: cache, + Engine: engine, + Cache: cache, + UnknownMACFallback: cfg.UnknownMACFallback, + }) + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + if err := srv.ListenAndServe(ctx, cfg.ListenAddr); err != nil { + slog.Error("server", "err", err) + os.Exit(1) + } +} diff --git a/config.example.env b/config.example.env new file mode 100644 index 0000000..280dca1 --- /dev/null +++ b/config.example.env @@ -0,0 +1,52 @@ +# bootapi configuration (environment variables). +# +# bootapi is configured entirely from the environment (12-factor style, same as +# encapi). In Kubernetes these come from the Deployment env + a Vault-sourced +# Secret (see docs/deployment.md). Locally, `env $(grep -v '^#' config.example.env | xargs) ./bin/bootapi`. + +# --- HTTP --- +BOOTAPI_LISTEN_ADDR=:8000 + +# --- NetBox (source of truth for host -> boot data) --- +BOOTAPI_NETBOX_URL=https://netbox.k8s.syd1.au.unkin.net +# Provide the token inline OR (preferred in k8s) via a file mounted from Vault: +BOOTAPI_NETBOX_TOKEN= +# BOOTAPI_NETBOX_TOKEN_FILE=/var/run/secrets/netbox/api_token +BOOTAPI_NETBOX_TIMEOUT=5s +BOOTAPI_NETBOX_INSECURE=false + +# --- caching --- +# Short by design: a re-provisioned host must pick up NetBox changes on its next +# boot. Set 0 to disable. +BOOTAPI_CACHE_TTL=30s + +# --- templates --- +# Optional override directory (a ConfigMap mount in k8s); files here win over +# the embedded defaults. Leave empty to use only the built-in templates. +# BOOTAPI_TEMPLATE_DIR=/etc/bootapi/templates +# Template used when NetBox provides no platform/role/override selection key. +BOOTAPI_DEFAULT_TEMPLATE=almalinux9 + +# --- URLs baked into rendered output --- +# bootapi's own externally-reachable base URL (goes into the iPXE inst.ks=). +BOOTAPI_BASE_URL=http://bootapi.k8s.syd1.au.unkin.net +# Base URL of the OS install trees (kernel/initrd + inst.repo). +BOOTAPI_BOOT_BASE_URL=http://mirror.k8s.syd1.au.unkin.net/almalinux/9 + +# --- puppet bootstrap targets (baked into kickstart %post) --- +BOOTAPI_PUPPET_SERVER=puppet.query.consul +BOOTAPI_PUPPET_CA_SERVER=puppetca.query.consul + +# --- network defaults (used when NetBox does not record them per-device) --- +BOOTAPI_DOMAIN=main.unkin.net +BOOTAPI_NAMESERVERS=198.18.19.19 + +# --- render-time secrets (NEVER stored in NetBox; from Vault in k8s) --- +# crypt(3) hash for the root account. Empty => root account locked. +BOOTAPI_ROOT_PASSWORD_HASH= +# BOOTAPI_ROOT_PASSWORD_HASH_FILE=/var/run/secrets/bootapi/root_password_hash +# Newline-separated SSH public keys installed for root. +BOOTAPI_SSH_AUTHORIZED_KEYS= + +# --- unknown-MAC fallback: "local" (safe: boot local disk) or "shell" (debug) --- +BOOTAPI_UNKNOWN_MAC_FALLBACK=local diff --git a/docs/data-model.md b/docs/data-model.md new file mode 100644 index 0000000..057da2b --- /dev/null +++ b/docs/data-model.md @@ -0,0 +1,77 @@ +# Template data model + +Every kickstart and iPXE template is rendered with Go `text/template` against a +single flat value. This is the exact, stable contract template authors code +against. It is assembled in `internal/render.dataFor` from a NetBox device +(`internal/netbox`) plus render-time config (env/Vault). + +## Kickstart templates (`*.ks.tmpl`) + +| Field | Type | Source | Notes | +|-------|------|--------|-------| +| `.Hostname` | string | NetBox device name | short name, e.g. `web01` | +| `.Domain` | string | NetBox CF `domain`, else `BOOTAPI_DOMAIN` | | +| `.FQDN` | string | derived | `Hostname.Domain` (or `Hostname` if no domain) | +| `.Platform` | string | NetBox platform slug | e.g. `almalinux9` — primary template-selection key | +| `.OSFamily` | string | derived from platform | e.g. `almalinux` | +| `.OSVersion` | string | derived from platform | e.g. `9` | +| `.Arch` | string | fixed `x86_64` (today) | | +| `.Role` | string | NetBox device role slug | e.g. `kubernetes-worker` | +| `.Interfaces` | `[]Interface` | NetBox interfaces + IPs | primary interface sorted first | +| `.PrimaryInterface` | `*Interface` | derived | the NIC carrying the primary IP (or first) | +| `.PrimaryIP` | string | NetBox device `primary_ip` | address only, no prefix | +| `.Nameservers` | `[]string` | NetBox CF `nameservers`, else `BOOTAPI_NAMESERVERS` | | +| `.RootPasswordHash` | string | **render-time** (`BOOTAPI_ROOT_PASSWORD_HASH[_FILE]`) | crypt(3) hash; empty ⇒ lock root. **Never** from NetBox — see [security.md](security.md) | +| `.SSHAuthorizedKeys` | `[]string` | **render-time** (`BOOTAPI_SSH_AUTHORIZED_KEYS`) | | +| `.PuppetServer` | string | `BOOTAPI_PUPPET_SERVER` | default `puppet.query.consul` | +| `.PuppetCAServer` | string | `BOOTAPI_PUPPET_CA_SERVER` | default `puppetca.query.consul` | +| `.BaseURL` | string | `BOOTAPI_BASE_URL` | bootapi's own URL | +| `.BootBaseURL` | string | `BOOTAPI_BOOT_BASE_URL` | OS install-tree base | +| `.KickstartURL` | string | derived | `BaseURL/ks/Hostname` | +| `.Custom` | `map[string]any` | **all** NetBox custom fields, verbatim | escape hatch for site-specific knobs without a code change | + +### `Interface` + +| Field | Type | Notes | +|-------|------|-------| +| `.Name` | string | NetBox interface name, e.g. `eth0` | +| `.MAC` | string | normalized lower-case colon form | +| `.IP` | string | address only (empty ⇒ no IP; skip in the network stanza) | +| `.PrefixLen` | int | CIDR length, e.g. `24` | +| `.Netmask` | string | dotted-quad, e.g. `255.255.255.0` | +| `.Gateway` | string | per-IP CF `gateway`, else device CF `gateway`, else empty | +| `.VLAN` | int | untagged VLAN id, or 0 | +| `.Primary` | bool | true for the NIC with the primary IP | + +## iPXE templates (`*.ipxe.tmpl`) + +Rendered with everything above **plus**: + +| Field | Type | Notes | +|-------|------|-------| +| `.KernelURL` | string | `BootBaseURL/images/pxeboot/vmlinuz` (empty if `BootBaseURL` unset) | +| `.InitrdURL` | string | `BootBaseURL/images/pxeboot/initrd.img` | + +The fallback templates (`fallback-local`, `fallback-shell`) are rendered with an +empty value — they take no host data by design. + +## NetBox custom fields bootapi reads + +Define these on the *device* (or, where noted, the *IP address*) in NetBox. +All are optional; sensible fallbacks apply. + +| Custom field | On | Effect | +|--------------|----|--------| +| `domain` | device | DNS domain; overrides `BOOTAPI_DOMAIN` | +| `gateway` | device / IP address | default gateway (IP-level wins) | +| `nameservers` | device | comma-separated resolvers; overrides `BOOTAPI_NAMESERVERS` | +| `provision_template` | device | force a specific template name (see below) | + +## Template selection precedence + +`SelectKickstart` picks the first template name that exists, in order: + +1. `provision_template` custom field (exact template name) +2. `.Platform` slug (e.g. `almalinux9`) +3. `.OSFamily` (e.g. `almalinux`, or `fedora`) +4. `BOOTAPI_DEFAULT_TEMPLATE` (default `almalinux9`) diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..ce665af --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,79 @@ +# Deploying bootapi + +> The actual argocd-apps deployment is a **follow-up task** and is intentionally +> not part of this repo. This document is the spec for that follow-up plus the +> DHCP change the estate needs. + +bootapi is a stateless HTTP service. It mirrors encapi's deployment shape: a Go +binary in a distroless image, config from env, secrets from Vault via the Vault +Secrets Operator (VSO). + +## Container image + +`git.unkin.net/unkin/bootapi:` (built + pushed by `.woodpecker/docker.yaml` +on a `v*` tag). Also mirror to the artifactapi local docker registry if desired. + +## Kubernetes wiring (argocd-apps follow-up) + +Create `apps/base/bootapi/` following the argocd-apps `AGENTS.md` pattern: + +1. **namespace** `bootapi`. +2. **VaultAuth** (`default`) — kubernetes method, mount `k8s/au/syd1`, role + `default`, SA `default` (copy netbox's `vaultauth.yaml`). +3. **VaultStaticSecret** → k8s Secret `bootapi-secrets`, from Vault kv path + `kubernetes/namespace/bootapi/default/bootapi-secrets` with keys: + - `netbox_token` — a **dedicated, read-only** NetBox API token for bootapi + (create a `bootapi` NetBox user/token via terraform-netbox rather than + reusing the seeded superuser token at + `kv/kubernetes/namespace/netbox/default/netbox-superuser`). + - `root_password_hash` — crypt(3) hash for the installed root account + (the successor to Cobbler's eyaml `default_password_crypted`). + - `ssh_authorized_keys` — optional, newline-separated. +4. **ConfigMap** `bootapi-templates` (optional) — override `*.ks.tmpl` / + `*.ipxe.tmpl`, mounted at `BOOTAPI_TEMPLATE_DIR=/etc/bootapi/templates`. Omit + to use the embedded defaults. Annotate the Deployment with + `reloader.stakater.com/auto: "true"` so template edits roll the pods. +5. **Deployment** — image above, env from `config.example.env`, secret keys wired + as `BOOTAPI_NETBOX_TOKEN_FILE`/`BOOTAPI_ROOT_PASSWORD_HASH_FILE` (mount the + Secret) or `...FROM secretKeyRef`. Least-privilege securityContext + (`runAsNonRoot`, `drop: [all]`). Baseline resources: requests `512Mi`/`1`, + limits `2Gi`/`2` cpu. +6. **Service** `bootapi` (ClusterIP, port 80 → 8000) plus a **LoadBalancer** (or + Gateway HTTPRoute) reachable by PXE clients at a stable address/hostname — + this is what DHCP points at. Reuse the Vault-issued TLS the Cobbler vhost used + if you terminate TLS at a gateway; note that iPXE fetches are plain HTTP, so a + plain HTTP listener on the PXE VLAN is required either way. +7. Register in `argocd/applicationsets/platform.yaml` (`apps/overlays/*/bootapi`) + and the platform AppProject destinations. + +### Cross-repo dependencies (per estate conventions) + +- **argocd-apps**: add a `serviceaccount_*` under `apps/base/woodpecker/` if the + bootapi pipelines need a dedicated SA (they use `default` today). +- **terraform-vault**: add the k8s auth role + kv policy granting the `bootapi` + namespace read on `kv/kubernetes/namespace/bootapi/default/*`. +- **terraform-netbox**: create the read-only `bootapi` NetBox token and seed it + (plus `root_password_hash`) into the Vault kv path above. + +## DHCP change (the cutover) + +Cobbler advertised itself at anycast `198.18.19.19` as the DHCP `next-server`, +with `filename "/ipxe.efi"` (UEFI arch 7/9) or `/undionly.kpxe` (BIOS arch 0). +Today those are set in `puppet-prod` hieradata +`hieradata/roles/infra/dhcp/server.yaml` (`pools.*.pxeserver` and the +`UEFI-64`/`Legacy` dhcp classes). + +To cut a subnet over to bootapi, repoint DHCP for that pool: + +- `next-server` → bootapi's LB IP (or keep the `198.18.19.19` anycast and move + the anycast advertisement to bootapi's node/LB). +- `filename` → the iPXE binary as before (`/ipxe.efi` / `/undionly.kpxe`); bootapi + does not serve the NBP itself. The chained iPXE must then be told to fetch + bootapi's script — either bake `chain http:///ipxe/${net0/mac}` into + the site iPXE binary/embedded script, or set DHCP option 67 to that URL for + iPXE user-class requests. This replaces Cobbler's + `chain http://${next-server}/cblr/svc/op/gpxe/mac/${net0/mac}`. + +Roll one pool at a time (the PXE subnets are `198.18.13.0/24`–`198.18.17.0/24`); +Puppet autosign already trusts those subnets and `*.main.unkin.net`, so a host +installed via bootapi checks in exactly as before. diff --git a/docs/endpoints.md b/docs/endpoints.md new file mode 100644 index 0000000..ebe6d31 --- /dev/null +++ b/docs/endpoints.md @@ -0,0 +1,68 @@ +# bootapi HTTP endpoints + +bootapi speaks plain HTTP. It is fronted by the same Vault-issued TLS the Cobbler +server used; the booting firmware reaches it at the DHCP `next-server` (see +[deployment.md](deployment.md)). + +## The PXE flow + +``` +DHCP ── next-server + filename (ipxe.efi / undionly.kpxe) ──▶ firmware loads iPXE +iPXE ── GET /ipxe/ ───────────────────────────────────▶ bootapi renders a boot script +boot ── kernel + initrd + inst.ks=/ks/ ────▶ Anaconda fetches the kickstart +KS ── GET /ks/ ────────────────────────────────────▶ bootapi renders the kickstart +``` + +This mirrors Cobbler, which chained iPXE to `/cblr/svc/op/gpxe/mac/` and +served a per-system script carrying `inst.ks=`. + +## Endpoints + +| Method | Path | Purpose | +|--------|------|---------| +| GET | `/ipxe/{mac}` | iPXE boot script for the host owning `{mac}`. `{mac}` may use `:`/`-`/`.` separators or be bare hex; a trailing `.ipxe` is stripped. | +| GET | `/boot/ipxe?mac=...` | Query-string alias of `/ipxe/{mac}` (some firmware finds this shape easier to template). | +| GET | `/ks/{ident}` | Rendered kickstart. `{ident}` is a MAC (auto-detected) or a hostname; trailing `.ks`/`.cfg` is stripped. | +| GET | `/healthz` | Liveness: always `200 ok`. | +| GET | `/readyz` | Readiness: `200` once templates parsed. Does **not** probe NetBox (a NetBox outage still lets iPXE serve the safe fallback). | +| GET | `/metrics` | Prometheus metrics (see below). | + +## Host identification + +A booting host is identified by the **MAC** of the NIC it PXE-booted from +(`/ipxe/{mac}`), which bootapi resolves via NetBox +`GET /api/dcim/interfaces/?mac_address=` → device → primary IP, platform, +role, interfaces. `/ks/{ident}` additionally accepts a **hostname** (NetBox +device name), for hand-testing and for installers that template the hostname +into the kickstart URL. + +## Error behavior (important, and deliberate) + +The two endpoints fail **differently** on an unknown host, because the cost of a +wrong answer differs: + +- **`/ipxe/{mac}` never returns 404.** iPXE needs a syntactically valid script or + the boot chain simply errors. An unknown MAC — or *any* NetBox error — returns + HTTP 200 with the **fallback script** selected by `BOOTAPI_UNKNOWN_MAC_FALLBACK`: + - `local` (default): `sanboot` the local disk. Safe: a machine that PXE-booted + by accident (or a NetBox blip) just boots its installed OS; a genuinely new + machine loops back to PXE next time, by which point NetBox should know it. We + deliberately do **not** start an installer for a machine we can't identify — + that could wipe a production box. + - `shell`: drop to an interactive iPXE shell so an operator racking a new box + can read `${net0/mac}` and register it. Opt-in; unsafe as a default because + it halts the boot. +- **`/ks/{ident}` returns 404** for an unknown host (and 502 on a NetBox error). + By the time Anaconda fetches the kickstart it has already committed to + installing; a clear failure is safer than serving an empty or wrong kickstart. + +## Metrics + +All on `/metrics`, prefix `bootapi_`: + +- `bootapi_http_requests_total{endpoint,status}` — endpoint = `ipxe|ks|healthz|readyz`, status = `2xx|3xx|4xx|5xx`. +- `bootapi_render_total{kind,result}` — kind = `kickstart|ipxe`, result = `ok|error`. +- `bootapi_netbox_lookups_total{field,result}` — field = `mac|name`, result = `ok|notfound|error`. +- `bootapi_netbox_lookup_duration_seconds{field}` — histogram. +- `bootapi_netbox_cache_hits_total` / `bootapi_netbox_cache_misses_total`. +- standard Go/process collectors. diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..d97db6a --- /dev/null +++ b/docs/security.md @@ -0,0 +1,49 @@ +# Security: secrets in kickstarts + +A kickstart is fetched over the network by an unauthenticated installer and can +embed real secrets: the root password hash, SSH keys, bootstrap tokens, repo +credentials. bootapi's rule is: + +**NetBox holds identity and topology, never secrets. Secrets are injected at +render time from Vault/env.** + +## What goes where + +| Value | Where it lives | How it reaches the template | +|-------|----------------|-----------------------------| +| hostname, domain, IPs, MACs, VLANs, gateway, platform, role | NetBox | `internal/netbox` → `model.Host` | +| template selection knobs (`provision_template`, `nameservers`, `gateway`) | NetBox custom fields | `.Custom` / typed fields | +| **root password hash** | Vault → `BOOTAPI_ROOT_PASSWORD_HASH[_FILE]` | `.RootPasswordHash` | +| **SSH authorized keys** | Vault → `BOOTAPI_SSH_AUTHORIZED_KEYS` | `.SSHAuthorizedKeys` | +| puppet CA/server names | env (not secret) | `.PuppetServer` / `.PuppetCAServer` | + +`BOOTAPI_ROOT_PASSWORD_HASH_FILE` and `BOOTAPI_NETBOX_TOKEN_FILE` let the values +arrive as Vault-mounted files rather than env, which is the k8s norm (see +[deployment.md](deployment.md)). If no root hash is configured, the default +templates emit `rootpw --lock` rather than a blank/guessable password. + +This matches the pre-bootapi setup, where the root hash was Cobbler's eyaml +`default_password_crypted` injected into `settings.yaml` — an operator-managed +secret, never in the NetBox/inventory layer. + +## Exposure notes + +- Kickstarts are served over **HTTP** to the installer, so treat any embedded + secret as visible to anything on the provisioning VLAN. Keep bootapi's + kickstart endpoint on the trusted PXE network, exactly as Cobbler's was. +- The root password hash *is* in the rendered kickstart by necessity (Anaconda + needs it). Prefer SSH-key login + a locked or strong-random root password, and + rotate the hash in Vault as normal. +- The **puppet bootstrap uses no long-lived token**: the host generates a CSR and + the puppetmaster autosigns it based on source subnet + `*.main.unkin.net` + (unchanged from Cobbler). So the kickstart carries no puppet secret. + +## Follow-up: per-template Vault lookups + +Today all render-time secrets are process-wide env/files (one root hash, one key +set for the fleet), which covers the current estate. If per-host or per-role +secrets are ever needed (e.g. a distinct bootstrap token per role), the seam is +`internal/render.dataFor`: add a Vault kv fetch keyed by host/role there, behind +an interface, the same way encapi's `internal/distro` resolver injects per-host +params behind an interface. Tracked as a follow-up, not implemented, to avoid +giving bootapi broad Vault read scope before it's needed. diff --git a/docs/template-authoring.md b/docs/template-authoring.md new file mode 100644 index 0000000..397a576 --- /dev/null +++ b/docs/template-authoring.md @@ -0,0 +1,71 @@ +# Authoring templates + +bootapi ships an embedded default set and lets you override or extend it. + +## Where templates live + +- **Embedded defaults**: `templates/kickstart/*.ks.tmpl` and + `templates/ipxe/*.ipxe.tmpl`, compiled into the binary (`templates/embed.go`). +- **Overrides**: any directory pointed to by `BOOTAPI_TEMPLATE_DIR`. Files there + with the same base name **replace** the embedded one; new names **add** to the + set. In Kubernetes this is a ConfigMap mount (see [deployment.md](deployment.md)). + +## Naming + +- Kickstart: `.ks.tmpl` → registered as template ``. +- iPXE: `.ipxe.tmpl` → registered as template ``. + +`` is what template selection matches against (platform slug, OS family, +`provision_template`, or the configured default — see +[data-model.md](data-model.md#template-selection-precedence)). + +Reserved iPXE names bootapi renders directly: +- `boot` — the per-host boot script (`/ipxe/{mac}` for a known host). +- `fallback-local`, `fallback-shell` — unknown-MAC fallbacks. + +## Engine and functions + +Standard Go `text/template`. Available funcs: `join`, `upper`, `lower`, +`default` (`{{ default "x" .Maybe }}` → `.Maybe` unless empty). The data model is +in [data-model.md](data-model.md). + +Example network stanza (iterate interfaces, skip those without an IP, set the +hostname on the primary): + +```gotemplate +{{- $primary := .PrimaryInterface }} +{{- range .Interfaces }} +{{- if .IP }} +network --bootproto=static --device={{ .MAC }} --ip={{ .IP }} --netmask={{ .Netmask }}{{ if .Gateway }} --gateway={{ .Gateway }}{{ end }}{{ range $.Nameservers }} --nameserver={{ . }}{{ end }}{{ if and $primary (eq .MAC $primary.MAC) }} --hostname={{ $.FQDN }}{{ end }} --activate +{{- end }} +{{- end }} +``` + +## What the default AlmaLinux template does (ported from Cobbler) + +The literal `.ks` bodies from the old Cobbler server are not in version control +(they lived in `/var/lib/cobbler/{templates,snippets}` on the Cobbler host). The +embedded `almalinux9.ks.tmpl` reproduces the **contract** that estate relied on: + +- static per-interface networking from NetBox, hostname on the primary NIC; +- `rootpw --iscrypted` from the render-time hash (Cobbler's + `default_password_crypted`), or `--lock` when unset; +- a minimal package set + `openssh-server`, `chrony`; +- a `%post` that installs the Puppet agent, points it at `puppet.query.consul` / + `puppetca.query.consul`, and enables it — handing off to the existing + `profiles::firstrun` Puppet bootstrap and autosign, exactly as the Cobbler + kickstart did. + +Adjust partitioning, package sets and repos to taste; keep the puppet `%post` +handoff so a freshly-installed host still checks in and converges. + +## Testing a template locally + +```bash +BOOTAPI_NETBOX_URL=... BOOTAPI_NETBOX_TOKEN=... \ +BOOTAPI_BASE_URL=http://localhost:8000 \ +BOOTAPI_BOOT_BASE_URL=http://mirror/almalinux/9 \ +BOOTAPI_TEMPLATE_DIR=./mytemplates ./bin/bootapi & +curl -s localhost:8000/ks/web01 # rendered kickstart +curl -s localhost:8000/ipxe/aa:bb:cc:00:11:22 +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..696a83e --- /dev/null +++ b/go.mod @@ -0,0 +1,20 @@ +module git.unkin.net/unkin/bootapi + +go 1.25 + +require ( + github.com/go-chi/chi/v5 v5.3.0 + github.com/prometheus/client_golang v1.23.0 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.65.0 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + golang.org/x/sys v0.33.0 // indirect + google.golang.org/protobuf v1.36.6 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..bf9bf1d --- /dev/null +++ b/go.sum @@ -0,0 +1,36 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= +github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc= +github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= +github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..8329d4c --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,163 @@ +// Package config loads bootapi server configuration from the environment, +// following the same env-first convention as encapi. +package config + +import ( + "fmt" + "os" + "strings" + "time" +) + +// Config is the fully-resolved server configuration. +type Config struct { + // ListenAddr is the HTTP bind address, e.g. ":8000". + ListenAddr string + + // NetBoxURL is the base URL of the NetBox API, + // e.g. "https://netbox.k8s.syd1.au.unkin.net". + NetBoxURL string + // NetBoxToken is the NetBox API token. Prefer NetBoxTokenFile in k8s. + NetBoxToken string + // NetBoxTimeout bounds each NetBox HTTP request. + NetBoxTimeout time.Duration + // NetBoxInsecure disables TLS verification against NetBox (dev only). + NetBoxInsecure bool + + // CacheTTL is how long a resolved host is cached in memory. Short by + // design: NetBox is the source of truth and a machine's provisioning data + // can change between boots. + CacheTTL time.Duration + + // TemplateDir, when set, is a directory of override templates layered on + // top of the embedded defaults (a Kubernetes ConfigMap mount in prod). + TemplateDir string + // DefaultTemplate is the kickstart template used when NetBox provides no + // platform/role/override selection key. + DefaultTemplate string + + // BaseURL is bootapi's own externally-reachable base URL, baked into the + // iPXE script's inst.ks= and repo URLs so a booting host calls back here. + // e.g. "http://bootapi.k8s.syd1.au.unkin.net". + BaseURL string + + // BootBaseURL is the base URL of the OS install trees (kernel/initrd + + // inst.repo), e.g. "http://mirror.k8s.syd1.au.unkin.net/almalinux". + BootBaseURL string + + // PuppetServer / PuppetCAServer are baked into kickstart %post so the + // freshly-installed host checks in to the right place. + PuppetServer string + PuppetCAServer string + + // Domain is the default DNS domain applied when NetBox does not record one + // for a device. + Domain string + + // Nameservers is the default resolver list applied when NetBox records + // none for a device. + Nameservers []string + + // RootPasswordHash is a crypt(3) hash injected into kickstarts at render + // time (sourced from Vault in k8s). Empty locks the root account. + RootPasswordHash string + // SSHAuthorizedKeys are public keys installed for root at render time. + SSHAuthorizedKeys []string + + // UnknownMACFallback selects what the iPXE endpoint returns for a MAC that + // NetBox does not know: "local" (chain to local disk, the safe default) or + // "shell" (drop to an iPXE shell for debugging). See docs/endpoints.md. + UnknownMACFallback string +} + +// Load reads configuration from the environment, applying defaults, and reads a +// token file when BOOTAPI_NETBOX_TOKEN_FILE is set (Vault-mounted secret). +func Load() (*Config, error) { + cacheTTL, err := time.ParseDuration(getenv("BOOTAPI_CACHE_TTL", "30s")) + if err != nil { + return nil, fmt.Errorf("invalid BOOTAPI_CACHE_TTL: %w", err) + } + nbTimeout, err := time.ParseDuration(getenv("BOOTAPI_NETBOX_TIMEOUT", "5s")) + if err != nil { + return nil, fmt.Errorf("invalid BOOTAPI_NETBOX_TIMEOUT: %w", err) + } + + token := os.Getenv("BOOTAPI_NETBOX_TOKEN") + if tf := os.Getenv("BOOTAPI_NETBOX_TOKEN_FILE"); tf != "" { + b, err := os.ReadFile(tf) + if err != nil { + return nil, fmt.Errorf("read BOOTAPI_NETBOX_TOKEN_FILE %q: %w", tf, err) + } + token = strings.TrimSpace(string(b)) + } + + fallback := getenv("BOOTAPI_UNKNOWN_MAC_FALLBACK", "local") + if fallback != "local" && fallback != "shell" { + return nil, fmt.Errorf("invalid BOOTAPI_UNKNOWN_MAC_FALLBACK %q: want \"local\" or \"shell\"", fallback) + } + + rootHash := os.Getenv("BOOTAPI_ROOT_PASSWORD_HASH") + if rf := os.Getenv("BOOTAPI_ROOT_PASSWORD_HASH_FILE"); rf != "" { + b, err := os.ReadFile(rf) + if err != nil { + return nil, fmt.Errorf("read BOOTAPI_ROOT_PASSWORD_HASH_FILE %q: %w", rf, err) + } + rootHash = strings.TrimSpace(string(b)) + } + + return &Config{ + ListenAddr: getenv("BOOTAPI_LISTEN_ADDR", ":8000"), + NetBoxURL: strings.TrimRight(os.Getenv("BOOTAPI_NETBOX_URL"), "/"), + NetBoxToken: token, + NetBoxTimeout: nbTimeout, + NetBoxInsecure: getenv("BOOTAPI_NETBOX_INSECURE", "false") == "true", + CacheTTL: cacheTTL, + TemplateDir: os.Getenv("BOOTAPI_TEMPLATE_DIR"), + DefaultTemplate: getenv("BOOTAPI_DEFAULT_TEMPLATE", "almalinux9"), + BaseURL: strings.TrimRight(os.Getenv("BOOTAPI_BASE_URL"), "/"), + BootBaseURL: strings.TrimRight(os.Getenv("BOOTAPI_BOOT_BASE_URL"), "/"), + PuppetServer: getenv("BOOTAPI_PUPPET_SERVER", "puppet.query.consul"), + PuppetCAServer: getenv("BOOTAPI_PUPPET_CA_SERVER", "puppetca.query.consul"), + Domain: getenv("BOOTAPI_DOMAIN", "main.unkin.net"), + Nameservers: splitList(os.Getenv("BOOTAPI_NAMESERVERS")), + RootPasswordHash: rootHash, + SSHAuthorizedKeys: splitLines(os.Getenv("BOOTAPI_SSH_AUTHORIZED_KEYS")), + UnknownMACFallback: fallback, + }, nil +} + +func getenv(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +// splitList splits a comma-separated env value into a trimmed, non-empty slice. +func splitList(v string) []string { + if v == "" { + return nil + } + var out []string + for _, p := range strings.Split(v, ",") { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} + +// splitLines splits a newline-separated env value (e.g. multiple SSH keys) into +// a trimmed, non-empty slice. +func splitLines(v string) []string { + if v == "" { + return nil + } + var out []string + for _, p := range strings.Split(v, "\n") { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..917be86 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,94 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestLoadDefaults(t *testing.T) { + clearEnv(t) + c, err := Load() + if err != nil { + t.Fatal(err) + } + if c.ListenAddr != ":8000" { + t.Errorf("ListenAddr = %q", c.ListenAddr) + } + if c.CacheTTL != 30*time.Second { + t.Errorf("CacheTTL = %v", c.CacheTTL) + } + if c.DefaultTemplate != "almalinux9" { + t.Errorf("DefaultTemplate = %q", c.DefaultTemplate) + } + if c.PuppetServer != "puppet.query.consul" || c.PuppetCAServer != "puppetca.query.consul" { + t.Errorf("puppet servers = %q / %q", c.PuppetServer, c.PuppetCAServer) + } + if c.UnknownMACFallback != "local" { + t.Errorf("UnknownMACFallback = %q", c.UnknownMACFallback) + } +} + +func TestLoadTokenFile(t *testing.T) { + clearEnv(t) + dir := t.TempDir() + tf := filepath.Join(dir, "token") + if err := os.WriteFile(tf, []byte(" secret-token\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("BOOTAPI_NETBOX_TOKEN_FILE", tf) + c, err := Load() + if err != nil { + t.Fatal(err) + } + if c.NetBoxToken != "secret-token" { + t.Errorf("token = %q, want trimmed file contents", c.NetBoxToken) + } +} + +func TestLoadRejectsBadFallback(t *testing.T) { + clearEnv(t) + t.Setenv("BOOTAPI_UNKNOWN_MAC_FALLBACK", "bogus") + if _, err := Load(); err == nil { + t.Fatal("expected error for invalid fallback") + } +} + +func TestLoadListsAndTrim(t *testing.T) { + clearEnv(t) + t.Setenv("BOOTAPI_NAMESERVERS", " 10.0.0.1, 10.0.0.2 ,") + t.Setenv("BOOTAPI_NETBOX_URL", "https://netbox.example.net/") + c, err := Load() + if err != nil { + t.Fatal(err) + } + if len(c.Nameservers) != 2 || c.Nameservers[1] != "10.0.0.2" { + t.Errorf("nameservers = %v", c.Nameservers) + } + if c.NetBoxURL != "https://netbox.example.net" { + t.Errorf("NetBoxURL trailing slash not trimmed: %q", c.NetBoxURL) + } +} + +// clearEnv unsets every BOOTAPI_* var so a developer's shell can't leak into +// the test. t.Setenv restores them after the test. +func clearEnv(t *testing.T) { + t.Helper() + for _, kv := range os.Environ() { + if k, _, ok := cut(kv, '='); ok && len(k) > 8 && k[:8] == "BOOTAPI_" { + // t.Setenv to "" is enough: Load treats empty as unset, and the + // test framework restores the original value on cleanup. + t.Setenv(k, "") + } + } +} + +func cut(s string, sep byte) (before, after string, found bool) { + for i := 0; i < len(s); i++ { + if s[i] == sep { + return s[:i], s[i+1:], true + } + } + return s, "", false +} diff --git a/internal/model/host.go b/internal/model/host.go new file mode 100644 index 0000000..2f6b0d0 --- /dev/null +++ b/internal/model/host.go @@ -0,0 +1,100 @@ +// Package model holds the provisioning data model bootapi renders templates +// against. A Host is the normalized view of a NetBox device: enough to build a +// kickstart and an iPXE boot script without the template author needing to know +// anything about NetBox's API shapes. +package model + +// Host is the fully-resolved provisioning view of a single machine. +// +// Every field here is safe to reference from a kickstart or iPXE template. The +// zero value of a field means "NetBox did not provide it"; templates should +// guard optional fields (e.g. Gateway) accordingly. +type Host struct { + // Hostname is the short name (NetBox device name), e.g. "web01". + Hostname string + // Domain is the DNS domain the host lives in, e.g. "syd1.au.unkin.net". + Domain string + // FQDN is Hostname joined to Domain when a domain is known, else Hostname. + FQDN string + + // Platform is the NetBox platform slug, e.g. "almalinux9". It is the + // primary template-selection key. + Platform string + // OSFamily is a coarse family derived from Platform ("almalinux", + // "fedora", "rocky", ...). Handy for shared template logic. + OSFamily string + // OSVersion is the major version string when derivable, e.g. "9". + OSVersion string + // Arch is the CPU architecture, defaulting to "x86_64". + Arch string + + // Role is the NetBox device role slug, e.g. "kubernetes-worker". Available + // as a secondary template-selection key and for %post logic. + Role string + + // Interfaces are the host's network interfaces, primary first. + Interfaces []Interface + + // PrimaryIP is the address of the primary interface (no prefix length), + // e.g. "10.0.1.20". Empty when NetBox has no primary IP set. + PrimaryIP string + + // Nameservers are DNS resolvers to configure, when NetBox provides them + // (via a custom field); otherwise empty and templates fall back to a + // site default. + Nameservers []string + + // RootPasswordHash is a crypt(3) hash for the root account, sourced at + // render time (env/Vault), NOT stored in NetBox. Empty means "locked + // account / template default". + RootPasswordHash string + + // SSHAuthorizedKeys are public keys to install for root, sourced at render + // time. Empty means none. + SSHAuthorizedKeys []string + + // TemplateOverride, when non-empty, names the template to use verbatim, + // bypassing platform/role selection. Sourced from a NetBox custom field. + TemplateOverride string + + // Custom carries every NetBox custom field verbatim so templates can read + // site-specific knobs without a code change. Keys are the custom-field + // names as defined in NetBox. + Custom map[string]any +} + +// Interface is one network interface of a Host. +type Interface struct { + // Name is the NetBox interface name, e.g. "eth0" / "bond0". + Name string + // MAC is the normalized (lower-case, colon-separated) hardware address. + MAC string + // IP is the interface address without prefix, e.g. "10.0.1.20". Empty for + // interfaces with no assigned address. + IP string + // PrefixLen is the CIDR prefix length of IP, e.g. 24. Zero when unknown. + PrefixLen int + // Netmask is the dotted-quad form of PrefixLen, e.g. "255.255.255.0". + Netmask string + // Gateway is the default gateway for this interface's prefix, when NetBox + // records one on the prefix. Empty otherwise. + Gateway string + // VLAN is the untagged VLAN id of the interface, or 0 when none. + VLAN int + // Primary reports whether this interface holds the device's primary IP. + Primary bool +} + +// PrimaryInterface returns the primary interface (the one carrying the primary +// IP), falling back to the first interface, or nil when there are none. +func (h *Host) PrimaryInterface() *Interface { + for i := range h.Interfaces { + if h.Interfaces[i].Primary { + return &h.Interfaces[i] + } + } + if len(h.Interfaces) > 0 { + return &h.Interfaces[0] + } + return nil +} diff --git a/internal/netbox/cache.go b/internal/netbox/cache.go new file mode 100644 index 0000000..45f7e79 --- /dev/null +++ b/internal/netbox/cache.go @@ -0,0 +1,89 @@ +package netbox + +import ( + "context" + "sync" + "sync/atomic" + "time" + + "git.unkin.net/unkin/bootapi/internal/model" +) + +// Cache wraps a Resolver with a short-TTL in-memory cache. PXE boots come in +// bursts (iPXE fetches the boot script, then the kickstart, then package repos +// hit repeatedly), so even a 30s TTL collapses many NetBox lookups per host +// while keeping the data fresh enough that a re-provisioned host picks up +// changes on its next boot. +type Cache struct { + inner Resolver + ttl time.Duration + now func() time.Time // injectable for tests + + mu sync.Mutex + entries map[string]cacheEntry + + hits atomic.Int64 + misses atomic.Int64 +} + +// Hits returns the cumulative cache-hit count (published as a metric). +func (c *Cache) Hits() int64 { return c.hits.Load() } + +// Misses returns the cumulative cache-miss count (published as a metric). +func (c *Cache) Misses() int64 { return c.misses.Load() } + +type cacheEntry struct { + host *model.Host + exp time.Time +} + +// NewCache wraps inner with a TTL cache. A non-positive ttl disables caching. +func NewCache(inner Resolver, ttl time.Duration) *Cache { + return &Cache{ + inner: inner, + ttl: ttl, + now: time.Now, + entries: map[string]cacheEntry{}, + } +} + +// HostByMAC returns a cached host or resolves and caches one. +func (c *Cache) HostByMAC(ctx context.Context, mac string) (*model.Host, error) { + return c.lookup(ctx, "mac:"+normalizeMAC(mac), func() (*model.Host, error) { + return c.inner.HostByMAC(ctx, mac) + }) +} + +// HostByName returns a cached host or resolves and caches one. +func (c *Cache) HostByName(ctx context.Context, name string) (*model.Host, error) { + return c.lookup(ctx, "name:"+name, func() (*model.Host, error) { + return c.inner.HostByName(ctx, name) + }) +} + +func (c *Cache) lookup(_ context.Context, key string, resolve func() (*model.Host, error)) (*model.Host, error) { + if c.ttl <= 0 { + return resolve() + } + now := c.now() + + c.mu.Lock() + if e, ok := c.entries[key]; ok && now.Before(e.exp) { + c.mu.Unlock() + c.hits.Add(1) + return e.host, nil + } + c.mu.Unlock() + c.misses.Add(1) + + // Resolve outside the lock so a slow NetBox call doesn't block cache hits. + host, err := resolve() + if err != nil { + return nil, err + } + + c.mu.Lock() + c.entries[key] = cacheEntry{host: host, exp: now.Add(c.ttl)} + c.mu.Unlock() + return host, nil +} diff --git a/internal/netbox/cache_test.go b/internal/netbox/cache_test.go new file mode 100644 index 0000000..4925e0f --- /dev/null +++ b/internal/netbox/cache_test.go @@ -0,0 +1,87 @@ +package netbox + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "git.unkin.net/unkin/bootapi/internal/model" +) + +// countingResolver records how many times the underlying resolver is hit. +type countingResolver struct { + mu sync.Mutex + calls int + host *model.Host + err error +} + +func (c *countingResolver) HostByMAC(context.Context, string) (*model.Host, error) { + c.mu.Lock() + defer c.mu.Unlock() + c.calls++ + return c.host, c.err +} +func (c *countingResolver) HostByName(context.Context, string) (*model.Host, error) { + return c.HostByMAC(context.Background(), "") +} + +func TestCacheHitAndExpiry(t *testing.T) { + inner := &countingResolver{host: &model.Host{Hostname: "web01"}} + cache := NewCache(inner, time.Minute) + + now := time.Unix(1000, 0) + cache.now = func() time.Time { return now } + + // First call misses and resolves. + if _, err := cache.HostByMAC(context.Background(), "aa:bb:cc:00:11:22"); err != nil { + t.Fatal(err) + } + // Second call within TTL is a hit; the inner resolver is not called again. + if _, err := cache.HostByMAC(context.Background(), "AA:BB:CC:00:11:22"); err != nil { + t.Fatal(err) + } + if inner.calls != 1 { + t.Fatalf("inner calls = %d, want 1 (second served from cache)", inner.calls) + } + if cache.Hits() != 1 || cache.Misses() != 1 { + t.Fatalf("hits=%d misses=%d, want 1/1", cache.Hits(), cache.Misses()) + } + + // Advance past the TTL -> next call misses and re-resolves. + now = now.Add(2 * time.Minute) + if _, err := cache.HostByMAC(context.Background(), "aa:bb:cc:00:11:22"); err != nil { + t.Fatal(err) + } + if inner.calls != 2 { + t.Fatalf("inner calls = %d, want 2 after expiry", inner.calls) + } +} + +func TestCacheDisabled(t *testing.T) { + inner := &countingResolver{host: &model.Host{Hostname: "web01"}} + cache := NewCache(inner, 0) // ttl <= 0 disables caching + for i := 0; i < 3; i++ { + if _, err := cache.HostByMAC(context.Background(), "aa:bb:cc:00:11:22"); err != nil { + t.Fatal(err) + } + } + if inner.calls != 3 { + t.Fatalf("inner calls = %d, want 3 (cache disabled)", inner.calls) + } +} + +func TestCacheDoesNotCacheErrors(t *testing.T) { + inner := &countingResolver{err: ErrNotFound} + cache := NewCache(inner, time.Minute) + for i := 0; i < 2; i++ { + if _, err := cache.HostByMAC(context.Background(), "de:ad:be:ef:00:00"); !errors.Is(err, ErrNotFound) { + t.Fatalf("err = %v, want ErrNotFound", err) + } + } + if inner.calls != 2 { + t.Fatalf("inner calls = %d, want 2 (errors are not cached)", inner.calls) + } +} diff --git a/internal/netbox/netbox.go b/internal/netbox/netbox.go new file mode 100644 index 0000000..0db4e0a --- /dev/null +++ b/internal/netbox/netbox.go @@ -0,0 +1,423 @@ +// Package netbox resolves a PXE-booting machine (by MAC or hostname) into the +// normalized model.Host that bootapi renders templates against. It talks to the +// NetBox REST API (v4.x) behind the Resolver interface so the server can be +// tested with an httptest fake. +package netbox + +import ( + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "strings" + "time" + + "git.unkin.net/unkin/bootapi/internal/model" +) + +// ErrNotFound is returned when NetBox has no device matching the query. The +// server maps it to an HTTP 404 (and, for iPXE, a safe fallback boot script). +var ErrNotFound = errors.New("netbox: device not found") + +// Resolver turns a MAC or hostname into a fully-resolved Host. +type Resolver interface { + HostByMAC(ctx context.Context, mac string) (*model.Host, error) + HostByName(ctx context.Context, name string) (*model.Host, error) +} + +// Client is the HTTP-backed Resolver. +type Client struct { + baseURL string + token string + http *http.Client +} + +// Options configures a Client. +type Options struct { + BaseURL string + Token string + Timeout time.Duration + Insecure bool + // HTTPClient overrides the constructed client (used by tests). + HTTPClient *http.Client +} + +// New builds a NetBox Client. +func New(o Options) *Client { + hc := o.HTTPClient + if hc == nil { + tr := &http.Transport{} + if o.Insecure { + tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // opt-in dev flag + } + timeout := o.Timeout + if timeout == 0 { + timeout = 5 * time.Second + } + hc = &http.Client{Timeout: timeout, Transport: tr} + } + return &Client{ + baseURL: strings.TrimRight(o.BaseURL, "/"), + token: o.Token, + http: hc, + } +} + +// --- NetBox API JSON shapes (only the fields bootapi consumes) --- + +type nbList[T any] struct { + Count int `json:"count"` + Results []T `json:"results"` +} + +type nbRef struct { + ID int `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` +} + +type nbVLAN struct { + VID int `json:"vid"` + Name string `json:"name"` +} + +type nbMAC struct { + MACAddress string `json:"mac_address"` +} + +type nbDevice struct { + ID int `json:"id"` + Name string `json:"name"` + Platform *nbRef `json:"platform"` + Role *nbRef `json:"role"` + Site *nbRef `json:"site"` + PrimaryIP *nbIPRef `json:"primary_ip"` + CustomFields map[string]any `json:"custom_fields"` +} + +type nbIPRef struct { + Address string `json:"address"` +} + +type nbInterface struct { + ID int `json:"id"` + Name string `json:"name"` + MACAddress string `json:"mac_address"` + PrimaryMACAddress *nbMAC `json:"primary_mac_address"` + UntaggedVLAN *nbVLAN `json:"untagged_vlan"` + Device *nbRef `json:"device"` +} + +func (i nbInterface) mac() string { + if i.MACAddress != "" { + return normalizeMAC(i.MACAddress) + } + if i.PrimaryMACAddress != nil { + return normalizeMAC(i.PrimaryMACAddress.MACAddress) + } + return "" +} + +type nbIPAddress struct { + Address string `json:"address"` + AssignedObjectID int `json:"assigned_object_id"` + CustomFields map[string]any `json:"custom_fields"` +} + +// --- Resolver implementation --- + +// HostByMAC finds the device owning an interface with the given MAC and +// resolves it to a Host. +func (c *Client) HostByMAC(ctx context.Context, mac string) (*model.Host, error) { + mac = normalizeMAC(mac) + if mac == "" { + return nil, fmt.Errorf("netbox: empty MAC") + } + var list nbList[nbInterface] + if err := c.get(ctx, "/api/dcim/interfaces/", url.Values{"mac_address": {mac}}, &list); err != nil { + return nil, err + } + var dev *nbRef + for _, i := range list.Results { + if i.mac() == mac && i.Device != nil { + dev = i.Device + break + } + } + if dev == nil { + return nil, ErrNotFound + } + return c.resolveDevice(ctx, dev.ID) +} + +// HostByName finds a device by its NetBox name and resolves it to a Host. +func (c *Client) HostByName(ctx context.Context, name string) (*model.Host, error) { + // Strip any domain suffix: NetBox device names are short hostnames. + short := name + if i := strings.IndexByte(short, '.'); i >= 0 { + short = short[:i] + } + var list nbList[nbDevice] + if err := c.get(ctx, "/api/dcim/devices/", url.Values{"name": {short}}, &list); err != nil { + return nil, err + } + if len(list.Results) == 0 { + return nil, ErrNotFound + } + return c.resolveDevice(ctx, list.Results[0].ID) +} + +// resolveDevice fetches the device, its interfaces and IP addresses, and +// assembles a Host. It performs three bounded API calls. +func (c *Client) resolveDevice(ctx context.Context, id int) (*model.Host, error) { + var dev nbDevice + if err := c.get(ctx, fmt.Sprintf("/api/dcim/devices/%d/", id), nil, &dev); err != nil { + return nil, err + } + + devID := url.Values{"device_id": {fmt.Sprint(id)}} + var ifaces nbList[nbInterface] + if err := c.get(ctx, "/api/dcim/interfaces/", devID, &ifaces); err != nil { + return nil, err + } + var ips nbList[nbIPAddress] + if err := c.get(ctx, "/api/ipam/ip-addresses/", devID, &ips); err != nil { + return nil, err + } + + return buildHost(&dev, ifaces.Results, ips.Results), nil +} + +// buildHost assembles the normalized Host from raw NetBox objects. It is pure +// (no I/O) so it can be unit-tested directly against fixture structs. +func buildHost(dev *nbDevice, ifaces []nbInterface, ips []nbIPAddress) *model.Host { + cf := dev.CustomFields + domain := cfString(cf, "domain") + + h := &model.Host{ + Hostname: dev.Name, + Domain: domain, + Custom: cf, + Nameservers: cfStringList(cf, "nameservers"), + TemplateOverride: cfString(cf, "provision_template"), + Arch: "x86_64", + } + if dev.Platform != nil { + h.Platform = dev.Platform.Slug + h.OSFamily, h.OSVersion = splitPlatform(dev.Platform.Slug) + } + if dev.Role != nil { + h.Role = dev.Role.Slug + } + + // Index IP addresses by the interface they are assigned to. + ipByIface := map[int]nbIPAddress{} + for _, ip := range ips { + if _, seen := ipByIface[ip.AssignedObjectID]; !seen { + ipByIface[ip.AssignedObjectID] = ip + } + } + + primaryAddr := "" + if dev.PrimaryIP != nil { + primaryAddr = dev.PrimaryIP.Address + h.PrimaryIP = addrOnly(primaryAddr) + } + + deviceGateway := cfString(cf, "gateway") + + for _, in := range ifaces { + iface := model.Interface{ + Name: in.Name, + MAC: in.mac(), + } + if in.UntaggedVLAN != nil { + iface.VLAN = in.UntaggedVLAN.VID + } + if ip, ok := ipByIface[in.ID]; ok { + iface.IP = addrOnly(ip.Address) + iface.PrefixLen = prefixLen(ip.Address) + iface.Netmask = netmaskFor(iface.PrefixLen) + if g := cfString(ip.CustomFields, "gateway"); g != "" { + iface.Gateway = g + } + if iface.IP != "" && iface.IP == h.PrimaryIP { + iface.Primary = true + } + } + if iface.Gateway == "" { + iface.Gateway = deviceGateway + } + h.Interfaces = append(h.Interfaces, iface) + } + + sortPrimaryFirst(h.Interfaces) + + if h.Domain != "" { + h.FQDN = h.Hostname + "." + h.Domain + } else { + h.FQDN = h.Hostname + } + return h +} + +// sortPrimaryFirst moves the primary interface to the front, preserving the +// relative order of the rest. +func sortPrimaryFirst(ifaces []model.Interface) { + for i := range ifaces { + if ifaces[i].Primary && i != 0 { + p := ifaces[i] + copy(ifaces[1:i+1], ifaces[0:i]) + ifaces[0] = p + return + } + } +} + +// get performs a GET against the NetBox API and decodes the JSON body into out. +func (c *Client) get(ctx context.Context, path string, q url.Values, out any) error { + u := c.baseURL + path + if len(q) > 0 { + u += "?" + q.Encode() + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return err + } + req.Header.Set("Accept", "application/json") + if c.token != "" { + req.Header.Set("Authorization", "Token "+c.token) + } + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("netbox request %s: %w", path, err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode == http.StatusNotFound { + return ErrNotFound + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("netbox %s: HTTP %d", path, resp.StatusCode) + } + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("netbox decode %s: %w", path, err) + } + return nil +} + +// --- helpers --- + +func normalizeMAC(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + // Drop common separators then re-insert colons every 2 hex chars. + var hex strings.Builder + for _, r := range strings.ToLower(s) { + if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') { + hex.WriteRune(r) + } + } + h := hex.String() + if len(h) != 12 { + // Not a canonical 48-bit MAC; return lower-cased trimmed input. + return strings.ToLower(s) + } + var b strings.Builder + for i := 0; i < 12; i += 2 { + if i > 0 { + b.WriteByte(':') + } + b.WriteString(h[i : i+2]) + } + return b.String() +} + +func addrOnly(cidr string) string { + if i := strings.IndexByte(cidr, '/'); i >= 0 { + return cidr[:i] + } + return cidr +} + +func prefixLen(cidr string) int { + _, ipnet, err := net.ParseCIDR(cidr) + if err != nil { + return 0 + } + ones, _ := ipnet.Mask.Size() + return ones +} + +func netmaskFor(prefix int) string { + if prefix <= 0 || prefix > 32 { + return "" + } + mask := net.CIDRMask(prefix, 32) + return fmt.Sprintf("%d.%d.%d.%d", mask[0], mask[1], mask[2], mask[3]) +} + +// splitPlatform derives (family, majorVersion) from a NetBox platform slug such +// as "almalinux9" -> ("almalinux","9") or "fedora42" -> ("fedora","42"). +func splitPlatform(slug string) (family, version string) { + slug = strings.ToLower(slug) + i := strings.IndexFunc(slug, func(r rune) bool { return r >= '0' && r <= '9' }) + if i < 0 { + return slug, "" + } + family = strings.Trim(slug[:i], "-_") + version = slug[i:] + if d := strings.IndexByte(version, '.'); d >= 0 { + version = version[:d] + } + return family, version +} + +func cfString(cf map[string]any, key string) string { + if cf == nil { + return "" + } + switch v := cf[key].(type) { + case string: + return v + case map[string]any: // NetBox object custom fields serialize as {value,label} + if s, ok := v["value"].(string); ok { + return s + } + } + return "" +} + +func cfStringList(cf map[string]any, key string) []string { + if cf == nil { + return nil + } + switch v := cf[key].(type) { + case string: + return splitComma(v) + case []any: + var out []string + for _, e := range v { + if s, ok := e.(string); ok && s != "" { + out = append(out, s) + } + } + return out + } + return nil +} + +func splitComma(v string) []string { + var out []string + for _, p := range strings.Split(v, ",") { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} diff --git a/internal/netbox/netbox_test.go b/internal/netbox/netbox_test.go new file mode 100644 index 0000000..5a80867 --- /dev/null +++ b/internal/netbox/netbox_test.go @@ -0,0 +1,220 @@ +package netbox + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// fakeNetBox serves canned NetBox v4.x JSON for the endpoints bootapi calls. +// The payloads are trimmed but structurally faithful to real API responses. +func fakeNetBox(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + + // Interfaces filtered by MAC -> the interface (with nested device brief). + mux.HandleFunc("/api/dcim/interfaces/", func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Token testtoken" { + http.Error(w, `{"detail":"auth"}`, http.StatusForbidden) + return + } + q := r.URL.Query() + switch { + case q.Get("mac_address") == "aa:bb:cc:00:11:22": + writeJSON(w, `{"count":1,"results":[ + {"id":40,"name":"eth0","mac_address":"AA:BB:CC:00:11:22", + "untagged_vlan":{"vid":100,"name":"prod"}, + "device":{"id":12,"name":"web01","slug":""}}]}`) + case q.Get("device_id") == "12": + // Full interface list for the device (two NICs). + writeJSON(w, `{"count":2,"results":[ + {"id":40,"name":"eth0","mac_address":"AA:BB:CC:00:11:22","untagged_vlan":{"vid":100,"name":"prod"}}, + {"id":41,"name":"eth1","mac_address":"AA:BB:CC:00:11:33"}]}`) + default: + writeJSON(w, `{"count":0,"results":[]}`) + } + }) + + // Device detail. + mux.HandleFunc("/api/dcim/devices/", func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/12/") { + writeJSON(w, `{ + "id":12,"name":"web01", + "platform":{"id":3,"name":"AlmaLinux 9","slug":"almalinux9"}, + "role":{"id":2,"name":"K8s Worker","slug":"kubernetes-worker"}, + "site":{"slug":"syd1"}, + "primary_ip":{"address":"10.0.1.20/24"}, + "custom_fields":{"domain":"syd1.au.unkin.net","gateway":"10.0.1.254","nameservers":"10.0.0.1,10.0.0.2","provision_template":null}}`) + return + } + // name= query (HostByName) + if r.URL.Query().Get("name") == "web01" { + writeJSON(w, `{"count":1,"results":[{"id":12,"name":"web01"}]}`) + return + } + writeJSON(w, `{"count":0,"results":[]}`) + }) + + // IP addresses for the device: eth0 has the primary, eth1 a second addr. + mux.HandleFunc("/api/ipam/ip-addresses/", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, `{"count":2,"results":[ + {"address":"10.0.1.20/24","assigned_object_id":40,"custom_fields":{"gateway":null}}, + {"address":"10.9.9.5/24","assigned_object_id":41,"custom_fields":{"gateway":"10.9.9.1"}}]}`) + }) + + return httptest.NewServer(mux) +} + +func writeJSON(w http.ResponseWriter, body string) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(body)) +} + +func newTestClient(t *testing.T, base string) *Client { + t.Helper() + return New(Options{BaseURL: base, Token: "testtoken"}) +} + +func TestHostByMAC(t *testing.T) { + srv := fakeNetBox(t) + defer srv.Close() + c := newTestClient(t, srv.URL) + + h, err := c.HostByMAC(context.Background(), "AA-BB-CC-00-11-22") + if err != nil { + t.Fatalf("HostByMAC: %v", err) + } + if h.Hostname != "web01" { + t.Errorf("hostname = %q, want web01", h.Hostname) + } + if h.FQDN != "web01.syd1.au.unkin.net" { + t.Errorf("fqdn = %q", h.FQDN) + } + if h.Platform != "almalinux9" || h.OSFamily != "almalinux" || h.OSVersion != "9" { + t.Errorf("platform=%q family=%q version=%q", h.Platform, h.OSFamily, h.OSVersion) + } + if h.Role != "kubernetes-worker" { + t.Errorf("role = %q", h.Role) + } + if h.PrimaryIP != "10.0.1.20" { + t.Errorf("primaryIP = %q", h.PrimaryIP) + } + if len(h.Nameservers) != 2 || h.Nameservers[0] != "10.0.0.1" { + t.Errorf("nameservers = %v", h.Nameservers) + } + if len(h.Interfaces) != 2 { + t.Fatalf("interfaces = %d, want 2", len(h.Interfaces)) + } + // Primary interface (eth0, carrying the primary IP) must sort first. + pi := h.PrimaryInterface() + if pi == nil || pi.Name != "eth0" || !pi.Primary { + t.Fatalf("primary interface = %+v", pi) + } + if pi.MAC != "aa:bb:cc:00:11:22" { + t.Errorf("primary MAC = %q (want normalized lower-colon)", pi.MAC) + } + if pi.IP != "10.0.1.20" || pi.Netmask != "255.255.255.0" || pi.PrefixLen != 24 { + t.Errorf("primary iface addr = %+v", pi) + } + // eth0 gateway comes from the device custom field (its IP had none). + if pi.Gateway != "10.0.1.254" { + t.Errorf("primary gateway = %q, want device CF 10.0.1.254", pi.Gateway) + } + if pi.VLAN != 100 { + t.Errorf("primary vlan = %d", pi.VLAN) + } + // eth1's IP custom field gateway wins over the device default. + for i := range h.Interfaces { + if h.Interfaces[i].Name == "eth1" { + if h.Interfaces[i].Gateway != "10.9.9.1" { + t.Errorf("eth1 gateway = %q, want per-IP CF 10.9.9.1", h.Interfaces[i].Gateway) + } + } + } +} + +func TestHostByMACNotFound(t *testing.T) { + srv := fakeNetBox(t) + defer srv.Close() + c := newTestClient(t, srv.URL) + + _, err := c.HostByMAC(context.Background(), "de:ad:be:ef:00:00") + if !errors.Is(err, ErrNotFound) { + t.Fatalf("err = %v, want ErrNotFound", err) + } +} + +func TestHostByName(t *testing.T) { + srv := fakeNetBox(t) + defer srv.Close() + c := newTestClient(t, srv.URL) + + // FQDN input should be reduced to the short name for the NetBox query. + h, err := c.HostByName(context.Background(), "web01.syd1.au.unkin.net") + if err != nil { + t.Fatalf("HostByName: %v", err) + } + if h.Hostname != "web01" || h.PrimaryIP != "10.0.1.20" { + t.Errorf("host = %+v", h) + } +} + +func TestHostByNameNotFound(t *testing.T) { + srv := fakeNetBox(t) + defer srv.Close() + c := newTestClient(t, srv.URL) + if _, err := c.HostByName(context.Background(), "nope"); !errors.Is(err, ErrNotFound) { + t.Fatalf("err = %v, want ErrNotFound", err) + } +} + +func TestAuthTokenRequired(t *testing.T) { + srv := fakeNetBox(t) + defer srv.Close() + // Client with the wrong token -> NetBox 403 -> surfaced as an error. + c := New(Options{BaseURL: srv.URL, Token: "wrong"}) + if _, err := c.HostByMAC(context.Background(), "aa:bb:cc:00:11:22"); err == nil { + t.Fatal("expected an auth error, got nil") + } +} + +func TestNormalizeMAC(t *testing.T) { + cases := map[string]string{ + "AA:BB:CC:00:11:22": "aa:bb:cc:00:11:22", + "aa-bb-cc-00-11-22": "aa:bb:cc:00:11:22", + "aabb.cc00.1122": "aa:bb:cc:00:11:22", + "AABBCC001122": "aa:bb:cc:00:11:22", + } + for in, want := range cases { + if got := normalizeMAC(in); got != want { + t.Errorf("normalizeMAC(%q) = %q, want %q", in, got, want) + } + } +} + +func TestSplitPlatform(t *testing.T) { + cases := []struct{ in, fam, ver string }{ + {"almalinux9", "almalinux", "9"}, + {"fedora42", "fedora", "42"}, + {"rocky9.4", "rocky", "9"}, + {"debian", "debian", ""}, + } + for _, c := range cases { + f, v := splitPlatform(c.in) + if f != c.fam || v != c.ver { + t.Errorf("splitPlatform(%q) = (%q,%q), want (%q,%q)", c.in, f, v, c.fam, c.ver) + } + } +} + +func TestNetmaskFor(t *testing.T) { + cases := map[int]string{24: "255.255.255.0", 16: "255.255.0.0", 25: "255.255.255.128", 0: ""} + for prefix, want := range cases { + if got := netmaskFor(prefix); got != want { + t.Errorf("netmaskFor(%d) = %q, want %q", prefix, got, want) + } + } +} diff --git a/internal/render/render.go b/internal/render/render.go new file mode 100644 index 0000000..c1838ba --- /dev/null +++ b/internal/render/render.go @@ -0,0 +1,268 @@ +// Package render turns a resolved model.Host into a kickstart file or an iPXE +// boot script using Go text/template. Templates come from an embedded default +// set (ported from Cobbler's kickstarts) optionally layered with an override +// directory (a Kubernetes ConfigMap mount in production). +package render + +import ( + "bytes" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + "text/template" + + "git.unkin.net/unkin/bootapi/internal/model" +) + +// Data is the exact, documented value passed to every kickstart/iPXE template. +// It is intentionally flat: template authors get one clear namespace. See +// docs/data-model.md. +type Data struct { + // --- identity (from NetBox) --- + Hostname string + Domain string + FQDN string + Platform string // NetBox platform slug, e.g. "almalinux9" + OSFamily string // "almalinux", "fedora", ... + OSVersion string // "9", "42", ... + Arch string // "x86_64" + Role string // NetBox device role slug + + // --- network (from NetBox) --- + Interfaces []model.Interface + PrimaryInterface *model.Interface + PrimaryIP string + Nameservers []string // resolved: host value, else site default + + // --- secrets (from Vault/env at render time, never NetBox) --- + RootPasswordHash string + SSHAuthorizedKeys []string + + // --- infra pointers (render-time config) --- + PuppetServer string + PuppetCAServer string + BaseURL string // bootapi's own base URL + BootBaseURL string // OS install-tree base URL + KickstartURL string // absolute URL a booting host fetches its KS from + + // --- escape hatch: every NetBox custom field, verbatim --- + Custom map[string]any +} + +// RenderConfig carries the render-time infra values merged into each Data. +type RenderConfig struct { + PuppetServer string + PuppetCAServer string + BaseURL string + BootBaseURL string + DefaultDomain string + DefaultNS []string + RootPasswordHash string + SSHAuthorizedKeys []string + DefaultTemplate string +} + +// Engine holds parsed templates and render-time defaults. +type Engine struct { + ks *template.Template // kickstart templates, named "" + ipxe *template.Template // ipxe templates, named "" + cfg RenderConfig + ksSet map[string]bool // which kickstart template names exist +} + +const ( + ksExt = ".ks.tmpl" + ipxeExt = ".ipxe.tmpl" +) + +// NewEngine parses the embedded defaults, then overlays overrideDir when +// non-empty (files there win over embedded ones of the same name). +func NewEngine(embedded fs.FS, overrideDir string, cfg RenderConfig) (*Engine, error) { + funcs := funcMap() + ks := template.New("kickstart").Funcs(funcs) + ipxe := template.New("ipxe").Funcs(funcs) + set := map[string]bool{} + + if err := parseTree(ks, ipxe, set, embedded, ".", true); err != nil { + return nil, fmt.Errorf("parse embedded templates: %w", err) + } + if overrideDir != "" { + if err := parseTree(ks, ipxe, set, os.DirFS(overrideDir), ".", false); err != nil { + return nil, fmt.Errorf("parse override templates in %q: %w", overrideDir, err) + } + } + return &Engine{ks: ks, ipxe: ipxe, cfg: cfg, ksSet: set}, nil +} + +// parseTree walks fsys under root, registering *.ks.tmpl into ks and +// *.ipxe.tmpl into ipxe under their base name (extension stripped). +func parseTree(ks, ipxe *template.Template, set map[string]bool, fsys fs.FS, root string, mustExist bool) error { + walked := false + err := fs.WalkDir(fsys, root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + walked = true + if d.IsDir() { + return nil + } + b, err := fs.ReadFile(fsys, path) + if err != nil { + return err + } + base := filepath.Base(path) + switch { + case strings.HasSuffix(base, ksExt): + name := strings.TrimSuffix(base, ksExt) + if _, err := ks.New(name).Parse(string(b)); err != nil { + return fmt.Errorf("%s: %w", path, err) + } + set[name] = true + case strings.HasSuffix(base, ipxeExt): + name := strings.TrimSuffix(base, ipxeExt) + if _, err := ipxe.New(name).Parse(string(b)); err != nil { + return fmt.Errorf("%s: %w", path, err) + } + } + return nil + }) + if err != nil { + return err + } + if mustExist && !walked { + return fmt.Errorf("no templates found under %q", root) + } + return nil +} + +// SelectKickstart returns the template name chosen for host, following the +// documented precedence: custom-field override → platform slug → OS family → +// configured default. It reports whether a concrete template was found. +func (e *Engine) SelectKickstart(h *model.Host) (string, bool) { + for _, cand := range []string{h.TemplateOverride, h.Platform, h.OSFamily, e.cfg.DefaultTemplate} { + if cand != "" && e.ksSet[cand] { + return cand, true + } + } + return e.cfg.DefaultTemplate, e.ksSet[e.cfg.DefaultTemplate] +} + +// dataFor builds the flat Data view for a host, merging render-time config. +func (e *Engine) dataFor(h *model.Host) Data { + ns := h.Nameservers + if len(ns) == 0 { + ns = e.cfg.DefaultNS + } + domain := h.Domain + if domain == "" { + domain = e.cfg.DefaultDomain + } + fqdn := h.Hostname + if domain != "" { + fqdn = h.Hostname + "." + domain + } + root := h.RootPasswordHash + if root == "" { + root = e.cfg.RootPasswordHash + } + keys := h.SSHAuthorizedKeys + if len(keys) == 0 { + keys = e.cfg.SSHAuthorizedKeys + } + ksURL := "" + if e.cfg.BaseURL != "" { + ksURL = strings.TrimRight(e.cfg.BaseURL, "/") + "/ks/" + h.Hostname + } + return Data{ + Hostname: h.Hostname, + Domain: domain, + FQDN: fqdn, + Platform: h.Platform, + OSFamily: h.OSFamily, + OSVersion: h.OSVersion, + Arch: h.Arch, + Role: h.Role, + Interfaces: h.Interfaces, + PrimaryInterface: h.PrimaryInterface(), + PrimaryIP: h.PrimaryIP, + Nameservers: ns, + RootPasswordHash: root, + SSHAuthorizedKeys: keys, + PuppetServer: e.cfg.PuppetServer, + PuppetCAServer: e.cfg.PuppetCAServer, + BaseURL: e.cfg.BaseURL, + BootBaseURL: e.cfg.BootBaseURL, + KickstartURL: ksURL, + Custom: h.Custom, + } +} + +// RenderKickstart renders the selected kickstart template for host. It returns +// the rendered bytes and the template name used. +func (e *Engine) RenderKickstart(h *model.Host) ([]byte, string, error) { + name, ok := e.SelectKickstart(h) + if !ok { + return nil, name, fmt.Errorf("no kickstart template for host %q (tried override/platform/family/default %q)", h.Hostname, name) + } + var buf bytes.Buffer + if err := e.ks.ExecuteTemplate(&buf, name, e.dataFor(h)); err != nil { + return nil, name, fmt.Errorf("render kickstart %q: %w", name, err) + } + return buf.Bytes(), name, nil +} + +// IPXEData is the value passed to iPXE templates. +type IPXEData struct { + Data + // KernelURL/InitrdURL point at the OS install tree; empty when BootBaseURL + // is unset, in which case the template should fall back to a static path. + KernelURL string + InitrdURL string +} + +// RenderIPXE renders the "boot" iPXE script that chains kernel+initrd with +// inst.ks= pointing back at bootapi. +func (e *Engine) RenderIPXE(h *model.Host) ([]byte, error) { + d := e.dataFor(h) + id := IPXEData{Data: d} + if d.BootBaseURL != "" { + tree := strings.TrimRight(d.BootBaseURL, "/") + id.KernelURL = tree + "/images/pxeboot/vmlinuz" + id.InitrdURL = tree + "/images/pxeboot/initrd.img" + } + return e.execIPXE("boot", id) +} + +// RenderFallback renders a fallback iPXE script ("local" or "shell") for an +// unknown MAC. See docs/endpoints.md for the safety rationale. +func (e *Engine) RenderFallback(kind string) ([]byte, error) { + name := "fallback-" + kind + return e.execIPXE(name, IPXEData{}) +} + +func (e *Engine) execIPXE(name string, d IPXEData) ([]byte, error) { + if e.ipxe.Lookup(name) == nil { + return nil, fmt.Errorf("no iPXE template %q", name) + } + var buf bytes.Buffer + if err := e.ipxe.ExecuteTemplate(&buf, name, d); err != nil { + return nil, fmt.Errorf("render ipxe %q: %w", name, err) + } + return buf.Bytes(), nil +} + +func funcMap() template.FuncMap { + return template.FuncMap{ + "join": strings.Join, + "upper": strings.ToUpper, + "lower": strings.ToLower, + "default": func(def, v string) string { // {{ default "x" .Maybe }} + if v == "" { + return def + } + return v + }, + } +} diff --git a/internal/render/render_test.go b/internal/render/render_test.go new file mode 100644 index 0000000..5faba44 --- /dev/null +++ b/internal/render/render_test.go @@ -0,0 +1,161 @@ +package render + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "git.unkin.net/unkin/bootapi/internal/model" + "git.unkin.net/unkin/bootapi/templates" +) + +func testEngine(t *testing.T, override string) *Engine { + t.Helper() + e, err := NewEngine(templates.FS, override, 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", + DefaultNS: []string{"10.0.0.1"}, + RootPasswordHash: "$6$rounds=4096$abc$deadbeef", + SSHAuthorizedKeys: []string{"ssh-ed25519 AAAAC3xxx root@ops"}, + DefaultTemplate: "almalinux9", + }) + if err != nil { + t.Fatalf("NewEngine: %v", err) + } + return e +} + +func almaHost() *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", + Role: "kubernetes-worker", + 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", VLAN: 100, Primary: true}, + {Name: "eth1", MAC: "aa:bb:cc:00:11:33"}, // no IP -> must be skipped in network stanza + }, + } +} + +func TestRenderKickstartAlma(t *testing.T) { + e := testEngine(t, "") + out, name, err := e.RenderKickstart(almaHost()) + if err != nil { + t.Fatalf("RenderKickstart: %v", err) + } + if name != "almalinux9" { + t.Errorf("selected template = %q, want almalinux9", name) + } + ks := string(out) + + mustContain(t, ks, "rootpw --iscrypted $6$rounds=4096$abc$deadbeef") + // The primary interface must produce a full static network line incl hostname. + mustContain(t, ks, "network --bootproto=static --device=aa:bb:cc:00:11:22 --ip=10.0.1.20 --netmask=255.255.255.0 --gateway=10.0.1.254 --nameserver=10.0.0.1 --hostname=web01.syd1.au.unkin.net") + mustContain(t, ks, `"$PUPPET_BIN" config set --section main server "puppet.query.consul"`) + mustContain(t, ks, `config set --section main ca_server "puppetca.query.consul"`) + mustContain(t, ks, "url --url=http://mirror.example.net/almalinux/9/BaseOS/x86_64/os/") + mustContain(t, ks, "ssh-ed25519 AAAAC3xxx root@ops") + mustContain(t, ks, "dnf install -y puppet-agent") + mustContain(t, ks, "%packages") + mustContain(t, ks, "%post") + + // eth1 has no IP, so it must NOT appear as a network device line. + if strings.Contains(ks, "--device=aa:bb:cc:00:11:33") { + t.Error("interface without an IP leaked into a network stanza") + } +} + +func TestRenderKickstartLockedRoot(t *testing.T) { + // With no root hash configured, the account must be locked, not blank. + e, err := NewEngine(templates.FS, "", RenderConfig{DefaultTemplate: "almalinux9", BootBaseURL: "http://m/9"}) + if err != nil { + t.Fatal(err) + } + out, _, err := e.RenderKickstart(almaHost()) + if err != nil { + t.Fatal(err) + } + ks := string(out) + mustContain(t, ks, "rootpw --lock") + if strings.Contains(ks, "--iscrypted") { + t.Error("expected locked root, got an --iscrypted line") + } +} + +func TestSelectKickstartPrecedence(t *testing.T) { + e := testEngine(t, "") + cases := []struct { + host *model.Host + want string + }{ + {&model.Host{TemplateOverride: "fedora", Platform: "almalinux9"}, "fedora"}, // override wins + {&model.Host{Platform: "almalinux9"}, "almalinux9"}, // platform + {&model.Host{Platform: "fedora42", OSFamily: "fedora"}, "fedora"}, // family fallback + {&model.Host{Platform: "unknownos"}, "almalinux9"}, // default + } + for _, c := range cases { + got, ok := e.SelectKickstart(c.host) + if !ok || got != c.want { + t.Errorf("SelectKickstart(%+v) = (%q,%v), want %q", c.host, got, ok, c.want) + } + } +} + +func TestRenderIPXE(t *testing.T) { + e := testEngine(t, "") + out, err := e.RenderIPXE(almaHost()) + if err != nil { + t.Fatalf("RenderIPXE: %v", err) + } + s := string(out) + mustContain(t, s, "#!ipxe") + mustContain(t, s, "kernel http://mirror.example.net/almalinux/9/images/pxeboot/vmlinuz") + mustContain(t, s, "inst.ks=http://bootapi.example.net/ks/web01") + mustContain(t, s, "initrd http://mirror.example.net/almalinux/9/images/pxeboot/initrd.img") +} + +func TestRenderFallback(t *testing.T) { + e := testEngine(t, "") + local, err := e.RenderFallback("local") + if err != nil { + t.Fatal(err) + } + mustContain(t, string(local), "sanboot") + shell, err := e.RenderFallback("shell") + if err != nil { + t.Fatal(err) + } + mustContain(t, string(shell), "shell") +} + +func TestOverrideDirWins(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "almalinux9.ks.tmpl"), []byte("OVERRIDDEN {{ .Hostname }}\n"), 0o600); err != nil { + t.Fatal(err) + } + e := testEngine(t, dir) + out, _, err := e.RenderKickstart(almaHost()) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(string(out), "OVERRIDDEN web01") { + t.Errorf("override not applied: %q", string(out)) + } +} + +func mustContain(t *testing.T, haystack, needle string) { + t.Helper() + if !strings.Contains(haystack, needle) { + t.Errorf("output missing %q\n--- output ---\n%s", needle, haystack) + } +} diff --git a/internal/server/metrics.go b/internal/server/metrics.go new file mode 100644 index 0000000..ff98636 --- /dev/null +++ b/internal/server/metrics.go @@ -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())) +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..c489ae1 --- /dev/null +++ b/internal/server/server.go @@ -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) + }) +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..67ff090 --- /dev/null +++ b/internal/server/server_test.go @@ -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) + } + } +} diff --git a/templates/embed.go b/templates/embed.go new file mode 100644 index 0000000..9979a43 --- /dev/null +++ b/templates/embed.go @@ -0,0 +1,11 @@ +// Package templates embeds bootapi's default kickstart and iPXE templates. +// These are the built-in fallback set; an operator can override any of them by +// mounting a ConfigMap at BOOTAPI_TEMPLATE_DIR (see docs/deployment.md). +package templates + +import "embed" + +// FS holds the default template tree: kickstart/*.ks.tmpl and ipxe/*.ipxe.tmpl. +// +//go:embed kickstart ipxe +var FS embed.FS diff --git a/templates/ipxe/boot.ipxe.tmpl b/templates/ipxe/boot.ipxe.tmpl new file mode 100644 index 0000000..a557e56 --- /dev/null +++ b/templates/ipxe/boot.ipxe.tmpl @@ -0,0 +1,20 @@ +{{- /* +iPXE boot script for a known host. Chains the OS installer kernel+initrd and +points inst.ks= back at bootapi's /ks/ endpoint, mirroring how Cobbler +generated a per-MAC gPXE script that carried inst.ks=. + +Requires BOOTAPI_BOOT_BASE_URL (KernelURL/InitrdURL) and BOOTAPI_BASE_URL +(KickstartURL) to be configured. +*/ -}} +#!ipxe +echo bootapi: provisioning {{ .FQDN }} ({{ .Platform }}) +{{ if and .KernelURL .InitrdURL -}} +kernel {{ .KernelURL }} initrd=initrd.img inst.repo={{ .BootBaseURL }} inst.ks={{ .KickstartURL }} inst.text ip=dhcp net.ifnames=0 +initrd {{ .InitrdURL }} +boot +{{- else -}} +echo bootapi: BOOTAPI_BOOT_BASE_URL not configured; cannot build a boot line +echo Falling back to local disk in 5s +sleep 5 +exit +{{- end }} diff --git a/templates/ipxe/fallback-local.ipxe.tmpl b/templates/ipxe/fallback-local.ipxe.tmpl new file mode 100644 index 0000000..bb5086f --- /dev/null +++ b/templates/ipxe/fallback-local.ipxe.tmpl @@ -0,0 +1,10 @@ +{{- /* +Safe default for an UNKNOWN MAC (NetBox has no matching device). We deliberately +do NOT start an installer for a machine we can't identify — that could wipe a +production box that PXE-booted by accident. Instead we boot from local disk, so +an already-installed host just continues, and a brand-new host loops back to PXE +on its next attempt (by which point NetBox should know it). +*/ -}} +#!ipxe +echo bootapi: unknown MAC ${net0/mac}; not provisioning. Booting local disk. +sanboot --no-describe --drive 0x80 || exit diff --git a/templates/ipxe/fallback-shell.ipxe.tmpl b/templates/ipxe/fallback-shell.ipxe.tmpl new file mode 100644 index 0000000..2b5d66c --- /dev/null +++ b/templates/ipxe/fallback-shell.ipxe.tmpl @@ -0,0 +1,10 @@ +{{- /* +Debug fallback for an unknown MAC (opt in via BOOTAPI_UNKNOWN_MAC_FALLBACK=shell). +Drops to an interactive iPXE shell instead of booting anything, so an operator +racking a new box can inspect ${net0/mac} and register it in NetBox. Not the +default because it halts the boot and is unsafe for an accidental PXE of a prod +host. +*/ -}} +#!ipxe +echo bootapi: unknown MAC ${net0/mac}; dropping to iPXE shell for debugging. +shell diff --git a/templates/kickstart/almalinux9.ks.tmpl b/templates/kickstart/almalinux9.ks.tmpl new file mode 100644 index 0000000..43c1392 --- /dev/null +++ b/templates/kickstart/almalinux9.ks.tmpl @@ -0,0 +1,96 @@ +{{- /* +AlmaLinux 9 kickstart, ported from the Cobbler default.ks contract. + +Rendered by bootapi from NetBox data + render-time secrets. The %post hands off +to the existing Puppet firstrun bootstrap: it installs the agent, points it at +the Consul-discovered puppet servers, and triggers the first run. Autosign +(*.main.unkin.net + the PXE subnets) and the `profiles::firstrun` class do the +rest, exactly as they did under Cobbler. + +Data model: see docs/data-model.md. `.RootPasswordHash` comes from Vault at +render time, never from NetBox. +*/ -}} +#version=RHEL9 +# Rendered by bootapi for {{ .FQDN }} (platform {{ .Platform }}, role {{ default "none" .Role }}) +text +eula --agreed +firstboot --disable +reboot + +# --- install source (served by bootapi's configured mirror) --- +url --url={{ .BootBaseURL }}/BaseOS/{{ .Arch }}/os/ +repo --name=AppStream --baseurl={{ .BootBaseURL }}/AppStream/{{ .Arch }}/os/ + +# --- localization --- +keyboard --xlayouts='us' +lang en_AU.UTF-8 +timezone Australia/Sydney --utc + +# --- security --- +{{ if .RootPasswordHash -}} +rootpw --iscrypted {{ .RootPasswordHash }} +{{- else -}} +rootpw --lock +{{- end }} +selinux --enforcing +firewall --enabled --service=ssh +authselect select sssd with-mkhomedir --force + +# --- networking (static, from NetBox) --- +{{- $primary := .PrimaryInterface }} +{{- range .Interfaces }} +{{- if .IP }} +network --bootproto=static --device={{ .MAC }} --ip={{ .IP }} --netmask={{ .Netmask }}{{ if .Gateway }} --gateway={{ .Gateway }}{{ end }}{{ range $.Nameservers }} --nameserver={{ . }}{{ end }}{{ if and $primary (eq .MAC $primary.MAC) }} --hostname={{ $.FQDN }}{{ end }} --activate --onboot=on --noipv6 +{{- end }} +{{- end }} + +# --- storage --- +ignoredisk --only-use=sda +clearpart --all --initlabel --drives=sda +bootloader --location=mbr --boot-drive=sda --append="crashkernel=auto" +autopart --type=lvm --nohome + +# --- packages --- +%packages --ignoremissing --excludedocs +@^minimal-environment +openssh-server +chrony +vim-minimal +tmux +git +-iwl*-firmware +%end + +# --- bootstrap: hand off to Puppet firstrun --- +%post --log=/root/bootapi-post.log +set -x + +# chrony: keep time sane before any cert work. +systemctl enable chronyd + +{{ if .SSHAuthorizedKeys -}} +# root authorized_keys (from render-time config, not NetBox). +install -d -m0700 /root/.ssh +cat > /root/.ssh/authorized_keys <<'EOF' +{{ range .SSHAuthorizedKeys }}{{ . }} +{{ end }}EOF +chmod 0600 /root/.ssh/authorized_keys +{{- end }} + +# Install the Puppet 8 agent from the puppet platform repo. +rpm -q puppet-agent >/dev/null 2>&1 || \ + dnf install -y https://yum.puppet.com/puppet8-release-el-9.noarch.rpm +dnf install -y puppet-agent + +# Point the agent at the Consul-discovered servers (matches the pre-bootapi +# Cobbler kickstart + hieradata/roles/infra/puppet). +PUPPET_BIN=/opt/puppetlabs/bin/puppet +"$PUPPET_BIN" config set --section main certname "{{ .FQDN }}" +"$PUPPET_BIN" config set --section main server "{{ .PuppetServer }}" +"$PUPPET_BIN" config set --section main ca_server "{{ .PuppetCAServer }}" +"$PUPPET_BIN" config set --section main report_server "{{ .PuppetServer }}" +"$PUPPET_BIN" config set --section main environment production + +# Enable the agent; the first boot triggers firstrun (autosign handles the CSR). +systemctl enable puppet +%end diff --git a/templates/kickstart/fedora.ks.tmpl b/templates/kickstart/fedora.ks.tmpl new file mode 100644 index 0000000..7d8f998 --- /dev/null +++ b/templates/kickstart/fedora.ks.tmpl @@ -0,0 +1,66 @@ +{{- /* +Fedora kickstart (family-level template: matches any "fedoraNN" platform slug +via the OS-family selection fallback). Kept close to the AlmaLinux template so +the two stay comparable; the differences are the install tree layout and that +Fedora ships a recent-enough dnf/agent story out of the box. +*/ -}} +#version=F{{ default "" .OSVersion }} +# Rendered by bootapi for {{ .FQDN }} (platform {{ .Platform }}) +text +firstboot --disable +reboot + +# --- install source --- +url --url={{ .BootBaseURL }}/releases/{{ default "rawhide" .OSVersion }}/Everything/{{ .Arch }}/os/ + +keyboard --xlayouts='us' +lang en_AU.UTF-8 +timezone Australia/Sydney --utc + +{{ if .RootPasswordHash -}} +rootpw --iscrypted {{ .RootPasswordHash }} +{{- else -}} +rootpw --lock +{{- end }} +selinux --enforcing +firewall --enabled --service=ssh + +# --- networking (static, from NetBox) --- +{{- $primary := .PrimaryInterface }} +{{- range .Interfaces }} +{{- if .IP }} +network --bootproto=static --device={{ .MAC }} --ip={{ .IP }} --netmask={{ .Netmask }}{{ if .Gateway }} --gateway={{ .Gateway }}{{ end }}{{ range $.Nameservers }} --nameserver={{ . }}{{ end }}{{ if and $primary (eq .MAC $primary.MAC) }} --hostname={{ $.FQDN }}{{ end }} --activate --onboot=on --noipv6 +{{- end }} +{{- end }} + +# --- storage --- +ignoredisk --only-use=sda +clearpart --all --initlabel --drives=sda +bootloader --location=mbr --boot-drive=sda +autopart --type=lvm --nohome + +%packages --ignoremissing +@^minimal-environment +openssh-server +chrony +git +%end + +%post --log=/root/bootapi-post.log +set -x +systemctl enable chronyd sshd +{{ if .SSHAuthorizedKeys -}} +install -d -m0700 /root/.ssh +cat > /root/.ssh/authorized_keys <<'EOF' +{{ range .SSHAuthorizedKeys }}{{ . }} +{{ end }}EOF +chmod 0600 /root/.ssh/authorized_keys +{{- end }} +dnf install -y https://yum.puppet.com/puppet8-release-fedora-{{ default "40" .OSVersion }}.noarch.rpm || true +dnf install -y puppet-agent +PUPPET_BIN=/opt/puppetlabs/bin/puppet +"$PUPPET_BIN" config set --section main certname "{{ .FQDN }}" +"$PUPPET_BIN" config set --section main server "{{ .PuppetServer }}" +"$PUPPET_BIN" config set --section main ca_server "{{ .PuppetCAServer }}" +systemctl enable puppet +%end