diff --git a/CHANGELOG.md b/CHANGELOG.md index 44d937a1..99052b72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -## v4.6.6, unreleased +## v4.7.0, unreleased ### Fixed @@ -13,13 +13,33 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Allow whitespace to be trimmed for wwdoc comments. #2109 - update go-chi to 5.2.5 to fix CVE-2025-69725 - Prevented profile `comment` field from being inherited by nodes. #2078 +- Error handling for /newroot mount during single-stage boot +- Bugfix for command-line arguments during single-stage image unpacking ### Added -- New --partwipe flag for profile and node set +- New `--partwipe` flag for profile and node set - Updated arguments for `ValidString` to match `regexp.MatchString` - New `mig` overlay to configure NVIDIA MIG devices. #2102 - Documented that booting a node twice fixes broken partition tables +- Updated arguments for `ValidString` to match `regexp.MatchString` +- New `mig` overlay to configure NVIDIA MIG devices. #2102 +- TLS with the command `wwctl configure tls` for key management. + Keys can be created automtically or imported. The runtime overlay is + if TLS is enabled is not distributed over plain http. + +- Documented that booting a node twice fixes broken partition tables +- TLS support for `warewulfd` and REST API +- New `wwctl configure tls` command to generate and configure TLS keys and + certificates +- New dedicated `warewulfd` server routes (`/ipxe/`, `/kernel/`, `/image/`, + `/initramfs/`, `/system/`, `/runtime/`, `/grub/`, `/efiboot/`) + +### Changed + +- Runtime overlay download failure during dracut/wwinit boot is now non-fatal; + the node continues to boot and `wwclient` retries the download at runtime. +- `hosts` overlay added to the default system overlay list ## v4.6.5, 2026-01-12 diff --git a/dracut/modules.d/90wwinit/load-wwinit.sh b/dracut/modules.d/90wwinit/load-wwinit.sh index a06c33b8..d89867d2 100644 --- a/dracut/modules.d/90wwinit/load-wwinit.sh +++ b/dracut/modules.d/90wwinit/load-wwinit.sh @@ -4,6 +4,8 @@ get_stage() { stage="${1}" + uri="${2:-${wwinit_uri}}" + cacert="${3}" info "warewulf: loading stage: ${stage}" # Load runtime overlay from a static privledged port. # Others use default settings. @@ -11,24 +13,28 @@ get_stage() { if [ "${stage}" = "runtime" ]; then localport="--local-port 1-1023" fi + cacert_opt="" + if [ -n "${cacert}" ]; then + cacert_opt="--cacert ${cacert}" + fi ( - curl --location --silent --get ${localport} \ + curl --location --silent --get ${localport} ${cacert_opt} \ --retry 60 --retry-connrefused --retry-delay 1 \ --data-urlencode "assetkey=${wwinit_assetkey}" \ --data-urlencode "uuid=${wwinit_uuid}" \ --data-urlencode "stage=${stage}" \ --data-urlencode "compress=gz" \ - "${wwinit_uri}" \ + "${uri}" \ | gzip -d \ | cpio -ium --directory="${NEWROOT}" - ) || die "Unable to load stage: ${stage}" + ) } mkdir /tmp/wwinit ( # fetch the system overlay into /tmp/wwinit local NEWROOT=/tmp/wwinit - get_stage "system" + get_stage "system" || die "Unable to load stage: system" ) if [ -x /tmp/wwinit/warewulf/run-wwinit.d ]; then PREFIX=/tmp/wwinit /tmp/wwinit/warewulf/run-wwinit.d @@ -43,10 +49,25 @@ info "warewulf: mounting ${wwinit_root_device} at ${NEWROOT}" fi ) || die "warewulf: failed to mount ${wwinit_root_device} at ${NEWROOT}" -for stage in "image" "system" "runtime"; do - get_stage "${stage}" +for stage in "image" "system"; do + get_stage "${stage}" || die "Unable to load stage: ${stage}" done +# Fetch runtime overlay (non-fatal) +# Source config from system overlay for TLS settings +. /tmp/wwinit/warewulf/config +cert_file="/tmp/wwinit/warewulf/tls/warewulf.crt" +if [ "${WWTLS}" = "true" ] && [ -f "$cert_file" ]; then + # TLS enabled: build HTTPS URI using wwid from kernel cmdline + # (mirrors wwclient URL construction in internal/app/wwclient/root.go) + wwid=$(getarg wwid) + runtime_uri="https://${WWIPADDR}:${WWTLSPORT}/provision/${wwid}" + get_stage "runtime" "${runtime_uri}" "${cert_file}" || warn "warewulf: unable to load runtime overlay over HTTPS (ignored)" +else + # No TLS: fetch runtime over HTTP + get_stage "runtime" || warn "warewulf: unable to load runtime overlay (ignored)" +fi + # Copy /warewulf/run from initramfs to NEWROOT # This preserves state files created by wwinit.d scripts (e.g., ignition marker) if [ -d /tmp/wwinit/warewulf/run ]; then diff --git a/etc/grub/grub.cfg.ww b/etc/grub/grub.cfg.ww index 7b88eaf8..43eccaec 100644 --- a/etc/grub/grub.cfg.ww +++ b/etc/grub/grub.cfg.ww @@ -66,7 +66,9 @@ menuentry "Single-stage boot" --id single-stage { echo "Downloading images..." image="${uri}&stage=image&compress=gz" system="${uri}&stage=system&compress=gz" + {{- if not (eq .TLS true) }} runtime="${uri}&stage=runtime&compress=gz" + {{- end }} initrd $image $system $runtime if [ $? != 0 ] then @@ -113,7 +115,9 @@ menuentry "Single-stage boot (no compression)" --id single-stage-nocompress { echo "Downloading images..." image="${uri}&stage=image" system="${uri}&stage=system" + {{- if not (eq .TLS true) }} runtime="${uri}&stage=runtime" + {{- end }} initrd $image $system $runtime if [ $? != 0 ] then diff --git a/etc/ipxe/default.ipxe b/etc/ipxe/default.ipxe index 872bdd24..8339b913 100644 --- a/etc/ipxe/default.ipxe +++ b/etc/ipxe/default.ipxe @@ -46,8 +46,10 @@ echo Downloading compressed image with imgextract... imgextract --name image ${uri}&stage=image&compress=gz || goto error_use_initrd echo Downloading compressed system overlay image with imgextract... imgextract --name system ${uri}&stage=system&compress=gz || goto error_reboot +{{- if not (eq .TLS true) }} echo Downloading compressed runtime overlay image with imgextract... imgextract --name runtime ${uri}&stage=runtime&compress=gz && set runtime_initrd initrd=runtime || echo Unable to download runtime overlay. (ignored) +{{- end }} goto boot_single_stage :error_use_initrd @@ -63,8 +65,10 @@ echo Downloading compressed image with initrd... initrd --name image ${uri}&stage=image&compress=gz || goto error_reboot echo Downloading compressed system overlay with initrd... initrd --name system ${uri}&stage=system&compress=gz || goto error_reboot +{{- if not (eq .TLS true) }} echo Downloading compressed runtime overlay with initrd... initrd --name runtime ${uri}&stage=runtime&compress=gz && set runtime_initrd initrd=runtime || echo Unable to download runtime overlay. (ignored) +{{- end }} goto boot_single_stage :initrd_nocompress @@ -76,8 +80,10 @@ echo Downloading uncompressed image with initrd... initrd --name image ${uri}&stage=image || goto error_reboot echo Downloading uncompressed system overlay with initrd... initrd --name system ${uri}&stage=system || goto error_reboot +{{- if not (eq .TLS true) }} echo Downloading uncompressed runtime overlay with initrd... initrd --name runtime ${uri}&stage=runtime && set runtime_initrd initrd=runtime || echo Unable to download runtime overlay. (ignored) +{{- end }} goto boot_single_stage :dracut diff --git a/etc/nodes.conf b/etc/nodes.conf index b7017f85..d5147c2f 100644 --- a/etc/nodes.conf +++ b/etc/nodes.conf @@ -7,6 +7,7 @@ nodeprofiles: system overlay: - wwinit - wwclient + - hosts - fstab - hostname - ssh.host_keys diff --git a/internal/app/wwclient/root.go b/internal/app/wwclient/root.go index 29c9a97d..97bbb18c 100644 --- a/internal/app/wwclient/root.go +++ b/internal/app/wwclient/root.go @@ -1,6 +1,10 @@ package wwclient import ( + "crypto/tls" + "crypto/x509" + "encoding/pem" + "errors" "fmt" "io" "net" @@ -112,9 +116,31 @@ func CobraRunE(cmd *cobra.Command, args []string) (err error) { wwlog.Info("running from trusted port: %d", localTCPAddr.Port) } + tlsConfig := &tls.Config{MinVersion: tls.VersionTLS13} + if conf.Warewulf.TLSEnabled() { + caCert, err := os.ReadFile("/warewulf/tls/warewulf.crt") + if err != nil { + wwlog.Error("failed to read ca cert: %s", err) + return err + } + block, _ := pem.Decode(caCert) + if block == nil { + wwlog.Warn("failed to parse certificate PEM") + } else if cert, err := x509.ParseCertificate(block.Bytes); err == nil { + wwlog.Info("using cert: %s", cert.SerialNumber) + } else { + wwlog.Warn("parsing cert failed: %s", err) + } + + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCert) + tlsConfig.RootCAs = caCertPool + } + Webclient = &http.Client{ Transport: &http.Transport{ - Proxy: http.ProxyFromEnvironment, + TLSClientConfig: tlsConfig, + Proxy: http.ProxyFromEnvironment, DialContext: (&net.Dialer{ LocalAddr: &localTCPAddr, Timeout: 30 * time.Second, @@ -216,7 +242,7 @@ func CobraRunE(cmd *cobra.Command, args []string) (err error) { stopTimer.Reset(0) case syscall.SIGTERM, syscall.SIGINT: wwlog.Info("terminating wwclient, %v", sig) - // Signal main loop to exit instead of calling os.Exit(0) + // Signal main loop to exit gracefully exitChan <- true return } @@ -232,8 +258,17 @@ func CobraRunE(cmd *cobra.Command, args []string) (err error) { } } + port := conf.Warewulf.Port + scheme := "http" + if conf.Warewulf.TLSEnabled() { + port = conf.Warewulf.TlsPort + scheme = "https" + } + for { - updateSystem(target, ipaddr, conf.Warewulf.Port, wwid, tag, localUUID) + if err := updateSystem(target, ipaddr, port, wwid, tag, localUUID, scheme); err != nil { + return err + } if !finishedInitialSync { // Notify systemd that the service has started successfully. // @@ -274,7 +309,7 @@ func parseWWIDFromCmdline(cmdline string) (string, error) { return "", fmt.Errorf("wwid parameter not found in kernel command line") } -func updateSystem(target string, ipaddr string, port int, wwid string, tag string, localUUID uuid.UUID) { +func updateSystem(target string, ipaddr string, port int, wwid string, tag string, localUUID uuid.UUID, scheme string) error { var resp *http.Response counter := 0 for { @@ -285,7 +320,7 @@ func updateSystem(target string, ipaddr string, port int, wwid string, tag strin values.Set("stage", "runtime") values.Set("compress", "gz") getURL := &url.URL{ - Scheme: "http", + Scheme: scheme, Host: fmt.Sprintf("%s:%d", ipaddr, port), Path: fmt.Sprintf("provision/%s", wwid), RawQuery: values.Encode(), @@ -293,9 +328,16 @@ func updateSystem(target string, ipaddr string, port int, wwid string, tag strin wwlog.Debug("making request: %s", getURL) resp, err = Webclient.Get(getURL.String()) if err == nil { - defer resp.Body.Close() break } else { + var certificateInvalidError x509.CertificateInvalidError + var unknownAuthorityError x509.UnknownAuthorityError + var hostnameError x509.HostnameError + if errors.As(err, &certificateInvalidError) || + errors.As(err, &unknownAuthorityError) || + errors.As(err, &hostnameError) { + return fmt.Errorf("TLS connection failed: %w", err) + } if counter > 60 { counter = 0 } @@ -306,10 +348,11 @@ func updateSystem(target string, ipaddr string, port int, wwid string, tag strin } time.Sleep(1000 * time.Millisecond) } + defer resp.Body.Close() if resp.StatusCode != 200 { wwlog.Warn("not applying runtime overlay: got status code: %d", resp.StatusCode) time.Sleep(60000 * time.Millisecond) - return + return nil } wwlog.Info("applying runtime overlay") @@ -318,7 +361,7 @@ func updateSystem(target string, ipaddr string, port int, wwid string, tag strin tempDir, err := os.MkdirTemp("", "wwclient-") if err != nil { wwlog.Error("failed to create temp directory: %s", err) - return + return nil } defer os.RemoveAll(tempDir) wwlog.Debug("unpacking runtime overlay to %s", tempDir) @@ -327,7 +370,7 @@ func updateSystem(target string, ipaddr string, port int, wwid string, tag strin err = command.Run() if err != nil { wwlog.Error("failed running cpio: %s", err) - return + return nil } // Atomically move files from temp directory to current working directory @@ -335,6 +378,7 @@ func updateSystem(target string, ipaddr string, port int, wwid string, tag strin if err != nil { wwlog.Error("failed to apply overlay: %s", err) } + return nil } func atomicApplyOverlay(srcDir, destDir string) error { diff --git a/internal/app/wwctl/configure/main.go b/internal/app/wwctl/configure/main.go index 366a7930..5f63dc9a 100644 --- a/internal/app/wwctl/configure/main.go +++ b/internal/app/wwctl/configure/main.go @@ -20,6 +20,10 @@ func CobraRunE(cmd *cobra.Command, args []string) error { } if allFunctions { + if _, err = configure.TLS(false); err != nil { + return err + } + err = configure.WAREWULFD() if err != nil { return err diff --git a/internal/app/wwctl/configure/root.go b/internal/app/wwctl/configure/root.go index cdacd64d..6b4fc5c6 100644 --- a/internal/app/wwctl/configure/root.go +++ b/internal/app/wwctl/configure/root.go @@ -7,6 +7,7 @@ import ( "github.com/warewulf/warewulf/internal/app/wwctl/configure/nfs" "github.com/warewulf/warewulf/internal/app/wwctl/configure/ssh" "github.com/warewulf/warewulf/internal/app/wwctl/configure/tftp" + "github.com/warewulf/warewulf/internal/app/wwctl/configure/tls" "github.com/warewulf/warewulf/internal/app/wwctl/configure/warewulfd" ) @@ -29,6 +30,7 @@ func init() { baseCmd.AddCommand(ssh.GetCommand()) baseCmd.AddCommand(nfs.GetCommand()) baseCmd.AddCommand(hostfile.GetCommand()) + baseCmd.AddCommand(tls.GetCommand()) baseCmd.AddCommand(warewulfd.GetCommand()) baseCmd.Flags().BoolVarP(&allFunctions, "all", "a", false, "Configure all services") diff --git a/internal/app/wwctl/configure/tls/main.go b/internal/app/wwctl/configure/tls/main.go new file mode 100644 index 00000000..d422515b --- /dev/null +++ b/internal/app/wwctl/configure/tls/main.go @@ -0,0 +1,129 @@ +package tls + +import ( + "crypto/x509" + "encoding/pem" + "fmt" + "os" + "path" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + "github.com/warewulf/warewulf/internal/pkg/config" + "github.com/warewulf/warewulf/internal/pkg/configure" + "github.com/warewulf/warewulf/internal/pkg/util" +) + +func CobraRunE(cmd *cobra.Command, args []string) error { + conf := config.Get() + keystore := path.Join(conf.Paths.Sysconfdir, "warewulf", "tls") + + keyFile := path.Join(keystore, "warewulf.key") + certFile := path.Join(keystore, "warewulf.crt") + + if importPath != "" { + info, err := os.Stat(importPath) + if err != nil { + return fmt.Errorf("could not access import path: %w", err) + } + + var sourceKey, sourceCert string + if info.IsDir() { + sourceKey = path.Join(importPath, "warewulf.key") + sourceCert = path.Join(importPath, "warewulf.crt") + } else { + return fmt.Errorf("import path must be a directory containing warewulf.key and warewulf.crt") + } + + if err := util.CopyFile(sourceKey, keyFile); err != nil { + return fmt.Errorf("failed to import key: %w", err) + } + if err := os.Chmod(keyFile, 0600); err != nil { + return fmt.Errorf("failed to set key file permissions: %w", err) + } + if err := util.CopyFile(sourceCert, certFile); err != nil { + return fmt.Errorf("failed to import cert: %w", err) + } + if err := os.Chmod(certFile, 0644); err != nil { + return fmt.Errorf("failed to set cert file permissions: %w", err) + } + fmt.Fprintf(cmd.OutOrStdout(), "Imported keys from %s\n", importPath) + + if err := configure.WAREWULFD(); err != nil { + return fmt.Errorf("failed to restart warewulfd: %w", err) + } + + return nil + } + + if exportPath != "" { + if err := os.MkdirAll(exportPath, 0755); err != nil { + return fmt.Errorf("could not create export directory: %w", err) + } + if err := util.CopyFile(keyFile, path.Join(exportPath, "warewulf.key")); err != nil { + return fmt.Errorf("failed to export key: %w", err) + } + if err := util.CopyFile(certFile, path.Join(exportPath, "warewulf.crt")); err != nil { + return fmt.Errorf("failed to export cert: %w", err) + } + fmt.Fprintf(cmd.OutOrStdout(), "Exported keys to %s\n", exportPath) + return nil + } + + if !conf.Warewulf.TLSEnabled() { + fmt.Fprintf(cmd.OutOrStdout(), "TLS is not enabled in warewulf.conf\n") + return nil + } + + created, err := configure.TLS(force) + if err != nil { + return err + } + if created { + if err := configure.WAREWULFD(); err != nil { + return fmt.Errorf("failed to restart warewulfd: %w", err) + } + } else { + fmt.Fprintf(cmd.OutOrStdout(), "Keys already exist in %s\n", keystore) + } + + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + + fmt.Fprintf(w, "Private Key:\t%s\n", keyFile) + fmt.Fprintf(w, "Certificate:\t%s\n", certFile) + + keyBytes, err := os.ReadFile(keyFile) + if err == nil { + block, _ := pem.Decode(keyBytes) + if block != nil { + if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { + fmt.Fprintf(w, "Key Size:\t%d bits\n", key.N.BitLen()) + } + } + } + + certBytes, err := os.ReadFile(certFile) + if err == nil { + block, _ := pem.Decode(certBytes) + if block != nil { + if cert, err := x509.ParseCertificate(block.Bytes); err == nil { + fmt.Fprintf(w, "Issuer:\t%s\n", cert.Issuer) + fmt.Fprintf(w, "Subject:\t%s\n", cert.Subject) + fmt.Fprintf(w, "Valid From:\t%s\n", cert.NotBefore) + fmt.Fprintf(w, "Valid Until:\t%s\n", cert.NotAfter) + fmt.Fprintf(w, "Serial Nr:\t%s\n", cert.SerialNumber) + + var ipStrings []string + for _, ip := range cert.IPAddresses { + ipStrings = append(ipStrings, ip.String()) + } + fmt.Fprintf(w, "IP Addresses:\t%s\n", strings.Join(ipStrings, ", ")) + fmt.Fprintf(w, "DNS Names:\t%s\n", strings.Join(cert.DNSNames, ", ")) + } + } + } + w.Flush() + + return nil +} diff --git a/internal/app/wwctl/configure/tls/main_test.go b/internal/app/wwctl/configure/tls/main_test.go new file mode 100644 index 00000000..8251b994 --- /dev/null +++ b/internal/app/wwctl/configure/tls/main_test.go @@ -0,0 +1,106 @@ +package tls + +import ( + "bytes" + "path" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/warewulf/warewulf/internal/pkg/testenv" + "github.com/warewulf/warewulf/internal/pkg/wwlog" +) + +func Test_Keys(t *testing.T) { + env := testenv.New(t) + defer env.RemoveAll() + + // Enable TLS in the test config + env.WriteFile("etc/warewulf/warewulf.conf", "warewulf:\n tls: true\n") + + // Define a keystore path within the test environment + keystoreRelPath := "etc/warewulf/tls" + keystorePath := env.GetPath(keystoreRelPath) + + // Reload configuration to pick up the change + env.Configure() + + t.Run("keys create", func(t *testing.T) { + baseCmd := GetCommand() + // Reset flags + importPath = "" + exportPath = "" + + baseCmd.SetArgs([]string{}) + buf := new(bytes.Buffer) + baseCmd.SetOut(buf) + baseCmd.SetErr(buf) + wwlog.SetLogWriter(buf) + + err := baseCmd.Execute() + assert.NoError(t, err) + assert.FileExists(t, path.Join(keystorePath, "warewulf.key")) + assert.FileExists(t, path.Join(keystorePath, "warewulf.crt")) + }) + + t.Run("keys exist check", func(t *testing.T) { + baseCmd := GetCommand() + // Reset flags + importPath = "" + exportPath = "" + + baseCmd.SetArgs([]string{}) + buf := new(bytes.Buffer) + baseCmd.SetOut(buf) + baseCmd.SetErr(buf) + wwlog.SetLogWriter(buf) + + err := baseCmd.Execute() + assert.NoError(t, err) + assert.Contains(t, buf.String(), "Keys already exist") + }) + + t.Run("keys display", func(t *testing.T) { + baseCmd := GetCommand() + // Reset flags + importPath = "" + exportPath = "" + + baseCmd.SetArgs([]string{}) + buf := new(bytes.Buffer) + baseCmd.SetOut(buf) + baseCmd.SetErr(buf) + wwlog.SetLogWriter(buf) + + err := baseCmd.Execute() + assert.NoError(t, err) + assert.Contains(t, buf.String(), "Private Key:") + assert.Contains(t, buf.String(), "Certificate:") + assert.Contains(t, buf.String(), "Key Size:") + assert.Contains(t, buf.String(), "Issuer:") + assert.Contains(t, buf.String(), "Subject:") + assert.Contains(t, buf.String(), "Valid From:") + assert.Contains(t, buf.String(), "Valid Until:") + }) + + t.Run("keys export", func(t *testing.T) { + exportRelPath := "exported_keys" + exportFullPath := env.GetPath(exportRelPath) + + baseCmd := GetCommand() + // Reset flags + importPath = "" + exportPath = exportFullPath + + baseCmd.SetArgs([]string{"--export", exportFullPath}) + buf := new(bytes.Buffer) + baseCmd.SetOut(buf) + baseCmd.SetErr(buf) + wwlog.SetLogWriter(buf) + + err := baseCmd.Execute() + assert.NoError(t, err) + assert.FileExists(t, path.Join(exportFullPath, "warewulf.key")) + assert.FileExists(t, path.Join(exportFullPath, "warewulf.crt")) + assert.Contains(t, buf.String(), "Exported keys to") + }) +} diff --git a/internal/app/wwctl/configure/tls/root.go b/internal/app/wwctl/configure/tls/root.go new file mode 100644 index 00000000..9f90db93 --- /dev/null +++ b/internal/app/wwctl/configure/tls/root.go @@ -0,0 +1,33 @@ +package tls + +import ( + "github.com/spf13/cobra" + "github.com/warewulf/warewulf/internal/app/wwctl/completions" +) + +var ( + baseCmd = &cobra.Command{ + DisableFlagsInUseLine: true, + Use: "tls [OPTIONS]", + Aliases: []string{"keys", "key", "cert", "crt"}, + Short: "Manage and initialize x509 keys", + Long: `This application allows you to manage the x509 keys and certificates for Warewulf.`, + RunE: CobraRunE, + Args: cobra.NoArgs, + ValidArgsFunction: completions.None, + } + importPath string + exportPath string + force bool +) + +func init() { + baseCmd.PersistentFlags().StringVar(&importPath, "import", "", "Import keys from directory") + baseCmd.PersistentFlags().StringVar(&exportPath, "export", "", "Export keys to directory") + baseCmd.PersistentFlags().BoolVarP(&force, "force", "f", false, "Enforce creation of keys even if they exist") +} + +// GetCommand returns the root cobra.Command for the application. +func GetCommand() *cobra.Command { + return baseCmd +} diff --git a/internal/app/wwctl/node/status/main.go b/internal/app/wwctl/node/status/main.go index 7244c22b..1e917297 100644 --- a/internal/app/wwctl/node/status/main.go +++ b/internal/app/wwctl/node/status/main.go @@ -16,6 +16,29 @@ import ( "golang.org/x/term" ) +func displayStage(stage string) string { + switch stage { + case "efiboot": + return "EFI" + case "grub": + return "GRUB" + case "ipxe": + return "IPXE" + case "kernel": + return "KERNEL" + case "image": + return "IMAGE" + case "system": + return "SYSTEM OVERLAY" + case "runtime": + return "RUNTIME OVERLAY" + case "initramfs": + return "INITRAMFS" + default: + return strings.ToUpper(stage) + } +} + func CobraRunE(cmd *cobra.Command, args []string) (err error) { controller := warewulfconf.Get() @@ -102,11 +125,11 @@ func CobraRunE(cmd *cobra.Command, args []string) (err error) { continue } if rightnow-o.Lastseen >= int64(controller.Warewulf.UpdateInterval*2) { - color.Red("%-20s %-20s %-25s %-10d\n", o.NodeName, o.Stage, o.Sent, rightnow-o.Lastseen) + color.Red("%-20s %-20s %-25s %-10d\n", o.NodeName, displayStage(o.Stage), o.Sent, rightnow-o.Lastseen) } else if rightnow-o.Lastseen >= int64(controller.Warewulf.UpdateInterval+5) { - color.Yellow("%-20s %-20s %-25s %-10d\n", o.NodeName, o.Stage, o.Sent, rightnow-o.Lastseen) + color.Yellow("%-20s %-20s %-25s %-10d\n", o.NodeName, displayStage(o.Stage), o.Sent, rightnow-o.Lastseen) } else { - fmt.Printf("%-20s %-20s %-25s %-10d\n", o.NodeName, o.Stage, o.Sent, rightnow-o.Lastseen) + fmt.Printf("%-20s %-20s %-25s %-10d\n", o.NodeName, displayStage(o.Stage), o.Sent, rightnow-o.Lastseen) } } else { color.HiBlack("%-20s %-20s %-25s %-10s\n", o.NodeName, "--", "--", "--") diff --git a/internal/pkg/config/api.go b/internal/pkg/config/api.go index 766c86db..74c058b5 100644 --- a/internal/pkg/config/api.go +++ b/internal/pkg/config/api.go @@ -29,6 +29,7 @@ func (n IPNet) IPNet() net.IPNet { type APIConf struct { EnabledP *bool `yaml:"enabled,omitempty" default:"false"` + TLSEnabledP *bool `yaml:"tls,omitempty"` AllowedNets []IPNet `yaml:"allowed subnets,omitempty" default:"[\"127.0.0.0/8\", \"::1/128\"]"` } @@ -49,3 +50,7 @@ func (conf *APIConf) Unmarshal(unmarshal func(interface{}) error) error { func (conf APIConf) Enabled() bool { return util.BoolP(conf.EnabledP) } + +func (conf APIConf) TLSEnabled() bool { + return util.BoolP(conf.TLSEnabledP) +} diff --git a/internal/pkg/config/buildconfig.go.in b/internal/pkg/config/buildconfig.go.in index a0ff26f9..4150ef2a 100644 --- a/internal/pkg/config/buildconfig.go.in +++ b/internal/pkg/config/buildconfig.go.in @@ -42,7 +42,9 @@ func (conf TFTPConf) Enabled() bool { // BaseConf. type WarewulfConf struct { Port int `yaml:"port,omitempty" default:"9873"` + TlsPort int `yaml:"tls port,omitempty" default:"9874"` SecureP *bool `yaml:"secure,omitempty" default:"true"` + TLSEnabledP *bool `yaml:"tls,omitempty"` UpdateInterval int `yaml:"update interval,omitempty" default:"60"` AutobuildOverlaysP *bool `yaml:"autobuild overlays,omitempty" default:"true"` EnableHostOverlayP *bool `yaml:"host overlay,omitempty" default:"true"` @@ -54,6 +56,10 @@ func (conf WarewulfConf) Secure() bool { return util.BoolP(conf.SecureP) } +func (conf WarewulfConf) TLSEnabled() bool { + return util.BoolP(conf.TLSEnabledP) +} + func (conf WarewulfConf) AutobuildOverlays() bool { return util.BoolP(conf.AutobuildOverlaysP) } diff --git a/internal/pkg/config/root_test.go b/internal/pkg/config/root_test.go index bbbdc016..0c21b945 100644 --- a/internal/pkg/config/root_test.go +++ b/internal/pkg/config/root_test.go @@ -21,6 +21,7 @@ warewulf: host overlay: true port: 9873 secure: true + tls port: 9874 update interval: 60 nfs: enabled: true @@ -67,6 +68,7 @@ warewulf: host overlay: true port: 9873 secure: true + tls port: 9874 update interval: 60 nfs: enabled: true @@ -115,6 +117,7 @@ warewulf: host overlay: true port: 9873 secure: true + tls port: 9874 update interval: 60 nfs: enabled: true @@ -160,6 +163,7 @@ warewulf: host overlay: true port: 9873 secure: true + tls port: 9874 update interval: 60 nfs: enabled: true @@ -234,6 +238,7 @@ warewulf: host overlay: true port: 9873 secure: false + tls port: 9874 update interval: 60 nfs: enabled: true diff --git a/internal/pkg/configure/tftp.go b/internal/pkg/configure/tftp.go index 44e88367..0f19b326 100644 --- a/internal/pkg/configure/tftp.go +++ b/internal/pkg/configure/tftp.go @@ -1,12 +1,13 @@ package configure import ( + "fmt" "os" "path" warewulfconf "github.com/warewulf/warewulf/internal/pkg/config" + "github.com/warewulf/warewulf/internal/pkg/image" "github.com/warewulf/warewulf/internal/pkg/util" - "github.com/warewulf/warewulf/internal/pkg/warewulfd" "github.com/warewulf/warewulf/internal/pkg/wwlog" "golang.org/x/sys/unix" ) @@ -35,7 +36,7 @@ func TFTP() (err error) { } if controller.Warewulf.GrubBoot() { - err := warewulfd.CopyShimGrub() + err := CopyShimGrub() if err != nil { wwlog.Warn("error when copying shim/grub binaries: %s", err) } @@ -70,3 +71,30 @@ func TFTP() (err error) { return nil } + +func CopyShimGrub() (err error) { + conf := warewulfconf.Get() + wwlog.Debug("copy shim and grub binaries from host") + shimPath := image.ShimFind("") + if shimPath == "" { + return fmt.Errorf("no shim found on the host os") + } + err = util.CopyFile(shimPath, path.Join(conf.TFTP.TftpRoot, "warewulf", "shim.efi")) + if err != nil { + return err + } + _ = os.Chmod(path.Join(conf.TFTP.TftpRoot, "warewulf", "shim.efi"), 0o755) + grubPath := image.GrubFind("") + if grubPath == "" { + return fmt.Errorf("no grub found on host os") + } + err = util.CopyFile(grubPath, path.Join(conf.TFTP.TftpRoot, "warewulf", "grub.efi")) + if err != nil { + return err + } + _ = os.Chmod(path.Join(conf.TFTP.TftpRoot, "warewulf", "grub.efi"), 0o755) + err = util.CopyFile(grubPath, path.Join(conf.TFTP.TftpRoot, "warewulf", "grubx64.efi")) + _ = os.Chmod(path.Join(conf.TFTP.TftpRoot, "warewulf", "grubx64.efi"), 0o755) + + return +} diff --git a/internal/pkg/configure/tls.go b/internal/pkg/configure/tls.go new file mode 100644 index 00000000..fb55cc14 --- /dev/null +++ b/internal/pkg/configure/tls.go @@ -0,0 +1,118 @@ +package configure + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "net" + "os" + "path" + "time" + + warewulfconf "github.com/warewulf/warewulf/internal/pkg/config" + "github.com/warewulf/warewulf/internal/pkg/util" + "github.com/warewulf/warewulf/internal/pkg/wwlog" +) + +// TLS ensures TLS keys exist if TLS is enabled. +// If force is true, regenerates even if keys already exist. +// Returns true if new keys were generated. +func TLS(force bool) (bool, error) { + conf := warewulfconf.Get() + if !conf.Warewulf.TLSEnabled() { + return false, nil + } + + keystore := path.Join(conf.Paths.Sysconfdir, "warewulf", "tls") + keyFile := path.Join(keystore, "warewulf.key") + certFile := path.Join(keystore, "warewulf.crt") + + if !force && util.IsFile(keyFile) && util.IsFile(certFile) { + wwlog.Info("TLS keys already exist in %s", keystore) + return false, nil + } + + if err := GenTLSKeys(); err != nil { + return false, err + } + wwlog.Info("TLS keys generated in %s", keystore) + return true, nil +} + +// GenTLSKeys generates new TLS keys and certificate unconditionally. +func GenTLSKeys() error { + conf := warewulfconf.Get() + keystore := path.Join(conf.Paths.Sysconfdir, "warewulf", "tls") + + if err := os.MkdirAll(keystore, 0755); err != nil { + return fmt.Errorf("could not create keystore directory: %w", err) + } + + keyFile := path.Join(keystore, "warewulf.key") + certFile := path.Join(keystore, "warewulf.crt") + wwlog.Verbose("Generating new x509 keys in %s", keystore) + priv, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + return fmt.Errorf("failed to generate rsa key: %w", err) + } + + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + return fmt.Errorf("failed to generate serial number: %w", err) + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + CommonName: "Warewulf Server", + Organization: []string{"Warewulf"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour * 24 * 365 * 10), + + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + + if ip := net.ParseIP(conf.Ipaddr); ip != nil { + template.IPAddresses = append(template.IPAddresses, ip) + } + if ip := net.ParseIP(conf.Ipaddr6); ip != nil { + template.IPAddresses = append(template.IPAddresses, ip) + } + if conf.Fqdn != "" { + template.DNSNames = append(template.DNSNames, conf.Fqdn) + } + template.DNSNames = append(template.DNSNames, "warewulf") + + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + if err != nil { + return fmt.Errorf("failed to create certificate: %w", err) + } + + keyOut, err := os.OpenFile(keyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return fmt.Errorf("failed to open key file for writing: %w", err) + } + defer keyOut.Close() + if err := pem.Encode(keyOut, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}); err != nil { + return fmt.Errorf("failed to write data to key file: %w", err) + } + + certOut, err := os.OpenFile(certFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + return fmt.Errorf("failed to open cert file for writing: %w", err) + } + defer certOut.Close() + if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil { + return fmt.Errorf("failed to write data to cert file: %w", err) + } + + return nil +} diff --git a/internal/pkg/warewulfd/copyshim.go b/internal/pkg/warewulfd/copyshim.go deleted file mode 100644 index 0ba3ea66..00000000 --- a/internal/pkg/warewulfd/copyshim.go +++ /dev/null @@ -1,45 +0,0 @@ -package warewulfd - -import ( - "fmt" - "os" - "path" - - warewulfconf "github.com/warewulf/warewulf/internal/pkg/config" - "github.com/warewulf/warewulf/internal/pkg/wwlog" - - "github.com/warewulf/warewulf/internal/pkg/image" - "github.com/warewulf/warewulf/internal/pkg/util" -) - -/* -Copies the default shim, which is the shim located on host -to the tftp directory -*/ - -func CopyShimGrub() (err error) { - conf := warewulfconf.Get() - wwlog.Debug("copy shim and grub binaries from host") - shimPath := image.ShimFind("") - if shimPath == "" { - return fmt.Errorf("no shim found on the host os") - } - err = util.CopyFile(shimPath, path.Join(conf.TFTP.TftpRoot, "warewulf", "shim.efi")) - if err != nil { - return err - } - _ = os.Chmod(path.Join(conf.TFTP.TftpRoot, "warewulf", "shim.efi"), 0o755) - grubPath := image.GrubFind("") - if grubPath == "" { - return fmt.Errorf("no grub found on host os") - } - err = util.CopyFile(grubPath, path.Join(conf.TFTP.TftpRoot, "warewulf", "grub.efi")) - if err != nil { - return err - } - _ = os.Chmod(path.Join(conf.TFTP.TftpRoot, "warewulf", "grub.efi"), 0o755) - err = util.CopyFile(grubPath, path.Join(conf.TFTP.TftpRoot, "warewulf", "grubx64.efi")) - _ = os.Chmod(path.Join(conf.TFTP.TftpRoot, "warewulf", "grubx64.efi"), 0o755) - - return -} diff --git a/internal/pkg/warewulfd/daemon.go b/internal/pkg/warewulfd/daemon.go index 5ee29d42..65bfe4d7 100644 --- a/internal/pkg/warewulfd/daemon.go +++ b/internal/pkg/warewulfd/daemon.go @@ -1,3 +1,8 @@ +// Package warewulfd implements the Warewulf provisioning daemon, handling +// HTTP requests from compute nodes for boot files, kernel images, overlays, +// and EFI bootloaders. +// +// See userdocs/server/routes.rst for more information. package warewulfd import ( diff --git a/internal/pkg/warewulfd/efi.go b/internal/pkg/warewulfd/efi.go new file mode 100644 index 00000000..b853d890 --- /dev/null +++ b/internal/pkg/warewulfd/efi.go @@ -0,0 +1,60 @@ +package warewulfd + +import ( + "fmt" + "net/http" + "path" + + "github.com/warewulf/warewulf/internal/pkg/image" + "github.com/warewulf/warewulf/internal/pkg/wwlog" +) + +// HandleEfiBoot handles EFI boot file requests (shim, grub, grub.cfg) +func HandleEfiBoot(w http.ResponseWriter, req *http.Request) { + ctx, err := initHandleRequest(w, req) + if err != nil { + return // response already written + } + + if !ctx.remoteNode.Valid() { + wwlog.Error("%s (unknown/unconfigured node)", ctx.rinfo.hwaddr) + sendResponse(w, req, "", nil, ctx) + return + } + + wwlog.Debug("requested method: %s", req.Method) + imageName := ctx.remoteNode.ImageName + var stageFile string + var tmplData *templateVars + + switch ctx.rinfo.efifile { + case "shim.efi": + stageFile = image.ShimFind(imageName) + if stageFile == "" { + wwlog.Error("couldn't find shim.efi for %s", imageName) + w.WriteHeader(http.StatusNotFound) + return + } + case "grub.efi", "grub-tpm.efi", "grubx64.efi", "grubia32.efi", "grubaa64.efi", "grubarm.efi": + stageFile = image.GrubFind(imageName) + if stageFile == "" { + wwlog.Error("could't find grub*.efi for %s", imageName) + w.WriteHeader(http.StatusNotFound) + return + } + case "grub.cfg": + stageFile = path.Join(ctx.conf.Paths.Sysconfdir, "warewulf/grub/grub.cfg.ww") + tmplData = buildTemplateVars(ctx.conf, ctx.rinfo, ctx.remoteNode) + if stageFile == "" { + wwlog.Error("could't find grub.cfg template for %s", imageName) + w.WriteHeader(http.StatusNotFound) + return + } + default: + wwlog.ErrorExc(fmt.Errorf("could't find efiboot file: %s", ctx.rinfo.efifile), "") + w.WriteHeader(http.StatusNotFound) + return + } + + sendResponse(w, req, stageFile, tmplData, ctx) +} 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 new file mode 100644 index 00000000..a810c233 --- /dev/null +++ b/internal/pkg/warewulfd/grub.go @@ -0,0 +1,26 @@ +package warewulfd + +import ( + "net/http" + "path" + + "github.com/warewulf/warewulf/internal/pkg/wwlog" +) + +// 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 + } + + if !ctx.remoteNode.Valid() { + wwlog.Error("%s (unknown/unconfigured node)", ctx.rinfo.hwaddr) + w.WriteHeader(http.StatusNotFound) + return + } + + 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/helpers.go b/internal/pkg/warewulfd/helpers.go new file mode 100644 index 00000000..f3ec78ac --- /dev/null +++ b/internal/pkg/warewulfd/helpers.go @@ -0,0 +1,155 @@ +package warewulfd + +import ( + "bytes" + "fmt" + "net/http" + "net/netip" + "path" + "path/filepath" + "strconv" + "strings" + "text/template" + + "github.com/Masterminds/sprig/v3" + warewulfconf "github.com/warewulf/warewulf/internal/pkg/config" + "github.com/warewulf/warewulf/internal/pkg/kernel" + "github.com/warewulf/warewulf/internal/pkg/node" + "github.com/warewulf/warewulf/internal/pkg/util" + "github.com/warewulf/warewulf/internal/pkg/wwlog" +) + +// buildTemplateVars constructs the templateVars struct with all necessary +// fields, including handling IPv6 authority formatting and kernel version resolution. +func buildTemplateVars(conf *warewulfconf.WarewulfYaml, rinfo parsedRequest, remoteNode node.Node) *templateVars { + kernelArgs := "" + kernelVersion := "" + if remoteNode.Kernel != nil { + kernelArgs = strings.Join(remoteNode.Kernel.Args, " ") + kernelVersion = remoteNode.Kernel.Version + } + if kernelVersion == "" { + if kernel_ := kernel.FromNode(&remoteNode); kernel_ != nil { + kernelVersion = kernel_.Version() + } + } + + authority := fmt.Sprintf("%s:%d", conf.Ipaddr, conf.Warewulf.Port) + ipaddr6 := "" + if confIpaddr6, err := netip.ParseAddr(conf.Ipaddr6); err == nil { + ipaddr6 = confIpaddr6.String() + } + if rinfoIpaddr, err := netip.ParseAddr(rinfo.ipaddr); err == nil { + if rinfoIpaddr.Is6() { + if ipaddr6 != "" { + authority = fmt.Sprintf("[%s]:%d", ipaddr6, conf.Warewulf.Port) + } else { + wwlog.Error("No valid IPv6 address configured, but request is IPv6") + } + } + } else { + wwlog.Error("Could not parse request IP address: %s", rinfo.ipaddr) + } + + return &templateVars{ + Id: remoteNode.Id(), + Cluster: remoteNode.ClusterName, + Fqdn: remoteNode.Id(), + Ipaddr: conf.Ipaddr, + Ipaddr6: ipaddr6, + Port: strconv.Itoa(conf.Warewulf.Port), + TLS: conf.Warewulf.TLSEnabled(), + Authority: authority, + Hostname: remoteNode.Id(), + Hwaddr: rinfo.hwaddr, + ImageName: remoteNode.ImageName, + Ipxe: remoteNode.Ipxe, + KernelArgs: kernelArgs, + KernelVersion: kernelVersion, + Root: remoteNode.Root, + NetDevs: remoteNode.NetDevs, + Tags: remoteNode.Tags} +} + +// sendResponse handles the common response logic for provision handlers. +// If tmplData is non-nil, it renders the stageFile as a template. Otherwise, it +// sends stageFile as a raw file (with optional .gz compression). +func sendResponse(w http.ResponseWriter, req *http.Request, stageFile string, tmplData *templateVars, ctx *requestContext) { + wwlog.Serv("stage_file '%s'", stageFile) + + if util.IsFile(stageFile) { + + if tmplData != nil { + if ctx.rinfo.compress != "" { + wwlog.Error("Unsupported %s compressed version for file: %s", + ctx.rinfo.compress, stageFile) + w.WriteHeader(http.StatusNotFound) + return + } + + // Create a template with the Sprig functions. + tmpl := template.New(filepath.Base(stageFile)).Funcs(sprig.TxtFuncMap()) + + // Parse the template. + parsedTmpl, err := tmpl.ParseFiles(stageFile) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + wwlog.ErrorExc(err, "") + return + } + + // template engine writes file to buffer in case rendering fails + var buf bytes.Buffer + + err = parsedTmpl.Execute(&buf, tmplData) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + wwlog.ErrorExc(err, "") + return + } + + w.Header().Set("Content-Type", "text") + w.Header().Set("Content-Length", strconv.Itoa(buf.Len())) + _, err = buf.WriteTo(w) + if err != nil { + wwlog.ErrorExc(err, "") + } + + wwlog.Info("send %s -> %s", stageFile, ctx.remoteNode.Id()) + + } else { + if ctx.rinfo.compress == "gz" { + stageFile += ".gz" + + if !util.IsFile(stageFile) { + wwlog.Error("unprepared for compressed version of file %s", + stageFile) + w.WriteHeader(http.StatusNotFound) + return + } + } else if ctx.rinfo.compress != "" { + wwlog.Error("unsupported %s compressed version of file %s", + ctx.rinfo.compress, stageFile) + w.WriteHeader(http.StatusNotFound) + } + + err := sendFile(w, req, stageFile, ctx.remoteNode.Id()) + if err != nil { + wwlog.ErrorExc(err, "") + return + } + } + + updateStatus(ctx.remoteNode.Id(), ctx.rinfo.stage, path.Base(stageFile), ctx.rinfo.ipaddr) + + } else if stageFile == "" { + w.WriteHeader(http.StatusBadRequest) + wwlog.Error("No resource selected") + updateStatus(ctx.remoteNode.Id(), ctx.rinfo.stage, "BAD_REQUEST", ctx.rinfo.ipaddr) + + } else { + w.WriteHeader(http.StatusNotFound) + wwlog.Error("Not found: %s", stageFile) + updateStatus(ctx.remoteNode.Id(), ctx.rinfo.stage, "NOT_FOUND", ctx.rinfo.ipaddr) + } +} diff --git a/internal/pkg/warewulfd/image.go b/internal/pkg/warewulfd/image.go new file mode 100644 index 00000000..53b5762d --- /dev/null +++ b/internal/pkg/warewulfd/image.go @@ -0,0 +1,30 @@ +package warewulfd + +import ( + "net/http" + + "github.com/warewulf/warewulf/internal/pkg/image" + "github.com/warewulf/warewulf/internal/pkg/wwlog" +) + +// HandleImage handles container/image requests +func HandleImage(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.ImageFile(ctx.remoteNode.ImageName) + } else { + wwlog.Warn("No image set for node %s", ctx.remoteNode.Id()) + } + } + + sendResponse(w, req, stageFile, nil, ctx) +} diff --git a/internal/pkg/warewulfd/initramfs.go b/internal/pkg/warewulfd/initramfs.go new file mode 100644 index 00000000..c2ce4186 --- /dev/null +++ b/internal/pkg/warewulfd/initramfs.go @@ -0,0 +1,39 @@ +package warewulfd + +import ( + "net/http" + + "github.com/warewulf/warewulf/internal/pkg/image" + "github.com/warewulf/warewulf/internal/pkg/kernel" + "github.com/warewulf/warewulf/internal/pkg/wwlog" +) + +// HandleInitramfs handles initramfs requests +func HandleInitramfs(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 kernel_ := kernel.FromNode(&ctx.remoteNode); kernel_ != nil { + if kver := kernel_.Version(); kver != "" { + if initramfs := image.FindInitramfs(ctx.remoteNode.ImageName, kver); initramfs != nil { + stageFile = initramfs.FullPath() + } else { + wwlog.Error("No initramfs found for kernel %s in image %s", kver, ctx.remoteNode.ImageName) + } + } else { + wwlog.Error("No initramfs found: unable to determine kernel version for node %s", ctx.remoteNode.Id()) + } + } else { + wwlog.Error("No initramfs found: unable to find kernel for node %s", ctx.remoteNode.Id()) + } + } + + sendResponse(w, req, stageFile, nil, ctx) +} 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.go b/internal/pkg/warewulfd/ipxe.go new file mode 100644 index 00000000..020b4072 --- /dev/null +++ b/internal/pkg/warewulfd/ipxe.go @@ -0,0 +1,35 @@ +package warewulfd + +import ( + "net/http" + "path" + + "github.com/warewulf/warewulf/internal/pkg/wwlog" +) + +// HandleIpxe handles iPXE boot script requests +func HandleIpxe(w http.ResponseWriter, req *http.Request) { + ctx, err := initHandleRequest(w, req) + if err != nil { + return // response already written + } + + var stageFile string + var tmplData *templateVars + + if !ctx.remoteNode.Valid() { + wwlog.Error("%s (unknown/unconfigured node)", ctx.rinfo.hwaddr) + stageFile = path.Join(ctx.conf.Paths.Sysconfdir, "/warewulf/ipxe/unconfigured.ipxe") + tmplData = &templateVars{ + Hwaddr: ctx.rinfo.hwaddr} + } else { + template := ctx.remoteNode.Ipxe + if template == "" { + template = "default" + } + stageFile = path.Join(ctx.conf.Paths.Sysconfdir, "warewulf/ipxe", template+".ipxe") + tmplData = buildTemplateVars(ctx.conf, ctx.rinfo, ctx.remoteNode) + } + + sendResponse(w, req, stageFile, tmplData, ctx) +} 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/kernel.go b/internal/pkg/warewulfd/kernel.go new file mode 100644 index 00000000..c69c8631 --- /dev/null +++ b/internal/pkg/warewulfd/kernel.go @@ -0,0 +1,34 @@ +package warewulfd + +import ( + "net/http" + + "github.com/warewulf/warewulf/internal/pkg/kernel" + "github.com/warewulf/warewulf/internal/pkg/wwlog" +) + +// HandleKernel handles kernel binary requests +func HandleKernel(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 { + kernel_ := kernel.FromNode(&ctx.remoteNode) + if kernel_ == nil { + wwlog.Error("No kernel found for node %s", ctx.remoteNode.Id()) + } else { + stageFile = kernel_.FullPath() + if stageFile == "" { + wwlog.Error("No kernel path found for node %s", ctx.remoteNode.Id()) + } + } + } + + sendResponse(w, req, stageFile, nil, ctx) +} diff --git a/internal/pkg/warewulfd/overlay.go b/internal/pkg/warewulfd/overlay.go index 4db3f1dc..a21c8d64 100644 --- a/internal/pkg/warewulfd/overlay.go +++ b/internal/pkg/warewulfd/overlay.go @@ -1,6 +1,7 @@ package warewulfd import ( + "errors" "fmt" "net" "net/http" @@ -16,7 +17,127 @@ import ( "github.com/warewulf/warewulf/internal/pkg/wwlog" ) -func OverlaySend(w http.ResponseWriter, req *http.Request) { +// HandleOverlayList handles requests for an explicit comma-separated list of +// named overlays via the ?overlay= query parameter. +func HandleOverlayList(w http.ResponseWriter, req *http.Request) { + ctx, err := initHandleRequest(w, req) + if err != nil { + return // response already written + } + + if !ctx.remoteNode.Valid() { + wwlog.Error("%s (unknown/unconfigured node)", ctx.rinfo.hwaddr) + sendResponse(w, req, "", nil, ctx) + return + } + + request_overlays := strings.Split(ctx.rinfo.overlay, ",") + stageFile, err := getOverlayFile( + ctx.remoteNode, + "", + request_overlays, + ctx.conf.Warewulf.AutobuildOverlays()) + + if err != nil { + if errors.Is(err, overlay.ErrDoesNotExist) { + w.WriteHeader(http.StatusNotFound) + wwlog.ErrorExc(err, "") + return + } + w.WriteHeader(http.StatusInternalServerError) + wwlog.ErrorExc(err, "") + return + } + + sendResponse(w, req, stageFile, nil, ctx) +} + +// HandleSystemOverlay handles system overlay requests. +// If an explicit ?overlay= list is present, delegates to HandleOverlayList. +func HandleSystemOverlay(w http.ResponseWriter, req *http.Request) { + if len(req.URL.Query()["overlay"]) > 0 { + HandleOverlayList(w, req) + return + } + + ctx, err := initHandleRequest(w, req) + if err != nil { + return // response already written + } + + if !ctx.remoteNode.Valid() { + wwlog.Error("%s (unknown/unconfigured node)", ctx.rinfo.hwaddr) + sendResponse(w, req, "", nil, ctx) + return + } + + stageFile, err := getOverlayFile( + ctx.remoteNode, + "system", + nil, + ctx.conf.Warewulf.AutobuildOverlays()) + + if err != nil { + if errors.Is(err, overlay.ErrDoesNotExist) { + w.WriteHeader(http.StatusNotFound) + wwlog.ErrorExc(err, "") + return + } + w.WriteHeader(http.StatusInternalServerError) + wwlog.ErrorExc(err, "") + return + } + + sendResponse(w, req, stageFile, nil, ctx) +} + +// HandleRuntimeOverlay handles runtime overlay requests. +// If TLS is enabled, returns 403 Forbidden for plain-HTTP requests. +// If an explicit ?overlay= list is present, delegates to HandleOverlayList. +func HandleRuntimeOverlay(w http.ResponseWriter, req *http.Request) { + if config.Get().Warewulf.TLSEnabled() && req.TLS == nil { + wwlog.Denied("runtime overlay requested over insecure connection") + w.WriteHeader(http.StatusForbidden) + return + } + + if len(req.URL.Query()["overlay"]) > 0 { + HandleOverlayList(w, req) + return + } + + ctx, err := initHandleRequest(w, req) + if err != nil { + return // response already written + } + + if !ctx.remoteNode.Valid() { + wwlog.Error("%s (unknown/unconfigured node)", ctx.rinfo.hwaddr) + sendResponse(w, req, "", nil, ctx) + return + } + + stageFile, err := getOverlayFile( + ctx.remoteNode, + "runtime", + nil, + ctx.conf.Warewulf.AutobuildOverlays()) + + if err != nil { + if errors.Is(err, overlay.ErrDoesNotExist) { + w.WriteHeader(http.StatusNotFound) + wwlog.ErrorExc(err, "") + return + } + w.WriteHeader(http.StatusInternalServerError) + wwlog.ErrorExc(err, "") + return + } + + sendResponse(w, req, stageFile, nil, ctx) +} + +func HandleOverlayFile(w http.ResponseWriter, req *http.Request) { rinfo, err := parseReqRender(req) if err != nil { message := "error parsing request: %s" diff --git a/internal/pkg/warewulfd/overlay_test.go b/internal/pkg/warewulfd/overlay_test.go index 0163ff56..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" ) @@ -95,7 +98,93 @@ nodes: t.Run(description, func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, tt.url, nil) w := httptest.NewRecorder() - OverlaySend(w, req) + HandleOverlayFile(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) + }) + } +} + +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() diff --git a/internal/pkg/warewulfd/parser.go b/internal/pkg/warewulfd/parser.go index bd29e80e..c7ae0285 100644 --- a/internal/pkg/warewulfd/parser.go +++ b/internal/pkg/warewulfd/parser.go @@ -1,15 +1,71 @@ package warewulfd import ( + "fmt" "net/http" "net/netip" "strings" "github.com/pkg/errors" + warewulfconf "github.com/warewulf/warewulf/internal/pkg/config" + "github.com/warewulf/warewulf/internal/pkg/node" "github.com/warewulf/warewulf/internal/pkg/wwlog" ) -type parserInfo struct { +// requestContext holds the validated results of the parsed request +type requestContext struct { + conf *warewulfconf.WarewulfYaml + rinfo parsedRequest + remoteNode node.Node +} + +// initHandleRequest performs common initial request parsing, security checks, +// node lookup, and asset key validation. On error, it writes the HTTP error +// response and returns a non-nil error so the caller can simply return. +func initHandleRequest(w http.ResponseWriter, req *http.Request) (*requestContext, error) { + wwlog.Debug("Requested URL: %s", req.URL.String()) + conf := warewulfconf.Get() + rinfo, err := parseRequest(req) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + wwlog.ErrorExc(err, "Bad status") + return nil, err + } + + wwlog.Debug("stage: %s", rinfo.stage) + + wwlog.Info("request from hwaddr:%s ipaddr:%s | stage:%s", rinfo.hwaddr, req.RemoteAddr, rinfo.stage) + + if (rinfo.stage == "runtime" || len(rinfo.overlay) > 0) && conf.Warewulf.Secure() { + if rinfo.remoteport >= 1024 { + wwlog.Denied("Non-privileged port: %s", req.RemoteAddr) + w.WriteHeader(http.StatusUnauthorized) + return nil, fmt.Errorf("non-privileged port") + } + } + + remoteNode, err := GetNodeOrSetDiscoverable(rinfo.hwaddr, conf.Warewulf.AutobuildOverlays()) + if err != nil && err != node.ErrNoUnconfigured { + wwlog.ErrorExc(err, "") + w.WriteHeader(http.StatusServiceUnavailable) + return nil, err + } + + if remoteNode.AssetKey != "" && remoteNode.AssetKey != rinfo.assetkey { + w.WriteHeader(http.StatusUnauthorized) + wwlog.Denied("incorrect asset key: node %s: %s", remoteNode.Id(), rinfo.assetkey) + updateStatus(remoteNode.Id(), rinfo.stage, "BAD_ASSET", rinfo.ipaddr) + return nil, fmt.Errorf("incorrect asset key") + } + + return &requestContext{ + conf: conf, + rinfo: rinfo, + remoteNode: remoteNode, + }, nil +} + +type parsedRequest struct { hwaddr string ipaddr string remoteport int @@ -21,85 +77,99 @@ type parserInfo struct { compress string } -func parseReq(req *http.Request) (parserInfo, error) { - var ret parserInfo +func parseHwaddr(hwaddr string) string { + hwaddr = strings.ReplaceAll(hwaddr, "-", ":") + hwaddr = strings.ToLower(hwaddr) + return hwaddr +} - url := strings.Split(req.URL.Path, "?")[0] - path_parts := strings.Split(url, "/") +// parseRequest extracts provisioning parameters from an HTTP request. The +// stage and hwaddr are taken from the URL path, with fallbacks to query +// parameters and ARP cache lookup for hwaddr. Stage aliases (e.g. +// "container", "overlay-system") are normalized to their canonical names. +// +// See userdocs/server/routes.rst for more information. +func parseRequest(req *http.Request) (parsedRequest, error) { + var ret parsedRequest - if len(path_parts) < 3 { - return ret, errors.New("unknown path components in GET") + path_parts := strings.Split(req.URL.Path, "/") + + // initial stage passed in the url path /[stage]/hwaddr + if len(path_parts) < 2 { + return ret, errors.New("path missing initial stage: " + req.URL.Path) + } + ret.stage = path_parts[1] + + // prefer stage from query string for provision stage + if ret.stage == "provision" && len(req.URL.Query()["stage"]) > 0 { + ret.stage = req.URL.Query()["stage"][0] } - // handle when stage was passed in the url path /[stage]/hwaddr - stage := path_parts[1] - hwaddr := "" - if stage != "efiboot" { - 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] + // map the requested stage to a known stage + switch ret.stage { + case "provision": + ret.stage = "ipxe" + case "container": + ret.stage = "image" + case "overlay-system": + ret.stage = "system" + case "overlay-runtime": + ret.stage = "runtime" } - ret.hwaddr = hwaddr + + if ret.stage == "" { + return ret, errors.New("no stage specified: " + req.URL.RawQuery) + } + + if len(path_parts) > 2 { + if ret.stage == "efiboot" { + // /efiboot/{file}: no wwid in path; identified via ARP + ret.efifile = strings.Join(path_parts[2:], "/") + } else { + ret.hwaddr = parseHwaddr(path_parts[2]) + } + } + remoteAddrPort, err := netip.ParseAddrPort(req.RemoteAddr) if err != nil { return ret, errors.New("could not parse remote address") } ret.ipaddr = remoteAddrPort.Addr().String() + if ret.ipaddr == "" { + return ret, errors.New("could not obtain ipaddr from HTTP request") + } ret.remoteport = int(remoteAddrPort.Port()) + if ret.remoteport == 0 { + return ret, errors.New("could not obtain remote port from HTTP request: " + req.RemoteAddr) + } + + if ret.hwaddr == "" && len(req.URL.Query()["wwid"]) > 0 { + ret.hwaddr = parseHwaddr(req.URL.Query()["wwid"][0]) + } + if ret.hwaddr == "" { + if hwaddr := parseHwaddr(ArpFind(ret.ipaddr)); hwaddr != "" { + ret.hwaddr = hwaddr + wwlog.Verbose("using %s from arp cache for %s", ret.hwaddr, ret.ipaddr) + } + } + if ret.hwaddr == "" { + return ret, errors.New("unable to determine wwid: " + req.URL.RawQuery) + } + if len(req.URL.Query()["assetkey"]) > 0 { ret.assetkey = req.URL.Query()["assetkey"][0] } - if len(req.URL.Query()["uuid"]) > 0 { ret.uuid = req.URL.Query()["uuid"][0] } - - if len(req.URL.Query()["stage"]) > 0 { - ret.stage = req.URL.Query()["stage"][0] - } else { - - if stage == "ipxe" || stage == "provision" { - ret.stage = "ipxe" - } else if stage == "kernel" { - ret.stage = "kernel" - } else if stage == "image" { - ret.stage = "image" - } else if stage == "overlay-system" { - ret.stage = "system" - } else if stage == "overlay-runtime" { - ret.stage = "runtime" - } else if stage == "efiboot" { - ret.stage = "efiboot" - } else if stage == "initramfs" { - ret.stage = "initramfs" - } - } - if len(req.URL.Query()["overlay"]) > 0 { ret.overlay = req.URL.Query()["overlay"][0] } if len(req.URL.Query()["compress"]) > 0 { ret.compress = req.URL.Query()["compress"][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 ret.ipaddr == "" { - return ret, errors.New("could not obtain ipaddr from HTTP request") - } - if ret.remoteport == 0 { - return ret, errors.New("could not obtain remote port from HTTP request: " + req.RemoteAddr) + if ret.efifile == "" && len(req.URL.Query()["file"]) > 0 { + ret.efifile = req.URL.Query()["file"][0] } return ret, nil diff --git a/internal/pkg/warewulfd/parser_test.go b/internal/pkg/warewulfd/parser_test.go index da595575..06cd82d6 100644 --- a/internal/pkg/warewulfd/parser_test.go +++ b/internal/pkg/warewulfd/parser_test.go @@ -11,14 +11,15 @@ import ( var parseReqTests = []struct { description string url string + rawQuery string remoteAddr string - result parserInfo + result parsedRequest }{ { description: "basic ipv4 request", url: "/provision/00:00:00:ff:ff:ff", remoteAddr: "10.5.1.1:9873", - result: parserInfo{ + result: parsedRequest{ hwaddr: "00:00:00:ff:ff:ff", ipaddr: "10.5.1.1", remoteport: 9873, @@ -29,23 +30,153 @@ var parseReqTests = []struct { description: "basic ipv6 request", url: "/provision/00:00:00:ff:ff:ff", remoteAddr: "[fd00:5::1:1]:9873", - result: parserInfo{ + result: parsedRequest{ hwaddr: "00:00:00:ff:ff:ff", ipaddr: "fd00:5::1:1", remoteport: 9873, 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_ParseReq(t *testing.T) { +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 := parseReq(req) + result, err := parseRequest(req) assert.NoError(t, err) assert.Equal(t, tt.result, result) }) diff --git a/internal/pkg/warewulfd/provision.go b/internal/pkg/warewulfd/provision.go index afe842ab..25d0b9ff 100644 --- a/internal/pkg/warewulfd/provision.go +++ b/internal/pkg/warewulfd/provision.go @@ -1,24 +1,9 @@ package warewulfd import ( - "bytes" - "errors" - "fmt" "net/http" - "net/netip" - "path" - "path/filepath" - "strconv" - "strings" - "text/template" - "github.com/Masterminds/sprig/v3" - warewulfconf "github.com/warewulf/warewulf/internal/pkg/config" - "github.com/warewulf/warewulf/internal/pkg/image" - "github.com/warewulf/warewulf/internal/pkg/kernel" "github.com/warewulf/warewulf/internal/pkg/node" - "github.com/warewulf/warewulf/internal/pkg/overlay" - "github.com/warewulf/warewulf/internal/pkg/util" "github.com/warewulf/warewulf/internal/pkg/wwlog" ) @@ -39,343 +24,41 @@ type templateVars struct { KernelArgs string KernelVersion string Root string + TLS bool Tags map[string]string NetDevs map[string]*node.NetDev } -func ProvisionSend(w http.ResponseWriter, req *http.Request) { - wwlog.Debug("Requested URL: %s", req.URL.String()) - conf := warewulfconf.Get() - rinfo, err := parseReq(req) +func HandleProvision(w http.ResponseWriter, req *http.Request) { + // Parse just enough to determine the stage + rinfo, err := parseRequest(req) if err != nil { w.WriteHeader(http.StatusBadRequest) wwlog.ErrorExc(err, "Bad status") return } - wwlog.Debug("stage: %s", rinfo.stage) - - wwlog.Info("request from hwaddr:%s ipaddr:%s | stage:%s", rinfo.hwaddr, req.RemoteAddr, rinfo.stage) - - if (rinfo.stage == "runtime" || len(rinfo.overlay) > 0) && conf.Warewulf.Secure() { - if rinfo.remoteport >= 1024 { - wwlog.Denied("Non-privileged port: %s", req.RemoteAddr) - w.WriteHeader(http.StatusUnauthorized) - return - } - } - - status_stages := map[string]string{ - "efiboot": "EFI", - "ipxe": "IPXE", - "kernel": "KERNEL", - "system": "SYSTEM_OVERLAY", - "runtime": "RUNTIME_OVERLAY", - "initramfs": "INITRAMFS"} - - status_stage := status_stages[rinfo.stage] - var stage_file string - - // TODO: when module version is upgraded to go1.18, should be 'any' type - var tmpl_data *templateVars - - remoteNode, err := GetNodeOrSetDiscoverable(rinfo.hwaddr, conf.Warewulf.AutobuildOverlays()) - if err != nil && err != node.ErrNoUnconfigured { - wwlog.ErrorExc(err, "") - w.WriteHeader(http.StatusServiceUnavailable) - return - } - - if remoteNode.AssetKey != "" && remoteNode.AssetKey != rinfo.assetkey { - w.WriteHeader(http.StatusUnauthorized) - wwlog.Denied("incorrect asset key: node %s: %s", remoteNode.Id(), rinfo.assetkey) - updateStatus(remoteNode.Id(), status_stage, "BAD_ASSET", rinfo.ipaddr) - return - } - - if !remoteNode.Valid() { - wwlog.Error("%s (unknown/unconfigured node)", rinfo.hwaddr) - if rinfo.stage == "ipxe" { - stage_file = path.Join(conf.Paths.Sysconfdir, "/warewulf/ipxe/unconfigured.ipxe") - tmpl_data = &templateVars{ - Hwaddr: rinfo.hwaddr} - } - - } else if rinfo.stage == "ipxe" { - template := remoteNode.Ipxe - if template == "" { - template = "default" - } - stage_file = path.Join(conf.Paths.Sysconfdir, "warewulf/ipxe", template+".ipxe") - kernelArgs := "" - kernelVersion := "" - if remoteNode.Kernel != nil { - kernelArgs = strings.Join(remoteNode.Kernel.Args, " ") - kernelVersion = remoteNode.Kernel.Version - } - if kernelVersion == "" { - if kernel_ := kernel.FromNode(&remoteNode); kernel_ != nil { - kernelVersion = kernel_.Version() - } - } - authority := fmt.Sprintf("%s:%d", conf.Ipaddr, conf.Warewulf.Port) - ipaddr6 := "" - if confIpaddr6, err := netip.ParseAddr(conf.Ipaddr6); err == nil { - ipaddr6 = confIpaddr6.String() - } - if rinfoIpaddr, err := netip.ParseAddr(rinfo.ipaddr); err == nil { - if rinfoIpaddr.Is6() { - if ipaddr6 != "" { - authority = fmt.Sprintf("[%s]:%d", ipaddr6, conf.Warewulf.Port) - } else { - wwlog.Error("No valid IPv6 address configured, but request is IPv6") - } - } - } else { - wwlog.Error("Could not parse request IP address: %s", rinfo.ipaddr) - } - tmpl_data = &templateVars{ - Id: remoteNode.Id(), - Cluster: remoteNode.ClusterName, - Fqdn: remoteNode.Id(), - Ipaddr: conf.Ipaddr, - Ipaddr6: ipaddr6, - Port: strconv.Itoa(conf.Warewulf.Port), - Authority: authority, - Hostname: remoteNode.Id(), - Hwaddr: rinfo.hwaddr, - ImageName: remoteNode.ImageName, - KernelArgs: kernelArgs, - KernelVersion: kernelVersion, - Root: remoteNode.Root, - NetDevs: remoteNode.NetDevs, - Tags: remoteNode.Tags} - } else if rinfo.stage == "kernel" { - kernel_ := kernel.FromNode(&remoteNode) - if kernel_ == nil { - wwlog.Error("No kernel found for node %s", remoteNode.Id()) - } else { - stage_file = kernel_.FullPath() - if stage_file == "" { - wwlog.Error("No kernel path found for node %s", remoteNode.Id()) - } - } - - } else if rinfo.stage == "image" { - if remoteNode.ImageName != "" { - stage_file = image.ImageFile(remoteNode.ImageName) - } else { - wwlog.Warn("No image set for node %s", remoteNode.Id()) - } - - } else if rinfo.stage == "system" || rinfo.stage == "runtime" { - var context string - var request_overlays []string - - if len(rinfo.overlay) > 0 { - request_overlays = strings.Split(rinfo.overlay, ",") - } else { - context = rinfo.stage - } - stage_file, err = getOverlayFile( - remoteNode, - context, - request_overlays, - conf.Warewulf.AutobuildOverlays()) - - if err != nil { - if errors.Is(err, overlay.ErrDoesNotExist) { - w.WriteHeader(http.StatusNotFound) - wwlog.ErrorExc(err, "") - return - } - w.WriteHeader(http.StatusInternalServerError) - wwlog.ErrorExc(err, "") - return - } - } else if rinfo.stage == "efiboot" { - wwlog.Debug("requested method: %s", req.Method) - imageName := remoteNode.ImageName - switch rinfo.efifile { - case "shim.efi": - stage_file = image.ShimFind(imageName) - if stage_file == "" { - wwlog.Error("couldn't find shim.efi for %s", imageName) - w.WriteHeader(http.StatusNotFound) - return - } - case "grub.efi", "grub-tpm.efi", "grubx64.efi", "grubia32.efi", "grubaa64.efi", "grubarm.efi": - stage_file = image.GrubFind(imageName) - if stage_file == "" { - wwlog.Error("could't find grub*.efi for %s", imageName) - w.WriteHeader(http.StatusNotFound) - return - } - case "grub.cfg": - stage_file = path.Join(conf.Paths.Sysconfdir, "warewulf/grub/grub.cfg.ww") - kernelArgs := "" - kernelVersion := "" - if remoteNode.Kernel != nil { - kernelArgs = strings.Join(remoteNode.Kernel.Args, " ") - kernelVersion = remoteNode.Kernel.Version - } - if kernelVersion == "" { - if kernel_ := kernel.FromNode(&remoteNode); kernel_ != nil { - kernelVersion = kernel_.Version() - } - } - authority := fmt.Sprintf("%s:%d", conf.Ipaddr, conf.Warewulf.Port) - ipaddr6 := "" - if confIpaddr6, err := netip.ParseAddr(conf.Ipaddr6); err == nil { - ipaddr6 = confIpaddr6.String() - } - if rinfoIpaddr, err := netip.ParseAddr(rinfo.ipaddr); err == nil { - if rinfoIpaddr.Is6() { - if ipaddr6 != "" { - authority = fmt.Sprintf("[%s]:%d", ipaddr6, conf.Warewulf.Port) - } else { - wwlog.Error("No valid IPv6 address configured, but request is IPv6") - } - } - } else { - wwlog.Error("Could not parse request IP address: %s", rinfo.ipaddr) - } - tmpl_data = &templateVars{ - Id: remoteNode.Id(), - Cluster: remoteNode.ClusterName, - Fqdn: remoteNode.Id(), - Ipaddr: conf.Ipaddr, - Ipaddr6: ipaddr6, - Port: strconv.Itoa(conf.Warewulf.Port), - Authority: authority, - Hostname: remoteNode.Id(), - Hwaddr: rinfo.hwaddr, - ImageName: remoteNode.ImageName, - Ipxe: remoteNode.Ipxe, - KernelArgs: kernelArgs, - KernelVersion: kernelVersion, - Root: remoteNode.Root, - NetDevs: remoteNode.NetDevs, - Tags: remoteNode.Tags} - if stage_file == "" { - wwlog.Error("could't find grub.cfg template for %s", imageName) - w.WriteHeader(http.StatusNotFound) - return - } - default: - wwlog.ErrorExc(fmt.Errorf("could't find efiboot file: %s", rinfo.efifile), "") - } - } else if rinfo.stage == "shim" { - if remoteNode.ImageName != "" { - stage_file = image.ShimFind(remoteNode.ImageName) - - if stage_file == "" { - wwlog.Error("No kernel found for image %s", remoteNode.ImageName) - } - } else { - wwlog.Warn("No image set for this %s", remoteNode.Id()) - } - } else if rinfo.stage == "grub" { - if remoteNode.ImageName != "" { - stage_file = image.GrubFind(remoteNode.ImageName) - if stage_file == "" { - wwlog.Error("No grub found for image %s", remoteNode.ImageName) - } - } else { - wwlog.Warn("No conainer set for node %s", remoteNode.Id()) - } - } else if rinfo.stage == "initramfs" { - if kernel_ := kernel.FromNode(&remoteNode); kernel_ != nil { - if kver := kernel_.Version(); kver != "" { - if initramfs := image.FindInitramfs(remoteNode.ImageName, kver); initramfs != nil { - stage_file = initramfs.FullPath() - } else { - wwlog.Error("No initramfs found for kernel %s in image %s", kver, remoteNode.ImageName) - } - } else { - wwlog.Error("No initramfs found: unable to determine kernel version for node %s", remoteNode.Id()) - } - } else { - wwlog.Error("No initramfs found: unable to find kernel for node %s", remoteNode.Id()) - } - } - - wwlog.Serv("stage_file '%s'", stage_file) - - if util.IsFile(stage_file) { - - if tmpl_data != nil { - if rinfo.compress != "" { - wwlog.Error("Unsupported %s compressed version for file: %s", - rinfo.compress, stage_file) - w.WriteHeader(http.StatusNotFound) - return - } - - // Create a template with the Sprig functions. - tmpl := template.New(filepath.Base(stage_file)).Funcs(sprig.TxtFuncMap()) - - // Parse the template. - parsedTmpl, err := tmpl.ParseFiles(stage_file) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - wwlog.ErrorExc(err, "") - return - } - - // template engine writes file to buffer in case rendering fails - var buf bytes.Buffer - - err = parsedTmpl.Execute(&buf, tmpl_data) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - wwlog.ErrorExc(err, "") - return - } - - w.Header().Set("Content-Type", "text") - w.Header().Set("Content-Length", strconv.Itoa(buf.Len())) - _, err = buf.WriteTo(w) - if err != nil { - wwlog.ErrorExc(err, "") - } - - wwlog.Info("send %s -> %s", stage_file, remoteNode.Id()) - - } else { - if rinfo.compress == "gz" { - stage_file += ".gz" - - if !util.IsFile(stage_file) { - wwlog.Error("unprepared for compressed version of file %s", - stage_file) - w.WriteHeader(http.StatusNotFound) - return - } - } else if rinfo.compress != "" { - wwlog.Error("unsupported %s compressed version of file %s", - rinfo.compress, stage_file) - w.WriteHeader(http.StatusNotFound) - } - - err = sendFile(w, req, stage_file, remoteNode.Id()) - if err != nil { - wwlog.ErrorExc(err, "") - return - } - } - - updateStatus(remoteNode.Id(), status_stage, path.Base(stage_file), rinfo.ipaddr) - - } else if stage_file == "" { + // Dispatch to the appropriate stage handler + var handler http.HandlerFunc + switch rinfo.stage { + case "ipxe": + handler = HandleIpxe + case "kernel": + handler = HandleKernel + case "image": + handler = HandleImage + case "system": + handler = HandleSystemOverlay + case "runtime": + handler = HandleRuntimeOverlay + case "grub": + handler = HandleGrub + case "initramfs": + handler = HandleInitramfs + default: w.WriteHeader(http.StatusBadRequest) - wwlog.Error("No resource selected") - updateStatus(remoteNode.Id(), status_stage, "BAD_REQUEST", rinfo.ipaddr) - - } else { - w.WriteHeader(http.StatusNotFound) - wwlog.Error("Not found: %s", stage_file) - updateStatus(remoteNode.Id(), status_stage, "NOT_FOUND", rinfo.ipaddr) + wwlog.Error("Unknown stage: %s", rinfo.stage) + return } - + handler(w, req) } diff --git a/internal/pkg/warewulfd/provision_test.go b/internal/pkg/warewulfd/provision_test.go index 769d5ea7..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) { @@ -100,7 +97,7 @@ nodes: req := httptest.NewRequest(http.MethodGet, tt.url, nil) req.RemoteAddr = tt.ip w := httptest.NewRecorder() - ProvisionSend(w, req) + HandleProvision(w, req) res := w.Result() defer res.Body.Close() diff --git a/internal/pkg/warewulfd/server/server.go b/internal/pkg/warewulfd/server/server.go index 26bac7f8..f3661f62 100644 --- a/internal/pkg/warewulfd/server/server.go +++ b/internal/pkg/warewulfd/server/server.go @@ -1,3 +1,7 @@ +// Package server starts the Warewulf HTTP(S) server, registers provisioning +// and API routes, and handles TLS configuration and SIGHUP-triggered reloads. +// +// See userdocs/server/routes.rst for more information. package server import ( @@ -5,6 +9,7 @@ import ( "net/http" "os" "os/signal" + "path" "strconv" "strings" "syscall" @@ -36,17 +41,40 @@ func (h *slashFix) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.mux.ServeHTTP(w, r) } -func defaultHandler() *slashFix { +func requireTLS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.TLS == nil { + wwlog.Denied("API request over insecure connection") + w.WriteHeader(http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) +} + +func configureRootHandler(apiHandler http.Handler) *slashFix { var wwHandler http.ServeMux - wwHandler.HandleFunc("/provision/", warewulfd.ProvisionSend) - wwHandler.HandleFunc("/ipxe/", warewulfd.ProvisionSend) - wwHandler.HandleFunc("/efiboot/", warewulfd.ProvisionSend) - wwHandler.HandleFunc("/kernel/", warewulfd.ProvisionSend) - wwHandler.HandleFunc("/container/", warewulfd.ProvisionSend) - wwHandler.HandleFunc("/overlay-system/", warewulfd.ProvisionSend) - wwHandler.HandleFunc("/overlay-runtime/", warewulfd.ProvisionSend) - wwHandler.HandleFunc("/overlay-file/", warewulfd.OverlaySend) - wwHandler.HandleFunc("/status", warewulfd.StatusSend) + 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) + + if apiHandler != nil { + wwHandler.Handle("/api/", apiHandler) + } + return &slashFix{&wwHandler} } @@ -73,18 +101,40 @@ func RunServer() error { } } - apiHandler := api.Handler(auth, conf.API.AllowedIPNets()) - defaultHandler := defaultHandler() - dispatchHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.HasPrefix(r.URL.Path, "/api") && conf.API != nil && conf.API.Enabled() { - apiHandler.ServeHTTP(w, r) - } else { - defaultHandler.ServeHTTP(w, r) + var apiHandler http.Handler + if conf.API != nil && conf.API.Enabled() { + apiHandler = api.Handler(auth, conf.API.AllowedIPNets()) + if conf.API.TLSEnabled() { + apiHandler = requireTLS(apiHandler) } - }) - if err := http.ListenAndServe(":"+strconv.Itoa(daemonPort), dispatchHandler); err != nil { - return fmt.Errorf("could not start listening service: %w", err) } - return nil + httpHandler := configureRootHandler(apiHandler) + + errChan := make(chan error, 2) + + if conf.Warewulf.TLSEnabled() { + key := path.Join(conf.Paths.Sysconfdir, "warewulf", "tls", "warewulf.key") + crt := path.Join(conf.Paths.Sysconfdir, "warewulf", "tls", "warewulf.crt") + + if !util.IsFile(key) || !util.IsFile(crt) { + return fmt.Errorf("TLS enabled but keys not found in %s, run 'wwctl configure tls' to generate keys", path.Join(conf.Paths.Sysconfdir, "warewulf", "tls")) + } + httpsHandler := configureRootHandler(apiHandler) + go func() { + wwlog.Info("Starting HTTPS service on port %d", conf.Warewulf.TlsPort) + if err := http.ListenAndServeTLS(":"+strconv.Itoa(conf.Warewulf.TlsPort), crt, key, httpsHandler); err != nil { + errChan <- fmt.Errorf("could not start HTTPS service: %w", err) + } + }() + } + + go func() { + wwlog.Info("Starting HTTP service on port %d", daemonPort) + if err := http.ListenAndServe(":"+strconv.Itoa(daemonPort), httpHandler); err != nil { + errChan <- fmt.Errorf("could not start HTTP service: %w", err) + } + }() + + return <-errChan } diff --git a/internal/pkg/warewulfd/status.go b/internal/pkg/warewulfd/status.go index 4a66e042..00c500ca 100644 --- a/internal/pkg/warewulfd/status.go +++ b/internal/pkg/warewulfd/status.go @@ -92,7 +92,7 @@ func statusJSON() ([]byte, error) { return ret, nil } -func StatusSend(w http.ResponseWriter, req *http.Request) { +func HandleStatus(w http.ResponseWriter, req *http.Request) { status, err := statusJSON() if err != nil { diff --git a/overlays/wwinit/internal/wwinit_test.go b/overlays/wwinit/internal/wwinit_test.go index 708fe255..c1149179 100644 --- a/overlays/wwinit/internal/wwinit_test.go +++ b/overlays/wwinit/internal/wwinit_test.go @@ -15,6 +15,8 @@ func Test_wwinitOverlay(t *testing.T) { defer env.RemoveAll() env.ImportFile("etc/warewulf/warewulf.conf", "warewulf.conf") env.ImportFile("etc/warewulf/nodes.conf", "nodes.conf") + env.Configure() // Reload configuration to pick up the changes to warewulf.conf and nodes.conf + env.ImportFile("var/lib/warewulf/overlays/wwinit/rootfs/etc/warewulf/warewulf.conf.ww", "../rootfs/etc/warewulf/warewulf.conf.ww") env.ImportFile("var/lib/warewulf/overlays/wwinit/rootfs/warewulf/config.ww", "../rootfs/warewulf/config.ww") env.ImportFile("var/lib/warewulf/overlays/wwinit/rootfs/warewulf/init.d/50-ipmi.ww", "../rootfs/warewulf/init.d/50-ipmi.ww") @@ -100,6 +102,9 @@ WWIPMI_GATEWAY="192.168.4.1" WWIPMI_USER="user" WWIPMI_PASSWORD="password" WWIPMI_WRITE="true" +WWTLS=false +WWTLSPORT=9874 +WWIPADDR=192.168.0.1 ` const wwinit_50_ipmi string = `backupFile: true diff --git a/overlays/wwinit/rootfs/init b/overlays/wwinit/rootfs/init index 239822e4..345ff590 100755 --- a/overlays/wwinit/rootfs/init +++ b/overlays/wwinit/rootfs/init @@ -52,9 +52,10 @@ else else mount ${WWROOT} /newroot fi + mountpoint -q /newroot || die "warewulf: ERROR: failed to mount ${WWROOT} at /newroot" info "warewulf: copying image to /newroot..." - tar -cf - --exclude ./proc --exclude ./sys --exclude ./dev --exclude --exclude ./newroot . | tar -xf - -C /newroot + tar -cf - --exclude ./proc --exclude ./sys --exclude ./dev --exclude ./newroot . | tar -xf - -C /newroot mkdir /newroot/proc /newroot/dev /newroot/sys 2>/dev/null diff --git a/overlays/wwinit/rootfs/warewulf/config.ww b/overlays/wwinit/rootfs/warewulf/config.ww index ae9600f6..4863d9ee 100644 --- a/overlays/wwinit/rootfs/warewulf/config.ww +++ b/overlays/wwinit/rootfs/warewulf/config.ww @@ -11,3 +11,6 @@ WWIPMI_WRITE="{{$.Ipmi.Write.Bool}}" {{- if $.Ipmi.Tags.vlan }} WWIPMI_VLAN="{{$.Ipmi.Tags.vlan}}" {{- end }} +WWTLS={{$.Warewulf.TLSEnabled}} +WWTLSPORT={{$.Warewulf.TlsPort}} +WWIPADDR={{$.Ipaddr}} diff --git a/overlays/wwinit/rootfs/warewulf/tls/warewulf.crt.ww b/overlays/wwinit/rootfs/warewulf/tls/warewulf.crt.ww new file mode 100644 index 00000000..bff0aa28 --- /dev/null +++ b/overlays/wwinit/rootfs/warewulf/tls/warewulf.crt.ww @@ -0,0 +1,6 @@ +{{- $crt := Include "tls/warewulf.crt" -}} +{{- if eq $crt "" -}} +{{ abort }} +{{- else -}} +{{ $crt }} +{{- end -}} 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..9d75ec0f --- /dev/null +++ b/userdocs/server/routes.rst @@ -0,0 +1,325 @@ +.. _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``. + +When TLS is enabled, access to the REST API can additionally be restricted to +HTTPS-only requests by setting ``api: tls: true`` in ``warewulf.conf``. + +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. diff --git a/userdocs/server/security.rst b/userdocs/server/security.rst index a8fb8eb1..a0aaa068 100644 --- a/userdocs/server/security.rst +++ b/userdocs/server/security.rst @@ -40,6 +40,10 @@ There are multiple ways to secure the Warewulf provisioning process: This means that the nodes only boot the kernel which is provided by the distributor and also custom complied modules can't be loaded. +* TLS (transport layer security) can be enabled for the Warewulf server. When + enabled, HTTPS is used when transferring runtime overlays. The kernel and + system image are *always* transferred unencrypted. + SELinux ======= @@ -82,3 +86,36 @@ with the Warewulf server. nft add rule inet filter input tcp dport 9873 accept nft list ruleset >/etc/nftables.conf systemctl restart nftables + +TLS / HTTPS +=========== + +TLS can be enabled by setting ``tls: true`` in the Warewulf server +configuration. + +.. code-block:: yaml + + warewulf: + tls: true + tls port: 9874 + +This enables an HTTPS server on the TLS port (default: ``9874``). A key and +self-signed certificate can be created with ``wwctl configure tls``. + +By default, the key and certificate are stored in ``/etc/warewulf/tls/``. + +You can also import your own keys with ``wwctl configure tls --import``. + +If HTTPS is enabled the delivery of the runtime overlay is disabled over HTTP, +and the runtime overlay is only retrieved by ``wwclient``. + +To additionally require TLS for access to the REST API, set ``tls: true`` under +the ``api:`` section: + +.. code-block:: yaml + + api: + enabled: true + tls: true + +When ``api: tls`` is set, the REST API rejects plain-HTTP requests.