Merge pull request #2125 from anderbubble/tlsSec

TLS for warewulfd and REST API, and warewulfd refactor
This commit is contained in:
Christian Goll
2026-03-11 09:28:42 +01:00
committed by GitHub
45 changed files with 2226 additions and 507 deletions

View File

@@ -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/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## v4.6.6, unreleased ## v4.7.0, unreleased
### Fixed ### Fixed
@@ -13,13 +13,33 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Allow whitespace to be trimmed for wwdoc comments. #2109 - Allow whitespace to be trimmed for wwdoc comments. #2109
- update go-chi to 5.2.5 to fix CVE-2025-69725 - update go-chi to 5.2.5 to fix CVE-2025-69725
- Prevented profile `comment` field from being inherited by nodes. #2078 - 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 ### 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` - Updated arguments for `ValidString` to match `regexp.MatchString`
- New `mig` overlay to configure NVIDIA MIG devices. #2102 - New `mig` overlay to configure NVIDIA MIG devices. #2102
- Documented that booting a node twice fixes broken partition tables - Documented that booting a node twice fixes broken partition tables
- Updated arguments for `ValidString` to match `regexp.MatchString`
- New `mig` overlay to configure NVIDIA MIG devices. #2102
- TLS with the command `wwctl configure tls` for key management.
Keys can be created automtically or imported. The runtime overlay is
if TLS is enabled is not distributed over plain http.
- Documented that booting a node twice fixes broken partition tables
- TLS support for `warewulfd` and REST API
- New `wwctl configure tls` command to generate and configure TLS keys and
certificates
- New dedicated `warewulfd` server routes (`/ipxe/`, `/kernel/`, `/image/`,
`/initramfs/`, `/system/`, `/runtime/`, `/grub/`, `/efiboot/`)
### Changed
- Runtime overlay download failure during dracut/wwinit boot is now non-fatal;
the node continues to boot and `wwclient` retries the download at runtime.
- `hosts` overlay added to the default system overlay list
## v4.6.5, 2026-01-12 ## v4.6.5, 2026-01-12

View File

@@ -4,6 +4,8 @@
get_stage() { get_stage() {
stage="${1}" stage="${1}"
uri="${2:-${wwinit_uri}}"
cacert="${3}"
info "warewulf: loading stage: ${stage}" info "warewulf: loading stage: ${stage}"
# Load runtime overlay from a static privledged port. # Load runtime overlay from a static privledged port.
# Others use default settings. # Others use default settings.
@@ -11,24 +13,28 @@ get_stage() {
if [ "${stage}" = "runtime" ]; then if [ "${stage}" = "runtime" ]; then
localport="--local-port 1-1023" localport="--local-port 1-1023"
fi 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 \ --retry 60 --retry-connrefused --retry-delay 1 \
--data-urlencode "assetkey=${wwinit_assetkey}" \ --data-urlencode "assetkey=${wwinit_assetkey}" \
--data-urlencode "uuid=${wwinit_uuid}" \ --data-urlencode "uuid=${wwinit_uuid}" \
--data-urlencode "stage=${stage}" \ --data-urlencode "stage=${stage}" \
--data-urlencode "compress=gz" \ --data-urlencode "compress=gz" \
"${wwinit_uri}" \ "${uri}" \
| gzip -d \ | gzip -d \
| cpio -ium --directory="${NEWROOT}" | cpio -ium --directory="${NEWROOT}"
) || die "Unable to load stage: ${stage}" )
} }
mkdir /tmp/wwinit mkdir /tmp/wwinit
( (
# fetch the system overlay into /tmp/wwinit # fetch the system overlay into /tmp/wwinit
local NEWROOT=/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 if [ -x /tmp/wwinit/warewulf/run-wwinit.d ]; then
PREFIX=/tmp/wwinit /tmp/wwinit/warewulf/run-wwinit.d PREFIX=/tmp/wwinit /tmp/wwinit/warewulf/run-wwinit.d
@@ -43,10 +49,25 @@ info "warewulf: mounting ${wwinit_root_device} at ${NEWROOT}"
fi fi
) || die "warewulf: failed to mount ${wwinit_root_device} at ${NEWROOT}" ) || 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}" get_stage "${stage}" || die "Unable to load stage: ${stage}"
done done
# Fetch runtime overlay (non-fatal)
# Source config from system overlay for TLS settings
. /tmp/wwinit/warewulf/config
cert_file="/tmp/wwinit/warewulf/tls/warewulf.crt"
if [ "${WWTLS}" = "true" ] && [ -f "$cert_file" ]; then
# TLS enabled: build HTTPS URI using wwid from kernel cmdline
# (mirrors wwclient URL construction in internal/app/wwclient/root.go)
wwid=$(getarg wwid)
runtime_uri="https://${WWIPADDR}:${WWTLSPORT}/provision/${wwid}"
get_stage "runtime" "${runtime_uri}" "${cert_file}" || warn "warewulf: unable to load runtime overlay over HTTPS (ignored)"
else
# No TLS: fetch runtime over HTTP
get_stage "runtime" || warn "warewulf: unable to load runtime overlay (ignored)"
fi
# Copy /warewulf/run from initramfs to NEWROOT # Copy /warewulf/run from initramfs to NEWROOT
# This preserves state files created by wwinit.d scripts (e.g., ignition marker) # This preserves state files created by wwinit.d scripts (e.g., ignition marker)
if [ -d /tmp/wwinit/warewulf/run ]; then if [ -d /tmp/wwinit/warewulf/run ]; then

View File

@@ -66,7 +66,9 @@ menuentry "Single-stage boot" --id single-stage {
echo "Downloading images..." echo "Downloading images..."
image="${uri}&stage=image&compress=gz" image="${uri}&stage=image&compress=gz"
system="${uri}&stage=system&compress=gz" system="${uri}&stage=system&compress=gz"
{{- if not (eq .TLS true) }}
runtime="${uri}&stage=runtime&compress=gz" runtime="${uri}&stage=runtime&compress=gz"
{{- end }}
initrd $image $system $runtime initrd $image $system $runtime
if [ $? != 0 ] if [ $? != 0 ]
then then
@@ -113,7 +115,9 @@ menuentry "Single-stage boot (no compression)" --id single-stage-nocompress {
echo "Downloading images..." echo "Downloading images..."
image="${uri}&stage=image" image="${uri}&stage=image"
system="${uri}&stage=system" system="${uri}&stage=system"
{{- if not (eq .TLS true) }}
runtime="${uri}&stage=runtime" runtime="${uri}&stage=runtime"
{{- end }}
initrd $image $system $runtime initrd $image $system $runtime
if [ $? != 0 ] if [ $? != 0 ]
then then

View File

@@ -46,8 +46,10 @@ echo Downloading compressed image with imgextract...
imgextract --name image ${uri}&stage=image&compress=gz || goto error_use_initrd imgextract --name image ${uri}&stage=image&compress=gz || goto error_use_initrd
echo Downloading compressed system overlay image with imgextract... echo Downloading compressed system overlay image with imgextract...
imgextract --name system ${uri}&stage=system&compress=gz || goto error_reboot imgextract --name system ${uri}&stage=system&compress=gz || goto error_reboot
{{- if not (eq .TLS true) }}
echo Downloading compressed runtime overlay image with imgextract... 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) 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 goto boot_single_stage
:error_use_initrd :error_use_initrd
@@ -63,8 +65,10 @@ echo Downloading compressed image with initrd...
initrd --name image ${uri}&stage=image&compress=gz || goto error_reboot initrd --name image ${uri}&stage=image&compress=gz || goto error_reboot
echo Downloading compressed system overlay with initrd... echo Downloading compressed system overlay with initrd...
initrd --name system ${uri}&stage=system&compress=gz || goto error_reboot initrd --name system ${uri}&stage=system&compress=gz || goto error_reboot
{{- if not (eq .TLS true) }}
echo Downloading compressed runtime overlay with initrd... 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) 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 goto boot_single_stage
:initrd_nocompress :initrd_nocompress
@@ -76,8 +80,10 @@ echo Downloading uncompressed image with initrd...
initrd --name image ${uri}&stage=image || goto error_reboot initrd --name image ${uri}&stage=image || goto error_reboot
echo Downloading uncompressed system overlay with initrd... echo Downloading uncompressed system overlay with initrd...
initrd --name system ${uri}&stage=system || goto error_reboot initrd --name system ${uri}&stage=system || goto error_reboot
{{- if not (eq .TLS true) }}
echo Downloading uncompressed runtime overlay with initrd... 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) initrd --name runtime ${uri}&stage=runtime && set runtime_initrd initrd=runtime || echo Unable to download runtime overlay. (ignored)
{{- end }}
goto boot_single_stage goto boot_single_stage
:dracut :dracut

View File

@@ -7,6 +7,7 @@ nodeprofiles:
system overlay: system overlay:
- wwinit - wwinit
- wwclient - wwclient
- hosts
- fstab - fstab
- hostname - hostname
- ssh.host_keys - ssh.host_keys

View File

