From 779434331c5ae4c94dde8313dec31ae6fa8e47ca Mon Sep 17 00:00:00 2001 From: Jonathon Anderson Date: Fri, 6 Mar 2026 11:38:40 -0700 Subject: [PATCH] Add GRUB config route, tests, and server route documentation - Add /grub/{hwaddr} route for serving GRUB configuration files, replacing the shim handler with a config-based approach - Add short URL aliases: 'system' for overlay-system, 'runtime' for overlay-runtime, and 'grub' stage in parser - Add 'grub' to status stage tracking in request handling - Add comprehensive test coverage for EFI, GRUB, initramfs, iPXE, overlay, and provision handlers (efi_test.go, grub_test.go, initramfs_test.go, ipxe_test.go, overlay_test.go) - Expand existing parser and provision tests - Remove shim.go (functionality folded into grub.go) - Add userdocs/server/routes.rst documenting all warewulfd HTTP routes Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Jonathon Anderson --- internal/pkg/warewulfd/efi_test.go | 90 +++++++ internal/pkg/warewulfd/grub.go | 21 +- internal/pkg/warewulfd/grub_test.go | 76 ++++++ internal/pkg/warewulfd/initramfs_test.go | 60 +++++ internal/pkg/warewulfd/ipxe_test.go | 80 ++++++ internal/pkg/warewulfd/overlay_test.go | 89 +++++++ internal/pkg/warewulfd/parser.go | 43 ++- internal/pkg/warewulfd/parser_test.go | 133 +++++++++- internal/pkg/warewulfd/provision.go | 4 - internal/pkg/warewulfd/provision_test.go | 15 +- internal/pkg/warewulfd/server/server.go | 8 +- internal/pkg/warewulfd/shim.go | 34 --- userdocs/index.rst | 1 + userdocs/server/routes.rst | 322 +++++++++++++++++++++++ 14 files changed, 902 insertions(+), 74 deletions(-) create mode 100644 internal/pkg/warewulfd/efi_test.go create mode 100644 internal/pkg/warewulfd/grub_test.go create mode 100644 internal/pkg/warewulfd/initramfs_test.go create mode 100644 internal/pkg/warewulfd/ipxe_test.go delete mode 100644 internal/pkg/warewulfd/shim.go create mode 100644 userdocs/server/routes.rst diff --git a/internal/pkg/warewulfd/efi_test.go b/internal/pkg/warewulfd/efi_test.go new file mode 100644 index 00000000..cc423fb5 --- /dev/null +++ b/internal/pkg/warewulfd/efi_test.go @@ -0,0 +1,90 @@ +package warewulfd + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + + warewulfconf "github.com/warewulf/warewulf/internal/pkg/config" + "github.com/warewulf/warewulf/internal/pkg/testenv" +) + +var efiBootTests = []struct { + description string + url string + body string + status int + ip string +}{ + {"find shim", "/efiboot/shim.efi", "", 200, "10.10.10.10:9873"}, + {"find shim: node with missing image returns 404", "/efiboot/shim.efi", "", 404, "10.10.10.11:9873"}, + {"find grub", "/efiboot/grub.efi", "", 200, "10.10.10.10:9873"}, + {"find grub: node with missing image returns 404", "/efiboot/grub.efi", "", 404, "10.10.10.11:9873"}, + {"find grub.cfg", "/efiboot/grub.cfg", "dracut 10.10.0.1:9873", 200, "10.10.10.11:9873"}, +} + +func Test_HandleEfiBoot(t *testing.T) { + env := testenv.New(t) + defer env.RemoveAll() + + env.WriteFile("/etc/warewulf/nodes.conf", `nodeprofiles: + default: + image name: suse +nodes: + n1: + network devices: + default: + hwaddr: 00:00:00:ff:ff:ff + profiles: + - default + n2: + network devices: + default: + hwaddr: 00:00:00:00:ff:ff + image name: none + tags: + GrubMenuEntry: dracut`) + + env.WriteFile("/var/tmp/arpcache", `IP address HW type Flags HW address Mask Device +10.10.10.10 0x1 0x2 00:00:00:ff:ff:ff * dummy +10.10.10.11 0x1 0x2 00:00:00:00:ff:ff * dummy`) + prevArpFile := arpFile + arpFile = env.GetPath("/var/tmp/arpcache") + defer func() { + arpFile = prevArpFile + }() + + env.CreateFile("/var/lib/warewulf/chroots/suse/rootfs/usr/lib64/efi/shim.efi") + env.CreateFile("/var/lib/warewulf/chroots/suse/rootfs/usr/share/efi/x86_64/grub.efi") + env.WriteFile("/etc/warewulf/grub/grub.cfg.ww", "{{ .Tags.GrubMenuEntry }} {{ .Authority }}") + + dbErr := LoadNodeDB() + assert.NoError(t, dbErr) + + conf := warewulfconf.Get() + secureFalse := false + conf.Warewulf.SecureP = &secureFalse + conf.Ipaddr = "10.10.0.1" + conf.Ipaddr6 = "fd00:10::1" + + for _, tt := range efiBootTests { + t.Run(tt.description, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tt.url, nil) + req.RemoteAddr = tt.ip + w := httptest.NewRecorder() + HandleEfiBoot(w, req) + res := w.Result() + defer res.Body.Close() + + data, readErr := io.ReadAll(res.Body) + assert.NoError(t, readErr) + if tt.body != "" { + assert.Equal(t, tt.body, string(data)) + } + assert.Equal(t, tt.status, res.StatusCode) + }) + } +} diff --git a/internal/pkg/warewulfd/grub.go b/internal/pkg/warewulfd/grub.go index f01a43d9..a810c233 100644 --- a/internal/pkg/warewulfd/grub.go +++ b/internal/pkg/warewulfd/grub.go @@ -2,32 +2,25 @@ package warewulfd import ( "net/http" + "path" - "github.com/warewulf/warewulf/internal/pkg/image" "github.com/warewulf/warewulf/internal/pkg/wwlog" ) -// HandleGrub handles direct GRUB binary requests +// HandleGrub handles GRUB configuration requests func HandleGrub(w http.ResponseWriter, req *http.Request) { ctx, err := initHandleRequest(w, req) if err != nil { return // response already written } - var stageFile string - if !ctx.remoteNode.Valid() { wwlog.Error("%s (unknown/unconfigured node)", ctx.rinfo.hwaddr) - } else { - if ctx.remoteNode.ImageName != "" { - stageFile = image.GrubFind(ctx.remoteNode.ImageName) - if stageFile == "" { - wwlog.Error("No grub found for image %s", ctx.remoteNode.ImageName) - } - } else { - wwlog.Warn("No conainer set for node %s", ctx.remoteNode.Id()) - } + w.WriteHeader(http.StatusNotFound) + return } - sendResponse(w, req, stageFile, nil, ctx) + stageFile := path.Join(ctx.conf.Paths.Sysconfdir, "warewulf/grub/grub.cfg.ww") + tmplData := buildTemplateVars(ctx.conf, ctx.rinfo, ctx.remoteNode) + sendResponse(w, req, stageFile, tmplData, ctx) } diff --git a/internal/pkg/warewulfd/grub_test.go b/internal/pkg/warewulfd/grub_test.go new file mode 100644 index 00000000..df57af44 --- /dev/null +++ b/internal/pkg/warewulfd/grub_test.go @@ -0,0 +1,76 @@ +package warewulfd + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + + warewulfconf "github.com/warewulf/warewulf/internal/pkg/config" + "github.com/warewulf/warewulf/internal/pkg/testenv" +) + +var grubHandlerTests = []struct { + description string + url string + body string + status int + ip string +}{ + {"grub config for node with image", "/grub/00:00:00:ff:ff:ff", "", 200, "10.10.10.10:9873"}, + {"grub config rendered with tag", "/grub/00:00:00:00:ff:ff", "dracut 10.10.0.1:9873", 200, "10.10.10.11:9873"}, +} + +func Test_HandleGrub(t *testing.T) { + env := testenv.New(t) + defer env.RemoveAll() + + env.WriteFile("/etc/warewulf/nodes.conf", `nodeprofiles: + default: + image name: suse +nodes: + n1: + network devices: + default: + hwaddr: 00:00:00:ff:ff:ff + profiles: + - default + n2: + network devices: + default: + hwaddr: 00:00:00:00:ff:ff + image name: none + tags: + GrubMenuEntry: dracut`) + + env.WriteFile("/etc/warewulf/grub/grub.cfg.ww", "{{ .Tags.GrubMenuEntry }} {{ .Authority }}") + + dbErr := LoadNodeDB() + assert.NoError(t, dbErr) + + conf := warewulfconf.Get() + secureFalse := false + conf.Warewulf.SecureP = &secureFalse + conf.Ipaddr = "10.10.0.1" + conf.Ipaddr6 = "fd00:10::1" + + for _, tt := range grubHandlerTests { + t.Run(tt.description, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tt.url, nil) + req.RemoteAddr = tt.ip + w := httptest.NewRecorder() + HandleGrub(w, req) + res := w.Result() + defer res.Body.Close() + + data, readErr := io.ReadAll(res.Body) + assert.NoError(t, readErr) + if tt.body != "" { + assert.Equal(t, tt.body, string(data)) + } + assert.Equal(t, tt.status, res.StatusCode) + }) + } +} diff --git a/internal/pkg/warewulfd/initramfs_test.go b/internal/pkg/warewulfd/initramfs_test.go new file mode 100644 index 00000000..febbedde --- /dev/null +++ b/internal/pkg/warewulfd/initramfs_test.go @@ -0,0 +1,60 @@ +package warewulfd + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + + warewulfconf "github.com/warewulf/warewulf/internal/pkg/config" + "github.com/warewulf/warewulf/internal/pkg/testenv" +) + +var initramfsHandlerTests = []struct { + description string + url string + status int + ip string +}{ + {"find initramfs", "/initramfs/00:00:00:ff:ff:ff", 200, "10.10.10.10:9873"}, +} + +func Test_HandleInitramfs(t *testing.T) { + env := testenv.New(t) + defer env.RemoveAll() + + env.WriteFile("/etc/warewulf/nodes.conf", `nodeprofiles: + default: + image name: suse +nodes: + n1: + network devices: + default: + hwaddr: 00:00:00:ff:ff:ff + profiles: + - default`) + + env.CreateFile("/var/lib/warewulf/chroots/suse/rootfs/boot/vmlinuz-1.1.0") + env.CreateFile("/var/lib/warewulf/chroots/suse/rootfs/boot/initramfs-1.1.0.img") + + dbErr := LoadNodeDB() + assert.NoError(t, dbErr) + + conf := warewulfconf.Get() + secureFalse := false + conf.Warewulf.SecureP = &secureFalse + + for _, tt := range initramfsHandlerTests { + t.Run(tt.description, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tt.url, nil) + req.RemoteAddr = tt.ip + w := httptest.NewRecorder() + HandleInitramfs(w, req) + res := w.Result() + defer res.Body.Close() + + assert.Equal(t, tt.status, res.StatusCode) + }) + } +} diff --git a/internal/pkg/warewulfd/ipxe_test.go b/internal/pkg/warewulfd/ipxe_test.go new file mode 100644 index 00000000..9aaacc51 --- /dev/null +++ b/internal/pkg/warewulfd/ipxe_test.go @@ -0,0 +1,80 @@ +package warewulfd + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + + warewulfconf "github.com/warewulf/warewulf/internal/pkg/config" + "github.com/warewulf/warewulf/internal/pkg/testenv" +) + +var ipxeHandlerTests = []struct { + description string + url string + body string + status int + ip string +}{ + { + "ipxe with NetDevs, KernelVersion, and Authority", + "/ipxe/00:00:00:00:00:ff", + "1.1.1 ifname=net:00:00:00:00:00:ff 10.10.0.1 fd00:10::1 10.10.0.1:9873", + 200, + "10.10.10.12:9873", + }, + { + "ipxe over ipv6", + "/ipxe/00:00:00:00:00:ff", + "1.1.1 ifname=net:00:00:00:00:00:ff 10.10.0.1 fd00:10::1 [fd00:10::1]:9873", + 200, + "[fd00:10::10:12]:9873", + }, +} + +func Test_HandleIpxe(t *testing.T) { + env := testenv.New(t) + defer env.RemoveAll() + + env.WriteFile("/etc/warewulf/nodes.conf", `nodes: + n3: + network devices: + default: + hwaddr: 00:00:00:00:00:ff + device: net + ipxe template: test + kernel: + version: 1.1.1`) + + env.WriteFile("/etc/warewulf/ipxe/test.ipxe", "{{.KernelVersion}}{{range $devname, $netdev := .NetDevs}}{{if and $netdev.Hwaddr $netdev.Device}} ifname={{$netdev.Device}}:{{$netdev.Hwaddr}} {{end}}{{end}} {{.Ipaddr}} {{.Ipaddr6}} {{.Authority}}") + + dbErr := LoadNodeDB() + assert.NoError(t, dbErr) + + conf := warewulfconf.Get() + secureFalse := false + conf.Warewulf.SecureP = &secureFalse + conf.Ipaddr = "10.10.0.1" + conf.Ipaddr6 = "fd00:10::1" + + for _, tt := range ipxeHandlerTests { + t.Run(tt.description, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tt.url, nil) + req.RemoteAddr = tt.ip + w := httptest.NewRecorder() + HandleIpxe(w, req) + res := w.Result() + defer res.Body.Close() + + data, readErr := io.ReadAll(res.Body) + assert.NoError(t, readErr) + if tt.body != "" { + assert.Equal(t, tt.body, string(data)) + } + assert.Equal(t, tt.status, res.StatusCode) + }) + } +} diff --git a/internal/pkg/warewulfd/overlay_test.go b/internal/pkg/warewulfd/overlay_test.go index f1668993..17d28dfb 100644 --- a/internal/pkg/warewulfd/overlay_test.go +++ b/internal/pkg/warewulfd/overlay_test.go @@ -4,10 +4,13 @@ import ( "io" "net/http" "net/http/httptest" + "os" + "path" "testing" "github.com/stretchr/testify/assert" + warewulfconf "github.com/warewulf/warewulf/internal/pkg/config" "github.com/warewulf/warewulf/internal/pkg/testenv" ) @@ -108,3 +111,89 @@ nodes: }) } } + +var systemOverlayTests = []struct { + description string + url string + body string + status int + ip string +}{ + {"system overlay", "/system/00:00:00:ff:ff:ff", "system overlay", 200, "10.10.10.10:9873"}, + {"fake overlay returns 404", "/system/00:00:00:ff:ff:ff?overlay=fake", "", 404, "10.10.10.10:9873"}, + {"specific overlay", "/system/00:00:00:ff:ff:ff?overlay=o1", "specific overlay", 200, "10.10.10.10:9873"}, +} + +var runtimeOverlayTests = []struct { + description string + url string + body string + status int + ip string +}{ + {"runtime overlay", "/runtime/00:00:00:ff:ff:ff", "runtime overlay", 200, "10.10.10.10:9873"}, +} + +func Test_HandleSystemRuntimeOverlay(t *testing.T) { + env := testenv.New(t) + defer env.RemoveAll() + + env.WriteFile("/etc/warewulf/nodes.conf", `nodeprofiles: + default: + image name: suse +nodes: + n1: + network devices: + default: + hwaddr: 00:00:00:ff:ff:ff + profiles: + - default`) + + dbErr := LoadNodeDB() + assert.NoError(t, dbErr) + + conf := warewulfconf.Get() + secureFalse := false + conf.Warewulf.SecureP = &secureFalse + + assert.NoError(t, os.MkdirAll(path.Join(conf.Paths.OverlayProvisiondir(), "n1"), 0700)) + assert.NoError(t, os.WriteFile(path.Join(conf.Paths.OverlayProvisiondir(), "n1", "__SYSTEM__.img"), []byte("system overlay"), 0600)) + assert.NoError(t, os.WriteFile(path.Join(conf.Paths.OverlayProvisiondir(), "n1", "__RUNTIME__.img"), []byte("runtime overlay"), 0600)) + assert.NoError(t, os.WriteFile(path.Join(conf.Paths.OverlayProvisiondir(), "n1", "o1.img"), []byte("specific overlay"), 0600)) + + for _, tt := range systemOverlayTests { + t.Run(tt.description, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tt.url, nil) + req.RemoteAddr = tt.ip + w := httptest.NewRecorder() + HandleSystemOverlay(w, req) + res := w.Result() + defer res.Body.Close() + + data, readErr := io.ReadAll(res.Body) + assert.NoError(t, readErr) + if tt.body != "" { + assert.Equal(t, tt.body, string(data)) + } + assert.Equal(t, tt.status, res.StatusCode) + }) + } + + for _, tt := range runtimeOverlayTests { + t.Run(tt.description, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tt.url, nil) + req.RemoteAddr = tt.ip + w := httptest.NewRecorder() + HandleRuntimeOverlay(w, req) + res := w.Result() + defer res.Body.Close() + + data, readErr := io.ReadAll(res.Body) + assert.NoError(t, readErr) + if tt.body != "" { + assert.Equal(t, tt.body, string(data)) + } + assert.Equal(t, tt.status, res.StatusCode) + }) + } +} diff --git a/internal/pkg/warewulfd/parser.go b/internal/pkg/warewulfd/parser.go index 8347a659..47efe6f4 100644 --- a/internal/pkg/warewulfd/parser.go +++ b/internal/pkg/warewulfd/parser.go @@ -47,6 +47,7 @@ func initHandleRequest(w http.ResponseWriter, req *http.Request) (*requestContex status_stages := map[string]string{ "efiboot": "EFI", + "grub": "GRUB", "ipxe": "IPXE", "kernel": "KERNEL", "system": "SYSTEM_OVERLAY", @@ -102,14 +103,23 @@ func parseRequest(req *http.Request) (parsedRequest, error) { // handle when stage was passed in the url path /[stage]/hwaddr stage := path_parts[1] hwaddr := "" - if stage != "efiboot" { + switch stage { + case "efiboot": + // /efiboot/{file}: no hwaddr in path; identified via ARP + if len(path_parts) > 3 { + ret.efifile = strings.Join(path_parts[2:], "/") + } else { + ret.efifile = path_parts[2] + } + case "grub": + // /grub/{hwaddr}: hwaddr explicit in path + hwaddr = path_parts[2] + hwaddr = strings.ReplaceAll(hwaddr, "-", ":") + hwaddr = strings.ToLower(hwaddr) + default: hwaddr = path_parts[2] hwaddr = strings.ReplaceAll(hwaddr, "-", ":") hwaddr = strings.ToLower(hwaddr) - } else if len(path_parts) > 3 { - ret.efifile = strings.Join(path_parts[2:], "/") - } else { - ret.efifile = path_parts[2] } ret.hwaddr = hwaddr remoteAddrPort, err := netip.ParseAddrPort(req.RemoteAddr) @@ -137,12 +147,14 @@ func parseRequest(req *http.Request) (parsedRequest, error) { ret.stage = "kernel" case "image", "container": ret.stage = "image" - case "overlay-system": + case "overlay-system", "system": ret.stage = "system" - case "overlay-runtime": + case "overlay-runtime", "runtime": ret.stage = "runtime" case "efiboot": ret.stage = "efiboot" + case "grub": + ret.stage = "grub" case "initramfs": ret.stage = "initramfs" } @@ -154,14 +166,23 @@ func parseRequest(req *http.Request) (parsedRequest, error) { if len(req.URL.Query()["compress"]) > 0 { ret.compress = req.URL.Query()["compress"][0] } + if ret.efifile == "" && len(req.URL.Query()["file"]) > 0 { + ret.efifile = req.URL.Query()["file"][0] + } if ret.stage == "" { return ret, errors.New("no stage encoded in GET") } if ret.hwaddr == "" { - ret.hwaddr = ArpFind(ret.ipaddr) - wwlog.Verbose("node mac not encoded, arp cache got %s for %s", ret.hwaddr, ret.ipaddr) - if ret.hwaddr == "" { - return ret, errors.New("no hwaddr encoded in GET") + if len(req.URL.Query()["wwid"]) > 0 { + ret.hwaddr = req.URL.Query()["wwid"][0] + ret.hwaddr = strings.ReplaceAll(ret.hwaddr, "-", ":") + ret.hwaddr = strings.ToLower(ret.hwaddr) + } else { + ret.hwaddr = ArpFind(ret.ipaddr) + wwlog.Verbose("node mac not encoded, arp cache got %s for %s", ret.hwaddr, ret.ipaddr) + if ret.hwaddr == "" { + return ret, errors.New("no hwaddr encoded in GET") + } } } if ret.ipaddr == "" { diff --git a/internal/pkg/warewulfd/parser_test.go b/internal/pkg/warewulfd/parser_test.go index 03efb651..06cd82d6 100644 --- a/internal/pkg/warewulfd/parser_test.go +++ b/internal/pkg/warewulfd/parser_test.go @@ -11,6 +11,7 @@ import ( var parseReqTests = []struct { description string url string + rawQuery string remoteAddr string result parsedRequest }{ @@ -36,13 +37,143 @@ var parseReqTests = []struct { stage: "ipxe", }, }, + { + description: "initramfs dedicated route", + url: "/initramfs/00:00:00:ff:ff:ff", + remoteAddr: "10.5.1.1:9873", + result: parsedRequest{ + hwaddr: "00:00:00:ff:ff:ff", + ipaddr: "10.5.1.1", + remoteport: 9873, + stage: "initramfs", + }, + }, + { + description: "grub route with hwaddr in path", + url: "/grub/00:00:00:ff:ff:ff", + remoteAddr: "10.5.1.1:9873", + result: parsedRequest{ + hwaddr: "00:00:00:ff:ff:ff", + ipaddr: "10.5.1.1", + remoteport: 9873, + stage: "grub", + }, + }, + { + description: "wwid query param on dedicated route", + url: "/kernel/", + rawQuery: "wwid=00%3A00%3A00%3Aff%3Aff%3Aff", + remoteAddr: "10.5.1.1:9873", + result: parsedRequest{ + hwaddr: "00:00:00:ff:ff:ff", + ipaddr: "10.5.1.1", + remoteport: 9873, + stage: "kernel", + }, + }, + { + description: "wwid and stage query params on provision route", + url: "/provision/", + rawQuery: "wwid=00%3A00%3A00%3Aff%3Aff%3Aff&stage=kernel", + remoteAddr: "10.5.1.1:9873", + result: parsedRequest{ + hwaddr: "00:00:00:ff:ff:ff", + ipaddr: "10.5.1.1", + remoteport: 9873, + stage: "kernel", + }, + }, + { + description: "efiboot with wwid and file query params", + url: "/efiboot/", + rawQuery: "wwid=00%3A00%3A00%3Aff%3Aff%3Aff&file=shim.efi", + remoteAddr: "10.5.1.1:9873", + result: parsedRequest{ + hwaddr: "00:00:00:ff:ff:ff", + ipaddr: "10.5.1.1", + remoteport: 9873, + stage: "efiboot", + efifile: "shim.efi", + }, + }, + { + description: "grub with wwid query param", + url: "/grub/", + rawQuery: "wwid=00%3A00%3A00%3Aff%3Aff%3Aff", + remoteAddr: "10.5.1.1:9873", + result: parsedRequest{ + hwaddr: "00:00:00:ff:ff:ff", + ipaddr: "10.5.1.1", + remoteport: 9873, + stage: "grub", + }, + }, + { + description: "path hwaddr takes priority over wwid query param", + url: "/kernel/00:00:00:ff:ff:ff", + rawQuery: "wwid=11%3A11%3A11%3A11%3A11%3A11", + remoteAddr: "10.5.1.1:9873", + result: parsedRequest{ + hwaddr: "00:00:00:ff:ff:ff", + ipaddr: "10.5.1.1", + remoteport: 9873, + stage: "kernel", + }, + }, + { + description: "system overlay dedicated route", + url: "/system/00:00:00:ff:ff:ff", + remoteAddr: "10.5.1.1:9873", + result: parsedRequest{ + hwaddr: "00:00:00:ff:ff:ff", + ipaddr: "10.5.1.1", + remoteport: 9873, + stage: "system", + }, + }, + { + description: "runtime overlay dedicated route", + url: "/runtime/00:00:00:ff:ff:ff", + remoteAddr: "10.5.1.1:9873", + result: parsedRequest{ + hwaddr: "00:00:00:ff:ff:ff", + ipaddr: "10.5.1.1", + remoteport: 9873, + stage: "runtime", + }, + }, + { + description: "stage=grub via provision route serves config (new semantics)", + url: "/provision/00:00:00:ff:ff:ff", + rawQuery: "stage=grub", + remoteAddr: "10.5.1.1:9873", + result: parsedRequest{ + hwaddr: "00:00:00:ff:ff:ff", + ipaddr: "10.5.1.1", + remoteport: 9873, + stage: "grub", + }, + }, + { + description: "path efifile takes priority over file query param", + url: "/efiboot/shim.efi", + rawQuery: "wwid=00%3A00%3A00%3Aff%3Aff%3Aff&file=grub.cfg", + remoteAddr: "10.5.1.1:9873", + result: parsedRequest{ + hwaddr: "00:00:00:ff:ff:ff", + ipaddr: "10.5.1.1", + remoteport: 9873, + stage: "efiboot", + efifile: "shim.efi", + }, + }, } func Test_ParseRequest(t *testing.T) { for _, tt := range parseReqTests { t.Run(tt.description, func(t *testing.T) { req := &http.Request{ - URL: &url.URL{Path: tt.url}, + URL: &url.URL{Path: tt.url, RawQuery: tt.rawQuery}, RemoteAddr: tt.remoteAddr, } result, err := parseRequest(req) diff --git a/internal/pkg/warewulfd/provision.go b/internal/pkg/warewulfd/provision.go index cebdfb43..25d0b9ff 100644 --- a/internal/pkg/warewulfd/provision.go +++ b/internal/pkg/warewulfd/provision.go @@ -51,10 +51,6 @@ func HandleProvision(w http.ResponseWriter, req *http.Request) { handler = HandleSystemOverlay case "runtime": handler = HandleRuntimeOverlay - case "efiboot": - handler = HandleEfiBoot - case "shim": - handler = HandleShim case "grub": handler = HandleGrub case "initramfs": diff --git a/internal/pkg/warewulfd/provision_test.go b/internal/pkg/warewulfd/provision_test.go index 0f0d043f..03ae754e 100644 --- a/internal/pkg/warewulfd/provision_test.go +++ b/internal/pkg/warewulfd/provision_test.go @@ -21,18 +21,15 @@ var provisionSendTests = []struct { status int ip string }{ - {"system overlay", "/overlay-system/00:00:00:ff:ff:ff", "system overlay", 200, "10.10.10.10:9873"}, - {"runtime overlay", "/overlay-runtime/00:00:00:ff:ff:ff", "runtime overlay", 200, "10.10.10.10:9873"}, - {"fake overlay", "/overlay-system/00:00:00:ff:ff:ff?overlay=fake", "", 404, "10.10.10.10:9873"}, - {"specific overlay", "/overlay-system/00:00:00:ff:ff:ff?overlay=o1", "specific overlay", 200, "10.10.10.10:9873"}, - {"find shim", "/efiboot/shim.efi", "", 200, "10.10.10.10:9873"}, - {"find shim", "/efiboot/shim.efi", "", 404, "10.10.10.11:9873"}, - {"find grub", "/efiboot/grub.efi", "", 200, "10.10.10.10:9873"}, - {"find grub", "/efiboot/grub.efi", "", 404, "10.10.10.11:9873"}, + {"system overlay", "/provision/00:00:00:ff:ff:ff?stage=system", "system overlay", 200, "10.10.10.10:9873"}, + {"runtime overlay", "/provision/00:00:00:ff:ff:ff?stage=runtime", "runtime overlay", 200, "10.10.10.10:9873"}, + {"fake overlay", "/provision/00:00:00:ff:ff:ff?stage=system&overlay=fake", "", 404, "10.10.10.10:9873"}, + {"specific overlay", "/provision/00:00:00:ff:ff:ff?stage=system&overlay=o1", "specific overlay", 200, "10.10.10.10:9873"}, + {"grub config", "/provision/00:00:00:ff:ff:ff?stage=grub", "", 200, "10.10.10.10:9873"}, + {"grub config rendered", "/provision/00:00:00:00:ff:ff?stage=grub", "dracut 10.10.0.1:9873", 200, "10.10.10.11:9873"}, {"find initramfs", "/provision/00:00:00:ff:ff:ff?stage=initramfs", "", 200, "10.10.10.10:9873"}, {"ipxe test with NetDevs, KernelVersion, and Authority", "/provision/00:00:00:00:00:ff?stage=ipxe", "1.1.1 ifname=net:00:00:00:00:00:ff 10.10.0.1 fd00:10::1 10.10.0.1:9873", 200, "10.10.10.12:9873"}, {"ipxe ipv6", "/provision/00:00:00:00:00:ff?stage=ipxe", "1.1.1 ifname=net:00:00:00:00:00:ff 10.10.0.1 fd00:10::1 [fd00:10::1]:9873", 200, "[fd00:10::10:12]:9873"}, - {"find grub.cfg", "/efiboot/grub.cfg", "dracut 10.10.0.1:9873", 200, "10.10.10.11:9873"}, } func Test_ProvisionSend(t *testing.T) { diff --git a/internal/pkg/warewulfd/server/server.go b/internal/pkg/warewulfd/server/server.go index 4523b15b..7e37c835 100644 --- a/internal/pkg/warewulfd/server/server.go +++ b/internal/pkg/warewulfd/server/server.go @@ -42,13 +42,19 @@ func configureRootHandler(apiHandler http.Handler) *slashFix { wwHandler.HandleFunc("/provision/", warewulfd.HandleProvision) wwHandler.HandleFunc("/ipxe/", warewulfd.HandleIpxe) wwHandler.HandleFunc("/efiboot/", warewulfd.HandleEfiBoot) + wwHandler.HandleFunc("/grub/", warewulfd.HandleGrub) wwHandler.HandleFunc("/kernel/", warewulfd.HandleKernel) wwHandler.HandleFunc("/image/", warewulfd.HandleImage) + wwHandler.HandleFunc("/initramfs/", warewulfd.HandleInitramfs) + wwHandler.HandleFunc("/system/", warewulfd.HandleSystemOverlay) + wwHandler.HandleFunc("/runtime/", warewulfd.HandleRuntimeOverlay) + wwHandler.HandleFunc("/status", warewulfd.HandleStatus) + + /* Deprecated */ wwHandler.HandleFunc("/container/", warewulfd.HandleImage) wwHandler.HandleFunc("/overlay-system/", warewulfd.HandleSystemOverlay) wwHandler.HandleFunc("/overlay-runtime/", warewulfd.HandleRuntimeOverlay) wwHandler.HandleFunc("/overlay-file/", warewulfd.HandleOverlayFile) - wwHandler.HandleFunc("/status", warewulfd.HandleStatus) if apiHandler != nil { wwHandler.Handle("/api/", apiHandler) diff --git a/internal/pkg/warewulfd/shim.go b/internal/pkg/warewulfd/shim.go deleted file mode 100644 index 9d3c6270..00000000 --- a/internal/pkg/warewulfd/shim.go +++ /dev/null @@ -1,34 +0,0 @@ -package warewulfd - -import ( - "net/http" - - "github.com/warewulf/warewulf/internal/pkg/image" - "github.com/warewulf/warewulf/internal/pkg/wwlog" -) - -// HandleShim handles direct shim binary requests -func HandleShim(w http.ResponseWriter, req *http.Request) { - ctx, err := initHandleRequest(w, req) - if err != nil { - return // response already written - } - - var stageFile string - - if !ctx.remoteNode.Valid() { - wwlog.Error("%s (unknown/unconfigured node)", ctx.rinfo.hwaddr) - } else { - if ctx.remoteNode.ImageName != "" { - stageFile = image.ShimFind(ctx.remoteNode.ImageName) - - if stageFile == "" { - wwlog.Error("No kernel found for image %s", ctx.remoteNode.ImageName) - } - } else { - wwlog.Warn("No image set for this %s", ctx.remoteNode.Id()) - } - } - - sendResponse(w, req, stageFile, nil, ctx) -} diff --git a/userdocs/index.rst b/userdocs/index.rst index 91021544..6558330c 100644 --- a/userdocs/index.rst +++ b/userdocs/index.rst @@ -23,6 +23,7 @@ Welcome to the Warewulf User Guide! Server Installation Controlling Warewulf (wwctl) Server Configuration + Server Routes Using dnsmasq Security Bootloaders diff --git a/userdocs/server/routes.rst b/userdocs/server/routes.rst new file mode 100644 index 00000000..7cc299b8 --- /dev/null +++ b/userdocs/server/routes.rst @@ -0,0 +1,322 @@ +.. _server-routes: + +============= +Server Routes +============= + +The Warewulf provisioning daemon, ``warewulfd``, serves all boot and provisioning +resources over HTTP. Each resource type has a dedicated route, and nodes are +identified by their Warewulf ID (``wwid``). + +``{wwid}`` is typically the node's default MAC address: a colon-separated +hexadecimal string, e.g., ``aa:bb:cc:dd:ee:ff``. Dashes are accepted in place +of colons and are normalized automatically. + +.. note:: + + The port ``warewulfd`` listens on is configured with ``warewulf:port`` in + ``warewulf.conf`` (default: ``9873``). When TLS is enabled, a second listener + is started on the port configured with ``warewulf:tls port`` (default: ``9874``). + +URL Patterns +============ + +Every provisioning route (except ``/overlay-file/`` and ``/status``) supports +six equivalent URL patterns for specifying the node identity: + +.. code-block:: none + + /{stage}/{wwid} # wwid in path + /{stage}?wwid={wwid} # wwid as query parameter + /{stage} # wwid resolved from ARP cache + + /provision/{wwid}?stage={stage} + /provision?wwid={wwid}&stage={stage} + /provision?stage={stage} # wwid resolved from ARP cache + +The ``/efiboot/`` route is an exception: the path segment contains the boot +file name rather than a wwid, and the node is identified via ``?wwid=`` or ARP: + +.. code-block:: none + + /efiboot/{file} # file in path, wwid from ARP + /efiboot/{file}?wwid={wwid} # file in path, explicit wwid + /efiboot?wwid={wwid}&file={file} # both as query parameters + +Common Query Parameters +======================= + +Most provisioning routes accept the following query parameters: + +* ``wwid``: Warewulf ID of the node, typically its default MAC address. Used + when the node identity is not embedded in the URL path. Takes priority over + ARP lookup; the path always takes priority over the ``wwid`` query parameter. + +* ``assetkey``: Hardware asset tag. If the node has an ``AssetKey`` configured + in ``nodes.conf``, the server requires this parameter to match before serving + any content. See :ref:`Security ` below. + +* ``uuid``: System UUID of the requesting node. Accepted for logging purposes. + +* ``compress``: Compression format for the response. The only supported value + is ``gz``. When ``compress=gz`` is specified, the server serves a pre-built + gzip-compressed version of the file. If no compressed version exists, the + server returns ``404 Not Found``. + +Provisioning Routes +=================== + +``/ipxe/{wwid}`` +---------------- + +Serves a rendered iPXE boot script for the node identified by ``{wwid}``. + +The script is rendered as a Go template from a file in +``/etc/warewulf/ipxe/``. The specific template used is determined by the +node's ``Ipxe`` field (defaulting to ``default``); for example, a node with +``Ipxe: dracut`` receives the template from +``/etc/warewulf/ipxe/dracut.ipxe``. + +If the requesting node is not known to Warewulf, the server falls back to +serving ``/etc/warewulf/ipxe/unconfigured.ipxe``. + +**Query parameters:** ``assetkey``, ``uuid`` + +``/kernel/{wwid}`` +------------------ + +Serves the raw kernel binary for the node identified by ``{wwid}``. The +kernel is taken from the node's assigned image. + +**Query parameters:** ``assetkey``, ``uuid``, ``compress`` + +``/image/{wwid}`` +----------------- + +Serves the raw node image file for the node identified by ``{wwid}``. + +**Query parameters:** ``assetkey``, ``uuid``, ``compress`` + +``/initramfs/{wwid}`` +--------------------- + +Serves the initramfs binary for the node identified by ``{wwid}``. The +initramfs is extracted from the node's assigned image based on the node's +kernel version. This route is used in two-stage boot configurations. See +:ref:`booting with dracut` for details. + +**Query parameters:** ``assetkey``, ``uuid``, ``compress`` + +``/system/{wwid}`` +------------------ + +Serves the system overlay image for the node identified by ``{wwid}``. +The system overlay is rendered at provisioning time and contains +configuration files that are static for the lifetime of the boot. + +When ``autobuild overlays`` is enabled in ``warewulf.conf``, the server +will automatically rebuild the overlay if it is out of date relative to +``nodes.conf`` or the overlay source files. + +**Query parameters:** ``assetkey``, ``uuid``, ``compress``, ``overlay`` + +* ``overlay``: A comma-separated list of overlay names. When specified, only + the named overlays are served (rather than the node's full system overlay + set). + +``/runtime/{wwid}`` +------------------- + +Serves the runtime overlay image for the node identified by ``{wwid}``. +The runtime overlay is rendered on demand and may contain node-specific +secrets. ``wwclient`` fetches the runtime overlay periodically during normal +operation. + +When ``warewulf:secure`` is enabled in ``warewulf.conf``, this route requires +that the request originate from a privileged TCP port (port number less than +1024). This prevents unprivileged users on a node from retrieving the runtime +overlay. + +When TLS is enabled in ``warewulf.conf``, this route requires that the request +arrive over HTTPS. Plain-HTTP requests are rejected with ``403 Forbidden``. The +HTTPS listener port is configured with ``warewulf:tls port``. + +**Query parameters:** ``assetkey``, ``uuid``, ``compress``, ``overlay`` + +* ``overlay``: A comma-separated list of overlay names. Same behavior as for + ``/system/``. + +``/overlay-file/{overlay}/{path}`` +---------------------------------- + +Provides direct access to an individual file within a named overlay. This +route uses a different URL structure than the other provisioning routes: the +overlay name is in the second path segment, and the file path within the overlay +follows. + +If the ``render`` parameter is provided, the file is rendered as a Go template +for the specified node and the rendered content is returned. If ``render`` is +absent, the raw file bytes are returned without any template processing. + +If the requested path does not end in ``.ww`` but a ``.ww``-suffixed version of +the file exists, and a ``render`` node is specified, the server automatically +serves the ``.ww`` template. + +**Query parameters:** + +* ``render``: Node ID to render the template for. If not specified, the raw + file is returned. + +.. note:: + + This route does not require authentication via ``assetkey`` and does not + perform node lookup by hardware address. + +``/efiboot/{file}`` +------------------- + +Serves EFI boot files for GRUB-based booting. The requesting node is +identified by ``?wwid=`` (preferred) or, if not supplied, by an ARP lookup +of the client's IP address against the kernel's ARP cache (``/proc/net/arp``). +This route is intended for EFI HTTP Boot clients, where the firmware fetches +a boot URI from DHCP and cannot perform variable substitution. + +The ``{file}`` component determines what is served: + +* ``shim.efi``: Serves the ``shim.efi`` binary extracted from the node's + assigned image. +* ``grub.efi`` (or ``grubx64.efi``, ``grubaa64.efi``, ``grubia32.efi``, + ``grubarm.efi``, ``grub-tpm.efi``): Serves the GRUB EFI binary extracted + from the node's assigned image. +* ``grub.cfg``: Serves a rendered GRUB configuration file from + ``/etc/warewulf/grub/grub.cfg.ww``. The configuration is rendered as a Go + template for the identified node. + +Because ``shim.efi`` resolves subsequent files relative to its own load URL, +GRUB and ``grub.cfg`` are also fetched from the ``/efiboot/`` path. The +``grub.cfg`` served by this route uses ``${net_default_mac}`` to embed the +node's wwid in all further provisioning URLs, directing subsequent requests +to the per-node ``/grub/{wwid}`` route. + +**Query parameters:** ``assetkey``, ``uuid``, ``wwid``, ``file`` + +* ``file``: The EFI file to serve (``shim.efi``, ``grub.efi``, or ``grub.cfg``). + Used when the file name is not embedded in the URL path. + +.. note:: + + ``/efiboot/`` is the recommended route for EFI HTTP Boot clients. For + TFTP-booted GRUB clients that know their own wwid, use + ``/grub/{wwid}`` to fetch the per-node GRUB configuration directly. + +``/grub/{wwid}`` +---------------- + +Serves a rendered GRUB configuration file for the node identified by +``{wwid}``. The configuration is rendered from +``/etc/warewulf/grub/grub.cfg.ww`` as a Go template. This route is the +preferred method for TFTP-booted GRUB clients to fetch their per-node +configuration, as the node identity is explicit in the URL rather than +resolved via ARP. + +**Query parameters:** ``assetkey``, ``uuid``, ``wwid`` + +``/provision/{wwid}`` +--------------------- + +.. deprecated:: + + This route is maintained for backwards compatibility. New configurations + should use the dedicated routes described above. + +A legacy dispatcher route. The provisioning stage is determined by the +``stage`` query parameter, which is dispatched to the appropriate handler: + +* ``stage=ipxe`` → ``/ipxe/`` +* ``stage=kernel`` → ``/kernel/`` +* ``stage=image`` → ``/image/`` +* ``stage=initramfs`` → ``/initramfs/`` +* ``stage=system`` → ``/system/`` +* ``stage=runtime`` → ``/runtime/`` +* ``stage=grub`` → ``/grub/`` + +**Query parameters:** ``stage`` (required), ``assetkey``, ``uuid``, ``compress``, +``overlay`` + +Status Route +============ + +``/status`` +----------- + +Returns a JSON object containing the last-known provisioning status for all +nodes that have contacted the server. No authentication is required. + +.. code-block:: none + + { + "nodes": { + "node01": { + "node name": "node01", + "stage": "RUNTIME_OVERLAY", + "sent": "2 kB", + "ipaddr": "10.0.1.1", + "last seen": 1712345678 + } + } + } + +The ``stage`` field reflects the most recent provisioning stage completed for +the node. Possible values include ``IPXE``, ``KERNEL``, ``IMAGE``, +``INITRAMFS``, ``SYSTEM_OVERLAY``, ``RUNTIME_OVERLAY``, and ``EFI``. + +REST API +======== + +When enabled in ``warewulf.conf``, ``warewulfd`` exposes a REST API under +``/api/``. The API provides programmatic access to nodes, profiles, images, +and overlays. Interactive documentation is available at ``/api/docs``. + +See :ref:`rest-api` for full details. + +.. _server-routes-security: + +Security +======== + +Several mechanisms are available to restrict access to provisioning routes. + +Asset key validation +-------------------- + +If a node is configured with an ``AssetKey`` in ``nodes.conf``, the Warewulf +server will only respond to provisioning requests that include a matching +``?assetkey=`` query parameter. Requests with a missing or incorrect asset key +receive ``401 Unauthorized``. The asset key is typically a hardware-level +firmware string (an "asset tag") that is accessible only with root or physical +access. + +.. code-block:: console + + # wwctl node set node01 --assetkey "SYSTEM-ASSET-TAG" + +Secure mode +----------- + +When ``warewulf:secure`` is set to ``true`` in ``warewulf.conf``, the +``/runtime/`` route requires that requests originate from a privileged +TCP source port (port number less than 1024). Because only processes running as +``root`` can bind to privileged ports, this prevents unprivileged users on a +cluster node from downloading the runtime overlay. + +TLS +--- + +When TLS is enabled in ``warewulf.conf``, the ``/runtime/`` route +rejects plain-HTTP requests with ``403 Forbidden``. Runtime overlays must be +fetched over HTTPS. Because iPXE and GRUB cannot handle HTTPS, the kernel, +image, and system overlay continue to be served over plain HTTP even when TLS +is enabled. + +See :ref:`Security ` for instructions on enabling TLS and +generating certificates.