From a1c11db8cc175f18b2401eabdfdc42623b32a4e6 Mon Sep 17 00:00:00 2001 From: Christian Goll Date: Mon, 1 Dec 2025 16:15:16 +0100 Subject: [PATCH 01/29] enable https and key handling - add key generation and import for warewulfd - add https port to warewulfd - configuration optiions for port and keys --- internal/app/wwctl/configure/keys/main.go | 127 ++++++++++++++++++ .../app/wwctl/configure/keys/main_test.go | 108 +++++++++++++++ internal/app/wwctl/configure/keys/root.go | 35 +++++ internal/app/wwctl/configure/main.go | 16 +++ internal/app/wwctl/configure/root.go | 2 + internal/pkg/config/buildconfig.go.in | 6 + internal/pkg/config/root_test.go | 10 ++ internal/pkg/configure/keys.go | 104 ++++++++++++++ internal/pkg/warewulfd/provision.go | 2 + internal/pkg/warewulfd/server/server.go | 19 +++ 10 files changed, 429 insertions(+) create mode 100644 internal/app/wwctl/configure/keys/main.go create mode 100644 internal/app/wwctl/configure/keys/main_test.go create mode 100644 internal/app/wwctl/configure/keys/root.go create mode 100644 internal/pkg/configure/keys.go diff --git a/internal/app/wwctl/configure/keys/main.go b/internal/app/wwctl/configure/keys/main.go new file mode 100644 index 00000000..78144783 --- /dev/null +++ b/internal/app/wwctl/configure/keys/main.go @@ -0,0 +1,127 @@ +package keys + +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", "keys") + + 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") + + 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 := util.CopyFile(sourceCert, certFile); err != nil { + return fmt.Errorf("failed to import cert: %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 util.IsFile(keyFile) && util.IsFile(certFile) && !force { + if create { + fmt.Fprintf(cmd.OutOrStdout(), "Keys already exist in %s\n", keystore) + } + } else { + if create { + if err := configure.GenKeys(); err != nil { + return err + } + if err := configure.WAREWULFD(); err != nil { + return fmt.Errorf("failed to restart warewulfd: %w", err) + } + } else { + return fmt.Errorf("keys not found in: %s", 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/keys/main_test.go b/internal/app/wwctl/configure/keys/main_test.go new file mode 100644 index 00000000..ae7d6e85 --- /dev/null +++ b/internal/app/wwctl/configure/keys/main_test.go @@ -0,0 +1,108 @@ +package keys + +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() + + // Define a keystore path within the test environment + keystoreRelPath := "etc/warewulf/keys" + 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 + create = true + importPath = "" + exportPath = "" + + baseCmd.SetArgs([]string{"--create"}) + 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")) + assert.FileExists(t, path.Join(keystorePath, "warewulf.rsa.pub")) + }) + + t.Run("keys exist check", func(t *testing.T) { + baseCmd := GetCommand() + // Reset flags + create = true // Even with create, it should say they exist + importPath = "" + exportPath = "" + + baseCmd.SetArgs([]string{"--create"}) + 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 + create = false + 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 + create = false + 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/keys/root.go b/internal/app/wwctl/configure/keys/root.go new file mode 100644 index 00000000..2614e3e5 --- /dev/null +++ b/internal/app/wwctl/configure/keys/root.go @@ -0,0 +1,35 @@ +package keys + +import ( + "github.com/spf13/cobra" + "github.com/warewulf/warewulf/internal/app/wwctl/completions" +) + +var ( + baseCmd = &cobra.Command{ + DisableFlagsInUseLine: true, + Use: "keys [OPTIONS]", + Short: "Manage and initialize x509 keys", + Long: "This application allows you to manage the x509 keys for Warewulf\n" + + "based on the configuration in the warewulf.conf file.", + RunE: CobraRunE, + Args: cobra.NoArgs, + ValidArgsFunction: completions.None, + } + importPath string + exportPath string + create bool + force bool +) + +func init() { + baseCmd.PersistentFlags().StringVar(&importPath, "import", "", "Import keys from directory") + baseCmd.PersistentFlags().StringVar(&exportPath, "export", "", "Export keys to directory") + baseCmd.PersistentFlags().BoolVar(&create, "create", false, "Create keys if they do not exist") + 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/configure/main.go b/internal/app/wwctl/configure/main.go index 366a7930..2db70ec8 100644 --- a/internal/app/wwctl/configure/main.go +++ b/internal/app/wwctl/configure/main.go @@ -1,11 +1,14 @@ package configure import ( + "fmt" "os" + "path" "github.com/spf13/cobra" warewulfconf "github.com/warewulf/warewulf/internal/pkg/config" "github.com/warewulf/warewulf/internal/pkg/configure" + "github.com/warewulf/warewulf/internal/pkg/util" "github.com/warewulf/warewulf/internal/pkg/wwlog" ) @@ -20,6 +23,19 @@ func CobraRunE(cmd *cobra.Command, args []string) error { } if allFunctions { + keystore := path.Join(conf.Paths.Sysconfdir, "warewulf", "keys") + keyFile := path.Join(keystore, "warewulf.key") + certFile := path.Join(keystore, "warewulf.crt") + + if !util.IsFile(keyFile) || !util.IsFile(certFile) { + err = configure.GenKeys() + if err != nil { + return err + } + } else { + fmt.Printf("Keys already exist in %s\n", keystore) + } + 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..3649a095 100644 --- a/internal/app/wwctl/configure/root.go +++ b/internal/app/wwctl/configure/root.go @@ -4,6 +4,7 @@ import ( "github.com/spf13/cobra" "github.com/warewulf/warewulf/internal/app/wwctl/configure/dhcp" "github.com/warewulf/warewulf/internal/app/wwctl/configure/hostfile" + "github.com/warewulf/warewulf/internal/app/wwctl/configure/keys" "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" @@ -29,6 +30,7 @@ func init() { baseCmd.AddCommand(ssh.GetCommand()) baseCmd.AddCommand(nfs.GetCommand()) baseCmd.AddCommand(hostfile.GetCommand()) + baseCmd.AddCommand(keys.GetCommand()) baseCmd.AddCommand(warewulfd.GetCommand()) baseCmd.Flags().BoolVarP(&allFunctions, "all", "a", false, "Configure all services") diff --git a/internal/pkg/config/buildconfig.go.in b/internal/pkg/config/buildconfig.go.in index a0ff26f9..5ce7aad8 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"` + SecurePort int `yaml:"secure port,omitempty" default:"9874"` SecureP *bool `yaml:"secure,omitempty" default:"true"` + EnableHttpsP *bool `yaml:"enable https,omitempty" default:"false"` 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) EnableHttps() bool { + return util.BoolP(conf.EnableHttpsP) +} + 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..5f49f253 100644 --- a/internal/pkg/config/root_test.go +++ b/internal/pkg/config/root_test.go @@ -17,10 +17,12 @@ func TestParse(t *testing.T) { result: ` warewulf: autobuild overlays: true + enable https: false grubboot: false host overlay: true port: 9873 secure: true + secure port: 9874 update interval: 60 nfs: enabled: true @@ -63,10 +65,12 @@ network: 192.168.0.0 netmask: 255.255.255.0 warewulf: autobuild overlays: true + enable https: false grubboot: false host overlay: true port: 9873 secure: true + secure port: 9874 update interval: 60 nfs: enabled: true @@ -111,10 +115,12 @@ network: 192.168.0.0 netmask: 255.255.0.0 warewulf: autobuild overlays: true + enable https: false grubboot: false host overlay: true port: 9873 secure: true + secure port: 9874 update interval: 60 nfs: enabled: true @@ -156,10 +162,12 @@ ipaddr6: "2001:db8::1" prefixlen6: "64" warewulf: autobuild overlays: true + enable https: false grubboot: false host overlay: true port: 9873 secure: true + secure port: 9874 update interval: 60 nfs: enabled: true @@ -230,10 +238,12 @@ netmask: 255.255.255.0 network: 192.168.200.0 warewulf: autobuild overlays: true + enable https: false grubboot: false host overlay: true port: 9873 secure: false + secure port: 9874 update interval: 60 nfs: enabled: true diff --git a/internal/pkg/configure/keys.go b/internal/pkg/configure/keys.go new file mode 100644 index 00000000..c54b55ea --- /dev/null +++ b/internal/pkg/configure/keys.go @@ -0,0 +1,104 @@ +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/wwlog" +) + +/* +GenKeys checks for existence of x509 keys and creates them if they don't exist. +*/ +func GenKeys() error { + conf := warewulfconf.Get() + keystore := path.Join(conf.Paths.Sysconfdir, "warewulf", "keys") + + keyFile := path.Join(keystore, "warewulf.key") + certFile := path.Join(keystore, "warewulf.crt") + pubFile := path.Join(keystore, "warewulf.rsa.pub") + 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) + } + + pubBytes, err := x509.MarshalPKIXPublicKey(&priv.PublicKey) + if err != nil { + return fmt.Errorf("failed to marshal public key: %w", err) + } + pubOut, err := os.OpenFile(pubFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + return fmt.Errorf("failed to open public key file for writing: %w", err) + } + defer pubOut.Close() + if err := pem.Encode(pubOut, &pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes}); err != nil { + return fmt.Errorf("failed to write data to public key file: %w", err) + } + + return nil +} diff --git a/internal/pkg/warewulfd/provision.go b/internal/pkg/warewulfd/provision.go index afe842ab..dffc4699 100644 --- a/internal/pkg/warewulfd/provision.go +++ b/internal/pkg/warewulfd/provision.go @@ -39,6 +39,7 @@ type templateVars struct { KernelArgs string KernelVersion string Root string + Https bool Tags map[string]string NetDevs map[string]*node.NetDev } @@ -247,6 +248,7 @@ func ProvisionSend(w http.ResponseWriter, req *http.Request) { Ipaddr: conf.Ipaddr, Ipaddr6: ipaddr6, Port: strconv.Itoa(conf.Warewulf.Port), + Https: conf.Warewulf.EnableHttps(), Authority: authority, Hostname: remoteNode.Id(), Hwaddr: rinfo.hwaddr, diff --git a/internal/pkg/warewulfd/server/server.go b/internal/pkg/warewulfd/server/server.go index 26bac7f8..3587c553 100644 --- a/internal/pkg/warewulfd/server/server.go +++ b/internal/pkg/warewulfd/server/server.go @@ -5,6 +5,7 @@ import ( "net/http" "os" "os/signal" + "path" "strconv" "strings" "syscall" @@ -82,6 +83,24 @@ func RunServer() error { defaultHandler.ServeHTTP(w, r) } }) + + if conf.Warewulf.EnableHttps() { + key := path.Join(conf.Paths.Sysconfdir, "warewulf", "keys", "warewulf.key") + crt := path.Join(conf.Paths.Sysconfdir, "warewulf", "keys", "warewulf.crt") + + if !util.IsFile(key) || !util.IsFile(crt) { + wwlog.Error("HTTPS enabled but keys not found in %s", path.Join(conf.Paths.Sysconfdir, "warewulf", "keys")) + } else { + go func() { + wwlog.Info("Starting HTTPS service on port %d", conf.Warewulf.SecurePort) + if err := http.ListenAndServeTLS(":"+strconv.Itoa(conf.Warewulf.SecurePort), crt, key, dispatchHandler); err != nil { + wwlog.Error("Could not start HTTPS service: %s", err) + } + }() + } + } + + wwlog.Info("Starting HTTP service on port %d", daemonPort) if err := http.ListenAndServe(":"+strconv.Itoa(daemonPort), dispatchHandler); err != nil { return fmt.Errorf("could not start listening service: %w", err) } From 2f88b4c3d58faec79f8f0256b883ad78c744ad90 Mon Sep 17 00:00:00 2001 From: Christian Goll Date: Wed, 3 Dec 2025 12:36:41 +0100 Subject: [PATCH 02/29] add https in wwclient and wwinit --- dracut/modules.d/90wwinit/load-wwinit.sh | 2 +- etc/grub/grub.cfg.ww | 4 ++ etc/ipxe/default.ipxe | 6 +++ internal/app/wwclient/root.go | 50 ++++++++++++++++++++++-- 4 files changed, 57 insertions(+), 5 deletions(-) diff --git a/dracut/modules.d/90wwinit/load-wwinit.sh b/dracut/modules.d/90wwinit/load-wwinit.sh index a06c33b8..e15d1ef2 100644 --- a/dracut/modules.d/90wwinit/load-wwinit.sh +++ b/dracut/modules.d/90wwinit/load-wwinit.sh @@ -43,7 +43,7 @@ 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 +for stage in "image" "system" ; do get_stage "${stage}" done diff --git a/etc/grub/grub.cfg.ww b/etc/grub/grub.cfg.ww index 7b88eaf8..03d44f85 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 .Https 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 .Https 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..4718322a 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 .Https 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 .Https 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 .Https 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/internal/app/wwclient/root.go b/internal/app/wwclient/root.go index 29c9a97d..791de899 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{} + if conf.Warewulf.EnableHttps() { + caCert, err := os.ReadFile("/warewulf/keys/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, @@ -232,8 +258,15 @@ func CobraRunE(cmd *cobra.Command, args []string) (err error) { } } + port := conf.Warewulf.Port + scheme := "http" + if conf.Warewulf.EnableHttps() { + port = conf.Warewulf.SecurePort + scheme = "https" + } + for { - updateSystem(target, ipaddr, conf.Warewulf.Port, wwid, tag, localUUID) + updateSystem(target, ipaddr, port, wwid, tag, localUUID, scheme) if !finishedInitialSync { // Notify systemd that the service has started successfully. // @@ -274,7 +307,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) { var resp *http.Response counter := 0 for { @@ -285,7 +318,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(), @@ -296,6 +329,15 @@ func updateSystem(target string, ipaddr string, port int, wwid string, tag strin 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) { + wwlog.Error("TLS connection failed: %v", err) + os.Exit(1) + } if counter > 60 { counter = 0 } From 04c54f19070161487bb908bacad37401962a99a5 Mon Sep 17 00:00:00 2001 From: Christian Goll Date: Wed, 3 Dec 2025 14:33:25 +0100 Subject: [PATCH 03/29] only server runtime over https if enabled server fix --- internal/pkg/warewulfd/server/server.go | 31 ++++++++++++++----------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/internal/pkg/warewulfd/server/server.go b/internal/pkg/warewulfd/server/server.go index 3587c553..baf28bb2 100644 --- a/internal/pkg/warewulfd/server/server.go +++ b/internal/pkg/warewulfd/server/server.go @@ -37,7 +37,7 @@ func (h *slashFix) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.mux.ServeHTTP(w, r) } -func defaultHandler() *slashFix { +func configureHandler(includeRuntime bool, apiHandler http.Handler) *slashFix { var wwHandler http.ServeMux wwHandler.HandleFunc("/provision/", warewulfd.ProvisionSend) wwHandler.HandleFunc("/ipxe/", warewulfd.ProvisionSend) @@ -45,9 +45,16 @@ func defaultHandler() *slashFix { wwHandler.HandleFunc("/kernel/", warewulfd.ProvisionSend) wwHandler.HandleFunc("/container/", warewulfd.ProvisionSend) wwHandler.HandleFunc("/overlay-system/", warewulfd.ProvisionSend) - wwHandler.HandleFunc("/overlay-runtime/", warewulfd.ProvisionSend) + if includeRuntime { + wwHandler.HandleFunc("/overlay-runtime/", warewulfd.ProvisionSend) + } wwHandler.HandleFunc("/overlay-file/", warewulfd.OverlaySend) wwHandler.HandleFunc("/status", warewulfd.StatusSend) + + if apiHandler != nil { + wwHandler.Handle("/api/", apiHandler) + } + return &slashFix{&wwHandler} } @@ -74,15 +81,12 @@ 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()) + } + + httpHandler := configureHandler(!conf.Warewulf.EnableHttps(), apiHandler) if conf.Warewulf.EnableHttps() { key := path.Join(conf.Paths.Sysconfdir, "warewulf", "keys", "warewulf.key") @@ -91,9 +95,10 @@ func RunServer() error { if !util.IsFile(key) || !util.IsFile(crt) { wwlog.Error("HTTPS enabled but keys not found in %s", path.Join(conf.Paths.Sysconfdir, "warewulf", "keys")) } else { + httpsHandler := configureHandler(true, apiHandler) go func() { wwlog.Info("Starting HTTPS service on port %d", conf.Warewulf.SecurePort) - if err := http.ListenAndServeTLS(":"+strconv.Itoa(conf.Warewulf.SecurePort), crt, key, dispatchHandler); err != nil { + if err := http.ListenAndServeTLS(":"+strconv.Itoa(conf.Warewulf.SecurePort), crt, key, httpsHandler); err != nil { wwlog.Error("Could not start HTTPS service: %s", err) } }() @@ -101,7 +106,7 @@ func RunServer() error { } wwlog.Info("Starting HTTP service on port %d", daemonPort) - if err := http.ListenAndServe(":"+strconv.Itoa(daemonPort), dispatchHandler); err != nil { + if err := http.ListenAndServe(":"+strconv.Itoa(daemonPort), httpHandler); err != nil { return fmt.Errorf("could not start listening service: %w", err) } From c4b8595f203cd0bfeacd124735fe29bbb1f5e0b8 Mon Sep 17 00:00:00 2001 From: Christian Goll Date: Wed, 3 Dec 2025 15:21:15 +0100 Subject: [PATCH 04/29] added documentation foo --- CHANGELOG.md | 11 +++++++++++ userdocs/server/security.rst | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44d937a1..3db081f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - 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. + ## v4.6.5, 2026-01-12 @@ -35,6 +41,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - New `range6 start` and `range6 end` in `warewulf.conf:dhcp`. #2068 - New `Gateway6` network device field. #2068 - New `systemd-networkd` overlay. #2068 +- `wwclient.aarch64` overlay always provides an aarch64 wwclient executable. +- `wwclient.x86_64` overlay always provides an x86_64 wwclient executable. +- systemd-networkd overlay with IPv6 support +- `wwctl overlay info` lists the variables used by an overlay template +- TLS ### Changed diff --git a/userdocs/server/security.rst b/userdocs/server/security.rst index a8fb8eb1..c8141764 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. However + the https is only used for runtime overlays. The kernel and system image are *always* + transfered unencrypted as iPXE and grub can't handle https. + SELinux ======= @@ -82,3 +86,34 @@ with the Warewulf server. nft add rule inet filter input tcp dport 9873 accept nft list ruleset >/etc/nftables.conf systemctl restart nftables + + +HTTPS +===== + +The https functionality can be enabled by setting + +.. code-block:: yaml +.. +.. warewulf: +.. enable https: true +.. secure port: 9874 +.. + +Which will enable a https server on the secure port. The certificate and key +can be created as self signed key with ``wwctl configure keys --create``. The +keys and certificate are stored if not configured otherwise as + +.. code-block:: console +.. +.. /etc/warewulf/keys/warewulf.crt # PEM certificate +.. /etc/warewulf/keys/warewulf.key # PEM RSA Key + + + +For the key and certifcate generation no addiotional parameters can be set, but +you can import your own keys, with ``wwctl configure keys import``. + +If HTTPS is enabled the delivery of the runtime overlays is disabled over HTTP, so +you **must** use `wwclient` to get the runtime overlays. + From bcf53f7efdad0ab3861b40f6b1a50b9810affac5 Mon Sep 17 00:00:00 2001 From: Christian Goll Date: Thu, 4 Dec 2025 08:54:33 +0100 Subject: [PATCH 05/29] renamed to tls instead of keys --- internal/app/wwclient/root.go | 4 ++-- internal/app/wwctl/configure/main.go | 2 +- internal/app/wwctl/configure/root.go | 4 ++-- internal/app/wwctl/configure/{keys => tls}/main.go | 4 ++-- .../app/wwctl/configure/{keys => tls}/main_test.go | 2 +- internal/app/wwctl/configure/{keys => tls}/root.go | 14 +++++++------- internal/pkg/config/buildconfig.go.in | 6 +++--- internal/pkg/config/root_test.go | 10 +++++----- internal/pkg/configure/{keys.go => tls.go} | 4 ++-- internal/pkg/warewulfd/provision.go | 2 +- internal/pkg/warewulfd/server/server.go | 6 +++--- userdocs/server/security.rst | 4 ++-- 12 files changed, 31 insertions(+), 31 deletions(-) rename internal/app/wwctl/configure/{keys => tls}/main.go (98%) rename internal/app/wwctl/configure/{keys => tls}/main_test.go (99%) rename internal/app/wwctl/configure/{keys => tls}/root.go (70%) rename internal/pkg/configure/{keys.go => tls.go} (96%) diff --git a/internal/app/wwclient/root.go b/internal/app/wwclient/root.go index 791de899..5d654f79 100644 --- a/internal/app/wwclient/root.go +++ b/internal/app/wwclient/root.go @@ -117,7 +117,7 @@ func CobraRunE(cmd *cobra.Command, args []string) (err error) { } tlsConfig := &tls.Config{} - if conf.Warewulf.EnableHttps() { + if conf.Warewulf.EnableTLS() { caCert, err := os.ReadFile("/warewulf/keys/warewulf.crt") if err != nil { wwlog.Error("failed to read ca cert: %s", err) @@ -260,7 +260,7 @@ func CobraRunE(cmd *cobra.Command, args []string) (err error) { port := conf.Warewulf.Port scheme := "http" - if conf.Warewulf.EnableHttps() { + if conf.Warewulf.EnableTLS() { port = conf.Warewulf.SecurePort scheme = "https" } diff --git a/internal/app/wwctl/configure/main.go b/internal/app/wwctl/configure/main.go index 2db70ec8..5eef8905 100644 --- a/internal/app/wwctl/configure/main.go +++ b/internal/app/wwctl/configure/main.go @@ -28,7 +28,7 @@ func CobraRunE(cmd *cobra.Command, args []string) error { certFile := path.Join(keystore, "warewulf.crt") if !util.IsFile(keyFile) || !util.IsFile(certFile) { - err = configure.GenKeys() + err = configure.GenTLSKeys() if err != nil { return err } diff --git a/internal/app/wwctl/configure/root.go b/internal/app/wwctl/configure/root.go index 3649a095..6b4fc5c6 100644 --- a/internal/app/wwctl/configure/root.go +++ b/internal/app/wwctl/configure/root.go @@ -4,10 +4,10 @@ import ( "github.com/spf13/cobra" "github.com/warewulf/warewulf/internal/app/wwctl/configure/dhcp" "github.com/warewulf/warewulf/internal/app/wwctl/configure/hostfile" - "github.com/warewulf/warewulf/internal/app/wwctl/configure/keys" "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" ) @@ -30,7 +30,7 @@ func init() { baseCmd.AddCommand(ssh.GetCommand()) baseCmd.AddCommand(nfs.GetCommand()) baseCmd.AddCommand(hostfile.GetCommand()) - baseCmd.AddCommand(keys.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/keys/main.go b/internal/app/wwctl/configure/tls/main.go similarity index 98% rename from internal/app/wwctl/configure/keys/main.go rename to internal/app/wwctl/configure/tls/main.go index 78144783..b9cdfea7 100644 --- a/internal/app/wwctl/configure/keys/main.go +++ b/internal/app/wwctl/configure/tls/main.go @@ -1,4 +1,4 @@ -package keys +package tls import ( "crypto/x509" @@ -75,7 +75,7 @@ func CobraRunE(cmd *cobra.Command, args []string) error { } } else { if create { - if err := configure.GenKeys(); err != nil { + if err := configure.GenTLSKeys(); err != nil { return err } if err := configure.WAREWULFD(); err != nil { diff --git a/internal/app/wwctl/configure/keys/main_test.go b/internal/app/wwctl/configure/tls/main_test.go similarity index 99% rename from internal/app/wwctl/configure/keys/main_test.go rename to internal/app/wwctl/configure/tls/main_test.go index ae7d6e85..ae3bb300 100644 --- a/internal/app/wwctl/configure/keys/main_test.go +++ b/internal/app/wwctl/configure/tls/main_test.go @@ -1,4 +1,4 @@ -package keys +package tls import ( "bytes" diff --git a/internal/app/wwctl/configure/keys/root.go b/internal/app/wwctl/configure/tls/root.go similarity index 70% rename from internal/app/wwctl/configure/keys/root.go rename to internal/app/wwctl/configure/tls/root.go index 2614e3e5..0fa24abc 100644 --- a/internal/app/wwctl/configure/keys/root.go +++ b/internal/app/wwctl/configure/tls/root.go @@ -1,4 +1,4 @@ -package keys +package tls import ( "github.com/spf13/cobra" @@ -8,13 +8,13 @@ import ( var ( baseCmd = &cobra.Command{ DisableFlagsInUseLine: true, - Use: "keys [OPTIONS]", + 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 for Warewulf\n" + - "based on the configuration in the warewulf.conf file.", - RunE: CobraRunE, - Args: cobra.NoArgs, - ValidArgsFunction: completions.None, + 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 diff --git a/internal/pkg/config/buildconfig.go.in b/internal/pkg/config/buildconfig.go.in index 5ce7aad8..d562f5cb 100644 --- a/internal/pkg/config/buildconfig.go.in +++ b/internal/pkg/config/buildconfig.go.in @@ -44,7 +44,7 @@ type WarewulfConf struct { Port int `yaml:"port,omitempty" default:"9873"` SecurePort int `yaml:"secure port,omitempty" default:"9874"` SecureP *bool `yaml:"secure,omitempty" default:"true"` - EnableHttpsP *bool `yaml:"enable https,omitempty" default:"false"` + EnableTLSP *bool `yaml:"tls,omitempty" default:"false"` UpdateInterval int `yaml:"update interval,omitempty" default:"60"` AutobuildOverlaysP *bool `yaml:"autobuild overlays,omitempty" default:"true"` EnableHostOverlayP *bool `yaml:"host overlay,omitempty" default:"true"` @@ -56,8 +56,8 @@ func (conf WarewulfConf) Secure() bool { return util.BoolP(conf.SecureP) } -func (conf WarewulfConf) EnableHttps() bool { - return util.BoolP(conf.EnableHttpsP) +func (conf WarewulfConf) EnableTLS() bool { + return util.BoolP(conf.EnableTLSP) } func (conf WarewulfConf) AutobuildOverlays() bool { diff --git a/internal/pkg/config/root_test.go b/internal/pkg/config/root_test.go index 5f49f253..fe5a6f41 100644 --- a/internal/pkg/config/root_test.go +++ b/internal/pkg/config/root_test.go @@ -17,7 +17,7 @@ func TestParse(t *testing.T) { result: ` warewulf: autobuild overlays: true - enable https: false + tls: false grubboot: false host overlay: true port: 9873 @@ -65,7 +65,7 @@ network: 192.168.0.0 netmask: 255.255.255.0 warewulf: autobuild overlays: true - enable https: false + tls: false grubboot: false host overlay: true port: 9873 @@ -115,7 +115,7 @@ network: 192.168.0.0 netmask: 255.255.0.0 warewulf: autobuild overlays: true - enable https: false + tls: false grubboot: false host overlay: true port: 9873 @@ -162,7 +162,7 @@ ipaddr6: "2001:db8::1" prefixlen6: "64" warewulf: autobuild overlays: true - enable https: false + tls: false grubboot: false host overlay: true port: 9873 @@ -238,7 +238,7 @@ netmask: 255.255.255.0 network: 192.168.200.0 warewulf: autobuild overlays: true - enable https: false + tls: false grubboot: false host overlay: true port: 9873 diff --git a/internal/pkg/configure/keys.go b/internal/pkg/configure/tls.go similarity index 96% rename from internal/pkg/configure/keys.go rename to internal/pkg/configure/tls.go index c54b55ea..1e753482 100644 --- a/internal/pkg/configure/keys.go +++ b/internal/pkg/configure/tls.go @@ -18,9 +18,9 @@ import ( ) /* -GenKeys checks for existence of x509 keys and creates them if they don't exist. +GenTLSKeys checks for existence of x509 keys and creates them if they don't exist. */ -func GenKeys() error { +func GenTLSKeys() error { conf := warewulfconf.Get() keystore := path.Join(conf.Paths.Sysconfdir, "warewulf", "keys") diff --git a/internal/pkg/warewulfd/provision.go b/internal/pkg/warewulfd/provision.go index dffc4699..0bd4888b 100644 --- a/internal/pkg/warewulfd/provision.go +++ b/internal/pkg/warewulfd/provision.go @@ -248,7 +248,7 @@ func ProvisionSend(w http.ResponseWriter, req *http.Request) { Ipaddr: conf.Ipaddr, Ipaddr6: ipaddr6, Port: strconv.Itoa(conf.Warewulf.Port), - Https: conf.Warewulf.EnableHttps(), + Https: conf.Warewulf.EnableTLS(), Authority: authority, Hostname: remoteNode.Id(), Hwaddr: rinfo.hwaddr, diff --git a/internal/pkg/warewulfd/server/server.go b/internal/pkg/warewulfd/server/server.go index baf28bb2..e9153cda 100644 --- a/internal/pkg/warewulfd/server/server.go +++ b/internal/pkg/warewulfd/server/server.go @@ -86,14 +86,14 @@ func RunServer() error { apiHandler = api.Handler(auth, conf.API.AllowedIPNets()) } - httpHandler := configureHandler(!conf.Warewulf.EnableHttps(), apiHandler) + httpHandler := configureHandler(!conf.Warewulf.EnableTLS(), apiHandler) - if conf.Warewulf.EnableHttps() { + if conf.Warewulf.EnableTLS() { key := path.Join(conf.Paths.Sysconfdir, "warewulf", "keys", "warewulf.key") crt := path.Join(conf.Paths.Sysconfdir, "warewulf", "keys", "warewulf.crt") if !util.IsFile(key) || !util.IsFile(crt) { - wwlog.Error("HTTPS enabled but keys not found in %s", path.Join(conf.Paths.Sysconfdir, "warewulf", "keys")) + wwlog.Error("TLS enabled but keys not found in %s", path.Join(conf.Paths.Sysconfdir, "warewulf", "keys")) } else { httpsHandler := configureHandler(true, apiHandler) go func() { diff --git a/userdocs/server/security.rst b/userdocs/server/security.rst index c8141764..139b0388 100644 --- a/userdocs/server/security.rst +++ b/userdocs/server/security.rst @@ -96,8 +96,8 @@ The https functionality can be enabled by setting .. code-block:: yaml .. .. warewulf: -.. enable https: true -.. secure port: 9874 +.. tls: true +.. secure port: 9874 .. Which will enable a https server on the secure port. The certificate and key From b46161dad0dcac2d7066884126df39476bd00116 Mon Sep 17 00:00:00 2001 From: Christian Goll Date: Wed, 25 Feb 2026 11:44:04 +0100 Subject: [PATCH 06/29] document known bug for ignition --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3db081f4..f69c3548 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). 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 ## v4.6.5, 2026-01-12 From 5f8b7104a1816332459442dd7f0dd02807ece1f0 Mon Sep 17 00:00:00 2001 From: Christian Goll Date: Wed, 3 Dec 2025 15:21:15 +0100 Subject: [PATCH 07/29] added documentation --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f69c3548..601106e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). if TLS is enabled is not distributed over plain http. - 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. + ## v4.6.5, 2026-01-12 From 0eaaf66883774c6c3a6f3292bd9c4d3d5a196448 Mon Sep 17 00:00:00 2001 From: Jonathon Anderson Date: Tue, 17 Feb 2026 13:25:13 -0700 Subject: [PATCH 08/29] Pass TLS variable to iPXE template Signed-off-by: Jonathon Anderson --- internal/pkg/warewulfd/provision.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/pkg/warewulfd/provision.go b/internal/pkg/warewulfd/provision.go index 0bd4888b..34f163ee 100644 --- a/internal/pkg/warewulfd/provision.go +++ b/internal/pkg/warewulfd/provision.go @@ -142,6 +142,7 @@ func ProvisionSend(w http.ResponseWriter, req *http.Request) { Ipaddr: conf.Ipaddr, Ipaddr6: ipaddr6, Port: strconv.Itoa(conf.Warewulf.Port), + Https: conf.Warewulf.EnableTLS(), Authority: authority, Hostname: remoteNode.Id(), Hwaddr: rinfo.hwaddr, From 226d81647bb2fa8d7fa16f68b0dc702c01132207 Mon Sep 17 00:00:00 2001 From: Jonathon Anderson Date: Tue, 17 Feb 2026 13:35:22 -0700 Subject: [PATCH 09/29] Add warewulf.crt to wwinit overlay Signed-off-by: Jonathon Anderson --- overlays/wwinit/rootfs/warewulf/keys/warewulf.crt.ww | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 overlays/wwinit/rootfs/warewulf/keys/warewulf.crt.ww diff --git a/overlays/wwinit/rootfs/warewulf/keys/warewulf.crt.ww b/overlays/wwinit/rootfs/warewulf/keys/warewulf.crt.ww new file mode 100644 index 00000000..8f984c66 --- /dev/null +++ b/overlays/wwinit/rootfs/warewulf/keys/warewulf.crt.ww @@ -0,0 +1,6 @@ +{{- $crt := Include "keys/warewulf.crt" -}} +{{- if eq $crt "" -}} +{{ abort }} +{{- else -}} +{{ $crt }} +{{- end -}} From 673d7627b5d25e326a92963fe77715f8bf246c73 Mon Sep 17 00:00:00 2001 From: Jonathon Anderson Date: Tue, 17 Feb 2026 13:50:55 -0700 Subject: [PATCH 10/29] Move TLS files from keys/ to tls/ Signed-off-by: Jonathon Anderson --- internal/app/wwclient/root.go | 2 +- internal/app/wwctl/configure/main.go | 2 +- internal/app/wwctl/configure/tls/main.go | 2 +- internal/app/wwctl/configure/tls/main_test.go | 2 +- internal/pkg/configure/tls.go | 2 +- internal/pkg/warewulfd/server/server.go | 6 +++--- .../wwinit/rootfs/warewulf/{keys => tls}/warewulf.crt.ww | 2 +- userdocs/server/security.rst | 4 ++-- 8 files changed, 11 insertions(+), 11 deletions(-) rename overlays/wwinit/rootfs/warewulf/{keys => tls}/warewulf.crt.ww (61%) diff --git a/internal/app/wwclient/root.go b/internal/app/wwclient/root.go index 5d654f79..b14c0279 100644 --- a/internal/app/wwclient/root.go +++ b/internal/app/wwclient/root.go @@ -118,7 +118,7 @@ func CobraRunE(cmd *cobra.Command, args []string) (err error) { tlsConfig := &tls.Config{} if conf.Warewulf.EnableTLS() { - caCert, err := os.ReadFile("/warewulf/keys/warewulf.crt") + caCert, err := os.ReadFile("/warewulf/tls/warewulf.crt") if err != nil { wwlog.Error("failed to read ca cert: %s", err) return err diff --git a/internal/app/wwctl/configure/main.go b/internal/app/wwctl/configure/main.go index 5eef8905..5532a197 100644 --- a/internal/app/wwctl/configure/main.go +++ b/internal/app/wwctl/configure/main.go @@ -23,7 +23,7 @@ func CobraRunE(cmd *cobra.Command, args []string) error { } if allFunctions { - keystore := path.Join(conf.Paths.Sysconfdir, "warewulf", "keys") + keystore := path.Join(conf.Paths.Sysconfdir, "warewulf", "tls") keyFile := path.Join(keystore, "warewulf.key") certFile := path.Join(keystore, "warewulf.crt") diff --git a/internal/app/wwctl/configure/tls/main.go b/internal/app/wwctl/configure/tls/main.go index b9cdfea7..bffc4147 100644 --- a/internal/app/wwctl/configure/tls/main.go +++ b/internal/app/wwctl/configure/tls/main.go @@ -17,7 +17,7 @@ import ( func CobraRunE(cmd *cobra.Command, args []string) error { conf := config.Get() - keystore := path.Join(conf.Paths.Sysconfdir, "warewulf", "keys") + 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) diff --git a/internal/app/wwctl/configure/tls/main_test.go b/internal/app/wwctl/configure/tls/main_test.go index ae3bb300..3c5b20f0 100644 --- a/internal/app/wwctl/configure/tls/main_test.go +++ b/internal/app/wwctl/configure/tls/main_test.go @@ -15,7 +15,7 @@ func Test_Keys(t *testing.T) { defer env.RemoveAll() // Define a keystore path within the test environment - keystoreRelPath := "etc/warewulf/keys" + keystoreRelPath := "etc/warewulf/tls" keystorePath := env.GetPath(keystoreRelPath) // Reload configuration to pick up the change diff --git a/internal/pkg/configure/tls.go b/internal/pkg/configure/tls.go index 1e753482..1df303ff 100644 --- a/internal/pkg/configure/tls.go +++ b/internal/pkg/configure/tls.go @@ -22,7 +22,7 @@ GenTLSKeys checks for existence of x509 keys and creates them if they don't exis */ func GenTLSKeys() error { conf := warewulfconf.Get() - keystore := path.Join(conf.Paths.Sysconfdir, "warewulf", "keys") + keystore := path.Join(conf.Paths.Sysconfdir, "warewulf", "tls") keyFile := path.Join(keystore, "warewulf.key") certFile := path.Join(keystore, "warewulf.crt") diff --git a/internal/pkg/warewulfd/server/server.go b/internal/pkg/warewulfd/server/server.go index e9153cda..18743f73 100644 --- a/internal/pkg/warewulfd/server/server.go +++ b/internal/pkg/warewulfd/server/server.go @@ -89,11 +89,11 @@ func RunServer() error { httpHandler := configureHandler(!conf.Warewulf.EnableTLS(), apiHandler) if conf.Warewulf.EnableTLS() { - key := path.Join(conf.Paths.Sysconfdir, "warewulf", "keys", "warewulf.key") - crt := path.Join(conf.Paths.Sysconfdir, "warewulf", "keys", "warewulf.crt") + 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) { - wwlog.Error("TLS enabled but keys not found in %s", path.Join(conf.Paths.Sysconfdir, "warewulf", "keys")) + wwlog.Error("TLS enabled but keys not found in %s", path.Join(conf.Paths.Sysconfdir, "warewulf", "tls")) } else { httpsHandler := configureHandler(true, apiHandler) go func() { diff --git a/overlays/wwinit/rootfs/warewulf/keys/warewulf.crt.ww b/overlays/wwinit/rootfs/warewulf/tls/warewulf.crt.ww similarity index 61% rename from overlays/wwinit/rootfs/warewulf/keys/warewulf.crt.ww rename to overlays/wwinit/rootfs/warewulf/tls/warewulf.crt.ww index 8f984c66..bff0aa28 100644 --- a/overlays/wwinit/rootfs/warewulf/keys/warewulf.crt.ww +++ b/overlays/wwinit/rootfs/warewulf/tls/warewulf.crt.ww @@ -1,4 +1,4 @@ -{{- $crt := Include "keys/warewulf.crt" -}} +{{- $crt := Include "tls/warewulf.crt" -}} {{- if eq $crt "" -}} {{ abort }} {{- else -}} diff --git a/userdocs/server/security.rst b/userdocs/server/security.rst index 139b0388..a2109f4b 100644 --- a/userdocs/server/security.rst +++ b/userdocs/server/security.rst @@ -106,8 +106,8 @@ keys and certificate are stored if not configured otherwise as .. code-block:: console .. -.. /etc/warewulf/keys/warewulf.crt # PEM certificate -.. /etc/warewulf/keys/warewulf.key # PEM RSA Key +.. /etc/warewulf/tls/warewulf.crt # PEM certificate +.. /etc/warewulf/tls/warewulf.key # PEM RSA Key From dc21302f323f22a188570920f9f0019c405b4641 Mon Sep 17 00:00:00 2001 From: Jonathon Anderson Date: Tue, 17 Feb 2026 14:00:32 -0700 Subject: [PATCH 11/29] Warn when server starts with TLS enabled but no key available Signed-off-by: Jonathon Anderson --- internal/pkg/warewulfd/server/server.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/pkg/warewulfd/server/server.go b/internal/pkg/warewulfd/server/server.go index 18743f73..2bcd68ff 100644 --- a/internal/pkg/warewulfd/server/server.go +++ b/internal/pkg/warewulfd/server/server.go @@ -94,6 +94,7 @@ func RunServer() error { if !util.IsFile(key) || !util.IsFile(crt) { wwlog.Error("TLS enabled but keys not found in %s", path.Join(conf.Paths.Sysconfdir, "warewulf", "tls")) + wwlog.Error("Runtime overlays will NOT be served. Run 'wwctl configure tls --create' to generate keys.") } else { httpsHandler := configureHandler(true, apiHandler) go func() { From 3d5aa6b0e38ab98d54805e52ba2d62e3c18a6cf3 Mon Sep 17 00:00:00 2001 From: Jonathon Anderson Date: Tue, 17 Feb 2026 14:07:10 -0700 Subject: [PATCH 12/29] Better TLS configuration error handling in warewulf server Signed-off-by: Jonathon Anderson --- internal/pkg/warewulfd/server/server.go | 32 +++++++++++++------------ 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/internal/pkg/warewulfd/server/server.go b/internal/pkg/warewulfd/server/server.go index 2bcd68ff..027da7e9 100644 --- a/internal/pkg/warewulfd/server/server.go +++ b/internal/pkg/warewulfd/server/server.go @@ -88,28 +88,30 @@ func RunServer() error { httpHandler := configureHandler(!conf.Warewulf.EnableTLS(), apiHandler) + errChan := make(chan error, 2) + if conf.Warewulf.EnableTLS() { 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) { - wwlog.Error("TLS enabled but keys not found in %s", path.Join(conf.Paths.Sysconfdir, "warewulf", "tls")) - wwlog.Error("Runtime overlays will NOT be served. Run 'wwctl configure tls --create' to generate keys.") - } else { - httpsHandler := configureHandler(true, apiHandler) - go func() { - wwlog.Info("Starting HTTPS service on port %d", conf.Warewulf.SecurePort) - if err := http.ListenAndServeTLS(":"+strconv.Itoa(conf.Warewulf.SecurePort), crt, key, httpsHandler); err != nil { - wwlog.Error("Could not start HTTPS service: %s", err) - } - }() + return fmt.Errorf("TLS enabled but keys not found in %s, run 'wwctl configure tls --create' to generate keys", path.Join(conf.Paths.Sysconfdir, "warewulf", "tls")) } + httpsHandler := configureHandler(true, apiHandler) + go func() { + wwlog.Info("Starting HTTPS service on port %d", conf.Warewulf.SecurePort) + if err := http.ListenAndServeTLS(":"+strconv.Itoa(conf.Warewulf.SecurePort), crt, key, httpsHandler); err != nil { + errChan <- fmt.Errorf("could not start HTTPS service: %w", err) + } + }() } - wwlog.Info("Starting HTTP service on port %d", daemonPort) - if err := http.ListenAndServe(":"+strconv.Itoa(daemonPort), httpHandler); err != nil { - return fmt.Errorf("could not start listening 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 nil + return <-errChan } From 26cec668e6da07163c9bb234dad32db4c4677dca Mon Sep 17 00:00:00 2001 From: Jonathon Anderson Date: Tue, 17 Feb 2026 14:12:11 -0700 Subject: [PATCH 13/29] Set an explicit minimum TLS version Signed-off-by: Jonathon Anderson --- internal/app/wwclient/root.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/app/wwclient/root.go b/internal/app/wwclient/root.go index b14c0279..9467c931 100644 --- a/internal/app/wwclient/root.go +++ b/internal/app/wwclient/root.go @@ -116,7 +116,7 @@ func CobraRunE(cmd *cobra.Command, args []string) (err error) { wwlog.Info("running from trusted port: %d", localTCPAddr.Port) } - tlsConfig := &tls.Config{} + tlsConfig := &tls.Config{MinVersion: tls.VersionTLS13} if conf.Warewulf.EnableTLS() { caCert, err := os.ReadFile("/warewulf/tls/warewulf.crt") if err != nil { From 2cac6b11a638afdfa1b50b7144a4439c56c91cdb Mon Sep 17 00:00:00 2001 From: Jonathon Anderson Date: Tue, 17 Feb 2026 14:22:33 -0700 Subject: [PATCH 14/29] Explicitly set permissions on import TLS keys and certificates Signed-off-by: Jonathon Anderson --- internal/app/wwctl/configure/tls/main.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/app/wwctl/configure/tls/main.go b/internal/app/wwctl/configure/tls/main.go index bffc4147..b84fe8a7 100644 --- a/internal/app/wwctl/configure/tls/main.go +++ b/internal/app/wwctl/configure/tls/main.go @@ -43,9 +43,15 @@ func CobraRunE(cmd *cobra.Command, args []string) error { 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 { From ff1bf8030cbecccf817fcc65c52e99020272e067 Mon Sep 17 00:00:00 2001 From: Jonathon Anderson Date: Tue, 17 Feb 2026 14:25:40 -0700 Subject: [PATCH 15/29] Remove unused TLS public key The certificate is used in stead of a discrete public key. The generated public key was never actually used. Signed-off-by: Jonathon Anderson --- internal/app/wwctl/configure/tls/main_test.go | 1 - internal/pkg/configure/tls.go | 14 -------------- 2 files changed, 15 deletions(-) diff --git a/internal/app/wwctl/configure/tls/main_test.go b/internal/app/wwctl/configure/tls/main_test.go index 3c5b20f0..7759dc91 100644 --- a/internal/app/wwctl/configure/tls/main_test.go +++ b/internal/app/wwctl/configure/tls/main_test.go @@ -38,7 +38,6 @@ func Test_Keys(t *testing.T) { assert.NoError(t, err) assert.FileExists(t, path.Join(keystorePath, "warewulf.key")) assert.FileExists(t, path.Join(keystorePath, "warewulf.crt")) - assert.FileExists(t, path.Join(keystorePath, "warewulf.rsa.pub")) }) t.Run("keys exist check", func(t *testing.T) { diff --git a/internal/pkg/configure/tls.go b/internal/pkg/configure/tls.go index 1df303ff..4a65f72c 100644 --- a/internal/pkg/configure/tls.go +++ b/internal/pkg/configure/tls.go @@ -26,7 +26,6 @@ func GenTLSKeys() error { keyFile := path.Join(keystore, "warewulf.key") certFile := path.Join(keystore, "warewulf.crt") - pubFile := path.Join(keystore, "warewulf.rsa.pub") wwlog.Verbose("Generating new x509 keys in %s", keystore) priv, err := rsa.GenerateKey(rand.Reader, 4096) if err != nil { @@ -87,18 +86,5 @@ func GenTLSKeys() error { return fmt.Errorf("failed to write data to cert file: %w", err) } - pubBytes, err := x509.MarshalPKIXPublicKey(&priv.PublicKey) - if err != nil { - return fmt.Errorf("failed to marshal public key: %w", err) - } - pubOut, err := os.OpenFile(pubFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) - if err != nil { - return fmt.Errorf("failed to open public key file for writing: %w", err) - } - defer pubOut.Close() - if err := pem.Encode(pubOut, &pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes}); err != nil { - return fmt.Errorf("failed to write data to public key file: %w", err) - } - return nil } From 6e85852c290f41059db1b533c621f1c5cfcf72bf Mon Sep 17 00:00:00 2001 From: Jonathon Anderson Date: Tue, 17 Feb 2026 14:57:32 -0700 Subject: [PATCH 16/29] Move defer out of the for loop Signed-off-by: Jonathon Anderson --- internal/app/wwclient/root.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/app/wwclient/root.go b/internal/app/wwclient/root.go index 9467c931..a666da22 100644 --- a/internal/app/wwclient/root.go +++ b/internal/app/wwclient/root.go @@ -326,7 +326,6 @@ 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 @@ -348,6 +347,7 @@ 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) From 02c7d293141abe5d7ab63c68285562aeb9537788 Mon Sep 17 00:00:00 2001 From: Jonathon Anderson Date: Tue, 17 Feb 2026 15:01:31 -0700 Subject: [PATCH 17/29] Return an error from wwclient updateSystem Avoiding calling os.Exit() directly. Signed-off-by: Jonathon Anderson --- internal/app/wwclient/root.go | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/internal/app/wwclient/root.go b/internal/app/wwclient/root.go index a666da22..f296eff2 100644 --- a/internal/app/wwclient/root.go +++ b/internal/app/wwclient/root.go @@ -242,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 } @@ -266,7 +266,9 @@ func CobraRunE(cmd *cobra.Command, args []string) (err error) { } for { - updateSystem(target, ipaddr, port, wwid, tag, localUUID, scheme) + if err := updateSystem(target, ipaddr, port, wwid, tag, localUUID, scheme); err != nil { + return err + } if !finishedInitialSync { // Notify systemd that the service has started successfully. // @@ -307,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, scheme string) { +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 { @@ -334,8 +336,7 @@ func updateSystem(target string, ipaddr string, port int, wwid string, tag strin if errors.As(err, &certificateInvalidError) || errors.As(err, &unknownAuthorityError) || errors.As(err, &hostnameError) { - wwlog.Error("TLS connection failed: %v", err) - os.Exit(1) + return fmt.Errorf("TLS connection failed: %w", err) } if counter > 60 { counter = 0 @@ -351,7 +352,7 @@ func updateSystem(target string, ipaddr string, port int, wwid string, tag strin 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") @@ -360,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) @@ -369,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 @@ -377,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 { From 01eef7d88d78f51fed9314436bfecb3cb4341f17 Mon Sep 17 00:00:00 2001 From: Jonathon Anderson Date: Tue, 17 Feb 2026 21:30:11 -0700 Subject: [PATCH 18/29] Restore dracut's ability to download the runtime overlay Signed-off-by: Jonathon Anderson --- dracut/modules.d/90wwinit/load-wwinit.sh | 33 ++++++++++++++++++----- overlays/wwinit/internal/wwinit_test.go | 5 ++++ overlays/wwinit/rootfs/warewulf/config.ww | 3 +++ 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/dracut/modules.d/90wwinit/load-wwinit.sh b/dracut/modules.d/90wwinit/load-wwinit.sh index e15d1ef2..a40ef978 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" ; 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}:${WWSECUREPORT}/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/overlays/wwinit/internal/wwinit_test.go b/overlays/wwinit/internal/wwinit_test.go index 708fe255..368ed72b 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 +WWSECUREPORT=9874 +WWIPADDR=192.168.0.1 ` const wwinit_50_ipmi string = `backupFile: true diff --git a/overlays/wwinit/rootfs/warewulf/config.ww b/overlays/wwinit/rootfs/warewulf/config.ww index ae9600f6..36e19fca 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.EnableTLS}} +WWSECUREPORT={{$.Warewulf.SecurePort}} +WWIPADDR={{$.Ipaddr}} From ced93bcc5a8c95f640f82720f98b8a1a74937b2a Mon Sep 17 00:00:00 2001 From: Jonathon Anderson Date: Tue, 17 Feb 2026 21:38:51 -0700 Subject: [PATCH 19/29] Rename .Https template variable to .TLS Signed-off-by: Jonathon Anderson --- etc/grub/grub.cfg.ww | 4 ++-- etc/ipxe/default.ipxe | 6 +++--- internal/pkg/warewulfd/provision.go | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/etc/grub/grub.cfg.ww b/etc/grub/grub.cfg.ww index 03d44f85..43eccaec 100644 --- a/etc/grub/grub.cfg.ww +++ b/etc/grub/grub.cfg.ww @@ -66,7 +66,7 @@ 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 .Https true) }} + {{- if not (eq .TLS true) }} runtime="${uri}&stage=runtime&compress=gz" {{- end }} initrd $image $system $runtime @@ -115,7 +115,7 @@ menuentry "Single-stage boot (no compression)" --id single-stage-nocompress { echo "Downloading images..." image="${uri}&stage=image" system="${uri}&stage=system" - {{- if not (eq .Https true) }} + {{- if not (eq .TLS true) }} runtime="${uri}&stage=runtime" {{- end }} initrd $image $system $runtime diff --git a/etc/ipxe/default.ipxe b/etc/ipxe/default.ipxe index 4718322a..8339b913 100644 --- a/etc/ipxe/default.ipxe +++ b/etc/ipxe/default.ipxe @@ -46,7 +46,7 @@ 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 .Https true) }} +{{- 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 }} @@ -65,7 +65,7 @@ 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 .Https true) }} +{{- 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 }} @@ -80,7 +80,7 @@ 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 .Https true) }} +{{- 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 }} diff --git a/internal/pkg/warewulfd/provision.go b/internal/pkg/warewulfd/provision.go index 34f163ee..e3bd229d 100644 --- a/internal/pkg/warewulfd/provision.go +++ b/internal/pkg/warewulfd/provision.go @@ -39,7 +39,7 @@ type templateVars struct { KernelArgs string KernelVersion string Root string - Https bool + TLS bool Tags map[string]string NetDevs map[string]*node.NetDev } @@ -142,7 +142,7 @@ func ProvisionSend(w http.ResponseWriter, req *http.Request) { Ipaddr: conf.Ipaddr, Ipaddr6: ipaddr6, Port: strconv.Itoa(conf.Warewulf.Port), - Https: conf.Warewulf.EnableTLS(), + TLS: conf.Warewulf.EnableTLS(), Authority: authority, Hostname: remoteNode.Id(), Hwaddr: rinfo.hwaddr, @@ -249,7 +249,7 @@ func ProvisionSend(w http.ResponseWriter, req *http.Request) { Ipaddr: conf.Ipaddr, Ipaddr6: ipaddr6, Port: strconv.Itoa(conf.Warewulf.Port), - Https: conf.Warewulf.EnableTLS(), + TLS: conf.Warewulf.EnableTLS(), Authority: authority, Hostname: remoteNode.Id(), Hwaddr: rinfo.hwaddr, From 91723a258a0e6474f46b39edb8c1b9a3f0ee75c3 Mon Sep 17 00:00:00 2001 From: Jonathon Anderson Date: Mon, 23 Feb 2026 11:40:22 -0700 Subject: [PATCH 20/29] Refactor server to separate handlers Signed-off-by: Jonathon Anderson --- internal/pkg/configure/tftp.go | 32 +- internal/pkg/warewulfd/copyshim.go | 45 --- internal/pkg/warewulfd/efi.go | 60 ++++ internal/pkg/warewulfd/grub.go | 33 ++ internal/pkg/warewulfd/helpers.go | 155 ++++++++++ internal/pkg/warewulfd/image.go | 30 ++ internal/pkg/warewulfd/initramfs.go | 39 +++ internal/pkg/warewulfd/ipxe.go | 35 +++ internal/pkg/warewulfd/kernel.go | 34 +++ internal/pkg/warewulfd/overlay.go | 45 ++- internal/pkg/warewulfd/overlay_test.go | 2 +- internal/pkg/warewulfd/parser.go | 89 +++++- internal/pkg/warewulfd/parser_test.go | 10 +- internal/pkg/warewulfd/provision.go | 370 ++--------------------- internal/pkg/warewulfd/provision_test.go | 2 +- internal/pkg/warewulfd/server/server.go | 25 +- internal/pkg/warewulfd/shim.go | 34 +++ internal/pkg/warewulfd/status.go | 2 +- 18 files changed, 620 insertions(+), 422 deletions(-) delete mode 100644 internal/pkg/warewulfd/copyshim.go create mode 100644 internal/pkg/warewulfd/efi.go create mode 100644 internal/pkg/warewulfd/grub.go create mode 100644 internal/pkg/warewulfd/helpers.go create mode 100644 internal/pkg/warewulfd/image.go create mode 100644 internal/pkg/warewulfd/initramfs.go create mode 100644 internal/pkg/warewulfd/ipxe.go create mode 100644 internal/pkg/warewulfd/kernel.go create mode 100644 internal/pkg/warewulfd/shim.go 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/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/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/grub.go b/internal/pkg/warewulfd/grub.go new file mode 100644 index 00000000..f01a43d9 --- /dev/null +++ b/internal/pkg/warewulfd/grub.go @@ -0,0 +1,33 @@ +package warewulfd + +import ( + "net/http" + + "github.com/warewulf/warewulf/internal/pkg/image" + "github.com/warewulf/warewulf/internal/pkg/wwlog" +) + +// HandleGrub handles direct GRUB binary requests +func HandleGrub(w http.ResponseWriter, req *http.Request) { + ctx, err := initHandleRequest(w, req) + if err != nil { + return // response already written + } + + var stageFile string + + if !ctx.remoteNode.Valid() { + wwlog.Error("%s (unknown/unconfigured node)", ctx.rinfo.hwaddr) + } else { + if ctx.remoteNode.ImageName != "" { + stageFile = image.GrubFind(ctx.remoteNode.ImageName) + if stageFile == "" { + wwlog.Error("No grub found for image %s", ctx.remoteNode.ImageName) + } + } else { + wwlog.Warn("No conainer set for node %s", ctx.remoteNode.Id()) + } + } + + sendResponse(w, req, stageFile, nil, ctx) +} diff --git a/internal/pkg/warewulfd/helpers.go b/internal/pkg/warewulfd/helpers.go new file mode 100644 index 00000000..29f2062b --- /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.EnableTLS(), + 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.statusStage, path.Base(stageFile), ctx.rinfo.ipaddr) + + } else if stageFile == "" { + w.WriteHeader(http.StatusBadRequest) + wwlog.Error("No resource selected") + updateStatus(ctx.remoteNode.Id(), ctx.statusStage, "BAD_REQUEST", ctx.rinfo.ipaddr) + + } else { + w.WriteHeader(http.StatusNotFound) + wwlog.Error("Not found: %s", stageFile) + updateStatus(ctx.remoteNode.Id(), ctx.statusStage, "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/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/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..5f45bb08 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,49 @@ import ( "github.com/warewulf/warewulf/internal/pkg/wwlog" ) -func OverlaySend(w http.ResponseWriter, req *http.Request) { +// HandleOverlay handles system and runtime overlay requests +func HandleOverlay(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 + } + + var context string + var request_overlays []string + + if len(ctx.rinfo.overlay) > 0 { + request_overlays = strings.Split(ctx.rinfo.overlay, ",") + } else { + context = ctx.rinfo.stage + } + + stageFile, err := getOverlayFile( + ctx.remoteNode, + context, + 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) +} + +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..f1668993 100644 --- a/internal/pkg/warewulfd/overlay_test.go +++ b/internal/pkg/warewulfd/overlay_test.go @@ -95,7 +95,7 @@ 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() diff --git a/internal/pkg/warewulfd/parser.go b/internal/pkg/warewulfd/parser.go index bd29e80e..8347a659 100644 --- a/internal/pkg/warewulfd/parser.go +++ b/internal/pkg/warewulfd/parser.go @@ -1,15 +1,83 @@ 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 + statusStage string +} + +// 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") + } + } + + status_stages := map[string]string{ + "efiboot": "EFI", + "ipxe": "IPXE", + "kernel": "KERNEL", + "system": "SYSTEM_OVERLAY", + "runtime": "RUNTIME_OVERLAY", + "initramfs": "INITRAMFS"} + + statusStage := status_stages[rinfo.stage] + + 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(), statusStage, "BAD_ASSET", rinfo.ipaddr) + return nil, fmt.Errorf("incorrect asset key") + } + + return &requestContext{ + conf: conf, + rinfo: rinfo, + remoteNode: remoteNode, + statusStage: statusStage, + }, nil +} + +type parsedRequest struct { hwaddr string ipaddr string remoteport int @@ -21,8 +89,8 @@ type parserInfo struct { compress string } -func parseReq(req *http.Request) (parserInfo, error) { - var ret parserInfo +func parseRequest(req *http.Request) (parsedRequest, error) { + var ret parsedRequest url := strings.Split(req.URL.Path, "?")[0] path_parts := strings.Split(url, "/") @@ -62,19 +130,20 @@ func parseReq(req *http.Request) (parserInfo, error) { ret.stage = req.URL.Query()["stage"][0] } else { - if stage == "ipxe" || stage == "provision" { + switch stage { + case "ipxe", "provision": ret.stage = "ipxe" - } else if stage == "kernel" { + case "kernel": ret.stage = "kernel" - } else if stage == "image" { + case "image", "container": ret.stage = "image" - } else if stage == "overlay-system" { + case "overlay-system": ret.stage = "system" - } else if stage == "overlay-runtime" { + case "overlay-runtime": ret.stage = "runtime" - } else if stage == "efiboot" { + case "efiboot": ret.stage = "efiboot" - } else if stage == "initramfs" { + case "initramfs": ret.stage = "initramfs" } } diff --git a/internal/pkg/warewulfd/parser_test.go b/internal/pkg/warewulfd/parser_test.go index da595575..03efb651 100644 --- a/internal/pkg/warewulfd/parser_test.go +++ b/internal/pkg/warewulfd/parser_test.go @@ -12,13 +12,13 @@ var parseReqTests = []struct { description string url 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,7 +29,7 @@ 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, @@ -38,14 +38,14 @@ var parseReqTests = []struct { }, } -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}, 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 e3bd229d..b07603c9 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" ) @@ -44,341 +29,38 @@ type templateVars struct { 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), - TLS: conf.Warewulf.EnableTLS(), - 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), - TLS: conf.Warewulf.EnableTLS(), - 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", "runtime": + handler = HandleOverlay + case "efiboot": + handler = HandleEfiBoot + case "shim": + handler = HandleShim + 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..0f0d043f 100644 --- a/internal/pkg/warewulfd/provision_test.go +++ b/internal/pkg/warewulfd/provision_test.go @@ -100,7 +100,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 027da7e9..26aa5ca1 100644 --- a/internal/pkg/warewulfd/server/server.go +++ b/internal/pkg/warewulfd/server/server.go @@ -37,19 +37,20 @@ func (h *slashFix) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.mux.ServeHTTP(w, r) } -func configureHandler(includeRuntime bool, apiHandler http.Handler) *slashFix { +func configureRootHandler(apiHandler http.Handler, includeRuntime bool) *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("/provision/", warewulfd.HandleProvision) + wwHandler.HandleFunc("/ipxe/", warewulfd.HandleIpxe) + wwHandler.HandleFunc("/efiboot/", warewulfd.HandleEfiBoot) + wwHandler.HandleFunc("/kernel/", warewulfd.HandleKernel) + wwHandler.HandleFunc("/image/", warewulfd.HandleImage) + wwHandler.HandleFunc("/container/", warewulfd.HandleImage) + wwHandler.HandleFunc("/overlay-system/", warewulfd.HandleOverlay) if includeRuntime { - wwHandler.HandleFunc("/overlay-runtime/", warewulfd.ProvisionSend) + wwHandler.HandleFunc("/overlay-runtime/", warewulfd.HandleOverlay) } - wwHandler.HandleFunc("/overlay-file/", warewulfd.OverlaySend) - wwHandler.HandleFunc("/status", warewulfd.StatusSend) + wwHandler.HandleFunc("/overlay-file/", warewulfd.HandleOverlayFile) + wwHandler.HandleFunc("/status", warewulfd.HandleStatus) if apiHandler != nil { wwHandler.Handle("/api/", apiHandler) @@ -86,7 +87,7 @@ func RunServer() error { apiHandler = api.Handler(auth, conf.API.AllowedIPNets()) } - httpHandler := configureHandler(!conf.Warewulf.EnableTLS(), apiHandler) + httpHandler := configureRootHandler(apiHandler, !conf.Warewulf.EnableTLS()) errChan := make(chan error, 2) @@ -97,7 +98,7 @@ func RunServer() error { if !util.IsFile(key) || !util.IsFile(crt) { return fmt.Errorf("TLS enabled but keys not found in %s, run 'wwctl configure tls --create' to generate keys", path.Join(conf.Paths.Sysconfdir, "warewulf", "tls")) } - httpsHandler := configureHandler(true, apiHandler) + httpsHandler := configureRootHandler(apiHandler, true) go func() { wwlog.Info("Starting HTTPS service on port %d", conf.Warewulf.SecurePort) if err := http.ListenAndServeTLS(":"+strconv.Itoa(conf.Warewulf.SecurePort), crt, key, httpsHandler); err != nil { diff --git a/internal/pkg/warewulfd/shim.go b/internal/pkg/warewulfd/shim.go new file mode 100644 index 00000000..9d3c6270 --- /dev/null +++ b/internal/pkg/warewulfd/shim.go @@ -0,0 +1,34 @@ +package warewulfd + +import ( + "net/http" + + "github.com/warewulf/warewulf/internal/pkg/image" + "github.com/warewulf/warewulf/internal/pkg/wwlog" +) + +// HandleShim handles direct shim binary requests +func HandleShim(w http.ResponseWriter, req *http.Request) { + ctx, err := initHandleRequest(w, req) + if err != nil { + return // response already written + } + + var stageFile string + + if !ctx.remoteNode.Valid() { + wwlog.Error("%s (unknown/unconfigured node)", ctx.rinfo.hwaddr) + } else { + if ctx.remoteNode.ImageName != "" { + stageFile = image.ShimFind(ctx.remoteNode.ImageName) + + if stageFile == "" { + wwlog.Error("No kernel found for image %s", ctx.remoteNode.ImageName) + } + } else { + wwlog.Warn("No image set for this %s", ctx.remoteNode.Id()) + } + } + + sendResponse(w, req, stageFile, nil, ctx) +} diff --git a/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 { From 6ce3d33f508c107d4a8298de8efd6eb9720c6297 Mon Sep 17 00:00:00 2001 From: Jonathon Anderson Date: Tue, 3 Mar 2026 17:38:14 -0700 Subject: [PATCH 21/29] Move TLS check into HandleRuntimeOverlay Signed-off-by: Jonathon Anderson --- internal/pkg/warewulfd/overlay.go | 98 ++++++++++++++++++++++--- internal/pkg/warewulfd/provision.go | 6 +- internal/pkg/warewulfd/server/server.go | 12 ++- 3 files changed, 97 insertions(+), 19 deletions(-) diff --git a/internal/pkg/warewulfd/overlay.go b/internal/pkg/warewulfd/overlay.go index 5f45bb08..74998518 100644 --- a/internal/pkg/warewulfd/overlay.go +++ b/internal/pkg/warewulfd/overlay.go @@ -17,8 +17,9 @@ import ( "github.com/warewulf/warewulf/internal/pkg/wwlog" ) -// HandleOverlay handles system and runtime overlay requests -func HandleOverlay(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 @@ -30,19 +31,96 @@ func HandleOverlay(w http.ResponseWriter, req *http.Request) { return } - var context string - var request_overlays []string + request_overlays := strings.Split(ctx.rinfo.overlay, ",") + stageFile, err := getOverlayFile( + ctx.remoteNode, + "", + request_overlays, + ctx.conf.Warewulf.AutobuildOverlays()) - if len(ctx.rinfo.overlay) > 0 { - request_overlays = strings.Split(ctx.rinfo.overlay, ",") - } else { - context = ctx.rinfo.stage + 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, - context, - request_overlays, + "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.EnableTLS() && 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 { diff --git a/internal/pkg/warewulfd/provision.go b/internal/pkg/warewulfd/provision.go index b07603c9..cebdfb43 100644 --- a/internal/pkg/warewulfd/provision.go +++ b/internal/pkg/warewulfd/provision.go @@ -47,8 +47,10 @@ func HandleProvision(w http.ResponseWriter, req *http.Request) { handler = HandleKernel case "image": handler = HandleImage - case "system", "runtime": - handler = HandleOverlay + case "system": + handler = HandleSystemOverlay + case "runtime": + handler = HandleRuntimeOverlay case "efiboot": handler = HandleEfiBoot case "shim": diff --git a/internal/pkg/warewulfd/server/server.go b/internal/pkg/warewulfd/server/server.go index 26aa5ca1..87dd9429 100644 --- a/internal/pkg/warewulfd/server/server.go +++ b/internal/pkg/warewulfd/server/server.go @@ -37,7 +37,7 @@ func (h *slashFix) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.mux.ServeHTTP(w, r) } -func configureRootHandler(apiHandler http.Handler, includeRuntime bool) *slashFix { +func configureRootHandler(apiHandler http.Handler) *slashFix { var wwHandler http.ServeMux wwHandler.HandleFunc("/provision/", warewulfd.HandleProvision) wwHandler.HandleFunc("/ipxe/", warewulfd.HandleIpxe) @@ -45,10 +45,8 @@ func configureRootHandler(apiHandler http.Handler, includeRuntime bool) *slashFi wwHandler.HandleFunc("/kernel/", warewulfd.HandleKernel) wwHandler.HandleFunc("/image/", warewulfd.HandleImage) wwHandler.HandleFunc("/container/", warewulfd.HandleImage) - wwHandler.HandleFunc("/overlay-system/", warewulfd.HandleOverlay) - if includeRuntime { - wwHandler.HandleFunc("/overlay-runtime/", warewulfd.HandleOverlay) - } + wwHandler.HandleFunc("/overlay-system/", warewulfd.HandleSystemOverlay) + wwHandler.HandleFunc("/overlay-runtime/", warewulfd.HandleRuntimeOverlay) wwHandler.HandleFunc("/overlay-file/", warewulfd.HandleOverlayFile) wwHandler.HandleFunc("/status", warewulfd.HandleStatus) @@ -87,7 +85,7 @@ func RunServer() error { apiHandler = api.Handler(auth, conf.API.AllowedIPNets()) } - httpHandler := configureRootHandler(apiHandler, !conf.Warewulf.EnableTLS()) + httpHandler := configureRootHandler(apiHandler) errChan := make(chan error, 2) @@ -98,7 +96,7 @@ func RunServer() error { if !util.IsFile(key) || !util.IsFile(crt) { return fmt.Errorf("TLS enabled but keys not found in %s, run 'wwctl configure tls --create' to generate keys", path.Join(conf.Paths.Sysconfdir, "warewulf", "tls")) } - httpsHandler := configureRootHandler(apiHandler, true) + httpsHandler := configureRootHandler(apiHandler) go func() { wwlog.Info("Starting HTTPS service on port %d", conf.Warewulf.SecurePort) if err := http.ListenAndServeTLS(":"+strconv.Itoa(conf.Warewulf.SecurePort), crt, key, httpsHandler); err != nil { From a886b4958b50f43d3e123b72fd0c729672c74143 Mon Sep 17 00:00:00 2001 From: Jonathon Anderson Date: Wed, 4 Mar 2026 08:18:07 -0700 Subject: [PATCH 22/29] Simplify and clarify configuration - changed "secure port" to "tls port" - removed the --create flag for "wwctl configure tls"; made it the default behavior - fixed a TLS creation bug in "wwctl configure --all" - added logging to "wwctl configure tls" and "wwctl configure --all" (for the tls case) Signed-off-by: Jonathon Anderson --- dracut/modules.d/90wwinit/load-wwinit.sh | 2 +- etc/nodes.conf | 1 + internal/app/wwclient/root.go | 2 +- internal/app/wwctl/configure/main.go | 16 ++------- internal/app/wwctl/configure/tls/main.go | 30 +++++++--------- internal/app/wwctl/configure/tls/main_test.go | 11 +++--- internal/app/wwctl/configure/tls/root.go | 2 -- internal/pkg/config/buildconfig.go.in | 2 +- internal/pkg/config/root_test.go | 10 +++--- internal/pkg/configure/tls.go | 34 +++++++++++++++++-- internal/pkg/warewulfd/server/server.go | 6 ++-- overlays/wwinit/internal/wwinit_test.go | 2 +- overlays/wwinit/rootfs/warewulf/config.ww | 2 +- 13 files changed, 65 insertions(+), 55 deletions(-) diff --git a/dracut/modules.d/90wwinit/load-wwinit.sh b/dracut/modules.d/90wwinit/load-wwinit.sh index a40ef978..d89867d2 100644 --- a/dracut/modules.d/90wwinit/load-wwinit.sh +++ b/dracut/modules.d/90wwinit/load-wwinit.sh @@ -61,7 +61,7 @@ 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}:${WWSECUREPORT}/provision/${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 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 f296eff2..e25d7144 100644 --- a/internal/app/wwclient/root.go +++ b/internal/app/wwclient/root.go @@ -261,7 +261,7 @@ func CobraRunE(cmd *cobra.Command, args []string) (err error) { port := conf.Warewulf.Port scheme := "http" if conf.Warewulf.EnableTLS() { - port = conf.Warewulf.SecurePort + port = conf.Warewulf.TlsPort scheme = "https" } diff --git a/internal/app/wwctl/configure/main.go b/internal/app/wwctl/configure/main.go index 5532a197..5f63dc9a 100644 --- a/internal/app/wwctl/configure/main.go +++ b/internal/app/wwctl/configure/main.go @@ -1,14 +1,11 @@ package configure import ( - "fmt" "os" - "path" "github.com/spf13/cobra" warewulfconf "github.com/warewulf/warewulf/internal/pkg/config" "github.com/warewulf/warewulf/internal/pkg/configure" - "github.com/warewulf/warewulf/internal/pkg/util" "github.com/warewulf/warewulf/internal/pkg/wwlog" ) @@ -23,17 +20,8 @@ func CobraRunE(cmd *cobra.Command, args []string) error { } if allFunctions { - keystore := path.Join(conf.Paths.Sysconfdir, "warewulf", "tls") - keyFile := path.Join(keystore, "warewulf.key") - certFile := path.Join(keystore, "warewulf.crt") - - if !util.IsFile(keyFile) || !util.IsFile(certFile) { - err = configure.GenTLSKeys() - if err != nil { - return err - } - } else { - fmt.Printf("Keys already exist in %s\n", keystore) + if _, err = configure.TLS(false); err != nil { + return err } err = configure.WAREWULFD() diff --git a/internal/app/wwctl/configure/tls/main.go b/internal/app/wwctl/configure/tls/main.go index b84fe8a7..af99bc37 100644 --- a/internal/app/wwctl/configure/tls/main.go +++ b/internal/app/wwctl/configure/tls/main.go @@ -19,10 +19,6 @@ func CobraRunE(cmd *cobra.Command, args []string) error { conf := config.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") @@ -75,21 +71,21 @@ func CobraRunE(cmd *cobra.Command, args []string) error { return nil } - if util.IsFile(keyFile) && util.IsFile(certFile) && !force { - if create { - fmt.Fprintf(cmd.OutOrStdout(), "Keys already exist in %s\n", keystore) + if !conf.Warewulf.EnableTLS() { + 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 { - if create { - if err := configure.GenTLSKeys(); err != nil { - return err - } - if err := configure.WAREWULFD(); err != nil { - return fmt.Errorf("failed to restart warewulfd: %w", err) - } - } else { - return fmt.Errorf("keys not found in: %s", keystore) - } + fmt.Fprintf(cmd.OutOrStdout(), "Keys already exist in %s\n", keystore) } w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) diff --git a/internal/app/wwctl/configure/tls/main_test.go b/internal/app/wwctl/configure/tls/main_test.go index 7759dc91..8251b994 100644 --- a/internal/app/wwctl/configure/tls/main_test.go +++ b/internal/app/wwctl/configure/tls/main_test.go @@ -14,6 +14,9 @@ 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) @@ -24,11 +27,10 @@ func Test_Keys(t *testing.T) { t.Run("keys create", func(t *testing.T) { baseCmd := GetCommand() // Reset flags - create = true importPath = "" exportPath = "" - baseCmd.SetArgs([]string{"--create"}) + baseCmd.SetArgs([]string{}) buf := new(bytes.Buffer) baseCmd.SetOut(buf) baseCmd.SetErr(buf) @@ -43,11 +45,10 @@ func Test_Keys(t *testing.T) { t.Run("keys exist check", func(t *testing.T) { baseCmd := GetCommand() // Reset flags - create = true // Even with create, it should say they exist importPath = "" exportPath = "" - baseCmd.SetArgs([]string{"--create"}) + baseCmd.SetArgs([]string{}) buf := new(bytes.Buffer) baseCmd.SetOut(buf) baseCmd.SetErr(buf) @@ -61,7 +62,6 @@ func Test_Keys(t *testing.T) { t.Run("keys display", func(t *testing.T) { baseCmd := GetCommand() // Reset flags - create = false importPath = "" exportPath = "" @@ -88,7 +88,6 @@ func Test_Keys(t *testing.T) { baseCmd := GetCommand() // Reset flags - create = false importPath = "" exportPath = exportFullPath diff --git a/internal/app/wwctl/configure/tls/root.go b/internal/app/wwctl/configure/tls/root.go index 0fa24abc..9f90db93 100644 --- a/internal/app/wwctl/configure/tls/root.go +++ b/internal/app/wwctl/configure/tls/root.go @@ -18,14 +18,12 @@ var ( } importPath string exportPath string - create bool force bool ) func init() { baseCmd.PersistentFlags().StringVar(&importPath, "import", "", "Import keys from directory") baseCmd.PersistentFlags().StringVar(&exportPath, "export", "", "Export keys to directory") - baseCmd.PersistentFlags().BoolVar(&create, "create", false, "Create keys if they do not exist") baseCmd.PersistentFlags().BoolVarP(&force, "force", "f", false, "Enforce creation of keys even if they exist") } diff --git a/internal/pkg/config/buildconfig.go.in b/internal/pkg/config/buildconfig.go.in index d562f5cb..18909050 100644 --- a/internal/pkg/config/buildconfig.go.in +++ b/internal/pkg/config/buildconfig.go.in @@ -42,7 +42,7 @@ func (conf TFTPConf) Enabled() bool { // BaseConf. type WarewulfConf struct { Port int `yaml:"port,omitempty" default:"9873"` - SecurePort int `yaml:"secure port,omitempty" default:"9874"` + TlsPort int `yaml:"tls port,omitempty" default:"9874"` SecureP *bool `yaml:"secure,omitempty" default:"true"` EnableTLSP *bool `yaml:"tls,omitempty" default:"false"` UpdateInterval int `yaml:"update interval,omitempty" default:"60"` diff --git a/internal/pkg/config/root_test.go b/internal/pkg/config/root_test.go index fe5a6f41..26de357c 100644 --- a/internal/pkg/config/root_test.go +++ b/internal/pkg/config/root_test.go @@ -22,7 +22,7 @@ warewulf: host overlay: true port: 9873 secure: true - secure port: 9874 + tls port: 9874 update interval: 60 nfs: enabled: true @@ -70,7 +70,7 @@ warewulf: host overlay: true port: 9873 secure: true - secure port: 9874 + tls port: 9874 update interval: 60 nfs: enabled: true @@ -120,7 +120,7 @@ warewulf: host overlay: true port: 9873 secure: true - secure port: 9874 + tls port: 9874 update interval: 60 nfs: enabled: true @@ -167,7 +167,7 @@ warewulf: host overlay: true port: 9873 secure: true - secure port: 9874 + tls port: 9874 update interval: 60 nfs: enabled: true @@ -243,7 +243,7 @@ warewulf: host overlay: true port: 9873 secure: false - secure port: 9874 + tls port: 9874 update interval: 60 nfs: enabled: true diff --git a/internal/pkg/configure/tls.go b/internal/pkg/configure/tls.go index 4a65f72c..c6695a66 100644 --- a/internal/pkg/configure/tls.go +++ b/internal/pkg/configure/tls.go @@ -14,16 +14,44 @@ import ( "time" warewulfconf "github.com/warewulf/warewulf/internal/pkg/config" + "github.com/warewulf/warewulf/internal/pkg/util" "github.com/warewulf/warewulf/internal/pkg/wwlog" ) -/* -GenTLSKeys checks for existence of x509 keys and creates them if they don't exist. -*/ +// 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.EnableTLS() { + 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) diff --git a/internal/pkg/warewulfd/server/server.go b/internal/pkg/warewulfd/server/server.go index 87dd9429..4523b15b 100644 --- a/internal/pkg/warewulfd/server/server.go +++ b/internal/pkg/warewulfd/server/server.go @@ -94,12 +94,12 @@ func RunServer() error { 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 --create' to generate keys", path.Join(conf.Paths.Sysconfdir, "warewulf", "tls")) + 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.SecurePort) - if err := http.ListenAndServeTLS(":"+strconv.Itoa(conf.Warewulf.SecurePort), crt, key, httpsHandler); err != nil { + 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) } }() diff --git a/overlays/wwinit/internal/wwinit_test.go b/overlays/wwinit/internal/wwinit_test.go index 368ed72b..c1149179 100644 --- a/overlays/wwinit/internal/wwinit_test.go +++ b/overlays/wwinit/internal/wwinit_test.go @@ -103,7 +103,7 @@ WWIPMI_USER="user" WWIPMI_PASSWORD="password" WWIPMI_WRITE="true" WWTLS=false -WWSECUREPORT=9874 +WWTLSPORT=9874 WWIPADDR=192.168.0.1 ` diff --git a/overlays/wwinit/rootfs/warewulf/config.ww b/overlays/wwinit/rootfs/warewulf/config.ww index 36e19fca..3b19adfb 100644 --- a/overlays/wwinit/rootfs/warewulf/config.ww +++ b/overlays/wwinit/rootfs/warewulf/config.ww @@ -12,5 +12,5 @@ WWIPMI_WRITE="{{$.Ipmi.Write.Bool}}" WWIPMI_VLAN="{{$.Ipmi.Tags.vlan}}" {{- end }} WWTLS={{$.Warewulf.EnableTLS}} -WWSECUREPORT={{$.Warewulf.SecurePort}} +WWTLSPORT={{$.Warewulf.TlsPort}} WWIPADDR={{$.Ipaddr}} From 779434331c5ae4c94dde8313dec31ae6fa8e47ca Mon Sep 17 00:00:00 2001 From: Jonathon Anderson Date: Fri, 6 Mar 2026 11:38:40 -0700 Subject: [PATCH 23/29] Add GRUB config route, tests, and server route documentation - Add /grub/{hwaddr} route for serving GRUB configuration files, replacing the shim handler with a config-based approach - Add short URL aliases: 'system' for overlay-system, 'runtime' for overlay-runtime, and 'grub' stage in parser - Add 'grub' to status stage tracking in request handling - Add comprehensive test coverage for EFI, GRUB, initramfs, iPXE, overlay, and provision handlers (efi_test.go, grub_test.go, initramfs_test.go, ipxe_test.go, overlay_test.go) - Expand existing parser and provision tests - Remove shim.go (functionality folded into grub.go) - Add userdocs/server/routes.rst documenting all warewulfd HTTP routes Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Jonathon Anderson --- internal/pkg/warewulfd/efi_test.go | 90 +++++++ internal/pkg/warewulfd/grub.go | 21 +- internal/pkg/warewulfd/grub_test.go | 76 ++++++ internal/pkg/warewulfd/initramfs_test.go | 60 +++++ internal/pkg/warewulfd/ipxe_test.go | 80 ++++++ internal/pkg/warewulfd/overlay_test.go | 89 +++++++ internal/pkg/warewulfd/parser.go | 43 ++- internal/pkg/warewulfd/parser_test.go | 133 +++++++++- internal/pkg/warewulfd/provision.go | 4 - internal/pkg/warewulfd/provision_test.go | 15 +- internal/pkg/warewulfd/server/server.go | 8 +- internal/pkg/warewulfd/shim.go | 34 --- userdocs/index.rst | 1 + userdocs/server/routes.rst | 322 +++++++++++++++++++++++ 14 files changed, 902 insertions(+), 74 deletions(-) create mode 100644 internal/pkg/warewulfd/efi_test.go create mode 100644 internal/pkg/warewulfd/grub_test.go create mode 100644 internal/pkg/warewulfd/initramfs_test.go create mode 100644 internal/pkg/warewulfd/ipxe_test.go delete mode 100644 internal/pkg/warewulfd/shim.go create mode 100644 userdocs/server/routes.rst diff --git a/internal/pkg/warewulfd/efi_test.go b/internal/pkg/warewulfd/efi_test.go new file mode 100644 index 00000000..cc423fb5 --- /dev/null +++ b/internal/pkg/warewulfd/efi_test.go @@ -0,0 +1,90 @@ +package warewulfd + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + + warewulfconf "github.com/warewulf/warewulf/internal/pkg/config" + "github.com/warewulf/warewulf/internal/pkg/testenv" +) + +var efiBootTests = []struct { + description string + url string + body string + status int + ip string +}{ + {"find shim", "/efiboot/shim.efi", "", 200, "10.10.10.10:9873"}, + {"find shim: node with missing image returns 404", "/efiboot/shim.efi", "", 404, "10.10.10.11:9873"}, + {"find grub", "/efiboot/grub.efi", "", 200, "10.10.10.10:9873"}, + {"find grub: node with missing image returns 404", "/efiboot/grub.efi", "", 404, "10.10.10.11:9873"}, + {"find grub.cfg", "/efiboot/grub.cfg", "dracut 10.10.0.1:9873", 200, "10.10.10.11:9873"}, +} + +func Test_HandleEfiBoot(t *testing.T) { + env := testenv.New(t) + defer env.RemoveAll() + + env.WriteFile("/etc/warewulf/nodes.conf", `nodeprofiles: + default: + image name: suse +nodes: + n1: + network devices: + default: + hwaddr: 00:00:00:ff:ff:ff + profiles: + - default + n2: + network devices: + default: + hwaddr: 00:00:00:00:ff:ff + image name: none + tags: + GrubMenuEntry: dracut`) + + env.WriteFile("/var/tmp/arpcache", `IP address HW type Flags HW address Mask Device +10.10.10.10 0x1 0x2 00:00:00:ff:ff:ff * dummy +10.10.10.11 0x1 0x2 00:00:00:00:ff:ff * dummy`) + prevArpFile := arpFile + arpFile = env.GetPath("/var/tmp/arpcache") + defer func() { + arpFile = prevArpFile + }() + + env.CreateFile("/var/lib/warewulf/chroots/suse/rootfs/usr/lib64/efi/shim.efi") + env.CreateFile("/var/lib/warewulf/chroots/suse/rootfs/usr/share/efi/x86_64/grub.efi") + env.WriteFile("/etc/warewulf/grub/grub.cfg.ww", "{{ .Tags.GrubMenuEntry }} {{ .Authority }}") + + dbErr := LoadNodeDB() + assert.NoError(t, dbErr) + + conf := warewulfconf.Get() + secureFalse := false + conf.Warewulf.SecureP = &secureFalse + conf.Ipaddr = "10.10.0.1" + conf.Ipaddr6 = "fd00:10::1" + + for _, tt := range efiBootTests { + t.Run(tt.description, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tt.url, nil) + req.RemoteAddr = tt.ip + w := httptest.NewRecorder() + HandleEfiBoot(w, req) + res := w.Result() + defer res.Body.Close() + + data, readErr := io.ReadAll(res.Body) + assert.NoError(t, readErr) + if tt.body != "" { + assert.Equal(t, tt.body, string(data)) + } + assert.Equal(t, tt.status, res.StatusCode) + }) + } +} diff --git a/internal/pkg/warewulfd/grub.go b/internal/pkg/warewulfd/grub.go index f01a43d9..a810c233 100644 --- a/internal/pkg/warewulfd/grub.go +++ b/internal/pkg/warewulfd/grub.go @@ -2,32 +2,25 @@ package warewulfd import ( "net/http" + "path" - "github.com/warewulf/warewulf/internal/pkg/image" "github.com/warewulf/warewulf/internal/pkg/wwlog" ) -// HandleGrub handles direct GRUB binary requests +// HandleGrub handles GRUB configuration requests func HandleGrub(w http.ResponseWriter, req *http.Request) { ctx, err := initHandleRequest(w, req) if err != nil { return // response already written } - var stageFile string - if !ctx.remoteNode.Valid() { wwlog.Error("%s (unknown/unconfigured node)", ctx.rinfo.hwaddr) - } else { - if ctx.remoteNode.ImageName != "" { - stageFile = image.GrubFind(ctx.remoteNode.ImageName) - if stageFile == "" { - wwlog.Error("No grub found for image %s", ctx.remoteNode.ImageName) - } - } else { - wwlog.Warn("No conainer set for node %s", ctx.remoteNode.Id()) - } + w.WriteHeader(http.StatusNotFound) + return } - sendResponse(w, req, stageFile, nil, ctx) + stageFile := path.Join(ctx.conf.Paths.Sysconfdir, "warewulf/grub/grub.cfg.ww") + tmplData := buildTemplateVars(ctx.conf, ctx.rinfo, ctx.remoteNode) + sendResponse(w, req, stageFile, tmplData, ctx) } diff --git a/internal/pkg/warewulfd/grub_test.go b/internal/pkg/warewulfd/grub_test.go new file mode 100644 index 00000000..df57af44 --- /dev/null +++ b/internal/pkg/warewulfd/grub_test.go @@ -0,0 +1,76 @@ +package warewulfd + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + + warewulfconf "github.com/warewulf/warewulf/internal/pkg/config" + "github.com/warewulf/warewulf/internal/pkg/testenv" +) + +var grubHandlerTests = []struct { + description string + url string + body string + status int + ip string +}{ + {"grub config for node with image", "/grub/00:00:00:ff:ff:ff", "", 200, "10.10.10.10:9873"}, + {"grub config rendered with tag", "/grub/00:00:00:00:ff:ff", "dracut 10.10.0.1:9873", 200, "10.10.10.11:9873"}, +} + +func Test_HandleGrub(t *testing.T) { + env := testenv.New(t) + defer env.RemoveAll() + + env.WriteFile("/etc/warewulf/nodes.conf", `nodeprofiles: + default: + image name: suse +nodes: + n1: + network devices: + default: + hwaddr: 00:00:00:ff:ff:ff + profiles: + - default + n2: + network devices: + default: + hwaddr: 00:00:00:00:ff:ff + image name: none + tags: + GrubMenuEntry: dracut`) + + env.WriteFile("/etc/warewulf/grub/grub.cfg.ww", "{{ .Tags.GrubMenuEntry }} {{ .Authority }}") + + dbErr := LoadNodeDB() + assert.NoError(t, dbErr) + + conf := warewulfconf.Get() + secureFalse := false + conf.Warewulf.SecureP = &secureFalse + conf.Ipaddr = "10.10.0.1" + conf.Ipaddr6 = "fd00:10::1" + + for _, tt := range grubHandlerTests { + t.Run(tt.description, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tt.url, nil) + req.RemoteAddr = tt.ip + w := httptest.NewRecorder() + HandleGrub(w, req) + res := w.Result() + defer res.Body.Close() + + data, readErr := io.ReadAll(res.Body) + assert.NoError(t, readErr) + if tt.body != "" { + assert.Equal(t, tt.body, string(data)) + } + assert.Equal(t, tt.status, res.StatusCode) + }) + } +} diff --git a/internal/pkg/warewulfd/initramfs_test.go b/internal/pkg/warewulfd/initramfs_test.go new file mode 100644 index 00000000..febbedde --- /dev/null +++ b/internal/pkg/warewulfd/initramfs_test.go @@ -0,0 +1,60 @@ +package warewulfd + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + + warewulfconf "github.com/warewulf/warewulf/internal/pkg/config" + "github.com/warewulf/warewulf/internal/pkg/testenv" +) + +var initramfsHandlerTests = []struct { + description string + url string + status int + ip string +}{ + {"find initramfs", "/initramfs/00:00:00:ff:ff:ff", 200, "10.10.10.10:9873"}, +} + +func Test_HandleInitramfs(t *testing.T) { + env := testenv.New(t) + defer env.RemoveAll() + + env.WriteFile("/etc/warewulf/nodes.conf", `nodeprofiles: + default: + image name: suse +nodes: + n1: + network devices: + default: + hwaddr: 00:00:00:ff:ff:ff + profiles: + - default`) + + env.CreateFile("/var/lib/warewulf/chroots/suse/rootfs/boot/vmlinuz-1.1.0") + env.CreateFile("/var/lib/warewulf/chroots/suse/rootfs/boot/initramfs-1.1.0.img") + + dbErr := LoadNodeDB() + assert.NoError(t, dbErr) + + conf := warewulfconf.Get() + secureFalse := false + conf.Warewulf.SecureP = &secureFalse + + for _, tt := range initramfsHandlerTests { + t.Run(tt.description, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tt.url, nil) + req.RemoteAddr = tt.ip + w := httptest.NewRecorder() + HandleInitramfs(w, req) + res := w.Result() + defer res.Body.Close() + + assert.Equal(t, tt.status, res.StatusCode) + }) + } +} diff --git a/internal/pkg/warewulfd/ipxe_test.go b/internal/pkg/warewulfd/ipxe_test.go new file mode 100644 index 00000000..9aaacc51 --- /dev/null +++ b/internal/pkg/warewulfd/ipxe_test.go @@ -0,0 +1,80 @@ +package warewulfd + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + + warewulfconf "github.com/warewulf/warewulf/internal/pkg/config" + "github.com/warewulf/warewulf/internal/pkg/testenv" +) + +var ipxeHandlerTests = []struct { + description string + url string + body string + status int + ip string +}{ + { + "ipxe with NetDevs, KernelVersion, and Authority", + "/ipxe/00:00:00:00:00:ff", + "1.1.1 ifname=net:00:00:00:00:00:ff 10.10.0.1 fd00:10::1 10.10.0.1:9873", + 200, + "10.10.10.12:9873", + }, + { + "ipxe over ipv6", + "/ipxe/00:00:00:00:00:ff", + "1.1.1 ifname=net:00:00:00:00:00:ff 10.10.0.1 fd00:10::1 [fd00:10::1]:9873", + 200, + "[fd00:10::10:12]:9873", + }, +} + +func Test_HandleIpxe(t *testing.T) { + env := testenv.New(t) + defer env.RemoveAll() + + env.WriteFile("/etc/warewulf/nodes.conf", `nodes: + n3: + network devices: + default: + hwaddr: 00:00:00:00:00:ff + device: net + ipxe template: test + kernel: + version: 1.1.1`) + + env.WriteFile("/etc/warewulf/ipxe/test.ipxe", "{{.KernelVersion}}{{range $devname, $netdev := .NetDevs}}{{if and $netdev.Hwaddr $netdev.Device}} ifname={{$netdev.Device}}:{{$netdev.Hwaddr}} {{end}}{{end}} {{.Ipaddr}} {{.Ipaddr6}} {{.Authority}}") + + dbErr := LoadNodeDB() + assert.NoError(t, dbErr) + + conf := warewulfconf.Get() + secureFalse := false + conf.Warewulf.SecureP = &secureFalse + conf.Ipaddr = "10.10.0.1" + conf.Ipaddr6 = "fd00:10::1" + + for _, tt := range ipxeHandlerTests { + t.Run(tt.description, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tt.url, nil) + req.RemoteAddr = tt.ip + w := httptest.NewRecorder() + HandleIpxe(w, req) + res := w.Result() + defer res.Body.Close() + + data, readErr := io.ReadAll(res.Body) + assert.NoError(t, readErr) + if tt.body != "" { + assert.Equal(t, tt.body, string(data)) + } + assert.Equal(t, tt.status, res.StatusCode) + }) + } +} diff --git a/internal/pkg/warewulfd/overlay_test.go b/internal/pkg/warewulfd/overlay_test.go index f1668993..17d28dfb 100644 --- a/internal/pkg/warewulfd/overlay_test.go +++ b/internal/pkg/warewulfd/overlay_test.go @@ -4,10 +4,13 @@ import ( "io" "net/http" "net/http/httptest" + "os" + "path" "testing" "github.com/stretchr/testify/assert" + warewulfconf "github.com/warewulf/warewulf/internal/pkg/config" "github.com/warewulf/warewulf/internal/pkg/testenv" ) @@ -108,3 +111,89 @@ nodes: }) } } + +var systemOverlayTests = []struct { + description string + url string + body string + status int + ip string +}{ + {"system overlay", "/system/00:00:00:ff:ff:ff", "system overlay", 200, "10.10.10.10:9873"}, + {"fake overlay returns 404", "/system/00:00:00:ff:ff:ff?overlay=fake", "", 404, "10.10.10.10:9873"}, + {"specific overlay", "/system/00:00:00:ff:ff:ff?overlay=o1", "specific overlay", 200, "10.10.10.10:9873"}, +} + +var runtimeOverlayTests = []struct { + description string + url string + body string + status int + ip string +}{ + {"runtime overlay", "/runtime/00:00:00:ff:ff:ff", "runtime overlay", 200, "10.10.10.10:9873"}, +} + +func Test_HandleSystemRuntimeOverlay(t *testing.T) { + env := testenv.New(t) + defer env.RemoveAll() + + env.WriteFile("/etc/warewulf/nodes.conf", `nodeprofiles: + default: + image name: suse +nodes: + n1: + network devices: + default: + hwaddr: 00:00:00:ff:ff:ff + profiles: + - default`) + + dbErr := LoadNodeDB() + assert.NoError(t, dbErr) + + conf := warewulfconf.Get() + secureFalse := false + conf.Warewulf.SecureP = &secureFalse + + assert.NoError(t, os.MkdirAll(path.Join(conf.Paths.OverlayProvisiondir(), "n1"), 0700)) + assert.NoError(t, os.WriteFile(path.Join(conf.Paths.OverlayProvisiondir(), "n1", "__SYSTEM__.img"), []byte("system overlay"), 0600)) + assert.NoError(t, os.WriteFile(path.Join(conf.Paths.OverlayProvisiondir(), "n1", "__RUNTIME__.img"), []byte("runtime overlay"), 0600)) + assert.NoError(t, os.WriteFile(path.Join(conf.Paths.OverlayProvisiondir(), "n1", "o1.img"), []byte("specific overlay"), 0600)) + + for _, tt := range systemOverlayTests { + t.Run(tt.description, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tt.url, nil) + req.RemoteAddr = tt.ip + w := httptest.NewRecorder() + HandleSystemOverlay(w, req) + res := w.Result() + defer res.Body.Close() + + data, readErr := io.ReadAll(res.Body) + assert.NoError(t, readErr) + if tt.body != "" { + assert.Equal(t, tt.body, string(data)) + } + assert.Equal(t, tt.status, res.StatusCode) + }) + } + + for _, tt := range runtimeOverlayTests { + t.Run(tt.description, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tt.url, nil) + req.RemoteAddr = tt.ip + w := httptest.NewRecorder() + HandleRuntimeOverlay(w, req) + res := w.Result() + defer res.Body.Close() + + data, readErr := io.ReadAll(res.Body) + assert.NoError(t, readErr) + if tt.body != "" { + assert.Equal(t, tt.body, string(data)) + } + assert.Equal(t, tt.status, res.StatusCode) + }) + } +} diff --git a/internal/pkg/warewulfd/parser.go b/internal/pkg/warewulfd/parser.go index 8347a659..47efe6f4 100644 --- a/internal/pkg/warewulfd/parser.go +++ b/internal/pkg/warewulfd/parser.go @@ -47,6 +47,7 @@ func initHandleRequest(w http.ResponseWriter, req *http.Request) (*requestContex status_stages := map[string]string{ "efiboot": "EFI", + "grub": "GRUB", "ipxe": "IPXE", "kernel": "KERNEL", "system": "SYSTEM_OVERLAY", @@ -102,14 +103,23 @@ func parseRequest(req *http.Request) (parsedRequest, error) { // handle when stage was passed in the url path /[stage]/hwaddr stage := path_parts[1] hwaddr := "" - if stage != "efiboot" { + switch stage { + case "efiboot": + // /efiboot/{file}: no hwaddr in path; identified via ARP + if len(path_parts) > 3 { + ret.efifile = strings.Join(path_parts[2:], "/") + } else { + ret.efifile = path_parts[2] + } + case "grub": + // /grub/{hwaddr}: hwaddr explicit in path + hwaddr = path_parts[2] + hwaddr = strings.ReplaceAll(hwaddr, "-", ":") + hwaddr = strings.ToLower(hwaddr) + default: hwaddr = path_parts[2] hwaddr = strings.ReplaceAll(hwaddr, "-", ":") hwaddr = strings.ToLower(hwaddr) - } else if len(path_parts) > 3 { - ret.efifile = strings.Join(path_parts[2:], "/") - } else { - ret.efifile = path_parts[2] } ret.hwaddr = hwaddr remoteAddrPort, err := netip.ParseAddrPort(req.RemoteAddr) @@ -137,12 +147,14 @@ func parseRequest(req *http.Request) (parsedRequest, error) { ret.stage = "kernel" case "image", "container": ret.stage = "image" - case "overlay-system": + case "overlay-system", "system": ret.stage = "system" - case "overlay-runtime": + case "overlay-runtime", "runtime": ret.stage = "runtime" case "efiboot": ret.stage = "efiboot" + case "grub": + ret.stage = "grub" case "initramfs": ret.stage = "initramfs" } @@ -154,14 +166,23 @@ func parseRequest(req *http.Request) (parsedRequest, error) { if len(req.URL.Query()["compress"]) > 0 { ret.compress = req.URL.Query()["compress"][0] } + if ret.efifile == "" && len(req.URL.Query()["file"]) > 0 { + ret.efifile = req.URL.Query()["file"][0] + } if ret.stage == "" { return ret, errors.New("no stage encoded in GET") } if ret.hwaddr == "" { - ret.hwaddr = ArpFind(ret.ipaddr) - wwlog.Verbose("node mac not encoded, arp cache got %s for %s", ret.hwaddr, ret.ipaddr) - if ret.hwaddr == "" { - return ret, errors.New("no hwaddr encoded in GET") + if len(req.URL.Query()["wwid"]) > 0 { + ret.hwaddr = req.URL.Query()["wwid"][0] + ret.hwaddr = strings.ReplaceAll(ret.hwaddr, "-", ":") + ret.hwaddr = strings.ToLower(ret.hwaddr) + } else { + ret.hwaddr = ArpFind(ret.ipaddr) + wwlog.Verbose("node mac not encoded, arp cache got %s for %s", ret.hwaddr, ret.ipaddr) + if ret.hwaddr == "" { + return ret, errors.New("no hwaddr encoded in GET") + } } } if ret.ipaddr == "" { diff --git a/internal/pkg/warewulfd/parser_test.go b/internal/pkg/warewulfd/parser_test.go index 03efb651..06cd82d6 100644 --- a/internal/pkg/warewulfd/parser_test.go +++ b/internal/pkg/warewulfd/parser_test.go @@ -11,6 +11,7 @@ import ( var parseReqTests = []struct { description string url string + rawQuery string remoteAddr string result parsedRequest }{ @@ -36,13 +37,143 @@ var parseReqTests = []struct { stage: "ipxe", }, }, + { + description: "initramfs dedicated route", + url: "/initramfs/00:00:00:ff:ff:ff", + remoteAddr: "10.5.1.1:9873", + result: parsedRequest{ + hwaddr: "00:00:00:ff:ff:ff", + ipaddr: "10.5.1.1", + remoteport: 9873, + stage: "initramfs", + }, + }, + { + description: "grub route with hwaddr in path", + url: "/grub/00:00:00:ff:ff:ff", + remoteAddr: "10.5.1.1:9873", + result: parsedRequest{ + hwaddr: "00:00:00:ff:ff:ff", + ipaddr: "10.5.1.1", + remoteport: 9873, + stage: "grub", + }, + }, + { + description: "wwid query param on dedicated route", + url: "/kernel/", + rawQuery: "wwid=00%3A00%3A00%3Aff%3Aff%3Aff", + remoteAddr: "10.5.1.1:9873", + result: parsedRequest{ + hwaddr: "00:00:00:ff:ff:ff", + ipaddr: "10.5.1.1", + remoteport: 9873, + stage: "kernel", + }, + }, + { + description: "wwid and stage query params on provision route", + url: "/provision/", + rawQuery: "wwid=00%3A00%3A00%3Aff%3Aff%3Aff&stage=kernel", + remoteAddr: "10.5.1.1:9873", + result: parsedRequest{ + hwaddr: "00:00:00:ff:ff:ff", + ipaddr: "10.5.1.1", + remoteport: 9873, + stage: "kernel", + }, + }, + { + description: "efiboot with wwid and file query params", + url: "/efiboot/", + rawQuery: "wwid=00%3A00%3A00%3Aff%3Aff%3Aff&file=shim.efi", + remoteAddr: "10.5.1.1:9873", + result: parsedRequest{ + hwaddr: "00:00:00:ff:ff:ff", + ipaddr: "10.5.1.1", + remoteport: 9873, + stage: "efiboot", + efifile: "shim.efi", + }, + }, + { + description: "grub with wwid query param", + url: "/grub/", + rawQuery: "wwid=00%3A00%3A00%3Aff%3Aff%3Aff", + remoteAddr: "10.5.1.1:9873", + result: parsedRequest{ + hwaddr: "00:00:00:ff:ff:ff", + ipaddr: "10.5.1.1", + remoteport: 9873, + stage: "grub", + }, + }, + { + description: "path hwaddr takes priority over wwid query param", + url: "/kernel/00:00:00:ff:ff:ff", + rawQuery: "wwid=11%3A11%3A11%3A11%3A11%3A11", + remoteAddr: "10.5.1.1:9873", + result: parsedRequest{ + hwaddr: "00:00:00:ff:ff:ff", + ipaddr: "10.5.1.1", + remoteport: 9873, + stage: "kernel", + }, + }, + { + description: "system overlay dedicated route", + url: "/system/00:00:00:ff:ff:ff", + remoteAddr: "10.5.1.1:9873", + result: parsedRequest{ + hwaddr: "00:00:00:ff:ff:ff", + ipaddr: "10.5.1.1", + remoteport: 9873, + stage: "system", + }, + }, + { + description: "runtime overlay dedicated route", + url: "/runtime/00:00:00:ff:ff:ff", + remoteAddr: "10.5.1.1:9873", + result: parsedRequest{ + hwaddr: "00:00:00:ff:ff:ff", + ipaddr: "10.5.1.1", + remoteport: 9873, + stage: "runtime", + }, + }, + { + description: "stage=grub via provision route serves config (new semantics)", + url: "/provision/00:00:00:ff:ff:ff", + rawQuery: "stage=grub", + remoteAddr: "10.5.1.1:9873", + result: parsedRequest{ + hwaddr: "00:00:00:ff:ff:ff", + ipaddr: "10.5.1.1", + remoteport: 9873, + stage: "grub", + }, + }, + { + description: "path efifile takes priority over file query param", + url: "/efiboot/shim.efi", + rawQuery: "wwid=00%3A00%3A00%3Aff%3Aff%3Aff&file=grub.cfg", + remoteAddr: "10.5.1.1:9873", + result: parsedRequest{ + hwaddr: "00:00:00:ff:ff:ff", + ipaddr: "10.5.1.1", + remoteport: 9873, + stage: "efiboot", + efifile: "shim.efi", + }, + }, } func Test_ParseRequest(t *testing.T) { for _, tt := range parseReqTests { t.Run(tt.description, func(t *testing.T) { req := &http.Request{ - URL: &url.URL{Path: tt.url}, + URL: &url.URL{Path: tt.url, RawQuery: tt.rawQuery}, RemoteAddr: tt.remoteAddr, } result, err := parseRequest(req) diff --git a/internal/pkg/warewulfd/provision.go b/internal/pkg/warewulfd/provision.go index cebdfb43..25d0b9ff 100644 --- a/internal/pkg/warewulfd/provision.go +++ b/internal/pkg/warewulfd/provision.go @@ -51,10 +51,6 @@ func HandleProvision(w http.ResponseWriter, req *http.Request) { handler = HandleSystemOverlay case "runtime": handler = HandleRuntimeOverlay - case "efiboot": - handler = HandleEfiBoot - case "shim": - handler = HandleShim case "grub": handler = HandleGrub case "initramfs": diff --git a/internal/pkg/warewulfd/provision_test.go b/internal/pkg/warewulfd/provision_test.go index 0f0d043f..03ae754e 100644 --- a/internal/pkg/warewulfd/provision_test.go +++ b/internal/pkg/warewulfd/provision_test.go @@ -21,18 +21,15 @@ var provisionSendTests = []struct { status int ip string }{ - {"system overlay", "/overlay-system/00:00:00:ff:ff:ff", "system overlay", 200, "10.10.10.10:9873"}, - {"runtime overlay", "/overlay-runtime/00:00:00:ff:ff:ff", "runtime overlay", 200, "10.10.10.10:9873"}, - {"fake overlay", "/overlay-system/00:00:00:ff:ff:ff?overlay=fake", "", 404, "10.10.10.10:9873"}, - {"specific overlay", "/overlay-system/00:00:00:ff:ff:ff?overlay=o1", "specific overlay", 200, "10.10.10.10:9873"}, - {"find shim", "/efiboot/shim.efi", "", 200, "10.10.10.10:9873"}, - {"find shim", "/efiboot/shim.efi", "", 404, "10.10.10.11:9873"}, - {"find grub", "/efiboot/grub.efi", "", 200, "10.10.10.10:9873"}, - {"find grub", "/efiboot/grub.efi", "", 404, "10.10.10.11:9873"}, + {"system overlay", "/provision/00:00:00:ff:ff:ff?stage=system", "system overlay", 200, "10.10.10.10:9873"}, + {"runtime overlay", "/provision/00:00:00:ff:ff:ff?stage=runtime", "runtime overlay", 200, "10.10.10.10:9873"}, + {"fake overlay", "/provision/00:00:00:ff:ff:ff?stage=system&overlay=fake", "", 404, "10.10.10.10:9873"}, + {"specific overlay", "/provision/00:00:00:ff:ff:ff?stage=system&overlay=o1", "specific overlay", 200, "10.10.10.10:9873"}, + {"grub config", "/provision/00:00:00:ff:ff:ff?stage=grub", "", 200, "10.10.10.10:9873"}, + {"grub config rendered", "/provision/00:00:00:00:ff:ff?stage=grub", "dracut 10.10.0.1:9873", 200, "10.10.10.11:9873"}, {"find initramfs", "/provision/00:00:00:ff:ff:ff?stage=initramfs", "", 200, "10.10.10.10:9873"}, {"ipxe test with NetDevs, KernelVersion, and Authority", "/provision/00:00:00:00:00:ff?stage=ipxe", "1.1.1 ifname=net:00:00:00:00:00:ff 10.10.0.1 fd00:10::1 10.10.0.1:9873", 200, "10.10.10.12:9873"}, {"ipxe ipv6", "/provision/00:00:00:00:00:ff?stage=ipxe", "1.1.1 ifname=net:00:00:00:00:00:ff 10.10.0.1 fd00:10::1 [fd00:10::1]:9873", 200, "[fd00:10::10:12]:9873"}, - {"find grub.cfg", "/efiboot/grub.cfg", "dracut 10.10.0.1:9873", 200, "10.10.10.11:9873"}, } func Test_ProvisionSend(t *testing.T) { diff --git a/internal/pkg/warewulfd/server/server.go b/internal/pkg/warewulfd/server/server.go index 4523b15b..7e37c835 100644 --- a/internal/pkg/warewulfd/server/server.go +++ b/internal/pkg/warewulfd/server/server.go @@ -42,13 +42,19 @@ func configureRootHandler(apiHandler http.Handler) *slashFix { wwHandler.HandleFunc("/provision/", warewulfd.HandleProvision) wwHandler.HandleFunc("/ipxe/", warewulfd.HandleIpxe) wwHandler.HandleFunc("/efiboot/", warewulfd.HandleEfiBoot) + wwHandler.HandleFunc("/grub/", warewulfd.HandleGrub) wwHandler.HandleFunc("/kernel/", warewulfd.HandleKernel) wwHandler.HandleFunc("/image/", warewulfd.HandleImage) + wwHandler.HandleFunc("/initramfs/", warewulfd.HandleInitramfs) + wwHandler.HandleFunc("/system/", warewulfd.HandleSystemOverlay) + wwHandler.HandleFunc("/runtime/", warewulfd.HandleRuntimeOverlay) + wwHandler.HandleFunc("/status", warewulfd.HandleStatus) + + /* Deprecated */ wwHandler.HandleFunc("/container/", warewulfd.HandleImage) wwHandler.HandleFunc("/overlay-system/", warewulfd.HandleSystemOverlay) wwHandler.HandleFunc("/overlay-runtime/", warewulfd.HandleRuntimeOverlay) wwHandler.HandleFunc("/overlay-file/", warewulfd.HandleOverlayFile) - wwHandler.HandleFunc("/status", warewulfd.HandleStatus) if apiHandler != nil { wwHandler.Handle("/api/", apiHandler) diff --git a/internal/pkg/warewulfd/shim.go b/internal/pkg/warewulfd/shim.go deleted file mode 100644 index 9d3c6270..00000000 --- a/internal/pkg/warewulfd/shim.go +++ /dev/null @@ -1,34 +0,0 @@ -package warewulfd - -import ( - "net/http" - - "github.com/warewulf/warewulf/internal/pkg/image" - "github.com/warewulf/warewulf/internal/pkg/wwlog" -) - -// HandleShim handles direct shim binary requests -func HandleShim(w http.ResponseWriter, req *http.Request) { - ctx, err := initHandleRequest(w, req) - if err != nil { - return // response already written - } - - var stageFile string - - if !ctx.remoteNode.Valid() { - wwlog.Error("%s (unknown/unconfigured node)", ctx.rinfo.hwaddr) - } else { - if ctx.remoteNode.ImageName != "" { - stageFile = image.ShimFind(ctx.remoteNode.ImageName) - - if stageFile == "" { - wwlog.Error("No kernel found for image %s", ctx.remoteNode.ImageName) - } - } else { - wwlog.Warn("No image set for this %s", ctx.remoteNode.Id()) - } - } - - sendResponse(w, req, stageFile, nil, ctx) -} diff --git a/userdocs/index.rst b/userdocs/index.rst index 91021544..6558330c 100644 --- a/userdocs/index.rst +++ b/userdocs/index.rst @@ -23,6 +23,7 @@ Welcome to the Warewulf User Guide! Server Installation Controlling Warewulf (wwctl) Server Configuration + Server Routes Using dnsmasq Security Bootloaders diff --git a/userdocs/server/routes.rst b/userdocs/server/routes.rst new file mode 100644 index 00000000..7cc299b8 --- /dev/null +++ b/userdocs/server/routes.rst @@ -0,0 +1,322 @@ +.. _server-routes: + +============= +Server Routes +============= + +The Warewulf provisioning daemon, ``warewulfd``, serves all boot and provisioning +resources over HTTP. Each resource type has a dedicated route, and nodes are +identified by their Warewulf ID (``wwid``). + +``{wwid}`` is typically the node's default MAC address: a colon-separated +hexadecimal string, e.g., ``aa:bb:cc:dd:ee:ff``. Dashes are accepted in place +of colons and are normalized automatically. + +.. note:: + + The port ``warewulfd`` listens on is configured with ``warewulf:port`` in + ``warewulf.conf`` (default: ``9873``). When TLS is enabled, a second listener + is started on the port configured with ``warewulf:tls port`` (default: ``9874``). + +URL Patterns +============ + +Every provisioning route (except ``/overlay-file/`` and ``/status``) supports +six equivalent URL patterns for specifying the node identity: + +.. code-block:: none + + /{stage}/{wwid} # wwid in path + /{stage}?wwid={wwid} # wwid as query parameter + /{stage} # wwid resolved from ARP cache + + /provision/{wwid}?stage={stage} + /provision?wwid={wwid}&stage={stage} + /provision?stage={stage} # wwid resolved from ARP cache + +The ``/efiboot/`` route is an exception: the path segment contains the boot +file name rather than a wwid, and the node is identified via ``?wwid=`` or ARP: + +.. code-block:: none + + /efiboot/{file} # file in path, wwid from ARP + /efiboot/{file}?wwid={wwid} # file in path, explicit wwid + /efiboot?wwid={wwid}&file={file} # both as query parameters + +Common Query Parameters +======================= + +Most provisioning routes accept the following query parameters: + +* ``wwid``: Warewulf ID of the node, typically its default MAC address. Used + when the node identity is not embedded in the URL path. Takes priority over + ARP lookup; the path always takes priority over the ``wwid`` query parameter. + +* ``assetkey``: Hardware asset tag. If the node has an ``AssetKey`` configured + in ``nodes.conf``, the server requires this parameter to match before serving + any content. See :ref:`Security ` below. + +* ``uuid``: System UUID of the requesting node. Accepted for logging purposes. + +* ``compress``: Compression format for the response. The only supported value + is ``gz``. When ``compress=gz`` is specified, the server serves a pre-built + gzip-compressed version of the file. If no compressed version exists, the + server returns ``404 Not Found``. + +Provisioning Routes +=================== + +``/ipxe/{wwid}`` +---------------- + +Serves a rendered iPXE boot script for the node identified by ``{wwid}``. + +The script is rendered as a Go template from a file in +``/etc/warewulf/ipxe/``. The specific template used is determined by the +node's ``Ipxe`` field (defaulting to ``default``); for example, a node with +``Ipxe: dracut`` receives the template from +``/etc/warewulf/ipxe/dracut.ipxe``. + +If the requesting node is not known to Warewulf, the server falls back to +serving ``/etc/warewulf/ipxe/unconfigured.ipxe``. + +**Query parameters:** ``assetkey``, ``uuid`` + +``/kernel/{wwid}`` +------------------ + +Serves the raw kernel binary for the node identified by ``{wwid}``. The +kernel is taken from the node's assigned image. + +**Query parameters:** ``assetkey``, ``uuid``, ``compress`` + +``/image/{wwid}`` +----------------- + +Serves the raw node image file for the node identified by ``{wwid}``. + +**Query parameters:** ``assetkey``, ``uuid``, ``compress`` + +``/initramfs/{wwid}`` +--------------------- + +Serves the initramfs binary for the node identified by ``{wwid}``. The +initramfs is extracted from the node's assigned image based on the node's +kernel version. This route is used in two-stage boot configurations. See +:ref:`booting with dracut` for details. + +**Query parameters:** ``assetkey``, ``uuid``, ``compress`` + +``/system/{wwid}`` +------------------ + +Serves the system overlay image for the node identified by ``{wwid}``. +The system overlay is rendered at provisioning time and contains +configuration files that are static for the lifetime of the boot. + +When ``autobuild overlays`` is enabled in ``warewulf.conf``, the server +will automatically rebuild the overlay if it is out of date relative to +``nodes.conf`` or the overlay source files. + +**Query parameters:** ``assetkey``, ``uuid``, ``compress``, ``overlay`` + +* ``overlay``: A comma-separated list of overlay names. When specified, only + the named overlays are served (rather than the node's full system overlay + set). + +``/runtime/{wwid}`` +------------------- + +Serves the runtime overlay image for the node identified by ``{wwid}``. +The runtime overlay is rendered on demand and may contain node-specific +secrets. ``wwclient`` fetches the runtime overlay periodically during normal +operation. + +When ``warewulf:secure`` is enabled in ``warewulf.conf``, this route requires +that the request originate from a privileged TCP port (port number less than +1024). This prevents unprivileged users on a node from retrieving the runtime +overlay. + +When TLS is enabled in ``warewulf.conf``, this route requires that the request +arrive over HTTPS. Plain-HTTP requests are rejected with ``403 Forbidden``. The +HTTPS listener port is configured with ``warewulf:tls port``. + +**Query parameters:** ``assetkey``, ``uuid``, ``compress``, ``overlay`` + +* ``overlay``: A comma-separated list of overlay names. Same behavior as for + ``/system/``. + +``/overlay-file/{overlay}/{path}`` +---------------------------------- + +Provides direct access to an individual file within a named overlay. This +route uses a different URL structure than the other provisioning routes: the +overlay name is in the second path segment, and the file path within the overlay +follows. + +If the ``render`` parameter is provided, the file is rendered as a Go template +for the specified node and the rendered content is returned. If ``render`` is +absent, the raw file bytes are returned without any template processing. + +If the requested path does not end in ``.ww`` but a ``.ww``-suffixed version of +the file exists, and a ``render`` node is specified, the server automatically +serves the ``.ww`` template. + +**Query parameters:** + +* ``render``: Node ID to render the template for. If not specified, the raw + file is returned. + +.. note:: + + This route does not require authentication via ``assetkey`` and does not + perform node lookup by hardware address. + +``/efiboot/{file}`` +------------------- + +Serves EFI boot files for GRUB-based booting. The requesting node is +identified by ``?wwid=`` (preferred) or, if not supplied, by an ARP lookup +of the client's IP address against the kernel's ARP cache (``/proc/net/arp``). +This route is intended for EFI HTTP Boot clients, where the firmware fetches +a boot URI from DHCP and cannot perform variable substitution. + +The ``{file}`` component determines what is served: + +* ``shim.efi``: Serves the ``shim.efi`` binary extracted from the node's + assigned image. +* ``grub.efi`` (or ``grubx64.efi``, ``grubaa64.efi``, ``grubia32.efi``, + ``grubarm.efi``, ``grub-tpm.efi``): Serves the GRUB EFI binary extracted + from the node's assigned image. +* ``grub.cfg``: Serves a rendered GRUB configuration file from + ``/etc/warewulf/grub/grub.cfg.ww``. The configuration is rendered as a Go + template for the identified node. + +Because ``shim.efi`` resolves subsequent files relative to its own load URL, +GRUB and ``grub.cfg`` are also fetched from the ``/efiboot/`` path. The +``grub.cfg`` served by this route uses ``${net_default_mac}`` to embed the +node's wwid in all further provisioning URLs, directing subsequent requests +to the per-node ``/grub/{wwid}`` route. + +**Query parameters:** ``assetkey``, ``uuid``, ``wwid``, ``file`` + +* ``file``: The EFI file to serve (``shim.efi``, ``grub.efi``, or ``grub.cfg``). + Used when the file name is not embedded in the URL path. + +.. note:: + + ``/efiboot/`` is the recommended route for EFI HTTP Boot clients. For + TFTP-booted GRUB clients that know their own wwid, use + ``/grub/{wwid}`` to fetch the per-node GRUB configuration directly. + +``/grub/{wwid}`` +---------------- + +Serves a rendered GRUB configuration file for the node identified by +``{wwid}``. The configuration is rendered from +``/etc/warewulf/grub/grub.cfg.ww`` as a Go template. This route is the +preferred method for TFTP-booted GRUB clients to fetch their per-node +configuration, as the node identity is explicit in the URL rather than +resolved via ARP. + +**Query parameters:** ``assetkey``, ``uuid``, ``wwid`` + +``/provision/{wwid}`` +--------------------- + +.. deprecated:: + + This route is maintained for backwards compatibility. New configurations + should use the dedicated routes described above. + +A legacy dispatcher route. The provisioning stage is determined by the +``stage`` query parameter, which is dispatched to the appropriate handler: + +* ``stage=ipxe`` → ``/ipxe/`` +* ``stage=kernel`` → ``/kernel/`` +* ``stage=image`` → ``/image/`` +* ``stage=initramfs`` → ``/initramfs/`` +* ``stage=system`` → ``/system/`` +* ``stage=runtime`` → ``/runtime/`` +* ``stage=grub`` → ``/grub/`` + +**Query parameters:** ``stage`` (required), ``assetkey``, ``uuid``, ``compress``, +``overlay`` + +Status Route +============ + +``/status`` +----------- + +Returns a JSON object containing the last-known provisioning status for all +nodes that have contacted the server. No authentication is required. + +.. code-block:: none + + { + "nodes": { + "node01": { + "node name": "node01", + "stage": "RUNTIME_OVERLAY", + "sent": "2 kB", + "ipaddr": "10.0.1.1", + "last seen": 1712345678 + } + } + } + +The ``stage`` field reflects the most recent provisioning stage completed for +the node. Possible values include ``IPXE``, ``KERNEL``, ``IMAGE``, +``INITRAMFS``, ``SYSTEM_OVERLAY``, ``RUNTIME_OVERLAY``, and ``EFI``. + +REST API +======== + +When enabled in ``warewulf.conf``, ``warewulfd`` exposes a REST API under +``/api/``. The API provides programmatic access to nodes, profiles, images, +and overlays. Interactive documentation is available at ``/api/docs``. + +See :ref:`rest-api` for full details. + +.. _server-routes-security: + +Security +======== + +Several mechanisms are available to restrict access to provisioning routes. + +Asset key validation +-------------------- + +If a node is configured with an ``AssetKey`` in ``nodes.conf``, the Warewulf +server will only respond to provisioning requests that include a matching +``?assetkey=`` query parameter. Requests with a missing or incorrect asset key +receive ``401 Unauthorized``. The asset key is typically a hardware-level +firmware string (an "asset tag") that is accessible only with root or physical +access. + +.. code-block:: console + + # wwctl node set node01 --assetkey "SYSTEM-ASSET-TAG" + +Secure mode +----------- + +When ``warewulf:secure`` is set to ``true`` in ``warewulf.conf``, the +``/runtime/`` route requires that requests originate from a privileged +TCP source port (port number less than 1024). Because only processes running as +``root`` can bind to privileged ports, this prevents unprivileged users on a +cluster node from downloading the runtime overlay. + +TLS +--- + +When TLS is enabled in ``warewulf.conf``, the ``/runtime/`` route +rejects plain-HTTP requests with ``403 Forbidden``. Runtime overlays must be +fetched over HTTPS. Because iPXE and GRUB cannot handle HTTPS, the kernel, +image, and system overlay continue to be served over plain HTTP even when TLS +is enabled. + +See :ref:`Security ` for instructions on enabling TLS and +generating certificates. From 395fa2c2835e1926b2b4e6f2bcfbc691028e5479 Mon Sep 17 00:00:00 2001 From: Jonathon Anderson Date: Fri, 6 Mar 2026 16:24:06 -0700 Subject: [PATCH 24/29] Support requiring TLS for API access Signed-off-by: Jonathon Anderson --- internal/app/wwclient/root.go | 4 ++-- internal/app/wwctl/configure/tls/main.go | 2 +- internal/pkg/config/api.go | 5 +++++ internal/pkg/config/buildconfig.go.in | 6 +++--- internal/pkg/config/root_test.go | 5 ----- internal/pkg/configure/tls.go | 2 +- internal/pkg/warewulfd/helpers.go | 2 +- internal/pkg/warewulfd/overlay.go | 2 +- internal/pkg/warewulfd/server/server.go | 16 +++++++++++++++- overlays/wwinit/rootfs/warewulf/config.ww | 2 +- 10 files changed, 30 insertions(+), 16 deletions(-) diff --git a/internal/app/wwclient/root.go b/internal/app/wwclient/root.go index e25d7144..97bbb18c 100644 --- a/internal/app/wwclient/root.go +++ b/internal/app/wwclient/root.go @@ -117,7 +117,7 @@ func CobraRunE(cmd *cobra.Command, args []string) (err error) { } tlsConfig := &tls.Config{MinVersion: tls.VersionTLS13} - if conf.Warewulf.EnableTLS() { + if conf.Warewulf.TLSEnabled() { caCert, err := os.ReadFile("/warewulf/tls/warewulf.crt") if err != nil { wwlog.Error("failed to read ca cert: %s", err) @@ -260,7 +260,7 @@ func CobraRunE(cmd *cobra.Command, args []string) (err error) { port := conf.Warewulf.Port scheme := "http" - if conf.Warewulf.EnableTLS() { + if conf.Warewulf.TLSEnabled() { port = conf.Warewulf.TlsPort scheme = "https" } diff --git a/internal/app/wwctl/configure/tls/main.go b/internal/app/wwctl/configure/tls/main.go index af99bc37..d422515b 100644 --- a/internal/app/wwctl/configure/tls/main.go +++ b/internal/app/wwctl/configure/tls/main.go @@ -71,7 +71,7 @@ func CobraRunE(cmd *cobra.Command, args []string) error { return nil } - if !conf.Warewulf.EnableTLS() { + if !conf.Warewulf.TLSEnabled() { fmt.Fprintf(cmd.OutOrStdout(), "TLS is not enabled in warewulf.conf\n") return nil } 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 18909050..4150ef2a 100644 --- a/internal/pkg/config/buildconfig.go.in +++ b/internal/pkg/config/buildconfig.go.in @@ -44,7 +44,7 @@ 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"` - EnableTLSP *bool `yaml:"tls,omitempty" default:"false"` + 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"` @@ -56,8 +56,8 @@ func (conf WarewulfConf) Secure() bool { return util.BoolP(conf.SecureP) } -func (conf WarewulfConf) EnableTLS() bool { - return util.BoolP(conf.EnableTLSP) +func (conf WarewulfConf) TLSEnabled() bool { + return util.BoolP(conf.TLSEnabledP) } func (conf WarewulfConf) AutobuildOverlays() bool { diff --git a/internal/pkg/config/root_test.go b/internal/pkg/config/root_test.go index 26de357c..0c21b945 100644 --- a/internal/pkg/config/root_test.go +++ b/internal/pkg/config/root_test.go @@ -17,7 +17,6 @@ func TestParse(t *testing.T) { result: ` warewulf: autobuild overlays: true - tls: false grubboot: false host overlay: true port: 9873 @@ -65,7 +64,6 @@ network: 192.168.0.0 netmask: 255.255.255.0 warewulf: autobuild overlays: true - tls: false grubboot: false host overlay: true port: 9873 @@ -115,7 +113,6 @@ network: 192.168.0.0 netmask: 255.255.0.0 warewulf: autobuild overlays: true - tls: false grubboot: false host overlay: true port: 9873 @@ -162,7 +159,6 @@ ipaddr6: "2001:db8::1" prefixlen6: "64" warewulf: autobuild overlays: true - tls: false grubboot: false host overlay: true port: 9873 @@ -238,7 +234,6 @@ netmask: 255.255.255.0 network: 192.168.200.0 warewulf: autobuild overlays: true - tls: false grubboot: false host overlay: true port: 9873 diff --git a/internal/pkg/configure/tls.go b/internal/pkg/configure/tls.go index c6695a66..fb55cc14 100644 --- a/internal/pkg/configure/tls.go +++ b/internal/pkg/configure/tls.go @@ -23,7 +23,7 @@ import ( // Returns true if new keys were generated. func TLS(force bool) (bool, error) { conf := warewulfconf.Get() - if !conf.Warewulf.EnableTLS() { + if !conf.Warewulf.TLSEnabled() { return false, nil } diff --git a/internal/pkg/warewulfd/helpers.go b/internal/pkg/warewulfd/helpers.go index 29f2062b..15fdaf08 100644 --- a/internal/pkg/warewulfd/helpers.go +++ b/internal/pkg/warewulfd/helpers.go @@ -58,7 +58,7 @@ func buildTemplateVars(conf *warewulfconf.WarewulfYaml, rinfo parsedRequest, rem Ipaddr: conf.Ipaddr, Ipaddr6: ipaddr6, Port: strconv.Itoa(conf.Warewulf.Port), - TLS: conf.Warewulf.EnableTLS(), + TLS: conf.Warewulf.TLSEnabled(), Authority: authority, Hostname: remoteNode.Id(), Hwaddr: rinfo.hwaddr, diff --git a/internal/pkg/warewulfd/overlay.go b/internal/pkg/warewulfd/overlay.go index 74998518..a21c8d64 100644 --- a/internal/pkg/warewulfd/overlay.go +++ b/internal/pkg/warewulfd/overlay.go @@ -95,7 +95,7 @@ func HandleSystemOverlay(w http.ResponseWriter, req *http.Request) { // 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.EnableTLS() && req.TLS == nil { + if config.Get().Warewulf.TLSEnabled() && req.TLS == nil { wwlog.Denied("runtime overlay requested over insecure connection") w.WriteHeader(http.StatusForbidden) return diff --git a/internal/pkg/warewulfd/server/server.go b/internal/pkg/warewulfd/server/server.go index 7e37c835..e436efdf 100644 --- a/internal/pkg/warewulfd/server/server.go +++ b/internal/pkg/warewulfd/server/server.go @@ -37,6 +37,17 @@ func (h *slashFix) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.mux.ServeHTTP(w, r) } +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.HandleProvision) @@ -89,13 +100,16 @@ func RunServer() error { 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) + } } httpHandler := configureRootHandler(apiHandler) errChan := make(chan error, 2) - if conf.Warewulf.EnableTLS() { + if conf.Warewulf.TLSEnabled() { key := path.Join(conf.Paths.Sysconfdir, "warewulf", "tls", "warewulf.key") crt := path.Join(conf.Paths.Sysconfdir, "warewulf", "tls", "warewulf.crt") diff --git a/overlays/wwinit/rootfs/warewulf/config.ww b/overlays/wwinit/rootfs/warewulf/config.ww index 3b19adfb..4863d9ee 100644 --- a/overlays/wwinit/rootfs/warewulf/config.ww +++ b/overlays/wwinit/rootfs/warewulf/config.ww @@ -11,6 +11,6 @@ WWIPMI_WRITE="{{$.Ipmi.Write.Bool}}" {{- if $.Ipmi.Tags.vlan }} WWIPMI_VLAN="{{$.Ipmi.Tags.vlan}}" {{- end }} -WWTLS={{$.Warewulf.EnableTLS}} +WWTLS={{$.Warewulf.TLSEnabled}} WWTLSPORT={{$.Warewulf.TlsPort}} WWIPADDR={{$.Ipaddr}} From 7b5d2de6ad769576c07e5ab1703dead319dff8a1 Mon Sep 17 00:00:00 2001 From: Jonathon Anderson Date: Mon, 9 Mar 2026 15:44:55 -0600 Subject: [PATCH 25/29] Documentation and CHANGELOG updates and corrections Signed-off-by: Jonathon Anderson --- CHANGELOG.md | 26 +++++++++---------- userdocs/server/routes.rst | 3 +++ userdocs/server/security.rst | 48 +++++++++++++++++++----------------- 3 files changed, 41 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 601106e9..be445ea7 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 @@ -16,7 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### 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 @@ -27,12 +27,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). if TLS is enabled is not distributed over plain http. - 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. - +- 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 @@ -48,11 +53,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - New `range6 start` and `range6 end` in `warewulf.conf:dhcp`. #2068 - New `Gateway6` network device field. #2068 - New `systemd-networkd` overlay. #2068 -- `wwclient.aarch64` overlay always provides an aarch64 wwclient executable. -- `wwclient.x86_64` overlay always provides an x86_64 wwclient executable. -- systemd-networkd overlay with IPv6 support -- `wwctl overlay info` lists the variables used by an overlay template -- TLS ### Changed diff --git a/userdocs/server/routes.rst b/userdocs/server/routes.rst index 7cc299b8..9d75ec0f 100644 --- a/userdocs/server/routes.rst +++ b/userdocs/server/routes.rst @@ -277,6 +277,9 @@ 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: diff --git a/userdocs/server/security.rst b/userdocs/server/security.rst index a2109f4b..a0aaa068 100644 --- a/userdocs/server/security.rst +++ b/userdocs/server/security.rst @@ -40,9 +40,9 @@ 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. However - the https is only used for runtime overlays. The kernel and system image are *always* - transfered unencrypted as iPXE and grub can't handle https. +* 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 ======= @@ -87,33 +87,35 @@ with the Warewulf server. nft list ruleset >/etc/nftables.conf systemctl restart nftables +TLS / HTTPS +=========== -HTTPS -===== - -The https functionality can be enabled by setting +TLS can be enabled by setting ``tls: true`` in the Warewulf server +configuration. .. code-block:: yaml -.. -.. warewulf: -.. tls: true -.. secure port: 9874 -.. -Which will enable a https server on the secure port. The certificate and key -can be created as self signed key with ``wwctl configure keys --create``. The -keys and certificate are stored if not configured otherwise as + warewulf: + tls: true + tls port: 9874 -.. code-block:: console -.. -.. /etc/warewulf/tls/warewulf.crt # PEM certificate -.. /etc/warewulf/tls/warewulf.key # PEM RSA Key +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``. -For the key and certifcate generation no addiotional parameters can be set, but -you can import your own keys, with ``wwctl configure keys import``. +If HTTPS is enabled the delivery of the runtime overlay is disabled over HTTP, +and the runtime overlay is only retrieved by ``wwclient``. -If HTTPS is enabled the delivery of the runtime overlays is disabled over HTTP, so -you **must** use `wwclient` to get the runtime overlays. +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. From f78f761be9c5b9ef65b61d8db5bed1d2c91df612 Mon Sep 17 00:00:00 2001 From: Jonathon Anderson Date: Mon, 9 Mar 2026 21:23:26 -0600 Subject: [PATCH 26/29] Error handling for /newroot mount during single-stage boot Signed-off-by: Jonathon Anderson --- CHANGELOG.md | 2 ++ overlays/wwinit/rootfs/init | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be445ea7..99052b72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ 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 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 From 5147d0970ea9239b17a2d00e90122d47d410bc95 Mon Sep 17 00:00:00 2001 From: Jonathon Anderson Date: Tue, 10 Mar 2026 15:17:40 -0600 Subject: [PATCH 27/29] Simplify warewulfd parser Signed-off-by: Jonathon Anderson --- internal/pkg/warewulfd/parser.go | 127 ++++++++++++++----------------- 1 file changed, 57 insertions(+), 70 deletions(-) diff --git a/internal/pkg/warewulfd/parser.go b/internal/pkg/warewulfd/parser.go index 47efe6f4..1fdbeda1 100644 --- a/internal/pkg/warewulfd/parser.go +++ b/internal/pkg/warewulfd/parser.go @@ -90,76 +90,85 @@ type parsedRequest struct { compress string } +func parseHwaddr(hwaddr string) string { + hwaddr = strings.ReplaceAll(hwaddr, "-", ":") + hwaddr = strings.ToLower(hwaddr) + return hwaddr +} + func parseRequest(req *http.Request) (parsedRequest, error) { var ret parsedRequest - url := strings.Split(req.URL.Path, "?")[0] - path_parts := strings.Split(url, "/") + path_parts := strings.Split(req.URL.Path, "/") - if len(path_parts) < 3 { - return ret, errors.New("unknown path components in GET") + // 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 := "" - switch stage { - case "efiboot": - // /efiboot/{file}: no hwaddr in path; identified via ARP - if len(path_parts) > 3 { + // 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" + } + + 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.efifile = path_parts[2] + ret.hwaddr = parseHwaddr(path_parts[2]) } - case "grub": - // /grub/{hwaddr}: hwaddr explicit in path - hwaddr = path_parts[2] - hwaddr = strings.ReplaceAll(hwaddr, "-", ":") - hwaddr = strings.ToLower(hwaddr) - default: - hwaddr = path_parts[2] - hwaddr = strings.ReplaceAll(hwaddr, "-", ":") - hwaddr = strings.ToLower(hwaddr) } - ret.hwaddr = hwaddr + 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 { - - switch stage { - case "ipxe", "provision": - ret.stage = "ipxe" - case "kernel": - ret.stage = "kernel" - case "image", "container": - ret.stage = "image" - case "overlay-system", "system": - ret.stage = "system" - case "overlay-runtime", "runtime": - ret.stage = "runtime" - case "efiboot": - ret.stage = "efiboot" - case "grub": - ret.stage = "grub" - case "initramfs": - ret.stage = "initramfs" - } - } - if len(req.URL.Query()["overlay"]) > 0 { ret.overlay = req.URL.Query()["overlay"][0] } @@ -169,28 +178,6 @@ func parseRequest(req *http.Request) (parsedRequest, error) { if ret.efifile == "" && len(req.URL.Query()["file"]) > 0 { ret.efifile = req.URL.Query()["file"][0] } - if ret.stage == "" { - return ret, errors.New("no stage encoded in GET") - } - if ret.hwaddr == "" { - if len(req.URL.Query()["wwid"]) > 0 { - ret.hwaddr = req.URL.Query()["wwid"][0] - ret.hwaddr = strings.ReplaceAll(ret.hwaddr, "-", ":") - ret.hwaddr = strings.ToLower(ret.hwaddr) - } else { - ret.hwaddr = ArpFind(ret.ipaddr) - wwlog.Verbose("node mac not encoded, arp cache got %s for %s", ret.hwaddr, ret.ipaddr) - if ret.hwaddr == "" { - return ret, errors.New("no hwaddr encoded in GET") - } - } - } - if ret.ipaddr == "" { - 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) - } return ret, nil } From abc5f0f0b9fd281013646e0e79e3d004bc1475fe Mon Sep 17 00:00:00 2001 From: Jonathon Anderson Date: Tue, 10 Mar 2026 15:35:44 -0600 Subject: [PATCH 28/29] Add doc comments to server code Points to userdocs/server/routes.rst for more information. Signed-off-by: Jonathon Anderson --- internal/pkg/warewulfd/daemon.go | 5 +++++ internal/pkg/warewulfd/parser.go | 6 ++++++ internal/pkg/warewulfd/server/server.go | 4 ++++ 3 files changed, 15 insertions(+) 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/parser.go b/internal/pkg/warewulfd/parser.go index 1fdbeda1..ef70e98d 100644 --- a/internal/pkg/warewulfd/parser.go +++ b/internal/pkg/warewulfd/parser.go @@ -96,6 +96,12 @@ func parseHwaddr(hwaddr string) string { return hwaddr } +// 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 diff --git a/internal/pkg/warewulfd/server/server.go b/internal/pkg/warewulfd/server/server.go index e436efdf..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 ( From 939f4290f42b68428755da1fe1daa5260309f8a5 Mon Sep 17 00:00:00 2001 From: Jonathon Anderson Date: Tue, 10 Mar 2026 16:06:04 -0600 Subject: [PATCH 29/29] Move stage display formatting to wwctl Signed-off-by: Jonathon Anderson --- internal/app/wwctl/node/status/main.go | 29 +++++++++++++++++++++++--- internal/pkg/warewulfd/helpers.go | 6 +++--- internal/pkg/warewulfd/parser.go | 27 +++++++----------------- 3 files changed, 36 insertions(+), 26 deletions(-) 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/warewulfd/helpers.go b/internal/pkg/warewulfd/helpers.go index 15fdaf08..f3ec78ac 100644 --- a/internal/pkg/warewulfd/helpers.go +++ b/internal/pkg/warewulfd/helpers.go @@ -140,16 +140,16 @@ func sendResponse(w http.ResponseWriter, req *http.Request, stageFile string, tm } } - updateStatus(ctx.remoteNode.Id(), ctx.statusStage, path.Base(stageFile), ctx.rinfo.ipaddr) + 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.statusStage, "BAD_REQUEST", ctx.rinfo.ipaddr) + 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.statusStage, "NOT_FOUND", ctx.rinfo.ipaddr) + updateStatus(ctx.remoteNode.Id(), ctx.rinfo.stage, "NOT_FOUND", ctx.rinfo.ipaddr) } } diff --git a/internal/pkg/warewulfd/parser.go b/internal/pkg/warewulfd/parser.go index ef70e98d..c7ae0285 100644 --- a/internal/pkg/warewulfd/parser.go +++ b/internal/pkg/warewulfd/parser.go @@ -14,10 +14,9 @@ import ( // requestContext holds the validated results of the parsed request type requestContext struct { - conf *warewulfconf.WarewulfYaml - rinfo parsedRequest - remoteNode node.Node - statusStage string + conf *warewulfconf.WarewulfYaml + rinfo parsedRequest + remoteNode node.Node } // initHandleRequest performs common initial request parsing, security checks, @@ -45,17 +44,6 @@ func initHandleRequest(w http.ResponseWriter, req *http.Request) (*requestContex } } - status_stages := map[string]string{ - "efiboot": "EFI", - "grub": "GRUB", - "ipxe": "IPXE", - "kernel": "KERNEL", - "system": "SYSTEM_OVERLAY", - "runtime": "RUNTIME_OVERLAY", - "initramfs": "INITRAMFS"} - - statusStage := status_stages[rinfo.stage] - remoteNode, err := GetNodeOrSetDiscoverable(rinfo.hwaddr, conf.Warewulf.AutobuildOverlays()) if err != nil && err != node.ErrNoUnconfigured { wwlog.ErrorExc(err, "") @@ -66,15 +54,14 @@ func initHandleRequest(w http.ResponseWriter, req *http.Request) (*requestContex 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(), statusStage, "BAD_ASSET", rinfo.ipaddr) + updateStatus(remoteNode.Id(), rinfo.stage, "BAD_ASSET", rinfo.ipaddr) return nil, fmt.Errorf("incorrect asset key") } return &requestContext{ - conf: conf, - rinfo: rinfo, - remoteNode: remoteNode, - statusStage: statusStage, + conf: conf, + rinfo: rinfo, + remoteNode: remoteNode, }, nil }