@@ -1,6 +1,10 @@
package wwclient package wwclient
import ( import (
"crypto/tls"
"crypto/x509"
"encoding/pem"
"errors"
"fmt" "fmt"
"io" "io"
"net" "net"
@@ -112,9 +116,31 @@ func CobraRunE(cmd *cobra.Command, args []string) (err error) {
wwlog.Info("running from trusted port: %d", localTCPAddr.Port) wwlog.Info("running from trusted port: %d", localTCPAddr.Port)
} }
tlsConfig := &tls.Config{MinVersion: tls.VersionTLS13}
if conf.Warewulf.TLSEnabled() {
caCert, err := os.ReadFile("/warewulf/tls/warewulf.crt")
if err != nil {
wwlog.Error("failed to read ca cert: %s", err)
return err
}
block, _ := pem.Decode(caCert)
if block == nil {
wwlog.Warn("failed to parse certificate PEM")
} else if cert, err := x509.ParseCertificate(block.Bytes); err == nil {
wwlog.Info("using cert: %s", cert.SerialNumber)
} else {
wwlog.Warn("parsing cert failed: %s", err)
}
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM(caCert)
tlsConfig.RootCAs = caCertPool
}
Webclient = &http.Client{ Webclient = &http.Client{
Transport: &http.Transport{ Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment, TLSClientConfig: tlsConfig,
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{ DialContext: (&net.Dialer{
LocalAddr: &localTCPAddr, LocalAddr: &localTCPAddr,
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
@@ -216,7 +242,7 @@ func CobraRunE(cmd *cobra.Command, args []string) (err error) {
stopTimer.Reset(0) stopTimer.Reset(0)
case syscall.SIGTERM, syscall.SIGINT: case syscall.SIGTERM, syscall.SIGINT:
wwlog.Info("terminating wwclient, %v", sig) 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 exitChan <- true
return return
} }
@@ -232,8 +258,17 @@ func CobraRunE(cmd *cobra.Command, args []string) (err error) {
} }
} }
port := conf.Warewulf.Port
scheme := "http"
if conf.Warewulf.TLSEnabled() {
port = conf.Warewulf.TlsPort
scheme = "https"
}
for { for {
updateSystem(target, ipaddr, conf.Warewulf.Port, wwid, tag, localUUID) if err := updateSystem(target, ipaddr, port, wwid, tag, localUUID, scheme); err != nil {
return err
}
if !finishedInitialSync { if !finishedInitialSync {
// Notify systemd that the service has started successfully. // Notify systemd that the service has started successfully.
// //
@@ -274,7 +309,7 @@ func parseWWIDFromCmdline(cmdline string) (string, error) {
return "", fmt.Errorf("wwid parameter not found in kernel command line") return "", fmt.Errorf("wwid parameter not found in kernel command line")
} }
func updateSystem(target string, ipaddr string, port int, wwid string, tag string, localUUID uuid.UUID) { func updateSystem(target string, ipaddr string, port int, wwid string, tag string, localUUID uuid.UUID, scheme string) error {
var resp *http.Response var resp *http.Response
counter := 0 counter := 0
for { for {
@@ -285,7 +320,7 @@ func updateSystem(target string, ipaddr string, port int, wwid string, tag strin
values.Set("stage", "runtime") values.Set("stage", "runtime")
values.Set("compress", "gz") values.Set("compress", "gz")
getURL := &url.URL{ getURL := &url.URL{
Scheme: "http", Scheme: scheme,
Host: fmt.Sprintf("%s:%d", ipaddr, port), Host: fmt.Sprintf("%s:%d", ipaddr, port),
Path: fmt.Sprintf("provision/%s", wwid), Path: fmt.Sprintf("provision/%s", wwid),
RawQuery: values.Encode(), RawQuery: values.Encode(),
@@ -293,9 +328,16 @@ func updateSystem(target string, ipaddr string, port int, wwid string, tag strin
wwlog.Debug("making request: %s", getURL) wwlog.Debug("making request: %s", getURL)
resp, err = Webclient.Get(getURL.String()) resp, err = Webclient.Get(getURL.String())
if err == nil { if err == nil {
defer resp.Body.Close()
break break
} else { } else {
var certificateInvalidError x509.CertificateInvalidError
var unknownAuthorityError x509.UnknownAuthorityError
var hostnameError x509.HostnameError
if errors.As(err, &certificateInvalidError) ||
errors.As(err, &unknownAuthorityError) ||
errors.As(err, &hostnameError) {
return fmt.Errorf("TLS connection failed: %w", err)
}
if counter > 60 { if counter > 60 {
counter = 0 counter = 0
} }
@@ -306,10 +348,11 @@ func updateSystem(target string, ipaddr string, port int, wwid string, tag strin
} }
time.Sleep(1000 * time.Millisecond) time.Sleep(1000 * time.Millisecond)
} }
defer resp.Body.Close()
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
wwlog.Warn("not applying runtime overlay: got status code: %d", resp.StatusCode) wwlog.Warn("not applying runtime overlay: got status code: %d", resp.StatusCode)
time.Sleep(60000 * time.Millisecond) time.Sleep(60000 * time.Millisecond)
return return nil
} }
wwlog.Info("applying runtime overlay") wwlog.Info("applying runtime overlay")
@@ -318,7 +361,7 @@ func updateSystem(target string, ipaddr string, port int, wwid string, tag strin
tempDir, err := os.MkdirTemp("", "wwclient-") tempDir, err := os.MkdirTemp("", "wwclient-")
if err != nil { if err != nil {
wwlog.Error("failed to create temp directory: %s", err) wwlog.Error("failed to create temp directory: %s", err)
return return nil
} }
defer os.RemoveAll(tempDir) defer os.RemoveAll(tempDir)
wwlog.Debug("unpacking runtime overlay to %s", tempDir) wwlog.Debug("unpacking runtime overlay to %s", tempDir)
@@ -327,7 +370,7 @@ func updateSystem(target string, ipaddr string, port int, wwid string, tag strin
err = command.Run() err = command.Run()
if err != nil { if err != nil {
wwlog.Error("failed running cpio: %s", err) wwlog.Error("failed running cpio: %s", err)
return return nil
} }
// Atomically move files from temp directory to current working directory // Atomically move files from temp directory to current working directory
@@ -335,6 +378,7 @@ func updateSystem(target string, ipaddr string, port int, wwid string, tag strin
if err != nil { if err != nil {
wwlog.Error("failed to apply overlay: %s", err) wwlog.Error("failed to apply overlay: %s", err)
} }
return nil
} }
func atomicApplyOverlay(srcDir, destDir string) error { func atomicApplyOverlay(srcDir, destDir string) error {

View File

@@ -20,6 +20,10 @@ func CobraRunE(cmd *cobra.Command, args []string) error {
} }
if allFunctions { if allFunctions {
if _, err = configure.TLS(false); err != nil {
return err
}
err = configure.WAREWULFD() err = configure.WAREWULFD()
if err != nil { if err != nil {
return err return err

View File

@@ -7,6 +7,7 @@ import (
"github.com/warewulf/warewulf/internal/app/wwctl/configure/nfs" "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/ssh"
"github.com/warewulf/warewulf/internal/app/wwctl/configure/tftp" "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" "github.com/warewulf/warewulf/internal/app/wwctl/configure/warewulfd"
) )
@@ -29,6 +30,7 @@ func init() {
baseCmd.AddCommand(ssh.GetCommand()) baseCmd.AddCommand(ssh.GetCommand())
baseCmd.AddCommand(nfs.GetCommand()) baseCmd.AddCommand(nfs.GetCommand())
baseCmd.AddCommand(hostfile.GetCommand()) baseCmd.AddCommand(hostfile.GetCommand())
baseCmd.AddCommand(tls.GetCommand())
baseCmd.AddCommand(warewulfd.GetCommand()) baseCmd.AddCommand(warewulfd.GetCommand())
baseCmd.Flags().BoolVarP(&allFunctions, "all", "a", false, "Configure all services") baseCmd.Flags().BoolVarP(&allFunctions, "all", "a", false, "Configure all services")

View File

@@ -0,0 +1,129 @@
package tls
import (
"crypto/x509"
"encoding/pem"
"fmt"
"os"
"path"
"strings"
"text/tabwriter"
"github.com/spf13/cobra"
"github.com/warewulf/warewulf/internal/pkg/config"
"github.com/warewulf/warewulf/internal/pkg/configure"
"github.com/warewulf/warewulf/internal/pkg/util"
)
func CobraRunE(cmd *cobra.Command, args []string) error {
conf := config.Get()
keystore := path.Join(conf.Paths.Sysconfdir, "warewulf", "tls")
keyFile := path.Join(keystore, "warewulf.key")
certFile := path.Join(keystore, "warewulf.crt")
if importPath != "" {
info, err := os.Stat(importPath)
if err != nil {
return fmt.Errorf("could not access import path: %w", err)
}
var sourceKey, sourceCert string
if info.IsDir() {
sourceKey = path.Join(importPath, "warewulf.key")
sourceCert = path.Join(importPath, "warewulf.crt")
} else {
return fmt.Errorf("import path must be a directory containing warewulf.key and warewulf.crt")
}
if err := util.CopyFile(sourceKey, keyFile); err != nil {
return fmt.Errorf("failed to import key: %w", err)
}
if err := os.Chmod(keyFile, 0600); err != nil {
return fmt.Errorf("failed to set key file permissions: %w", err)
}
if err := util.CopyFile(sourceCert, certFile); err != nil {
return fmt.Errorf("failed to import cert: %w", err)
}
if err := os.Chmod(certFile, 0644); err != nil {
return fmt.Errorf("failed to set cert file permissions: %w", err)
}
fmt.Fprintf(cmd.OutOrStdout(), "Imported keys from %s\n", importPath)
if err := configure.WAREWULFD(); err != nil {
return fmt.Errorf("failed to restart warewulfd: %w", err)
}
return nil
}
if exportPath != "" {
if err := os.MkdirAll(exportPath, 0755); err != nil {
return fmt.Errorf("could not create export directory: %w", err)
}
if err := util.CopyFile(keyFile, path.Join(exportPath, "warewulf.key")); err != nil {
return fmt.Errorf("failed to export key: %w", err)
}
if err := util.CopyFile(certFile, path.Join(exportPath, "warewulf.crt")); err != nil {
return fmt.Errorf("failed to export cert: %w", err)
}
fmt.Fprintf(cmd.OutOrStdout(), "Exported keys to %s\n", exportPath)
return nil
}
if !conf.Warewulf.TLSEnabled() {
fmt.Fprintf(cmd.OutOrStdout(), "TLS is not enabled in warewulf.conf\n")
return nil
}
created, err := configure.TLS(force)
if err != nil {
return err
}
if created {
if err := configure.WAREWULFD(); err != nil {
return fmt.Errorf("failed to restart warewulfd: %w", err)
}
} else {
fmt.Fprintf(cmd.OutOrStdout(), "Keys already exist in %s\n", keystore)
}
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
fmt.Fprintf(w, "Private Key:\t%s\n", keyFile)
fmt.Fprintf(w, "Certificate:\t%s\n", certFile)
keyBytes, err := os.ReadFile(keyFile)
if err == nil {
block, _ := pem.Decode(keyBytes)
if block != nil {
if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
fmt.Fprintf(w, "Key Size:\t%d bits\n", key.N.BitLen())
}
}
}
certBytes, err := os.ReadFile(certFile)
if err == nil {
block, _ := pem.Decode(certBytes)
if block != nil {
if cert, err := x509.ParseCertificate(block.Bytes); err == nil {
fmt.Fprintf(w, "Issuer:\t%s\n", cert.Issuer)
fmt.Fprintf(w, "Subject:\t%s\n", cert.Subject)
fmt.Fprintf(w, "Valid From:\t%s\n", cert.NotBefore)
fmt.Fprintf(w, "Valid Until:\t%s\n", cert.NotAfter)
fmt.Fprintf(w, "Serial Nr:\t%s\n", cert.SerialNumber)
var ipStrings []string
for _, ip := range cert.IPAddresses {
ipStrings = append(ipStrings, ip.String())
}
fmt.Fprintf(w, "IP Addresses:\t%s\n", strings.Join(ipStrings, ", "))
fmt.Fprintf(w, "DNS Names:\t%s\n", strings.Join(cert.DNSNames, ", "))
}
}
}
w.Flush()
return nil
}

View File

@@ -0,0 +1,106 @@
package tls
import (
"bytes"
"path"
"testing"
"github.com/stretchr/testify/assert"
"github.com/warewulf/warewulf/internal/pkg/testenv"
"github.com/warewulf/warewulf/internal/pkg/wwlog"
)
func Test_Keys(t *testing.T) {
env := testenv.New(t)
defer env.RemoveAll()
// Enable TLS in the test config
env.WriteFile("etc/warewulf/warewulf.conf", "warewulf:\n tls: true\n")
// Define a keystore path within the test environment
keystoreRelPath := "etc/warewulf/tls"
keystorePath := env.GetPath(keystoreRelPath)
// Reload configuration to pick up the change
env.Configure()
t.Run("keys create", func(t *testing.T) {
baseCmd := GetCommand()
// Reset flags
importPath = ""
exportPath = ""
baseCmd.SetArgs([]string{})
buf := new(bytes.Buffer)
baseCmd.SetOut(buf)
baseCmd.SetErr(buf)
wwlog.SetLogWriter(buf)
err := baseCmd.Execute()
assert.NoError(t, err)
assert.FileExists(t, path.Join(keystorePath, "warewulf.key"))
assert.FileExists(t, path.Join(keystorePath, "warewulf.crt"))
})
t.Run("keys exist check", func(t *testing.T) {
baseCmd := GetCommand()
// Reset flags
importPath = ""
exportPath = ""
baseCmd.SetArgs([]string{})
buf := new(bytes.Buffer)
baseCmd.SetOut(buf)
baseCmd.SetErr(buf)
wwlog.SetLogWriter(buf)
err := baseCmd.Execute()
assert.NoError(t, err)
assert.Contains(t, buf.String(), "Keys already exist")
})
t.Run("keys display", func(t *testing.T) {
baseCmd := GetCommand()
// Reset flags
importPath = ""
exportPath = ""
baseCmd.SetArgs([]string{})
buf := new(bytes.Buffer)
baseCmd.SetOut(buf)
baseCmd.SetErr(buf)
wwlog.SetLogWriter(buf)
err := baseCmd.Execute()
assert.NoError(t, err)
assert.Contains(t, buf.String(), "Private Key:")
assert.Contains(t, buf.String(), "Certificate:")
assert.Contains(t, buf.String(), "Key Size:")
assert.Contains(t, buf.String(), "Issuer:")
assert.Contains(t, buf.String(), "Subject:")
assert.Contains(t, buf.String(), "Valid From:")
assert.Contains(t, buf.String(), "Valid Until:")
})
t.Run("keys export", func(t *testing.T) {
exportRelPath := "exported_keys"
exportFullPath := env.GetPath(exportRelPath)
baseCmd := GetCommand()
// Reset flags
importPath = ""
exportPath = exportFullPath
baseCmd.SetArgs([]string{"--export", exportFullPath})
buf := new(bytes.Buffer)
baseCmd.SetOut(buf)
baseCmd.SetErr(buf)
wwlog.SetLogWriter(buf)
err := baseCmd.Execute()
assert.NoError(t, err)
assert.FileExists(t, path.Join(exportFullPath, "warewulf.key"))
assert.FileExists(t, path.Join(exportFullPath, "warewulf.crt"))
assert.Contains(t, buf.String(), "Exported keys to")
})
}

View File

@@ -0,0 +1,33 @@
package tls
import (
"github.com/spf13/cobra"
"github.com/warewulf/warewulf/internal/app/wwctl/completions"
)
var (
baseCmd = &cobra.Command{
DisableFlagsInUseLine: true,
Use: "tls [OPTIONS]",
Aliases: []string{"keys", "key", "cert", "crt"},
Short: "Manage and initialize x509 keys",
Long: `This application allows you to manage the x509 keys and certificates for Warewulf.`,
RunE: CobraRunE,
Args: cobra.NoArgs,
ValidArgsFunction: completions.None,
}
importPath string
exportPath string
force bool
)
func init() {
baseCmd.PersistentFlags().StringVar(&importPath, "import", "", "Import keys from directory")
baseCmd.PersistentFlags().StringVar(&exportPath, "export", "", "Export keys to directory")
baseCmd.PersistentFlags().BoolVarP(&force, "force", "f", false, "Enforce creation of keys even if they exist")
}
// GetCommand returns the root cobra.Command for the application.
func GetCommand() *cobra.Command {
return baseCmd
}

View File

@@ -16,6 +16,29 @@ import (
"golang.org/x/term" "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) { func CobraRunE(cmd *cobra.Command, args []string) (err error) {
controller := warewulfconf.Get() controller := warewulfconf.Get()
@@ -102,11 +125,11 @@ func CobraRunE(cmd *cobra.Command, args []string) (err error) {
continue continue
} }
if rightnow-o.Lastseen >= int64(controller.Warewulf.UpdateInterval*2) { 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) { } 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 { } 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 { } else {
color.HiBlack("%-20s %-20s %-25s %-10s\n", o.NodeName, "--", "--", "--") color.HiBlack("%-20s %-20s %-25s %-10s\n", o.NodeName, "--", "--", "--")

View File

@@ -29,6 +29,7 @@ func (n IPNet) IPNet() net.IPNet {
type APIConf struct { type APIConf struct {
EnabledP *bool `yaml:"enabled,omitempty" default:"false"` 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\"]"` 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 { func (conf APIConf) Enabled() bool {
return util.BoolP(conf.EnabledP) return util.BoolP(conf.EnabledP)
} }
func (conf APIConf) TLSEnabled() bool {
return util.BoolP(conf.TLSEnabledP)
}

View File

@@ -42,7 +42,9 @@ func (conf TFTPConf) Enabled() bool {
// BaseConf. // BaseConf.
type WarewulfConf struct { type WarewulfConf struct {
Port int `yaml:"port,omitempty" default:"9873"` Port int `yaml:"port,omitempty" default:"9873"`
TlsPort int `yaml:"tls port,omitempty" default:"9874"`
SecureP *bool `yaml:"secure,omitempty" default:"true"` SecureP *bool `yaml:"secure,omitempty" default:"true"`
TLSEnabledP *bool `yaml:"tls,omitempty"`
UpdateInterval int `yaml:"update interval,omitempty" default:"60"` UpdateInterval int `yaml:"update interval,omitempty" default:"60"`
AutobuildOverlaysP *bool `yaml:"autobuild overlays,omitempty" default:"true"` AutobuildOverlaysP *bool `yaml:"autobuild overlays,omitempty" default:"true"`
EnableHostOverlayP *bool `yaml:"host overlay,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) return util.BoolP(conf.SecureP)
} }
func (conf WarewulfConf) TLSEnabled() bool {
return util.BoolP(conf.TLSEnabledP)
}
func (conf WarewulfConf) AutobuildOverlays() bool { func (conf WarewulfConf) AutobuildOverlays() bool {
return util.BoolP(conf.AutobuildOverlaysP) return util.BoolP(conf.AutobuildOverlaysP)
} }

View File

@@ -21,6 +21,7 @@ warewulf:
host overlay: true host overlay: true
port: 9873 port: 9873
secure: true secure: true
tls port: 9874
update interval: 60 update interval: 60
nfs: nfs:
enabled: true enabled: true
@@ -67,6 +68,7 @@ warewulf:
host overlay: true host overlay: true
port: 9873 port: 9873
secure: true secure: true
tls port: 9874
update interval: 60 update interval: 60
nfs: nfs:
enabled: true enabled: true
@@ -115,6 +117,7 @@ warewulf:
host overlay: true host overlay: true
port: 9873 port: 9873
secure: true secure: true
tls port: 9874
update interval: 60 update interval: 60
nfs: nfs:
enabled: true enabled: true
@@ -160,6 +163,7 @@ warewulf:
host overlay: true host overlay: true
port: 9873 port: 9873
secure: true secure: true
tls port: 9874
update interval: 60 update interval: 60
nfs: nfs:
enabled: true enabled: true
@@ -234,6 +238,7 @@ warewulf:
host overlay: true host overlay: true
port: 9873 port: 9873
secure: false secure: false
tls port: 9874
update interval: 60 update interval: 60
nfs: nfs:
enabled: true enabled: true

View File

@@ -1,12 +1,13 @@
package configure package configure
import ( import (
"fmt"
"os" "os"
"path" "path"
warewulfconf "github.com/warewulf/warewulf/internal/pkg/config" 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/util"
"github.com/warewulf/warewulf/internal/pkg/warewulfd"
"github.com/warewulf/warewulf/internal/pkg/wwlog" "github.com/warewulf/warewulf/internal/pkg/wwlog"
"golang.org/x/sys/unix" "golang.org/x/sys/unix"
) )
@@ -35,7 +36,7 @@ func TFTP() (err error) {
} }
if controller.Warewulf.GrubBoot() { if controller.Warewulf.GrubBoot() {
err := warewulfd.CopyShimGrub() err := CopyShimGrub()
if err != nil { if err != nil {
wwlog.Warn("error when copying shim/grub binaries: %s", err) wwlog.Warn("error when copying shim/grub binaries: %s", err)
} }
@@ -70,3 +71,30 @@ func TFTP() (err error) {
return nil 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
}

View File

@@ -0,0 +1,118 @@
package configure
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"net"
"os"
"path"
"time"
warewulfconf "github.com/warewulf/warewulf/internal/pkg/config"
"github.com/warewulf/warewulf/internal/pkg/util"
"github.com/warewulf/warewulf/internal/pkg/wwlog"
)
// TLS ensures TLS keys exist if TLS is enabled.
// If force is true, regenerates even if keys already exist.
// Returns true if new keys were generated.
func TLS(force bool) (bool, error) {
conf := warewulfconf.Get()
if !conf.Warewulf.TLSEnabled() {
return false, nil
}
keystore := path.Join(conf.Paths.Sysconfdir, "warewulf", "tls")
keyFile := path.Join(keystore, "warewulf.key")
certFile := path.Join(keystore, "warewulf.crt")
if !force && util.IsFile(keyFile) && util.IsFile(certFile) {
wwlog.Info("TLS keys already exist in %s", keystore)
return false, nil
}
if err := GenTLSKeys(); err != nil {
return false, err
}
wwlog.Info("TLS keys generated in %s", keystore)
return true, nil
}
// GenTLSKeys generates new TLS keys and certificate unconditionally.
func GenTLSKeys() error {
conf := warewulfconf.Get()
keystore := path.Join(conf.Paths.Sysconfdir, "warewulf", "tls")
if err := os.MkdirAll(keystore, 0755); err != nil {
return fmt.Errorf("could not create keystore directory: %w", err)
}
keyFile := path.Join(keystore, "warewulf.key")
certFile := path.Join(keystore, "warewulf.crt")
wwlog.Verbose("Generating new x509 keys in %s", keystore)
priv, err := rsa.GenerateKey(rand.Reader, 4096)
if err != nil {
return fmt.Errorf("failed to generate rsa key: %w", err)
}
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
return fmt.Errorf("failed to generate serial number: %w", err)
}
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
CommonName: "Warewulf Server",
Organization: []string{"Warewulf"},
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(time.Hour * 24 * 365 * 10),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
if ip := net.ParseIP(conf.Ipaddr); ip != nil {
template.IPAddresses = append(template.IPAddresses, ip)
}
if ip := net.ParseIP(conf.Ipaddr6); ip != nil {
template.IPAddresses = append(template.IPAddresses, ip)
}
if conf.Fqdn != "" {
template.DNSNames = append(template.DNSNames, conf.Fqdn)
}
template.DNSNames = append(template.DNSNames, "warewulf")
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
if err != nil {
return fmt.Errorf("failed to create certificate: %w", err)
}
keyOut, err := os.OpenFile(keyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return fmt.Errorf("failed to open key file for writing: %w", err)
}
defer keyOut.Close()
if err := pem.Encode(keyOut, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}); err != nil {
return fmt.Errorf("failed to write data to key file: %w", err)
}
certOut, err := os.OpenFile(certFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
return fmt.Errorf("failed to open cert file for writing: %w", err)
}
defer certOut.Close()
if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil {
return fmt.Errorf("failed to write data to cert file: %w", err)
}
return nil
}

View File

@@ -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
}

View File

@@ -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 package warewulfd
import ( import (

View File

@@ -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)
}

View File

@@ -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)
})
}
}

View File

@@ -0,0 +1,26 @@
package warewulfd
import (
"net/http"
"path"
"github.com/warewulf/warewulf/internal/pkg/wwlog"
)
// HandleGrub handles GRUB configuration requests
func HandleGrub(w http.ResponseWriter, req *http.Request) {
ctx, err := initHandleRequest(w, req)
if err != nil {
return // response already written
}
if !ctx.remoteNode.Valid() {
wwlog.Error("%s (unknown/unconfigured node)", ctx.rinfo.hwaddr)
w.WriteHeader(http.StatusNotFound)
return
}
stageFile := path.Join(ctx.conf.Paths.Sysconfdir, "warewulf/grub/grub.cfg.ww")
tmplData := buildTemplateVars(ctx.conf, ctx.rinfo, ctx.remoteNode)
sendResponse(w, req, stageFile, tmplData, ctx)
}

View File

@@ -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)
})
}
}

View File

@@ -0,0 +1,155 @@
package warewulfd
import (
"bytes"
"fmt"
"net/http"
"net/netip"
"path"
"path/filepath"
"strconv"
"strings"
"text/template"
"github.com/Masterminds/sprig/v3"
warewulfconf "github.com/warewulf/warewulf/internal/pkg/config"
"github.com/warewulf/warewulf/internal/pkg/kernel"
"github.com/warewulf/warewulf/internal/pkg/node"
"github.com/warewulf/warewulf/internal/pkg/util"
"github.com/warewulf/warewulf/internal/pkg/wwlog"
)
// buildTemplateVars constructs the templateVars struct with all necessary
// fields, including handling IPv6 authority formatting and kernel version resolution.
func buildTemplateVars(conf *warewulfconf.WarewulfYaml, rinfo parsedRequest, remoteNode node.Node) *templateVars {
kernelArgs := ""
kernelVersion := ""
if remoteNode.Kernel != nil {
kernelArgs = strings.Join(remoteNode.Kernel.Args, " ")
kernelVersion = remoteNode.Kernel.Version
}
if kernelVersion == "" {
if kernel_ := kernel.FromNode(&remoteNode); kernel_ != nil {
kernelVersion = kernel_.Version()
}
}
authority := fmt.Sprintf("%s:%d", conf.Ipaddr, conf.Warewulf.Port)
ipaddr6 := ""
if confIpaddr6, err := netip.ParseAddr(conf.Ipaddr6); err == nil {
ipaddr6 = confIpaddr6.String()
}
if rinfoIpaddr, err := netip.ParseAddr(rinfo.ipaddr); err == nil {
if rinfoIpaddr.Is6() {
if ipaddr6 != "" {
authority = fmt.Sprintf("[%s]:%d", ipaddr6, conf.Warewulf.Port)
} else {
wwlog.Error("No valid IPv6 address configured, but request is IPv6")
}
}
} else {
wwlog.Error("Could not parse request IP address: %s", rinfo.ipaddr)
}
return &templateVars{
Id: remoteNode.Id(),
Cluster: remoteNode.ClusterName,
Fqdn: remoteNode.Id(),
Ipaddr: conf.Ipaddr,
Ipaddr6: ipaddr6,
Port: strconv.Itoa(conf.Warewulf.Port),
TLS: conf.Warewulf.TLSEnabled(),
Authority: authority,
Hostname: remoteNode.Id(),
Hwaddr: rinfo.hwaddr,
ImageName: remoteNode.ImageName,
Ipxe: remoteNode.Ipxe,
KernelArgs: kernelArgs,
KernelVersion: kernelVersion,
Root: remoteNode.Root,
NetDevs: remoteNode.NetDevs,
Tags: remoteNode.Tags}
}
// sendResponse handles the common response logic for provision handlers.
// If tmplData is non-nil, it renders the stageFile as a template. Otherwise, it
// sends stageFile as a raw file (with optional .gz compression).
func sendResponse(w http.ResponseWriter, req *http.Request, stageFile string, tmplData *templateVars, ctx *requestContext) {
wwlog.Serv("stage_file '%s'", stageFile)
if util.IsFile(stageFile) {
if tmplData != nil {
if ctx.rinfo.compress != "" {
wwlog.Error("Unsupported %s compressed version for file: %s",
ctx.rinfo.compress, stageFile)
w.WriteHeader(http.StatusNotFound)
return
}
// Create a template with the Sprig functions.
tmpl := template.New(filepath.Base(stageFile)).Funcs(sprig.TxtFuncMap())
// Parse the template.
parsedTmpl, err := tmpl.ParseFiles(stageFile)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
wwlog.ErrorExc(err, "")
return
}
// template engine writes file to buffer in case rendering fails
var buf bytes.Buffer
err = parsedTmpl.Execute(&buf, tmplData)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
wwlog.ErrorExc(err, "")
return
}
w.Header().Set("Content-Type", "text")
w.Header().Set("Content-Length", strconv.Itoa(buf.Len()))
_, err = buf.WriteTo(w)
if err != nil {
wwlog.ErrorExc(err, "")
}
wwlog.Info("send %s -> %s", stageFile, ctx.remoteNode.Id())
} else {
if ctx.rinfo.compress == "gz" {
stageFile += ".gz"
if !util.IsFile(stageFile) {
wwlog.Error("unprepared for compressed version of file %s",
stageFile)
w.WriteHeader(http.StatusNotFound)
return
}
} else if ctx.rinfo.compress != "" {
wwlog.Error("unsupported %s compressed version of file %s",
ctx.rinfo.compress, stageFile)
w.WriteHeader(http.StatusNotFound)
}
err := sendFile(w, req, stageFile, ctx.remoteNode.Id())
if err != nil {
wwlog.ErrorExc(err, "")
return
}
}
updateStatus(ctx.remoteNode.Id(), ctx.rinfo.stage, path.Base(stageFile), ctx.rinfo.ipaddr)
} else if stageFile == "" {
w.WriteHeader(http.StatusBadRequest)
wwlog.Error("No resource selected")
updateStatus(ctx.remoteNode.Id(), ctx.rinfo.stage, "BAD_REQUEST", ctx.rinfo.ipaddr)
} else {
w.WriteHeader(http.StatusNotFound)
wwlog.Error("Not found: %s", stageFile)
updateStatus(ctx.remoteNode.Id(), ctx.rinfo.stage, "NOT_FOUND", ctx.rinfo.ipaddr)
}
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
})
}
}

View File

@@ -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)
}

View File

@@ -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)
})
}
}

View File

@@ -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)
}

View File

@@ -1,6 +1,7 @@
package warewulfd package warewulfd
import ( import (
"errors"
"fmt" "fmt"
"net" "net"
"net/http" "net/http"
@@ -16,7 +17,127 @@ import (
"github.com/warewulf/warewulf/internal/pkg/wwlog" "github.com/warewulf/warewulf/internal/pkg/wwlog"
) )
func OverlaySend(w http.ResponseWriter, req *http.Request) { // HandleOverlayList handles requests for an explicit comma-separated list of
// named overlays via the ?overlay= query parameter.
func HandleOverlayList(w http.ResponseWriter, req *http.Request) {
ctx, err := initHandleRequest(w, req)
if err != nil {
return // response already written
}
if !ctx.remoteNode.Valid() {
wwlog.Error("%s (unknown/unconfigured node)", ctx.rinfo.hwaddr)
sendResponse(w, req, "", nil, ctx)
return
}
request_overlays := strings.Split(ctx.rinfo.overlay, ",")
stageFile, err := getOverlayFile(
ctx.remoteNode,
"",
request_overlays,
ctx.conf.Warewulf.AutobuildOverlays())
if err != nil {
if errors.Is(err, overlay.ErrDoesNotExist) {
w.WriteHeader(http.StatusNotFound)
wwlog.ErrorExc(err, "")
return
}
w.WriteHeader(http.StatusInternalServerError)
wwlog.ErrorExc(err, "")
return
}
sendResponse(w, req, stageFile, nil, ctx)
}
// HandleSystemOverlay handles system overlay requests.
// If an explicit ?overlay= list is present, delegates to HandleOverlayList.
func HandleSystemOverlay(w http.ResponseWriter, req *http.Request) {
if len(req.URL.Query()["overlay"]) > 0 {
HandleOverlayList(w, req)
return
}
ctx, err := initHandleRequest(w, req)
if err != nil {
return // response already written
}
if !ctx.remoteNode.Valid() {
wwlog.Error("%s (unknown/unconfigured node)", ctx.rinfo.hwaddr)
sendResponse(w, req, "", nil, ctx)
return
}
stageFile, err := getOverlayFile(
ctx.remoteNode,
"system",
nil,
ctx.conf.Warewulf.AutobuildOverlays())
if err != nil {
if errors.Is(err, overlay.ErrDoesNotExist) {
w.WriteHeader(http.StatusNotFound)
wwlog.ErrorExc(err, "")
return
}
w.WriteHeader(http.StatusInternalServerError)
wwlog.ErrorExc(err, "")
return
}
sendResponse(w, req, stageFile, nil, ctx)
}
// HandleRuntimeOverlay handles runtime overlay requests.
// If TLS is enabled, returns 403 Forbidden for plain-HTTP requests.
// If an explicit ?overlay= list is present, delegates to HandleOverlayList.
func HandleRuntimeOverlay(w http.ResponseWriter, req *http.Request) {
if config.Get().Warewulf.TLSEnabled() && req.TLS == nil {
wwlog.Denied("runtime overlay requested over insecure connection")
w.WriteHeader(http.StatusForbidden)
return
}
if len(req.URL.Query()["overlay"]) > 0 {
HandleOverlayList(w, req)
return
}
ctx, err := initHandleRequest(w, req)
if err != nil {
return // response already written
}
if !ctx.remoteNode.Valid() {
wwlog.Error("%s (unknown/unconfigured node)", ctx.rinfo.hwaddr)
sendResponse(w, req, "", nil, ctx)
return
}
stageFile, err := getOverlayFile(
ctx.remoteNode,
"runtime",
nil,
ctx.conf.Warewulf.AutobuildOverlays())
if err != nil {
if errors.Is(err, overlay.ErrDoesNotExist) {
w.WriteHeader(http.StatusNotFound)
wwlog.ErrorExc(err, "")
return
}
w.WriteHeader(http.StatusInternalServerError)
wwlog.ErrorExc(err, "")
return
}
sendResponse(w, req, stageFile, nil, ctx)
}
func HandleOverlayFile(w http.ResponseWriter, req *http.Request) {
rinfo, err := parseReqRender(req) rinfo, err := parseReqRender(req)
if err != nil { if err != nil {
message := "error parsing request: %s" message := "error parsing request: %s"

View File

@@ -4,10 +4,13 @@ import (
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"path"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
warewulfconf "github.com/warewulf/warewulf/internal/pkg/config"
"github.com/warewulf/warewulf/internal/pkg/testenv" "github.com/warewulf/warewulf/internal/pkg/testenv"
) )
@@ -95,7 +98,93 @@ nodes:
t.Run(description, func(t *testing.T) { t.Run(description, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tt.url, nil) req := httptest.NewRequest(http.MethodGet, tt.url, nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
OverlaySend(w, req) HandleOverlayFile(w, req)
res := w.Result()
defer res.Body.Close()
data, readErr := io.ReadAll(res.Body)
assert.NoError(t, readErr)
if tt.body != "" {
assert.Equal(t, tt.body, string(data))
}
assert.Equal(t, tt.status, res.StatusCode)
})
}
}
var systemOverlayTests = []struct {
description string
url string
body string
status int
ip string
}{
{"system overlay", "/system/00:00:00:ff:ff:ff", "system overlay", 200, "10.10.10.10:9873"},
{"fake overlay returns 404", "/system/00:00:00:ff:ff:ff?overlay=fake", "", 404, "10.10.10.10:9873"},
{"specific overlay", "/system/00:00:00:ff:ff:ff?overlay=o1", "specific overlay", 200, "10.10.10.10:9873"},
}
var runtimeOverlayTests = []struct {
description string
url string
body string
status int
ip string
}{
{"runtime overlay", "/runtime/00:00:00:ff:ff:ff", "runtime overlay", 200, "10.10.10.10:9873"},
}
func Test_HandleSystemRuntimeOverlay(t *testing.T) {
env := testenv.New(t)
defer env.RemoveAll()
env.WriteFile("/etc/warewulf/nodes.conf", `nodeprofiles:
default:
image name: suse
nodes:
n1:
network devices:
default:
hwaddr: 00:00:00:ff:ff:ff
profiles:
- default`)
dbErr := LoadNodeDB()
assert.NoError(t, dbErr)
conf := warewulfconf.Get()
secureFalse := false
conf.Warewulf.SecureP = &secureFalse
assert.NoError(t, os.MkdirAll(path.Join(conf.Paths.OverlayProvisiondir(), "n1"), 0700))
assert.NoError(t, os.WriteFile(path.Join(conf.Paths.OverlayProvisiondir(), "n1", "__SYSTEM__.img"), []byte("system overlay"), 0600))
assert.NoError(t, os.WriteFile(path.Join(conf.Paths.OverlayProvisiondir(), "n1", "__RUNTIME__.img"), []byte("runtime overlay"), 0600))
assert.NoError(t, os.WriteFile(path.Join(conf.Paths.OverlayProvisiondir(), "n1", "o1.img"), []byte("specific overlay"), 0600))
for _, tt := range systemOverlayTests {
t.Run(tt.description, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tt.url, nil)
req.RemoteAddr = tt.ip
w := httptest.NewRecorder()
HandleSystemOverlay(w, req)
res := w.Result()
defer res.Body.Close()
data, readErr := io.ReadAll(res.Body)
assert.NoError(t, readErr)
if tt.body != "" {
assert.Equal(t, tt.body, string(data))
}
assert.Equal(t, tt.status, res.StatusCode)
})
}
for _, tt := range runtimeOverlayTests {
t.Run(tt.description, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tt.url, nil)
req.RemoteAddr = tt.ip
w := httptest.NewRecorder()
HandleRuntimeOverlay(w, req)
res := w.Result() res := w.Result()
defer res.Body.Close() defer res.Body.Close()

View File

@@ -1,15 +1,71 @@
package warewulfd package warewulfd
import ( import (
"fmt"
"net/http" "net/http"
"net/netip" "net/netip"
"strings" "strings"
"github.com/pkg/errors" "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" "github.com/warewulf/warewulf/internal/pkg/wwlog"
) )
type parserInfo struct { // requestContext holds the validated results of the parsed request
type requestContext struct {
conf *warewulfconf.WarewulfYaml
rinfo parsedRequest
remoteNode node.Node
}
// initHandleRequest performs common initial request parsing, security checks,
// node lookup, and asset key validation. On error, it writes the HTTP error
// response and returns a non-nil error so the caller can simply return.
func initHandleRequest(w http.ResponseWriter, req *http.Request) (*requestContext, error) {
wwlog.Debug("Requested URL: %s", req.URL.String())
conf := warewulfconf.Get()
rinfo, err := parseRequest(req)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
wwlog.ErrorExc(err, "Bad status")
return nil, err
}
wwlog.Debug("stage: %s", rinfo.stage)
wwlog.Info("request from hwaddr:%s ipaddr:%s | stage:%s", rinfo.hwaddr, req.RemoteAddr, rinfo.stage)
if (rinfo.stage == "runtime" || len(rinfo.overlay) > 0) && conf.Warewulf.Secure() {
if rinfo.remoteport >= 1024 {
wwlog.Denied("Non-privileged port: %s", req.RemoteAddr)
w.WriteHeader(http.StatusUnauthorized)
return nil, fmt.Errorf("non-privileged port")
}
}
remoteNode, err := GetNodeOrSetDiscoverable(rinfo.hwaddr, conf.Warewulf.AutobuildOverlays())
if err != nil && err != node.ErrNoUnconfigured {
wwlog.ErrorExc(err, "")
w.WriteHeader(http.StatusServiceUnavailable)
return nil, err
}
if remoteNode.AssetKey != "" && remoteNode.AssetKey != rinfo.assetkey {
w.WriteHeader(http.StatusUnauthorized)
wwlog.Denied("incorrect asset key: node %s: %s", remoteNode.Id(), rinfo.assetkey)
updateStatus(remoteNode.Id(), rinfo.stage, "BAD_ASSET", rinfo.ipaddr)
return nil, fmt.Errorf("incorrect asset key")
}
return &requestContext{
conf: conf,
rinfo: rinfo,
remoteNode: remoteNode,
}, nil
}
type parsedRequest struct {
hwaddr string hwaddr string
ipaddr string ipaddr string
remoteport int remoteport int
@@ -21,85 +77,99 @@ type parserInfo struct {
compress string compress string
} }
func parseReq(req *http.Request) (parserInfo, error) { func parseHwaddr(hwaddr string) string {
var ret parserInfo hwaddr = strings.ReplaceAll(hwaddr, "-", ":")
hwaddr = strings.ToLower(hwaddr)
return hwaddr
}
url := strings.Split(req.URL.Path, "?")[0] // parseRequest extracts provisioning parameters from an HTTP request. The
path_parts := strings.Split(url, "/") // stage and hwaddr are taken from the URL path, with fallbacks to query
// parameters and ARP cache lookup for hwaddr. Stage aliases (e.g.
// "container", "overlay-system") are normalized to their canonical names.
//
// See userdocs/server/routes.rst for more information.
func parseRequest(req *http.Request) (parsedRequest, error) {
var ret parsedRequest
if len(path_parts) < 3 { path_parts := strings.Split(req.URL.Path, "/")
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 // map the requested stage to a known stage
stage := path_parts[1] switch ret.stage {
hwaddr := "" case "provision":
if stage != "efiboot" { ret.stage = "ipxe"
hwaddr = path_parts[2] case "container":
hwaddr = strings.ReplaceAll(hwaddr, "-", ":") ret.stage = "image"
hwaddr = strings.ToLower(hwaddr) case "overlay-system":
} else if len(path_parts) > 3 { ret.stage = "system"
ret.efifile = strings.Join(path_parts[2:], "/") case "overlay-runtime":
} else { ret.stage = "runtime"
ret.efifile = path_parts[2]
} }
ret.hwaddr = hwaddr
if ret.stage == "" {
return ret, errors.New("no stage specified: " + req.URL.RawQuery)
}
if len(path_parts) > 2 {
if ret.stage == "efiboot" {
// /efiboot/{file}: no wwid in path; identified via ARP
ret.efifile = strings.Join(path_parts[2:], "/")
} else {
ret.hwaddr = parseHwaddr(path_parts[2])
}
}
remoteAddrPort, err := netip.ParseAddrPort(req.RemoteAddr) remoteAddrPort, err := netip.ParseAddrPort(req.RemoteAddr)
if err != nil { if err != nil {
return ret, errors.New("could not parse remote address") return ret, errors.New("could not parse remote address")
} }
ret.ipaddr = remoteAddrPort.Addr().String() ret.ipaddr = remoteAddrPort.Addr().String()
if ret.ipaddr == "" {
return ret, errors.New("could not obtain ipaddr from HTTP request")
}
ret.remoteport = int(remoteAddrPort.Port()) 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 { if len(req.URL.Query()["assetkey"]) > 0 {
ret.assetkey = req.URL.Query()["assetkey"][0] ret.assetkey = req.URL.Query()["assetkey"][0]
} }
if len(req.URL.Query()["uuid"]) > 0 { if len(req.URL.Query()["uuid"]) > 0 {
ret.uuid = req.URL.Query()["uuid"][0] ret.uuid = req.URL.Query()["uuid"][0]
} }
if len(req.URL.Query()["stage"]) > 0 {
ret.stage = req.URL.Query()["stage"][0]
} else {
if stage == "ipxe" || stage == "provision" {
ret.stage = "ipxe"
} else if stage == "kernel" {
ret.stage = "kernel"
} else if stage == "image" {
ret.stage = "image"
} else if stage == "overlay-system" {
ret.stage = "system"
} else if stage == "overlay-runtime" {
ret.stage = "runtime"
} else if stage == "efiboot" {
ret.stage = "efiboot"
} else if stage == "initramfs" {
ret.stage = "initramfs"
}
}
if len(req.URL.Query()["overlay"]) > 0 { if len(req.URL.Query()["overlay"]) > 0 {
ret.overlay = req.URL.Query()["overlay"][0] ret.overlay = req.URL.Query()["overlay"][0]
} }
if len(req.URL.Query()["compress"]) > 0 { if len(req.URL.Query()["compress"]) > 0 {
ret.compress = req.URL.Query()["compress"][0] ret.compress = req.URL.Query()["compress"][0]
} }
if ret.stage == "" { if ret.efifile == "" && len(req.URL.Query()["file"]) > 0 {
return ret, errors.New("no stage encoded in GET") ret.efifile = req.URL.Query()["file"][0]
}
if ret.hwaddr == "" {
ret.hwaddr = ArpFind(ret.ipaddr)
wwlog.Verbose("node mac not encoded, arp cache got %s for %s", ret.hwaddr, ret.ipaddr)
if ret.hwaddr == "" {
return ret, errors.New("no hwaddr encoded in GET")
}
}
if ret.ipaddr == "" {
return ret, errors.New("could not obtain ipaddr from HTTP request")
}
if ret.remoteport == 0 {
return ret, errors.New("could not obtain remote port from HTTP request: " + req.RemoteAddr)
} }
return ret, nil return ret, nil

View File

@@ -11,14 +11,15 @@ import (
var parseReqTests = []struct { var parseReqTests = []struct {
description string description string
url string url string
rawQuery string
remoteAddr string remoteAddr string
result parserInfo result parsedRequest
}{ }{
{ {
description: "basic ipv4 request", description: "basic ipv4 request",
url: "/provision/00:00:00:ff:ff:ff", url: "/provision/00:00:00:ff:ff:ff",
remoteAddr: "10.5.1.1:9873", remoteAddr: "10.5.1.1:9873",
result: parserInfo{ result: parsedRequest{
hwaddr: "00:00:00:ff:ff:ff", hwaddr: "00:00:00:ff:ff:ff",
ipaddr: "10.5.1.1", ipaddr: "10.5.1.1",
remoteport: 9873, remoteport: 9873,
@@ -29,23 +30,153 @@ var parseReqTests = []struct {
description: "basic ipv6 request", description: "basic ipv6 request",
url: "/provision/00:00:00:ff:ff:ff", url: "/provision/00:00:00:ff:ff:ff",
remoteAddr: "[fd00:5::1:1]:9873", remoteAddr: "[fd00:5::1:1]:9873",
result: parserInfo{ result: parsedRequest{
hwaddr: "00:00:00:ff:ff:ff", hwaddr: "00:00:00:ff:ff:ff",
ipaddr: "fd00:5::1:1", ipaddr: "fd00:5::1:1",
remoteport: 9873, remoteport: 9873,
stage: "ipxe", stage: "ipxe",
}, },
}, },
{
description: "initramfs dedicated route",
url: "/initramfs/00:00:00:ff:ff:ff",
remoteAddr: "10.5.1.1:9873",
result: parsedRequest{
hwaddr: "00:00:00:ff:ff:ff",
ipaddr: "10.5.1.1",
remoteport: 9873,
stage: "initramfs",
},
},
{
description: "grub route with hwaddr in path",
url: "/grub/00:00:00:ff:ff:ff",
remoteAddr: "10.5.1.1:9873",
result: parsedRequest{
hwaddr: "00:00:00:ff:ff:ff",
ipaddr: "10.5.1.1",
remoteport: 9873,
stage: "grub",
},
},
{
description: "wwid query param on dedicated route",
url: "/kernel/",
rawQuery: "wwid=00%3A00%3A00%3Aff%3Aff%3Aff",
remoteAddr: "10.5.1.1:9873",
result: parsedRequest{
hwaddr: "00:00:00:ff:ff:ff",
ipaddr: "10.5.1.1",
remoteport: 9873,
stage: "kernel",
},
},
{
description: "wwid and stage query params on provision route",
url: "/provision/",
rawQuery: "wwid=00%3A00%3A00%3Aff%3Aff%3Aff&stage=kernel",
remoteAddr: "10.5.1.1:9873",
result: parsedRequest{
hwaddr: "00:00:00:ff:ff:ff",
ipaddr: "10.5.1.1",
remoteport: 9873,
stage: "kernel",
},
},
{
description: "efiboot with wwid and file query params",
url: "/efiboot/",
rawQuery: "wwid=00%3A00%3A00%3Aff%3Aff%3Aff&file=shim.efi",
remoteAddr: "10.5.1.1:9873",
result: parsedRequest{
hwaddr: "00:00:00:ff:ff:ff",
ipaddr: "10.5.1.1",
remoteport: 9873,
stage: "efiboot",
efifile: "shim.efi",
},
},
{
description: "grub with wwid query param",
url: "/grub/",
rawQuery: "wwid=00%3A00%3A00%3Aff%3Aff%3Aff",
remoteAddr: "10.5.1.1:9873",
result: parsedRequest{
hwaddr: "00:00:00:ff:ff:ff",
ipaddr: "10.5.1.1",
remoteport: 9873,
stage: "grub",
},
},
{
description: "path hwaddr takes priority over wwid query param",
url: "/kernel/00:00:00:ff:ff:ff",
rawQuery: "wwid=11%3A11%3A11%3A11%3A11%3A11",
remoteAddr: "10.5.1.1:9873",
result: parsedRequest{
hwaddr: "00:00:00:ff:ff:ff",
ipaddr: "10.5.1.1",
remoteport: 9873,
stage: "kernel",
},
},
{
description: "system overlay dedicated route",
url: "/system/00:00:00:ff:ff:ff",
remoteAddr: "10.5.1.1:9873",
result: parsedRequest{
hwaddr: "00:00:00:ff:ff:ff",
ipaddr: "10.5.1.1",
remoteport: 9873,
stage: "system",
},
},
{
description: "runtime overlay dedicated route",
url: "/runtime/00:00:00:ff:ff:ff",
remoteAddr: "10.5.1.1:9873",
result: parsedRequest{
hwaddr: "00:00:00:ff:ff:ff",
ipaddr: "10.5.1.1",
remoteport: 9873,
stage: "runtime",
},
},
{
description: "stage=grub via provision route serves config (new semantics)",
url: "/provision/00:00:00:ff:ff:ff",
rawQuery: "stage=grub",
remoteAddr: "10.5.1.1:9873",
result: parsedRequest{
hwaddr: "00:00:00:ff:ff:ff",
ipaddr: "10.5.1.1",
remoteport: 9873,
stage: "grub",
},
},
{
description: "path efifile takes priority over file query param",
url: "/efiboot/shim.efi",
rawQuery: "wwid=00%3A00%3A00%3Aff%3Aff%3Aff&file=grub.cfg",
remoteAddr: "10.5.1.1:9873",
result: parsedRequest{
hwaddr: "00:00:00:ff:ff:ff",
ipaddr: "10.5.1.1",
remoteport: 9873,
stage: "efiboot",
efifile: "shim.efi",
},
},
} }
func Test_ParseReq(t *testing.T) { func Test_ParseRequest(t *testing.T) {
for _, tt := range parseReqTests { for _, tt := range parseReqTests {
t.Run(tt.description, func(t *testing.T) { t.Run(tt.description, func(t *testing.T) {
req := &http.Request{ req := &http.Request{
URL: &url.URL{Path: tt.url}, URL: &url.URL{Path: tt.url, RawQuery: tt.rawQuery},
RemoteAddr: tt.remoteAddr, RemoteAddr: tt.remoteAddr,
} }
result, err := parseReq(req) result, err := parseRequest(req)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, tt.result, result) assert.Equal(t, tt.result, result)
}) })

View File

@@ -1,24 +1,9 @@
package warewulfd package warewulfd
import ( import (
"bytes"
"errors"
"fmt"
"net/http" "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/node"
"github.com/warewulf/warewulf/internal/pkg/overlay"
"github.com/warewulf/warewulf/internal/pkg/util"
"github.com/warewulf/warewulf/internal/pkg/wwlog" "github.com/warewulf/warewulf/internal/pkg/wwlog"
) )
@@ -39,343 +24,41 @@ type templateVars struct {
KernelArgs string KernelArgs string
KernelVersion string KernelVersion string
Root string Root string
TLS bool
Tags map[string]string Tags map[string]string
NetDevs map[string]*node.NetDev NetDevs map[string]*node.NetDev
} }
func ProvisionSend(w http.ResponseWriter, req *http.Request) { func HandleProvision(w http.ResponseWriter, req *http.Request) {
wwlog.Debug("Requested URL: %s", req.URL.String()) // Parse just enough to determine the stage
conf := warewulfconf.Get() rinfo, err := parseRequest(req)
rinfo, err := parseReq(req)
if err != nil { if err != nil {
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
wwlog.ErrorExc(err, "Bad status") wwlog.ErrorExc(err, "Bad status")
return return
} }
wwlog.Debug("stage: %s", rinfo.stage) // Dispatch to the appropriate stage handler
var handler http.HandlerFunc
wwlog.Info("request from hwaddr:%s ipaddr:%s | stage:%s", rinfo.hwaddr, req.RemoteAddr, rinfo.stage) switch rinfo.stage {
case "ipxe":
if (rinfo.stage == "runtime" || len(rinfo.overlay) > 0) && conf.Warewulf.Secure() { handler = HandleIpxe
if rinfo.remoteport >= 1024 { case "kernel":
wwlog.Denied("Non-privileged port: %s", req.RemoteAddr) handler = HandleKernel
w.WriteHeader(http.StatusUnauthorized) case "image":
return handler = HandleImage
} case "system":
} handler = HandleSystemOverlay
case "runtime":
status_stages := map[string]string{ handler = HandleRuntimeOverlay
"efiboot": "EFI", case "grub":
"ipxe": "IPXE", handler = HandleGrub
"kernel": "KERNEL", case "initramfs":
"system": "SYSTEM_OVERLAY", handler = HandleInitramfs
"runtime": "RUNTIME_OVERLAY", default:
"initramfs": "INITRAMFS"}
status_stage := status_stages[rinfo.stage]
var stage_file string
// TODO: when module version is upgraded to go1.18, should be 'any' type
var tmpl_data *templateVars
remoteNode, err := GetNodeOrSetDiscoverable(rinfo.hwaddr, conf.Warewulf.AutobuildOverlays())
if err != nil && err != node.ErrNoUnconfigured {
wwlog.ErrorExc(err, "")
w.WriteHeader(http.StatusServiceUnavailable)
return
}
if remoteNode.AssetKey != "" && remoteNode.AssetKey != rinfo.assetkey {
w.WriteHeader(http.StatusUnauthorized)
wwlog.Denied("incorrect asset key: node %s: %s", remoteNode.Id(), rinfo.assetkey)
updateStatus(remoteNode.Id(), status_stage, "BAD_ASSET", rinfo.ipaddr)
return
}
if !remoteNode.Valid() {
wwlog.Error("%s (unknown/unconfigured node)", rinfo.hwaddr)
if rinfo.stage == "ipxe" {
stage_file = path.Join(conf.Paths.Sysconfdir, "/warewulf/ipxe/unconfigured.ipxe")
tmpl_data = &templateVars{
Hwaddr: rinfo.hwaddr}
}
} else if rinfo.stage == "ipxe" {
template := remoteNode.Ipxe
if template == "" {
template = "default"
}
stage_file = path.Join(conf.Paths.Sysconfdir, "warewulf/ipxe", template+".ipxe")
kernelArgs := ""
kernelVersion := ""
if remoteNode.Kernel != nil {
kernelArgs = strings.Join(remoteNode.Kernel.Args, " ")
kernelVersion = remoteNode.Kernel.Version
}
if kernelVersion == "" {
if kernel_ := kernel.FromNode(&remoteNode); kernel_ != nil {
kernelVersion = kernel_.Version()
}
}
authority := fmt.Sprintf("%s:%d", conf.Ipaddr, conf.Warewulf.Port)
ipaddr6 := ""
if confIpaddr6, err := netip.ParseAddr(conf.Ipaddr6); err == nil {
ipaddr6 = confIpaddr6.String()
}
if rinfoIpaddr, err := netip.ParseAddr(rinfo.ipaddr); err == nil {
if rinfoIpaddr.Is6() {
if ipaddr6 != "" {
authority = fmt.Sprintf("[%s]:%d", ipaddr6, conf.Warewulf.Port)
} else {
wwlog.Error("No valid IPv6 address configured, but request is IPv6")
}
}
} else {
wwlog.Error("Could not parse request IP address: %s", rinfo.ipaddr)
}
tmpl_data = &templateVars{
Id: remoteNode.Id(),
Cluster: remoteNode.ClusterName,
Fqdn: remoteNode.Id(),
Ipaddr: conf.Ipaddr,
Ipaddr6: ipaddr6,
Port: strconv.Itoa(conf.Warewulf.Port),
Authority: authority,
Hostname: remoteNode.Id(),
Hwaddr: rinfo.hwaddr,
ImageName: remoteNode.ImageName,
KernelArgs: kernelArgs,
KernelVersion: kernelVersion,
Root: remoteNode.Root,
NetDevs: remoteNode.NetDevs,
Tags: remoteNode.Tags}
} else if rinfo.stage == "kernel" {
kernel_ := kernel.FromNode(&remoteNode)
if kernel_ == nil {
wwlog.Error("No kernel found for node %s", remoteNode.Id())
} else {
stage_file = kernel_.FullPath()
if stage_file == "" {
wwlog.Error("No kernel path found for node %s", remoteNode.Id())
}
}
} else if rinfo.stage == "image" {
if remoteNode.ImageName != "" {
stage_file = image.ImageFile(remoteNode.ImageName)
} else {
wwlog.Warn("No image set for node %s", remoteNode.Id())
}
} else if rinfo.stage == "system" || rinfo.stage == "runtime" {
var context string
var request_overlays []string
if len(rinfo.overlay) > 0 {
request_overlays = strings.Split(rinfo.overlay, ",")
} else {
context = rinfo.stage
}
stage_file, err = getOverlayFile(
remoteNode,
context,
request_overlays,
conf.Warewulf.AutobuildOverlays())
if err != nil {
if errors.Is(err, overlay.ErrDoesNotExist) {
w.WriteHeader(http.StatusNotFound)
wwlog.ErrorExc(err, "")
return
}
w.WriteHeader(http.StatusInternalServerError)
wwlog.ErrorExc(err, "")
return
}
} else if rinfo.stage == "efiboot" {
wwlog.Debug("requested method: %s", req.Method)
imageName := remoteNode.ImageName
switch rinfo.efifile {
case "shim.efi":
stage_file = image.ShimFind(imageName)
if stage_file == "" {
wwlog.Error("couldn't find shim.efi for %s", imageName)
w.WriteHeader(http.StatusNotFound)
return
}
case "grub.efi", "grub-tpm.efi", "grubx64.efi", "grubia32.efi", "grubaa64.efi", "grubarm.efi":
stage_file = image.GrubFind(imageName)
if stage_file == "" {
wwlog.Error("could't find grub*.efi for %s", imageName)
w.WriteHeader(http.StatusNotFound)
return
}
case "grub.cfg":
stage_file = path.Join(conf.Paths.Sysconfdir, "warewulf/grub/grub.cfg.ww")
kernelArgs := ""
kernelVersion := ""
if remoteNode.Kernel != nil {
kernelArgs = strings.Join(remoteNode.Kernel.Args, " ")
kernelVersion = remoteNode.Kernel.Version
}
if kernelVersion == "" {
if kernel_ := kernel.FromNode(&remoteNode); kernel_ != nil {
kernelVersion = kernel_.Version()
}
}
authority := fmt.Sprintf("%s:%d", conf.Ipaddr, conf.Warewulf.Port)
ipaddr6 := ""
if confIpaddr6, err := netip.ParseAddr(conf.Ipaddr6); err == nil {
ipaddr6 = confIpaddr6.String()
}
if rinfoIpaddr, err := netip.ParseAddr(rinfo.ipaddr); err == nil {
if rinfoIpaddr.Is6() {
if ipaddr6 != "" {
authority = fmt.Sprintf("[%s]:%d", ipaddr6, conf.Warewulf.Port)
} else {
wwlog.Error("No valid IPv6 address configured, but request is IPv6")
}
}
} else {
wwlog.Error("Could not parse request IP address: %s", rinfo.ipaddr)
}
tmpl_data = &templateVars{
Id: remoteNode.Id(),
Cluster: remoteNode.ClusterName,
Fqdn: remoteNode.Id(),
Ipaddr: conf.Ipaddr,
Ipaddr6: ipaddr6,
Port: strconv.Itoa(conf.Warewulf.Port),
Authority: authority,
Hostname: remoteNode.Id(),
Hwaddr: rinfo.hwaddr,
ImageName: remoteNode.ImageName,
Ipxe: remoteNode.Ipxe,
KernelArgs: kernelArgs,
KernelVersion: kernelVersion,
Root: remoteNode.Root,
NetDevs: remoteNode.NetDevs,
Tags: remoteNode.Tags}
if stage_file == "" {
wwlog.Error("could't find grub.cfg template for %s", imageName)
w.WriteHeader(http.StatusNotFound)
return
}
default:
wwlog.ErrorExc(fmt.Errorf("could't find efiboot file: %s", rinfo.efifile), "")
}
} else if rinfo.stage == "shim" {
if remoteNode.ImageName != "" {
stage_file = image.ShimFind(remoteNode.ImageName)
if stage_file == "" {
wwlog.Error("No kernel found for image %s", remoteNode.ImageName)
}
} else {
wwlog.Warn("No image set for this %s", remoteNode.Id())
}
} else if rinfo.stage == "grub" {
if remoteNode.ImageName != "" {
stage_file = image.GrubFind(remoteNode.ImageName)
if stage_file == "" {
wwlog.Error("No grub found for image %s", remoteNode.ImageName)
}
} else {
wwlog.Warn("No conainer set for node %s", remoteNode.Id())
}
} else if rinfo.stage == "initramfs" {
if kernel_ := kernel.FromNode(&remoteNode); kernel_ != nil {
if kver := kernel_.Version(); kver != "" {
if initramfs := image.FindInitramfs(remoteNode.ImageName, kver); initramfs != nil {
stage_file = initramfs.FullPath()
} else {
wwlog.Error("No initramfs found for kernel %s in image %s", kver, remoteNode.ImageName)
}
} else {
wwlog.Error("No initramfs found: unable to determine kernel version for node %s", remoteNode.Id())
}
} else {
wwlog.Error("No initramfs found: unable to find kernel for node %s", remoteNode.Id())
}
}
wwlog.Serv("stage_file '%s'", stage_file)
if util.IsFile(stage_file) {
if tmpl_data != nil {
if rinfo.compress != "" {
wwlog.Error("Unsupported %s compressed version for file: %s",
rinfo.compress, stage_file)
w.WriteHeader(http.StatusNotFound)
return
}
// Create a template with the Sprig functions.
tmpl := template.New(filepath.Base(stage_file)).Funcs(sprig.TxtFuncMap())
// Parse the template.
parsedTmpl, err := tmpl.ParseFiles(stage_file)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
wwlog.ErrorExc(err, "")
return
}
// template engine writes file to buffer in case rendering fails
var buf bytes.Buffer
err = parsedTmpl.Execute(&buf, tmpl_data)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
wwlog.ErrorExc(err, "")
return
}
w.Header().Set("Content-Type", "text")
w.Header().Set("Content-Length", strconv.Itoa(buf.Len()))
_, err = buf.WriteTo(w)
if err != nil {
wwlog.ErrorExc(err, "")
}
wwlog.Info("send %s -> %s", stage_file, remoteNode.Id())
} else {
if rinfo.compress == "gz" {
stage_file += ".gz"
if !util.IsFile(stage_file) {
wwlog.Error("unprepared for compressed version of file %s",
stage_file)
w.WriteHeader(http.StatusNotFound)
return
}
} else if rinfo.compress != "" {
wwlog.Error("unsupported %s compressed version of file %s",
rinfo.compress, stage_file)
w.WriteHeader(http.StatusNotFound)
}
err = sendFile(w, req, stage_file, remoteNode.Id())
if err != nil {
wwlog.ErrorExc(err, "")
return
}
}
updateStatus(remoteNode.Id(), status_stage, path.Base(stage_file), rinfo.ipaddr)
} else if stage_file == "" {
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
wwlog.Error("No resource selected") wwlog.Error("Unknown stage: %s", rinfo.stage)
updateStatus(remoteNode.Id(), status_stage, "BAD_REQUEST", rinfo.ipaddr) return
} else {
w.WriteHeader(http.StatusNotFound)
wwlog.Error("Not found: %s", stage_file)
updateStatus(remoteNode.Id(), status_stage, "NOT_FOUND", rinfo.ipaddr)
} }
handler(w, req)
} }

View File

@@ -21,18 +21,15 @@ var provisionSendTests = []struct {
status int status int
ip string ip string
}{ }{
{"system overlay", "/overlay-system/00:00:00:ff:ff:ff", "system overlay", 200, "10.10.10.10:9873"}, {"system overlay", "/provision/00:00:00:ff:ff:ff?stage=system", "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"}, {"runtime overlay", "/provision/00:00:00:ff:ff:ff?stage=runtime", "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"}, {"fake overlay", "/provision/00:00:00:ff:ff:ff?stage=system&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"}, {"specific overlay", "/provision/00:00:00:ff:ff:ff?stage=system&overlay=o1", "specific overlay", 200, "10.10.10.10:9873"},
{"find shim", "/efiboot/shim.efi", "", 200, "10.10.10.10:9873"}, {"grub config", "/provision/00:00:00:ff:ff:ff?stage=grub", "", 200, "10.10.10.10:9873"},
{"find shim", "/efiboot/shim.efi", "", 404, "10.10.10.11: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 grub", "/efiboot/grub.efi", "", 200, "10.10.10.10:9873"},
{"find grub", "/efiboot/grub.efi", "", 404, "10.10.10.11:9873"},
{"find initramfs", "/provision/00:00:00:ff:ff:ff?stage=initramfs", "", 200, "10.10.10.10: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 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"}, {"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) { func Test_ProvisionSend(t *testing.T) {
@@ -100,7 +97,7 @@ nodes:
req := httptest.NewRequest(http.MethodGet, tt.url, nil) req := httptest.NewRequest(http.MethodGet, tt.url, nil)
req.RemoteAddr = tt.ip req.RemoteAddr = tt.ip
w := httptest.NewRecorder() w := httptest.NewRecorder()
ProvisionSend(w, req) HandleProvision(w, req)
res := w.Result() res := w.Result()
defer res.Body.Close() defer res.Body.Close()

View File

@@ -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 package server
import ( import (
@@ -5,6 +9,7 @@ import (
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"path"
"strconv" "strconv"
"strings" "strings"
"syscall" "syscall"
@@ -36,17 +41,40 @@ func (h *slashFix) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.mux.ServeHTTP(w, r) h.mux.ServeHTTP(w, r)
} }
func defaultHandler() *slashFix { func requireTLS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.TLS == nil {
wwlog.Denied("API request over insecure connection")
w.WriteHeader(http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func configureRootHandler(apiHandler http.Handler) *slashFix {
var wwHandler http.ServeMux var wwHandler http.ServeMux
wwHandler.HandleFunc("/provision/", warewulfd.ProvisionSend) wwHandler.HandleFunc("/provision/", warewulfd.HandleProvision)
wwHandler.HandleFunc("/ipxe/", warewulfd.ProvisionSend) wwHandler.HandleFunc("/ipxe/", warewulfd.HandleIpxe)
wwHandler.HandleFunc("/efiboot/", warewulfd.ProvisionSend) wwHandler.HandleFunc("/efiboot/", warewulfd.HandleEfiBoot)
wwHandler.HandleFunc("/kernel/", warewulfd.ProvisionSend) wwHandler.HandleFunc("/grub/", warewulfd.HandleGrub)
wwHandler.HandleFunc("/container/", warewulfd.ProvisionSend) wwHandler.HandleFunc("/kernel/", warewulfd.HandleKernel)
wwHandler.HandleFunc("/overlay-system/", warewulfd.ProvisionSend) wwHandler.HandleFunc("/image/", warewulfd.HandleImage)
wwHandler.HandleFunc("/overlay-runtime/", warewulfd.ProvisionSend) wwHandler.HandleFunc("/initramfs/", warewulfd.HandleInitramfs)
wwHandler.HandleFunc("/overlay-file/", warewulfd.OverlaySend) wwHandler.HandleFunc("/system/", warewulfd.HandleSystemOverlay)
wwHandler.HandleFunc("/status", warewulfd.StatusSend) wwHandler.HandleFunc("/runtime/", warewulfd.HandleRuntimeOverlay)
wwHandler.HandleFunc("/status", warewulfd.HandleStatus)
/* Deprecated */
wwHandler.HandleFunc("/container/", warewulfd.HandleImage)
wwHandler.HandleFunc("/overlay-system/", warewulfd.HandleSystemOverlay)
wwHandler.HandleFunc("/overlay-runtime/", warewulfd.HandleRuntimeOverlay)
wwHandler.HandleFunc("/overlay-file/", warewulfd.HandleOverlayFile)
if apiHandler != nil {
wwHandler.Handle("/api/", apiHandler)
}
return &slashFix{&wwHandler} return &slashFix{&wwHandler}
} }
@@ -73,18 +101,40 @@ func RunServer() error {
} }
} }
apiHandler := api.Handler(auth, conf.API.AllowedIPNets()) var apiHandler http.Handler
defaultHandler := defaultHandler() if conf.API != nil && conf.API.Enabled() {
dispatchHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { apiHandler = api.Handler(auth, conf.API.AllowedIPNets())
if strings.HasPrefix(r.URL.Path, "/api") && conf.API != nil && conf.API.Enabled() { if conf.API.TLSEnabled() {
apiHandler.ServeHTTP(w, r) apiHandler = requireTLS(apiHandler)
} else {
defaultHandler.ServeHTTP(w, r)
} }
})
if err := http.ListenAndServe(":"+strconv.Itoa(daemonPort), dispatchHandler); err != nil {
return fmt.Errorf("could not start listening service: %w", err)
} }
return nil httpHandler := configureRootHandler(apiHandler)
errChan := make(chan error, 2)
if conf.Warewulf.TLSEnabled() {
key := path.Join(conf.Paths.Sysconfdir, "warewulf", "tls", "warewulf.key")
crt := path.Join(conf.Paths.Sysconfdir, "warewulf", "tls", "warewulf.crt")
if !util.IsFile(key) || !util.IsFile(crt) {
return fmt.Errorf("TLS enabled but keys not found in %s, run 'wwctl configure tls' to generate keys", path.Join(conf.Paths.Sysconfdir, "warewulf", "tls"))
}
httpsHandler := configureRootHandler(apiHandler)
go func() {
wwlog.Info("Starting HTTPS service on port %d", conf.Warewulf.TlsPort)
if err := http.ListenAndServeTLS(":"+strconv.Itoa(conf.Warewulf.TlsPort), crt, key, httpsHandler); err != nil {
errChan <- fmt.Errorf("could not start HTTPS service: %w", err)
}
}()
}
go func() {
wwlog.Info("Starting HTTP service on port %d", daemonPort)
if err := http.ListenAndServe(":"+strconv.Itoa(daemonPort), httpHandler); err != nil {
errChan <- fmt.Errorf("could not start HTTP service: %w", err)
}
}()
return <-errChan
} }

View File

@@ -92,7 +92,7 @@ func statusJSON() ([]byte, error) {
return ret, nil return ret, nil
} }
func StatusSend(w http.ResponseWriter, req *http.Request) { func HandleStatus(w http.ResponseWriter, req *http.Request) {
status, err := statusJSON() status, err := statusJSON()
if err != nil { if err != nil {

View File

@@ -15,6 +15,8 @@ func Test_wwinitOverlay(t *testing.T) {
defer env.RemoveAll() defer env.RemoveAll()
env.ImportFile("etc/warewulf/warewulf.conf", "warewulf.conf") env.ImportFile("etc/warewulf/warewulf.conf", "warewulf.conf")
env.ImportFile("etc/warewulf/nodes.conf", "nodes.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/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/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") 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_USER="user"
WWIPMI_PASSWORD="password" WWIPMI_PASSWORD="password"
WWIPMI_WRITE="true" WWIPMI_WRITE="true"
WWTLS=false
WWTLSPORT=9874
WWIPADDR=192.168.0.1
` `
const wwinit_50_ipmi string = `backupFile: true const wwinit_50_ipmi string = `backupFile: true

View File

@@ -52,9 +52,10 @@ else
else else
mount ${WWROOT} /newroot mount ${WWROOT} /newroot
fi fi
mountpoint -q /newroot || die "warewulf: ERROR: failed to mount ${WWROOT} at /newroot"
info "warewulf: copying image to /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 mkdir /newroot/proc /newroot/dev /newroot/sys 2>/dev/null

View File

@@ -11,3 +11,6 @@ WWIPMI_WRITE="{{$.Ipmi.Write.Bool}}"
{{- if $.Ipmi.Tags.vlan }} {{- if $.Ipmi.Tags.vlan }}
WWIPMI_VLAN="{{$.Ipmi.Tags.vlan}}" WWIPMI_VLAN="{{$.Ipmi.Tags.vlan}}"
{{- end }} {{- end }}
WWTLS={{$.Warewulf.TLSEnabled}}
WWTLSPORT={{$.Warewulf.TlsPort}}
WWIPADDR={{$.Ipaddr}}

View File

@@ -0,0 +1,6 @@
{{- $crt := Include "tls/warewulf.crt" -}}
{{- if eq $crt "" -}}
{{ abort }}
{{- else -}}
{{ $crt }}
{{- end -}}

View File

@@ -23,6 +23,7 @@ Welcome to the Warewulf User Guide!
Server Installation <server/installation> Server Installation <server/installation>
Controlling Warewulf (wwctl) <server/wwctl> Controlling Warewulf (wwctl) <server/wwctl>
Server Configuration <server/configuration> Server Configuration <server/configuration>
Server Routes <server/routes>
Using dnsmasq <server/dnsmasq> Using dnsmasq <server/dnsmasq>
Security <server/security> Security <server/security>
Bootloaders <server/bootloaders> Bootloaders <server/bootloaders>

325
userdocs/server/routes.rst Normal file
View File

@@ -0,0 +1,325 @@
.. _server-routes:
=============
Server Routes
=============
The Warewulf provisioning daemon, ``warewulfd``, serves all boot and provisioning
resources over HTTP. Each resource type has a dedicated route, and nodes are
identified by their Warewulf ID (``wwid``).
``{wwid}`` is typically the node's default MAC address: a colon-separated
hexadecimal string, e.g., ``aa:bb:cc:dd:ee:ff``. Dashes are accepted in place
of colons and are normalized automatically.
.. note::
The port ``warewulfd`` listens on is configured with ``warewulf:port`` in
``warewulf.conf`` (default: ``9873``). When TLS is enabled, a second listener
is started on the port configured with ``warewulf:tls port`` (default: ``9874``).
URL Patterns
============
Every provisioning route (except ``/overlay-file/`` and ``/status``) supports
six equivalent URL patterns for specifying the node identity:
.. code-block:: none
/{stage}/{wwid} # wwid in path
/{stage}?wwid={wwid} # wwid as query parameter
/{stage} # wwid resolved from ARP cache
/provision/{wwid}?stage={stage}
/provision?wwid={wwid}&stage={stage}
/provision?stage={stage} # wwid resolved from ARP cache
The ``/efiboot/`` route is an exception: the path segment contains the boot
file name rather than a wwid, and the node is identified via ``?wwid=`` or ARP:
.. code-block:: none
/efiboot/{file} # file in path, wwid from ARP
/efiboot/{file}?wwid={wwid} # file in path, explicit wwid
/efiboot?wwid={wwid}&file={file} # both as query parameters
Common Query Parameters
=======================
Most provisioning routes accept the following query parameters:
* ``wwid``: Warewulf ID of the node, typically its default MAC address. Used
when the node identity is not embedded in the URL path. Takes priority over
ARP lookup; the path always takes priority over the ``wwid`` query parameter.
* ``assetkey``: Hardware asset tag. If the node has an ``AssetKey`` configured
in ``nodes.conf``, the server requires this parameter to match before serving
any content. See :ref:`Security <server-routes-security>` below.
* ``uuid``: System UUID of the requesting node. Accepted for logging purposes.
* ``compress``: Compression format for the response. The only supported value
is ``gz``. When ``compress=gz`` is specified, the server serves a pre-built
gzip-compressed version of the file. If no compressed version exists, the
server returns ``404 Not Found``.
Provisioning Routes
===================
``/ipxe/{wwid}``
----------------
Serves a rendered iPXE boot script for the node identified by ``{wwid}``.
The script is rendered as a Go template from a file in
``/etc/warewulf/ipxe/``. The specific template used is determined by the
node's ``Ipxe`` field (defaulting to ``default``); for example, a node with
``Ipxe: dracut`` receives the template from
``/etc/warewulf/ipxe/dracut.ipxe``.
If the requesting node is not known to Warewulf, the server falls back to
serving ``/etc/warewulf/ipxe/unconfigured.ipxe``.
**Query parameters:** ``assetkey``, ``uuid``
``/kernel/{wwid}``
------------------
Serves the raw kernel binary for the node identified by ``{wwid}``. The
kernel is taken from the node's assigned image.
**Query parameters:** ``assetkey``, ``uuid``, ``compress``
``/image/{wwid}``
-----------------
Serves the raw node image file for the node identified by ``{wwid}``.
**Query parameters:** ``assetkey``, ``uuid``, ``compress``
``/initramfs/{wwid}``
---------------------
Serves the initramfs binary for the node identified by ``{wwid}``. The
initramfs is extracted from the node's assigned image based on the node's
kernel version. This route is used in two-stage boot configurations. See
:ref:`booting with dracut` for details.
**Query parameters:** ``assetkey``, ``uuid``, ``compress``
``/system/{wwid}``
------------------
Serves the system overlay image for the node identified by ``{wwid}``.
The system overlay is rendered at provisioning time and contains
configuration files that are static for the lifetime of the boot.
When ``autobuild overlays`` is enabled in ``warewulf.conf``, the server
will automatically rebuild the overlay if it is out of date relative to
``nodes.conf`` or the overlay source files.
**Query parameters:** ``assetkey``, ``uuid``, ``compress``, ``overlay``
* ``overlay``: A comma-separated list of overlay names. When specified, only
the named overlays are served (rather than the node's full system overlay
set).
``/runtime/{wwid}``
-------------------
Serves the runtime overlay image for the node identified by ``{wwid}``.
The runtime overlay is rendered on demand and may contain node-specific
secrets. ``wwclient`` fetches the runtime overlay periodically during normal
operation.
When ``warewulf:secure`` is enabled in ``warewulf.conf``, this route requires
that the request originate from a privileged TCP port (port number less than
1024). This prevents unprivileged users on a node from retrieving the runtime
overlay.
When TLS is enabled in ``warewulf.conf``, this route requires that the request
arrive over HTTPS. Plain-HTTP requests are rejected with ``403 Forbidden``. The
HTTPS listener port is configured with ``warewulf:tls port``.
**Query parameters:** ``assetkey``, ``uuid``, ``compress``, ``overlay``
* ``overlay``: A comma-separated list of overlay names. Same behavior as for
``/system/``.
``/overlay-file/{overlay}/{path}``
----------------------------------
Provides direct access to an individual file within a named overlay. This
route uses a different URL structure than the other provisioning routes: the
overlay name is in the second path segment, and the file path within the overlay
follows.
If the ``render`` parameter is provided, the file is rendered as a Go template
for the specified node and the rendered content is returned. If ``render`` is
absent, the raw file bytes are returned without any template processing.
If the requested path does not end in ``.ww`` but a ``.ww``-suffixed version of
the file exists, and a ``render`` node is specified, the server automatically
serves the ``.ww`` template.
**Query parameters:**
* ``render``: Node ID to render the template for. If not specified, the raw
file is returned.
.. note::
This route does not require authentication via ``assetkey`` and does not
perform node lookup by hardware address.
``/efiboot/{file}``
-------------------
Serves EFI boot files for GRUB-based booting. The requesting node is
identified by ``?wwid=`` (preferred) or, if not supplied, by an ARP lookup
of the client's IP address against the kernel's ARP cache (``/proc/net/arp``).
This route is intended for EFI HTTP Boot clients, where the firmware fetches
a boot URI from DHCP and cannot perform variable substitution.
The ``{file}`` component determines what is served:
* ``shim.efi``: Serves the ``shim.efi`` binary extracted from the node's
assigned image.
* ``grub.efi`` (or ``grubx64.efi``, ``grubaa64.efi``, ``grubia32.efi``,
``grubarm.efi``, ``grub-tpm.efi``): Serves the GRUB EFI binary extracted
from the node's assigned image.
* ``grub.cfg``: Serves a rendered GRUB configuration file from
``/etc/warewulf/grub/grub.cfg.ww``. The configuration is rendered as a Go
template for the identified node.
Because ``shim.efi`` resolves subsequent files relative to its own load URL,
GRUB and ``grub.cfg`` are also fetched from the ``/efiboot/`` path. The
``grub.cfg`` served by this route uses ``${net_default_mac}`` to embed the
node's wwid in all further provisioning URLs, directing subsequent requests
to the per-node ``/grub/{wwid}`` route.
**Query parameters:** ``assetkey``, ``uuid``, ``wwid``, ``file``
* ``file``: The EFI file to serve (``shim.efi``, ``grub.efi``, or ``grub.cfg``).
Used when the file name is not embedded in the URL path.
.. note::
``/efiboot/`` is the recommended route for EFI HTTP Boot clients. For
TFTP-booted GRUB clients that know their own wwid, use
``/grub/{wwid}`` to fetch the per-node GRUB configuration directly.
``/grub/{wwid}``
----------------
Serves a rendered GRUB configuration file for the node identified by
``{wwid}``. The configuration is rendered from
``/etc/warewulf/grub/grub.cfg.ww`` as a Go template. This route is the
preferred method for TFTP-booted GRUB clients to fetch their per-node
configuration, as the node identity is explicit in the URL rather than
resolved via ARP.
**Query parameters:** ``assetkey``, ``uuid``, ``wwid``
``/provision/{wwid}``
---------------------
.. deprecated::
This route is maintained for backwards compatibility. New configurations
should use the dedicated routes described above.
A legacy dispatcher route. The provisioning stage is determined by the
``stage`` query parameter, which is dispatched to the appropriate handler:
* ``stage=ipxe````/ipxe/``
* ``stage=kernel````/kernel/``
* ``stage=image````/image/``
* ``stage=initramfs````/initramfs/``
* ``stage=system````/system/``
* ``stage=runtime````/runtime/``
* ``stage=grub````/grub/``
**Query parameters:** ``stage`` (required), ``assetkey``, ``uuid``, ``compress``,
``overlay``
Status Route
============
``/status``
-----------
Returns a JSON object containing the last-known provisioning status for all
nodes that have contacted the server. No authentication is required.
.. code-block:: none
{
"nodes": {
"node01": {
"node name": "node01",
"stage": "RUNTIME_OVERLAY",
"sent": "2 kB",
"ipaddr": "10.0.1.1",
"last seen": 1712345678
}
}
}
The ``stage`` field reflects the most recent provisioning stage completed for
the node. Possible values include ``IPXE``, ``KERNEL``, ``IMAGE``,
``INITRAMFS``, ``SYSTEM_OVERLAY``, ``RUNTIME_OVERLAY``, and ``EFI``.
REST API
========
When enabled in ``warewulf.conf``, ``warewulfd`` exposes a REST API under
``/api/``. The API provides programmatic access to nodes, profiles, images,
and overlays. Interactive documentation is available at ``/api/docs``.
When TLS is enabled, access to the REST API can additionally be restricted to
HTTPS-only requests by setting ``api: tls: true`` in ``warewulf.conf``.
See :ref:`rest-api` for full details.
.. _server-routes-security:
Security
========
Several mechanisms are available to restrict access to provisioning routes.
Asset key validation
--------------------
If a node is configured with an ``AssetKey`` in ``nodes.conf``, the Warewulf
server will only respond to provisioning requests that include a matching
``?assetkey=`` query parameter. Requests with a missing or incorrect asset key
receive ``401 Unauthorized``. The asset key is typically a hardware-level
firmware string (an "asset tag") that is accessible only with root or physical
access.
.. code-block:: console
# wwctl node set node01 --assetkey "SYSTEM-ASSET-TAG"
Secure mode
-----------
When ``warewulf:secure`` is set to ``true`` in ``warewulf.conf``, the
``/runtime/`` route requires that requests originate from a privileged
TCP source port (port number less than 1024). Because only processes running as
``root`` can bind to privileged ports, this prevents unprivileged users on a
cluster node from downloading the runtime overlay.
TLS
---
When TLS is enabled in ``warewulf.conf``, the ``/runtime/`` route
rejects plain-HTTP requests with ``403 Forbidden``. Runtime overlays must be
fetched over HTTPS. Because iPXE and GRUB cannot handle HTTPS, the kernel,
image, and system overlay continue to be served over plain HTTP even when TLS
is enabled.
See :ref:`Security <server/security>` for instructions on enabling TLS and
generating certificates.

View File

@@ -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 This means that the nodes only boot the kernel which is provided by the
distributor and also custom complied modules can't be loaded. distributor and also custom complied modules can't be loaded.
* TLS (transport layer security) can be enabled for the Warewulf server. When
enabled, HTTPS is used when transferring runtime overlays. The kernel and
system image are *always* transferred unencrypted.
SELinux SELinux
======= =======
@@ -82,3 +86,36 @@ with the Warewulf server.
nft add rule inet filter input tcp dport 9873 accept nft add rule inet filter input tcp dport 9873 accept
nft list ruleset >/etc/nftables.conf nft list ruleset >/etc/nftables.conf
systemctl restart nftables systemctl restart nftables
TLS / HTTPS
===========
TLS can be enabled by setting ``tls: true`` in the Warewulf server
configuration.
.. code-block:: yaml
warewulf:
tls: true
tls port: 9874
This enables an HTTPS server on the TLS port (default: ``9874``). A key and
self-signed certificate can be created with ``wwctl configure tls``.
By default, the key and certificate are stored in ``/etc/warewulf/tls/``.
You can also import your own keys with ``wwctl configure tls --import``.
If HTTPS is enabled the delivery of the runtime overlay is disabled over HTTP,
and the runtime overlay is only retrieved by ``wwclient``.
To additionally require TLS for access to the REST API, set ``tls: true`` under
the ``api:`` section:
.. code-block:: yaml
api:
enabled: true
tls: true
When ``api: tls`` is set, the REST API rejects plain-HTTP requests.