Merge pull request #2125 from anderbubble/tlsSec
TLS for warewulfd and REST API, and warewulfd refactor
This commit is contained in:
24
CHANGELOG.md
24
CHANGELOG.md
@@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## v4.6.6, unreleased
|
||||
## v4.7.0, unreleased
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -13,13 +13,33 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Allow whitespace to be trimmed for wwdoc comments. #2109
|
||||
- update go-chi to 5.2.5 to fix CVE-2025-69725
|
||||
- Prevented profile `comment` field from being inherited by nodes. #2078
|
||||
- Error handling for /newroot mount during single-stage boot
|
||||
- Bugfix for command-line arguments during single-stage image unpacking
|
||||
|
||||
### Added
|
||||
|
||||
- New --partwipe flag for profile and node set
|
||||
- New `--partwipe` flag for profile and node set
|
||||
- Updated arguments for `ValidString` to match `regexp.MatchString`
|
||||
- New `mig` overlay to configure NVIDIA MIG devices. #2102
|
||||
- Documented that booting a node twice fixes broken partition tables
|
||||
- Updated arguments for `ValidString` to match `regexp.MatchString`
|
||||
- New `mig` overlay to configure NVIDIA MIG devices. #2102
|
||||
- TLS with the command `wwctl configure tls` for key management.
|
||||
Keys can be created automtically or imported. The runtime overlay is
|
||||
if TLS is enabled is not distributed over plain http.
|
||||
|
||||
- Documented that booting a node twice fixes broken partition tables
|
||||
- TLS support for `warewulfd` and REST API
|
||||
- New `wwctl configure tls` command to generate and configure TLS keys and
|
||||
certificates
|
||||
- New dedicated `warewulfd` server routes (`/ipxe/`, `/kernel/`, `/image/`,
|
||||
`/initramfs/`, `/system/`, `/runtime/`, `/grub/`, `/efiboot/`)
|
||||
|
||||
### Changed
|
||||
|
||||
- Runtime overlay download failure during dracut/wwinit boot is now non-fatal;
|
||||
the node continues to boot and `wwclient` retries the download at runtime.
|
||||
- `hosts` overlay added to the default system overlay list
|
||||
|
||||
## v4.6.5, 2026-01-12
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
get_stage() {
|
||||
stage="${1}"
|
||||
uri="${2:-${wwinit_uri}}"
|
||||
cacert="${3}"
|
||||
info "warewulf: loading stage: ${stage}"
|
||||
# Load runtime overlay from a static privledged port.
|
||||
# Others use default settings.
|
||||
@@ -11,24 +13,28 @@ get_stage() {
|
||||
if [ "${stage}" = "runtime" ]; then
|
||||
localport="--local-port 1-1023"
|
||||
fi
|
||||
cacert_opt=""
|
||||
if [ -n "${cacert}" ]; then
|
||||
cacert_opt="--cacert ${cacert}"
|
||||
fi
|
||||
(
|
||||
curl --location --silent --get ${localport} \
|
||||
curl --location --silent --get ${localport} ${cacert_opt} \
|
||||
--retry 60 --retry-connrefused --retry-delay 1 \
|
||||
--data-urlencode "assetkey=${wwinit_assetkey}" \
|
||||
--data-urlencode "uuid=${wwinit_uuid}" \
|
||||
--data-urlencode "stage=${stage}" \
|
||||
--data-urlencode "compress=gz" \
|
||||
"${wwinit_uri}" \
|
||||
"${uri}" \
|
||||
| gzip -d \
|
||||
| cpio -ium --directory="${NEWROOT}"
|
||||
) || die "Unable to load stage: ${stage}"
|
||||
)
|
||||
}
|
||||
|
||||
mkdir /tmp/wwinit
|
||||
(
|
||||
# fetch the system overlay into /tmp/wwinit
|
||||
local NEWROOT=/tmp/wwinit
|
||||
get_stage "system"
|
||||
get_stage "system" || die "Unable to load stage: system"
|
||||
)
|
||||
if [ -x /tmp/wwinit/warewulf/run-wwinit.d ]; then
|
||||
PREFIX=/tmp/wwinit /tmp/wwinit/warewulf/run-wwinit.d
|
||||
@@ -43,10 +49,25 @@ info "warewulf: mounting ${wwinit_root_device} at ${NEWROOT}"
|
||||
fi
|
||||
) || die "warewulf: failed to mount ${wwinit_root_device} at ${NEWROOT}"
|
||||
|
||||
for stage in "image" "system" "runtime"; do
|
||||
get_stage "${stage}"
|
||||
for stage in "image" "system"; do
|
||||
get_stage "${stage}" || die "Unable to load stage: ${stage}"
|
||||
done
|
||||
|
||||
# Fetch runtime overlay (non-fatal)
|
||||
# Source config from system overlay for TLS settings
|
||||
. /tmp/wwinit/warewulf/config
|
||||
cert_file="/tmp/wwinit/warewulf/tls/warewulf.crt"
|
||||
if [ "${WWTLS}" = "true" ] && [ -f "$cert_file" ]; then
|
||||
# TLS enabled: build HTTPS URI using wwid from kernel cmdline
|
||||
# (mirrors wwclient URL construction in internal/app/wwclient/root.go)
|
||||
wwid=$(getarg wwid)
|
||||
runtime_uri="https://${WWIPADDR}:${WWTLSPORT}/provision/${wwid}"
|
||||
get_stage "runtime" "${runtime_uri}" "${cert_file}" || warn "warewulf: unable to load runtime overlay over HTTPS (ignored)"
|
||||
else
|
||||
# No TLS: fetch runtime over HTTP
|
||||
get_stage "runtime" || warn "warewulf: unable to load runtime overlay (ignored)"
|
||||
fi
|
||||
|
||||
# Copy /warewulf/run from initramfs to NEWROOT
|
||||
# This preserves state files created by wwinit.d scripts (e.g., ignition marker)
|
||||
if [ -d /tmp/wwinit/warewulf/run ]; then
|
||||
|
||||
@@ -66,7 +66,9 @@ menuentry "Single-stage boot" --id single-stage {
|
||||
echo "Downloading images..."
|
||||
image="${uri}&stage=image&compress=gz"
|
||||
system="${uri}&stage=system&compress=gz"
|
||||
{{- if not (eq .TLS true) }}
|
||||
runtime="${uri}&stage=runtime&compress=gz"
|
||||
{{- end }}
|
||||
initrd $image $system $runtime
|
||||
if [ $? != 0 ]
|
||||
then
|
||||
@@ -113,7 +115,9 @@ menuentry "Single-stage boot (no compression)" --id single-stage-nocompress {
|
||||
echo "Downloading images..."
|
||||
image="${uri}&stage=image"
|
||||
system="${uri}&stage=system"
|
||||
{{- if not (eq .TLS true) }}
|
||||
runtime="${uri}&stage=runtime"
|
||||
{{- end }}
|
||||
initrd $image $system $runtime
|
||||
if [ $? != 0 ]
|
||||
then
|
||||
|
||||
@@ -46,8 +46,10 @@ echo Downloading compressed image with imgextract...
|
||||
imgextract --name image ${uri}&stage=image&compress=gz || goto error_use_initrd
|
||||
echo Downloading compressed system overlay image with imgextract...
|
||||
imgextract --name system ${uri}&stage=system&compress=gz || goto error_reboot
|
||||
{{- if not (eq .TLS true) }}
|
||||
echo Downloading compressed runtime overlay image with imgextract...
|
||||
imgextract --name runtime ${uri}&stage=runtime&compress=gz && set runtime_initrd initrd=runtime || echo Unable to download runtime overlay. (ignored)
|
||||
{{- end }}
|
||||
goto boot_single_stage
|
||||
|
||||
:error_use_initrd
|
||||
@@ -63,8 +65,10 @@ echo Downloading compressed image with initrd...
|
||||
initrd --name image ${uri}&stage=image&compress=gz || goto error_reboot
|
||||
echo Downloading compressed system overlay with initrd...
|
||||
initrd --name system ${uri}&stage=system&compress=gz || goto error_reboot
|
||||
{{- if not (eq .TLS true) }}
|
||||
echo Downloading compressed runtime overlay with initrd...
|
||||
initrd --name runtime ${uri}&stage=runtime&compress=gz && set runtime_initrd initrd=runtime || echo Unable to download runtime overlay. (ignored)
|
||||
{{- end }}
|
||||
goto boot_single_stage
|
||||
|
||||
:initrd_nocompress
|
||||
@@ -76,8 +80,10 @@ echo Downloading uncompressed image with initrd...
|
||||
initrd --name image ${uri}&stage=image || goto error_reboot
|
||||
echo Downloading uncompressed system overlay with initrd...
|
||||
initrd --name system ${uri}&stage=system || goto error_reboot
|
||||
{{- if not (eq .TLS true) }}
|
||||
echo Downloading uncompressed runtime overlay with initrd...
|
||||
initrd --name runtime ${uri}&stage=runtime && set runtime_initrd initrd=runtime || echo Unable to download runtime overlay. (ignored)
|
||||
{{- end }}
|
||||
goto boot_single_stage
|
||||
|
||||
:dracut
|
||||
|
||||
@@ -7,6 +7,7 @@ nodeprofiles:
|
||||
system overlay:
|
||||
- wwinit
|
||||
- wwclient
|
||||
- hosts
|
||||
- fstab
|
||||
- hostname
|
||||
- ssh.host_keys
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package wwclient
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
@@ -112,9 +116,31 @@ func CobraRunE(cmd *cobra.Command, args []string) (err error) {
|
||||
wwlog.Info("running from trusted port: %d", localTCPAddr.Port)
|
||||
}
|
||||
|
||||
tlsConfig := &tls.Config{MinVersion: tls.VersionTLS13}
|
||||
if conf.Warewulf.TLSEnabled() {
|
||||
caCert, err := os.ReadFile("/warewulf/tls/warewulf.crt")
|
||||
if err != nil {
|
||||
wwlog.Error("failed to read ca cert: %s", err)
|
||||
return err
|
||||
}
|
||||
block, _ := pem.Decode(caCert)
|
||||
if block == nil {
|
||||
wwlog.Warn("failed to parse certificate PEM")
|
||||
} else if cert, err := x509.ParseCertificate(block.Bytes); err == nil {
|
||||
wwlog.Info("using cert: %s", cert.SerialNumber)
|
||||
} else {
|
||||
wwlog.Warn("parsing cert failed: %s", err)
|
||||
}
|
||||
|
||||
caCertPool := x509.NewCertPool()
|
||||
caCertPool.AppendCertsFromPEM(caCert)
|
||||
tlsConfig.RootCAs = caCertPool
|
||||
}
|
||||
|
||||
Webclient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
TLSClientConfig: tlsConfig,
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
LocalAddr: &localTCPAddr,
|
||||
Timeout: 30 * time.Second,
|
||||
@@ -216,7 +242,7 @@ func CobraRunE(cmd *cobra.Command, args []string) (err error) {
|
||||
stopTimer.Reset(0)
|
||||
case syscall.SIGTERM, syscall.SIGINT:
|
||||
wwlog.Info("terminating wwclient, %v", sig)
|
||||
// Signal main loop to exit instead of calling os.Exit(0)
|
||||
// Signal main loop to exit gracefully
|
||||
exitChan <- true
|
||||
return
|
||||
}
|
||||
@@ -232,8 +258,17 @@ func CobraRunE(cmd *cobra.Command, args []string) (err error) {
|
||||
}
|
||||
}
|
||||
|
||||
port := conf.Warewulf.Port
|
||||
scheme := "http"
|
||||
if conf.Warewulf.TLSEnabled() {
|
||||
port = conf.Warewulf.TlsPort
|
||||
scheme = "https"
|
||||
}
|
||||
|
||||
for {
|
||||
updateSystem(target, ipaddr, conf.Warewulf.Port, wwid, tag, localUUID)
|
||||
if err := updateSystem(target, ipaddr, port, wwid, tag, localUUID, scheme); err != nil {
|
||||
return err
|
||||
}
|
||||
if !finishedInitialSync {
|
||||
// Notify systemd that the service has started successfully.
|
||||
//
|
||||
@@ -274,7 +309,7 @@ func parseWWIDFromCmdline(cmdline string) (string, error) {
|
||||
return "", fmt.Errorf("wwid parameter not found in kernel command line")
|
||||
}
|
||||
|
||||
func updateSystem(target string, ipaddr string, port int, wwid string, tag string, localUUID uuid.UUID) {
|
||||
func updateSystem(target string, ipaddr string, port int, wwid string, tag string, localUUID uuid.UUID, scheme string) error {
|
||||
var resp *http.Response
|
||||
counter := 0
|
||||
for {
|
||||
@@ -285,7 +320,7 @@ func updateSystem(target string, ipaddr string, port int, wwid string, tag strin
|
||||
values.Set("stage", "runtime")
|
||||
values.Set("compress", "gz")
|
||||
getURL := &url.URL{
|
||||
Scheme: "http",
|
||||
Scheme: scheme,
|
||||
Host: fmt.Sprintf("%s:%d", ipaddr, port),
|
||||
Path: fmt.Sprintf("provision/%s", wwid),
|
||||
RawQuery: values.Encode(),
|
||||
@@ -293,9 +328,16 @@ func updateSystem(target string, ipaddr string, port int, wwid string, tag strin
|
||||
wwlog.Debug("making request: %s", getURL)
|
||||
resp, err = Webclient.Get(getURL.String())
|
||||
if err == nil {
|
||||
defer resp.Body.Close()
|
||||
break
|
||||
} else {
|
||||
var certificateInvalidError x509.CertificateInvalidError
|
||||
var unknownAuthorityError x509.UnknownAuthorityError
|
||||
var hostnameError x509.HostnameError
|
||||
if errors.As(err, &certificateInvalidError) ||
|
||||
errors.As(err, &unknownAuthorityError) ||
|
||||
errors.As(err, &hostnameError) {
|
||||
return fmt.Errorf("TLS connection failed: %w", err)
|
||||
}
|
||||
if counter > 60 {
|
||||
counter = 0
|
||||
}
|
||||
@@ -306,10 +348,11 @@ func updateSystem(target string, ipaddr string, port int, wwid string, tag strin
|
||||
}
|
||||
time.Sleep(1000 * time.Millisecond)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
wwlog.Warn("not applying runtime overlay: got status code: %d", resp.StatusCode)
|
||||
time.Sleep(60000 * time.Millisecond)
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
wwlog.Info("applying runtime overlay")
|
||||
@@ -318,7 +361,7 @@ func updateSystem(target string, ipaddr string, port int, wwid string, tag strin
|
||||
tempDir, err := os.MkdirTemp("", "wwclient-")
|
||||
if err != nil {
|
||||
wwlog.Error("failed to create temp directory: %s", err)
|
||||
return
|
||||
return nil
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
wwlog.Debug("unpacking runtime overlay to %s", tempDir)
|
||||
@@ -327,7 +370,7 @@ func updateSystem(target string, ipaddr string, port int, wwid string, tag strin
|
||||
err = command.Run()
|
||||
if err != nil {
|
||||
wwlog.Error("failed running cpio: %s", err)
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
// Atomically move files from temp directory to current working directory
|
||||
@@ -335,6 +378,7 @@ func updateSystem(target string, ipaddr string, port int, wwid string, tag strin
|
||||
if err != nil {
|
||||
wwlog.Error("failed to apply overlay: %s", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func atomicApplyOverlay(srcDir, destDir string) error {
|
||||
|
||||
@@ -20,6 +20,10 @@ func CobraRunE(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
if allFunctions {
|
||||
if _, err = configure.TLS(false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = configure.WAREWULFD()
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/warewulf/warewulf/internal/app/wwctl/configure/nfs"
|
||||
"github.com/warewulf/warewulf/internal/app/wwctl/configure/ssh"
|
||||
"github.com/warewulf/warewulf/internal/app/wwctl/configure/tftp"
|
||||
"github.com/warewulf/warewulf/internal/app/wwctl/configure/tls"
|
||||
"github.com/warewulf/warewulf/internal/app/wwctl/configure/warewulfd"
|
||||
)
|
||||
|
||||
@@ -29,6 +30,7 @@ func init() {
|
||||
baseCmd.AddCommand(ssh.GetCommand())
|
||||
baseCmd.AddCommand(nfs.GetCommand())
|
||||
baseCmd.AddCommand(hostfile.GetCommand())
|
||||
baseCmd.AddCommand(tls.GetCommand())
|
||||
baseCmd.AddCommand(warewulfd.GetCommand())
|
||||
|
||||
baseCmd.Flags().BoolVarP(&allFunctions, "all", "a", false, "Configure all services")
|
||||
|
||||
129
internal/app/wwctl/configure/tls/main.go
Normal file
129
internal/app/wwctl/configure/tls/main.go
Normal 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
|
||||
}
|
||||
106
internal/app/wwctl/configure/tls/main_test.go
Normal file
106
internal/app/wwctl/configure/tls/main_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
33
internal/app/wwctl/configure/tls/root.go
Normal file
33
internal/app/wwctl/configure/tls/root.go
Normal 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
|
||||
}
|
||||
@@ -16,6 +16,29 @@ import (
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
func displayStage(stage string) string {
|
||||
switch stage {
|
||||
case "efiboot":
|
||||
return "EFI"
|
||||
case "grub":
|
||||
return "GRUB"
|
||||
case "ipxe":
|
||||
return "IPXE"
|
||||
case "kernel":
|
||||
return "KERNEL"
|
||||
case "image":
|
||||
return "IMAGE"
|
||||
case "system":
|
||||
return "SYSTEM OVERLAY"
|
||||
case "runtime":
|
||||
return "RUNTIME OVERLAY"
|
||||
case "initramfs":
|
||||
return "INITRAMFS"
|
||||
default:
|
||||
return strings.ToUpper(stage)
|
||||
}
|
||||
}
|
||||
|
||||
func CobraRunE(cmd *cobra.Command, args []string) (err error) {
|
||||
|
||||
controller := warewulfconf.Get()
|
||||
@@ -102,11 +125,11 @@ func CobraRunE(cmd *cobra.Command, args []string) (err error) {
|
||||
continue
|
||||
}
|
||||
if rightnow-o.Lastseen >= int64(controller.Warewulf.UpdateInterval*2) {
|
||||
color.Red("%-20s %-20s %-25s %-10d\n", o.NodeName, o.Stage, o.Sent, rightnow-o.Lastseen)
|
||||
color.Red("%-20s %-20s %-25s %-10d\n", o.NodeName, displayStage(o.Stage), o.Sent, rightnow-o.Lastseen)
|
||||
} else if rightnow-o.Lastseen >= int64(controller.Warewulf.UpdateInterval+5) {
|
||||
color.Yellow("%-20s %-20s %-25s %-10d\n", o.NodeName, o.Stage, o.Sent, rightnow-o.Lastseen)
|
||||
color.Yellow("%-20s %-20s %-25s %-10d\n", o.NodeName, displayStage(o.Stage), o.Sent, rightnow-o.Lastseen)
|
||||
} else {
|
||||
fmt.Printf("%-20s %-20s %-25s %-10d\n", o.NodeName, o.Stage, o.Sent, rightnow-o.Lastseen)
|
||||
fmt.Printf("%-20s %-20s %-25s %-10d\n", o.NodeName, displayStage(o.Stage), o.Sent, rightnow-o.Lastseen)
|
||||
}
|
||||
} else {
|
||||
color.HiBlack("%-20s %-20s %-25s %-10s\n", o.NodeName, "--", "--", "--")
|
||||
|
||||
@@ -29,6 +29,7 @@ func (n IPNet) IPNet() net.IPNet {
|
||||
|
||||
type APIConf struct {
|
||||
EnabledP *bool `yaml:"enabled,omitempty" default:"false"`
|
||||
TLSEnabledP *bool `yaml:"tls,omitempty"`
|
||||
AllowedNets []IPNet `yaml:"allowed subnets,omitempty" default:"[\"127.0.0.0/8\", \"::1/128\"]"`
|
||||
}
|
||||
|
||||
@@ -49,3 +50,7 @@ func (conf *APIConf) Unmarshal(unmarshal func(interface{}) error) error {
|
||||
func (conf APIConf) Enabled() bool {
|
||||
return util.BoolP(conf.EnabledP)
|
||||
}
|
||||
|
||||
func (conf APIConf) TLSEnabled() bool {
|
||||
return util.BoolP(conf.TLSEnabledP)
|
||||
}
|
||||
|
||||
@@ -42,7 +42,9 @@ func (conf TFTPConf) Enabled() bool {
|
||||
// BaseConf.
|
||||
type WarewulfConf struct {
|
||||
Port int `yaml:"port,omitempty" default:"9873"`
|
||||
TlsPort int `yaml:"tls port,omitempty" default:"9874"`
|
||||
SecureP *bool `yaml:"secure,omitempty" default:"true"`
|
||||
TLSEnabledP *bool `yaml:"tls,omitempty"`
|
||||
UpdateInterval int `yaml:"update interval,omitempty" default:"60"`
|
||||
AutobuildOverlaysP *bool `yaml:"autobuild overlays,omitempty" default:"true"`
|
||||
EnableHostOverlayP *bool `yaml:"host overlay,omitempty" default:"true"`
|
||||
@@ -54,6 +56,10 @@ func (conf WarewulfConf) Secure() bool {
|
||||
return util.BoolP(conf.SecureP)
|
||||
}
|
||||
|
||||
func (conf WarewulfConf) TLSEnabled() bool {
|
||||
return util.BoolP(conf.TLSEnabledP)
|
||||
}
|
||||
|
||||
func (conf WarewulfConf) AutobuildOverlays() bool {
|
||||
return util.BoolP(conf.AutobuildOverlaysP)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ warewulf:
|
||||
host overlay: true
|
||||
port: 9873
|
||||
secure: true
|
||||
tls port: 9874
|
||||
update interval: 60
|
||||
nfs:
|
||||
enabled: true
|
||||
@@ -67,6 +68,7 @@ warewulf:
|
||||
host overlay: true
|
||||
port: 9873
|
||||
secure: true
|
||||
tls port: 9874
|
||||
update interval: 60
|
||||
nfs:
|
||||
enabled: true
|
||||
@@ -115,6 +117,7 @@ warewulf:
|
||||
host overlay: true
|
||||
port: 9873
|
||||
secure: true
|
||||
tls port: 9874
|
||||
update interval: 60
|
||||
nfs:
|
||||
enabled: true
|
||||
@@ -160,6 +163,7 @@ warewulf:
|
||||
host overlay: true
|
||||
port: 9873
|
||||
secure: true
|
||||
tls port: 9874
|
||||
update interval: 60
|
||||
nfs:
|
||||
enabled: true
|
||||
@@ -234,6 +238,7 @@ warewulf:
|
||||
host overlay: true
|
||||
port: 9873
|
||||
secure: false
|
||||
tls port: 9874
|
||||
update interval: 60
|
||||
nfs:
|
||||
enabled: true
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
package configure
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
warewulfconf "github.com/warewulf/warewulf/internal/pkg/config"
|
||||
"github.com/warewulf/warewulf/internal/pkg/image"
|
||||
"github.com/warewulf/warewulf/internal/pkg/util"
|
||||
"github.com/warewulf/warewulf/internal/pkg/warewulfd"
|
||||
"github.com/warewulf/warewulf/internal/pkg/wwlog"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
@@ -35,7 +36,7 @@ func TFTP() (err error) {
|
||||
}
|
||||
|
||||
if controller.Warewulf.GrubBoot() {
|
||||
err := warewulfd.CopyShimGrub()
|
||||
err := CopyShimGrub()
|
||||
if err != nil {
|
||||
wwlog.Warn("error when copying shim/grub binaries: %s", err)
|
||||
}
|
||||
@@ -70,3 +71,30 @@ func TFTP() (err error) {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func CopyShimGrub() (err error) {
|
||||
conf := warewulfconf.Get()
|
||||
wwlog.Debug("copy shim and grub binaries from host")
|
||||
shimPath := image.ShimFind("")
|
||||
if shimPath == "" {
|
||||
return fmt.Errorf("no shim found on the host os")
|
||||
}
|
||||
err = util.CopyFile(shimPath, path.Join(conf.TFTP.TftpRoot, "warewulf", "shim.efi"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = os.Chmod(path.Join(conf.TFTP.TftpRoot, "warewulf", "shim.efi"), 0o755)
|
||||
grubPath := image.GrubFind("")
|
||||
if grubPath == "" {
|
||||
return fmt.Errorf("no grub found on host os")
|
||||
}
|
||||
err = util.CopyFile(grubPath, path.Join(conf.TFTP.TftpRoot, "warewulf", "grub.efi"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = os.Chmod(path.Join(conf.TFTP.TftpRoot, "warewulf", "grub.efi"), 0o755)
|
||||
err = util.CopyFile(grubPath, path.Join(conf.TFTP.TftpRoot, "warewulf", "grubx64.efi"))
|
||||
_ = os.Chmod(path.Join(conf.TFTP.TftpRoot, "warewulf", "grubx64.efi"), 0o755)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
118
internal/pkg/configure/tls.go
Normal file
118
internal/pkg/configure/tls.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
// Package warewulfd implements the Warewulf provisioning daemon, handling
|
||||
// HTTP requests from compute nodes for boot files, kernel images, overlays,
|
||||
// and EFI bootloaders.
|
||||
//
|
||||
// See userdocs/server/routes.rst for more information.
|
||||
package warewulfd
|
||||
|
||||
import (
|
||||
|
||||
60
internal/pkg/warewulfd/efi.go
Normal file
60
internal/pkg/warewulfd/efi.go
Normal 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)
|
||||
}
|
||||
90
internal/pkg/warewulfd/efi_test.go
Normal file
90
internal/pkg/warewulfd/efi_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
26
internal/pkg/warewulfd/grub.go
Normal file
26
internal/pkg/warewulfd/grub.go
Normal 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)
|
||||
}
|
||||
76
internal/pkg/warewulfd/grub_test.go
Normal file
76
internal/pkg/warewulfd/grub_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
155
internal/pkg/warewulfd/helpers.go
Normal file
155
internal/pkg/warewulfd/helpers.go
Normal 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)
|
||||
}
|
||||
}
|
||||
30
internal/pkg/warewulfd/image.go
Normal file
30
internal/pkg/warewulfd/image.go
Normal 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)
|
||||
}
|
||||
39
internal/pkg/warewulfd/initramfs.go
Normal file
39
internal/pkg/warewulfd/initramfs.go
Normal 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)
|
||||
}
|
||||
60
internal/pkg/warewulfd/initramfs_test.go
Normal file
60
internal/pkg/warewulfd/initramfs_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
35
internal/pkg/warewulfd/ipxe.go
Normal file
35
internal/pkg/warewulfd/ipxe.go
Normal 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)
|
||||
}
|
||||
80
internal/pkg/warewulfd/ipxe_test.go
Normal file
80
internal/pkg/warewulfd/ipxe_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
34
internal/pkg/warewulfd/kernel.go
Normal file
34
internal/pkg/warewulfd/kernel.go
Normal 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)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package warewulfd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -16,7 +17,127 @@ import (
|
||||
"github.com/warewulf/warewulf/internal/pkg/wwlog"
|
||||
)
|
||||
|
||||
func OverlaySend(w http.ResponseWriter, req *http.Request) {
|
||||
// HandleOverlayList handles requests for an explicit comma-separated list of
|
||||
// named overlays via the ?overlay= query parameter.
|
||||
func HandleOverlayList(w http.ResponseWriter, req *http.Request) {
|
||||
ctx, err := initHandleRequest(w, req)
|
||||
if err != nil {
|
||||
return // response already written
|
||||
}
|
||||
|
||||
if !ctx.remoteNode.Valid() {
|
||||
wwlog.Error("%s (unknown/unconfigured node)", ctx.rinfo.hwaddr)
|
||||
sendResponse(w, req, "", nil, ctx)
|
||||
return
|
||||
}
|
||||
|
||||
request_overlays := strings.Split(ctx.rinfo.overlay, ",")
|
||||
stageFile, err := getOverlayFile(
|
||||
ctx.remoteNode,
|
||||
"",
|
||||
request_overlays,
|
||||
ctx.conf.Warewulf.AutobuildOverlays())
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, overlay.ErrDoesNotExist) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
wwlog.ErrorExc(err, "")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
wwlog.ErrorExc(err, "")
|
||||
return
|
||||
}
|
||||
|
||||
sendResponse(w, req, stageFile, nil, ctx)
|
||||
}
|
||||
|
||||
// HandleSystemOverlay handles system overlay requests.
|
||||
// If an explicit ?overlay= list is present, delegates to HandleOverlayList.
|
||||
func HandleSystemOverlay(w http.ResponseWriter, req *http.Request) {
|
||||
if len(req.URL.Query()["overlay"]) > 0 {
|
||||
HandleOverlayList(w, req)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, err := initHandleRequest(w, req)
|
||||
if err != nil {
|
||||
return // response already written
|
||||
}
|
||||
|
||||
if !ctx.remoteNode.Valid() {
|
||||
wwlog.Error("%s (unknown/unconfigured node)", ctx.rinfo.hwaddr)
|
||||
sendResponse(w, req, "", nil, ctx)
|
||||
return
|
||||
}
|
||||
|
||||
stageFile, err := getOverlayFile(
|
||||
ctx.remoteNode,
|
||||
"system",
|
||||
nil,
|
||||
ctx.conf.Warewulf.AutobuildOverlays())
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, overlay.ErrDoesNotExist) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
wwlog.ErrorExc(err, "")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
wwlog.ErrorExc(err, "")
|
||||
return
|
||||
}
|
||||
|
||||
sendResponse(w, req, stageFile, nil, ctx)
|
||||
}
|
||||
|
||||
// HandleRuntimeOverlay handles runtime overlay requests.
|
||||
// If TLS is enabled, returns 403 Forbidden for plain-HTTP requests.
|
||||
// If an explicit ?overlay= list is present, delegates to HandleOverlayList.
|
||||
func HandleRuntimeOverlay(w http.ResponseWriter, req *http.Request) {
|
||||
if config.Get().Warewulf.TLSEnabled() && req.TLS == nil {
|
||||
wwlog.Denied("runtime overlay requested over insecure connection")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.URL.Query()["overlay"]) > 0 {
|
||||
HandleOverlayList(w, req)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, err := initHandleRequest(w, req)
|
||||
if err != nil {
|
||||
return // response already written
|
||||
}
|
||||
|
||||
if !ctx.remoteNode.Valid() {
|
||||
wwlog.Error("%s (unknown/unconfigured node)", ctx.rinfo.hwaddr)
|
||||
sendResponse(w, req, "", nil, ctx)
|
||||
return
|
||||
}
|
||||
|
||||
stageFile, err := getOverlayFile(
|
||||
ctx.remoteNode,
|
||||
"runtime",
|
||||
nil,
|
||||
ctx.conf.Warewulf.AutobuildOverlays())
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, overlay.ErrDoesNotExist) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
wwlog.ErrorExc(err, "")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
wwlog.ErrorExc(err, "")
|
||||
return
|
||||
}
|
||||
|
||||
sendResponse(w, req, stageFile, nil, ctx)
|
||||
}
|
||||
|
||||
func HandleOverlayFile(w http.ResponseWriter, req *http.Request) {
|
||||
rinfo, err := parseReqRender(req)
|
||||
if err != nil {
|
||||
message := "error parsing request: %s"
|
||||
|
||||
@@ -4,10 +4,13 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
warewulfconf "github.com/warewulf/warewulf/internal/pkg/config"
|
||||
"github.com/warewulf/warewulf/internal/pkg/testenv"
|
||||
)
|
||||
|
||||
@@ -95,7 +98,93 @@ nodes:
|
||||
t.Run(description, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, tt.url, nil)
|
||||
w := httptest.NewRecorder()
|
||||
OverlaySend(w, req)
|
||||
HandleOverlayFile(w, req)
|
||||
res := w.Result()
|
||||
defer res.Body.Close()
|
||||
|
||||
data, readErr := io.ReadAll(res.Body)
|
||||
assert.NoError(t, readErr)
|
||||
if tt.body != "" {
|
||||
assert.Equal(t, tt.body, string(data))
|
||||
}
|
||||
assert.Equal(t, tt.status, res.StatusCode)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var systemOverlayTests = []struct {
|
||||
description string
|
||||
url string
|
||||
body string
|
||||
status int
|
||||
ip string
|
||||
}{
|
||||
{"system overlay", "/system/00:00:00:ff:ff:ff", "system overlay", 200, "10.10.10.10:9873"},
|
||||
{"fake overlay returns 404", "/system/00:00:00:ff:ff:ff?overlay=fake", "", 404, "10.10.10.10:9873"},
|
||||
{"specific overlay", "/system/00:00:00:ff:ff:ff?overlay=o1", "specific overlay", 200, "10.10.10.10:9873"},
|
||||
}
|
||||
|
||||
var runtimeOverlayTests = []struct {
|
||||
description string
|
||||
url string
|
||||
body string
|
||||
status int
|
||||
ip string
|
||||
}{
|
||||
{"runtime overlay", "/runtime/00:00:00:ff:ff:ff", "runtime overlay", 200, "10.10.10.10:9873"},
|
||||
}
|
||||
|
||||
func Test_HandleSystemRuntimeOverlay(t *testing.T) {
|
||||
env := testenv.New(t)
|
||||
defer env.RemoveAll()
|
||||
|
||||
env.WriteFile("/etc/warewulf/nodes.conf", `nodeprofiles:
|
||||
default:
|
||||
image name: suse
|
||||
nodes:
|
||||
n1:
|
||||
network devices:
|
||||
default:
|
||||
hwaddr: 00:00:00:ff:ff:ff
|
||||
profiles:
|
||||
- default`)
|
||||
|
||||
dbErr := LoadNodeDB()
|
||||
assert.NoError(t, dbErr)
|
||||
|
||||
conf := warewulfconf.Get()
|
||||
secureFalse := false
|
||||
conf.Warewulf.SecureP = &secureFalse
|
||||
|
||||
assert.NoError(t, os.MkdirAll(path.Join(conf.Paths.OverlayProvisiondir(), "n1"), 0700))
|
||||
assert.NoError(t, os.WriteFile(path.Join(conf.Paths.OverlayProvisiondir(), "n1", "__SYSTEM__.img"), []byte("system overlay"), 0600))
|
||||
assert.NoError(t, os.WriteFile(path.Join(conf.Paths.OverlayProvisiondir(), "n1", "__RUNTIME__.img"), []byte("runtime overlay"), 0600))
|
||||
assert.NoError(t, os.WriteFile(path.Join(conf.Paths.OverlayProvisiondir(), "n1", "o1.img"), []byte("specific overlay"), 0600))
|
||||
|
||||
for _, tt := range systemOverlayTests {
|
||||
t.Run(tt.description, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, tt.url, nil)
|
||||
req.RemoteAddr = tt.ip
|
||||
w := httptest.NewRecorder()
|
||||
HandleSystemOverlay(w, req)
|
||||
res := w.Result()
|
||||
defer res.Body.Close()
|
||||
|
||||
data, readErr := io.ReadAll(res.Body)
|
||||
assert.NoError(t, readErr)
|
||||
if tt.body != "" {
|
||||
assert.Equal(t, tt.body, string(data))
|
||||
}
|
||||
assert.Equal(t, tt.status, res.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
for _, tt := range runtimeOverlayTests {
|
||||
t.Run(tt.description, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, tt.url, nil)
|
||||
req.RemoteAddr = tt.ip
|
||||
w := httptest.NewRecorder()
|
||||
HandleRuntimeOverlay(w, req)
|
||||
res := w.Result()
|
||||
defer res.Body.Close()
|
||||
|
||||
|
||||
@@ -1,15 +1,71 @@
|
||||
package warewulfd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
warewulfconf "github.com/warewulf/warewulf/internal/pkg/config"
|
||||
"github.com/warewulf/warewulf/internal/pkg/node"
|
||||
"github.com/warewulf/warewulf/internal/pkg/wwlog"
|
||||
)
|
||||
|
||||
type parserInfo struct {
|
||||
// requestContext holds the validated results of the parsed request
|
||||
type requestContext struct {
|
||||
conf *warewulfconf.WarewulfYaml
|
||||
rinfo parsedRequest
|
||||
remoteNode node.Node
|
||||
}
|
||||
|
||||
// initHandleRequest performs common initial request parsing, security checks,
|
||||
// node lookup, and asset key validation. On error, it writes the HTTP error
|
||||
// response and returns a non-nil error so the caller can simply return.
|
||||
func initHandleRequest(w http.ResponseWriter, req *http.Request) (*requestContext, error) {
|
||||
wwlog.Debug("Requested URL: %s", req.URL.String())
|
||||
conf := warewulfconf.Get()
|
||||
rinfo, err := parseRequest(req)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
wwlog.ErrorExc(err, "Bad status")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
wwlog.Debug("stage: %s", rinfo.stage)
|
||||
|
||||
wwlog.Info("request from hwaddr:%s ipaddr:%s | stage:%s", rinfo.hwaddr, req.RemoteAddr, rinfo.stage)
|
||||
|
||||
if (rinfo.stage == "runtime" || len(rinfo.overlay) > 0) && conf.Warewulf.Secure() {
|
||||
if rinfo.remoteport >= 1024 {
|
||||
wwlog.Denied("Non-privileged port: %s", req.RemoteAddr)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return nil, fmt.Errorf("non-privileged port")
|
||||
}
|
||||
}
|
||||
|
||||
remoteNode, err := GetNodeOrSetDiscoverable(rinfo.hwaddr, conf.Warewulf.AutobuildOverlays())
|
||||
if err != nil && err != node.ErrNoUnconfigured {
|
||||
wwlog.ErrorExc(err, "")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if remoteNode.AssetKey != "" && remoteNode.AssetKey != rinfo.assetkey {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
wwlog.Denied("incorrect asset key: node %s: %s", remoteNode.Id(), rinfo.assetkey)
|
||||
updateStatus(remoteNode.Id(), rinfo.stage, "BAD_ASSET", rinfo.ipaddr)
|
||||
return nil, fmt.Errorf("incorrect asset key")
|
||||
}
|
||||
|
||||
return &requestContext{
|
||||
conf: conf,
|
||||
rinfo: rinfo,
|
||||
remoteNode: remoteNode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type parsedRequest struct {
|
||||
hwaddr string
|
||||
ipaddr string
|
||||
remoteport int
|
||||
@@ -21,85 +77,99 @@ type parserInfo struct {
|
||||
compress string
|
||||
}
|
||||
|
||||
func parseReq(req *http.Request) (parserInfo, error) {
|
||||
var ret parserInfo
|
||||
func parseHwaddr(hwaddr string) string {
|
||||
hwaddr = strings.ReplaceAll(hwaddr, "-", ":")
|
||||
hwaddr = strings.ToLower(hwaddr)
|
||||
return hwaddr
|
||||
}
|
||||
|
||||
url := strings.Split(req.URL.Path, "?")[0]
|
||||
path_parts := strings.Split(url, "/")
|
||||
// parseRequest extracts provisioning parameters from an HTTP request. The
|
||||
// stage and hwaddr are taken from the URL path, with fallbacks to query
|
||||
// parameters and ARP cache lookup for hwaddr. Stage aliases (e.g.
|
||||
// "container", "overlay-system") are normalized to their canonical names.
|
||||
//
|
||||
// See userdocs/server/routes.rst for more information.
|
||||
func parseRequest(req *http.Request) (parsedRequest, error) {
|
||||
var ret parsedRequest
|
||||
|
||||
if len(path_parts) < 3 {
|
||||
return ret, errors.New("unknown path components in GET")
|
||||
path_parts := strings.Split(req.URL.Path, "/")
|
||||
|
||||
// initial stage passed in the url path /[stage]/hwaddr
|
||||
if len(path_parts) < 2 {
|
||||
return ret, errors.New("path missing initial stage: " + req.URL.Path)
|
||||
}
|
||||
ret.stage = path_parts[1]
|
||||
|
||||
// prefer stage from query string for provision stage
|
||||
if ret.stage == "provision" && len(req.URL.Query()["stage"]) > 0 {
|
||||
ret.stage = req.URL.Query()["stage"][0]
|
||||
}
|
||||
|
||||
// handle when stage was passed in the url path /[stage]/hwaddr
|
||||
stage := path_parts[1]
|
||||
hwaddr := ""
|
||||
if stage != "efiboot" {
|
||||
hwaddr = path_parts[2]
|
||||
hwaddr = strings.ReplaceAll(hwaddr, "-", ":")
|
||||
hwaddr = strings.ToLower(hwaddr)
|
||||
} else if len(path_parts) > 3 {
|
||||
ret.efifile = strings.Join(path_parts[2:], "/")
|
||||
} else {
|
||||
ret.efifile = path_parts[2]
|
||||
// map the requested stage to a known stage
|
||||
switch ret.stage {
|
||||
case "provision":
|
||||
ret.stage = "ipxe"
|
||||
case "container":
|
||||
ret.stage = "image"
|
||||
case "overlay-system":
|
||||
ret.stage = "system"
|
||||
case "overlay-runtime":
|
||||
ret.stage = "runtime"
|
||||
}
|
||||
ret.hwaddr = hwaddr
|
||||
|
||||
if ret.stage == "" {
|
||||
return ret, errors.New("no stage specified: " + req.URL.RawQuery)
|
||||
}
|
||||
|
||||
if len(path_parts) > 2 {
|
||||
if ret.stage == "efiboot" {
|
||||
// /efiboot/{file}: no wwid in path; identified via ARP
|
||||
ret.efifile = strings.Join(path_parts[2:], "/")
|
||||
} else {
|
||||
ret.hwaddr = parseHwaddr(path_parts[2])
|
||||
}
|
||||
}
|
||||
|
||||
remoteAddrPort, err := netip.ParseAddrPort(req.RemoteAddr)
|
||||
if err != nil {
|
||||
return ret, errors.New("could not parse remote address")
|
||||
}
|
||||
ret.ipaddr = remoteAddrPort.Addr().String()
|
||||
if ret.ipaddr == "" {
|
||||
return ret, errors.New("could not obtain ipaddr from HTTP request")
|
||||
}
|
||||
ret.remoteport = int(remoteAddrPort.Port())
|
||||
if ret.remoteport == 0 {
|
||||
return ret, errors.New("could not obtain remote port from HTTP request: " + req.RemoteAddr)
|
||||
}
|
||||
|
||||
if ret.hwaddr == "" && len(req.URL.Query()["wwid"]) > 0 {
|
||||
ret.hwaddr = parseHwaddr(req.URL.Query()["wwid"][0])
|
||||
}
|
||||
if ret.hwaddr == "" {
|
||||
if hwaddr := parseHwaddr(ArpFind(ret.ipaddr)); hwaddr != "" {
|
||||
ret.hwaddr = hwaddr
|
||||
wwlog.Verbose("using %s from arp cache for %s", ret.hwaddr, ret.ipaddr)
|
||||
}
|
||||
}
|
||||
if ret.hwaddr == "" {
|
||||
return ret, errors.New("unable to determine wwid: " + req.URL.RawQuery)
|
||||
}
|
||||
|
||||
if len(req.URL.Query()["assetkey"]) > 0 {
|
||||
ret.assetkey = req.URL.Query()["assetkey"][0]
|
||||
}
|
||||
|
||||
if len(req.URL.Query()["uuid"]) > 0 {
|
||||
ret.uuid = req.URL.Query()["uuid"][0]
|
||||
}
|
||||
|
||||
if len(req.URL.Query()["stage"]) > 0 {
|
||||
ret.stage = req.URL.Query()["stage"][0]
|
||||
} else {
|
||||
|
||||
if stage == "ipxe" || stage == "provision" {
|
||||
ret.stage = "ipxe"
|
||||
} else if stage == "kernel" {
|
||||
ret.stage = "kernel"
|
||||
} else if stage == "image" {
|
||||
ret.stage = "image"
|
||||
} else if stage == "overlay-system" {
|
||||
ret.stage = "system"
|
||||
} else if stage == "overlay-runtime" {
|
||||
ret.stage = "runtime"
|
||||
} else if stage == "efiboot" {
|
||||
ret.stage = "efiboot"
|
||||
} else if stage == "initramfs" {
|
||||
ret.stage = "initramfs"
|
||||
}
|
||||
}
|
||||
|
||||
if len(req.URL.Query()["overlay"]) > 0 {
|
||||
ret.overlay = req.URL.Query()["overlay"][0]
|
||||
}
|
||||
if len(req.URL.Query()["compress"]) > 0 {
|
||||
ret.compress = req.URL.Query()["compress"][0]
|
||||
}
|
||||
if ret.stage == "" {
|
||||
return ret, errors.New("no stage encoded in GET")
|
||||
}
|
||||
if ret.hwaddr == "" {
|
||||
ret.hwaddr = ArpFind(ret.ipaddr)
|
||||
wwlog.Verbose("node mac not encoded, arp cache got %s for %s", ret.hwaddr, ret.ipaddr)
|
||||
if ret.hwaddr == "" {
|
||||
return ret, errors.New("no hwaddr encoded in GET")
|
||||
}
|
||||
}
|
||||
if ret.ipaddr == "" {
|
||||
return ret, errors.New("could not obtain ipaddr from HTTP request")
|
||||
}
|
||||
if ret.remoteport == 0 {
|
||||
return ret, errors.New("could not obtain remote port from HTTP request: " + req.RemoteAddr)
|
||||
if ret.efifile == "" && len(req.URL.Query()["file"]) > 0 {
|
||||
ret.efifile = req.URL.Query()["file"][0]
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
|
||||
@@ -11,14 +11,15 @@ import (
|
||||
var parseReqTests = []struct {
|
||||
description string
|
||||
url string
|
||||
rawQuery string
|
||||
remoteAddr string
|
||||
result parserInfo
|
||||
result parsedRequest
|
||||
}{
|
||||
{
|
||||
description: "basic ipv4 request",
|
||||
url: "/provision/00:00:00:ff:ff:ff",
|
||||
remoteAddr: "10.5.1.1:9873",
|
||||
result: parserInfo{
|
||||
result: parsedRequest{
|
||||
hwaddr: "00:00:00:ff:ff:ff",
|
||||
ipaddr: "10.5.1.1",
|
||||
remoteport: 9873,
|
||||
@@ -29,23 +30,153 @@ var parseReqTests = []struct {
|
||||
description: "basic ipv6 request",
|
||||
url: "/provision/00:00:00:ff:ff:ff",
|
||||
remoteAddr: "[fd00:5::1:1]:9873",
|
||||
result: parserInfo{
|
||||
result: parsedRequest{
|
||||
hwaddr: "00:00:00:ff:ff:ff",
|
||||
ipaddr: "fd00:5::1:1",
|
||||
remoteport: 9873,
|
||||
stage: "ipxe",
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "initramfs dedicated route",
|
||||
url: "/initramfs/00:00:00:ff:ff:ff",
|
||||
remoteAddr: "10.5.1.1:9873",
|
||||
result: parsedRequest{
|
||||
hwaddr: "00:00:00:ff:ff:ff",
|
||||
ipaddr: "10.5.1.1",
|
||||
remoteport: 9873,
|
||||
stage: "initramfs",
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "grub route with hwaddr in path",
|
||||
url: "/grub/00:00:00:ff:ff:ff",
|
||||
remoteAddr: "10.5.1.1:9873",
|
||||
result: parsedRequest{
|
||||
hwaddr: "00:00:00:ff:ff:ff",
|
||||
ipaddr: "10.5.1.1",
|
||||
remoteport: 9873,
|
||||
stage: "grub",
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "wwid query param on dedicated route",
|
||||
url: "/kernel/",
|
||||
rawQuery: "wwid=00%3A00%3A00%3Aff%3Aff%3Aff",
|
||||
remoteAddr: "10.5.1.1:9873",
|
||||
result: parsedRequest{
|
||||
hwaddr: "00:00:00:ff:ff:ff",
|
||||
ipaddr: "10.5.1.1",
|
||||
remoteport: 9873,
|
||||
stage: "kernel",
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "wwid and stage query params on provision route",
|
||||
url: "/provision/",
|
||||
rawQuery: "wwid=00%3A00%3A00%3Aff%3Aff%3Aff&stage=kernel",
|
||||
remoteAddr: "10.5.1.1:9873",
|
||||
result: parsedRequest{
|
||||
hwaddr: "00:00:00:ff:ff:ff",
|
||||
ipaddr: "10.5.1.1",
|
||||
remoteport: 9873,
|
||||
stage: "kernel",
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "efiboot with wwid and file query params",
|
||||
url: "/efiboot/",
|
||||
rawQuery: "wwid=00%3A00%3A00%3Aff%3Aff%3Aff&file=shim.efi",
|
||||
remoteAddr: "10.5.1.1:9873",
|
||||
result: parsedRequest{
|
||||
hwaddr: "00:00:00:ff:ff:ff",
|
||||
ipaddr: "10.5.1.1",
|
||||
remoteport: 9873,
|
||||
stage: "efiboot",
|
||||
efifile: "shim.efi",
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "grub with wwid query param",
|
||||
url: "/grub/",
|
||||
rawQuery: "wwid=00%3A00%3A00%3Aff%3Aff%3Aff",
|
||||
remoteAddr: "10.5.1.1:9873",
|
||||
result: parsedRequest{
|
||||
hwaddr: "00:00:00:ff:ff:ff",
|
||||
ipaddr: "10.5.1.1",
|
||||
remoteport: 9873,
|
||||
stage: "grub",
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "path hwaddr takes priority over wwid query param",
|
||||
url: "/kernel/00:00:00:ff:ff:ff",
|
||||
rawQuery: "wwid=11%3A11%3A11%3A11%3A11%3A11",
|
||||
remoteAddr: "10.5.1.1:9873",
|
||||
result: parsedRequest{
|
||||
hwaddr: "00:00:00:ff:ff:ff",
|
||||
ipaddr: "10.5.1.1",
|
||||
remoteport: 9873,
|
||||
stage: "kernel",
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "system overlay dedicated route",
|
||||
url: "/system/00:00:00:ff:ff:ff",
|
||||
remoteAddr: "10.5.1.1:9873",
|
||||
result: parsedRequest{
|
||||
hwaddr: "00:00:00:ff:ff:ff",
|
||||
ipaddr: "10.5.1.1",
|
||||
remoteport: 9873,
|
||||
stage: "system",
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "runtime overlay dedicated route",
|
||||
url: "/runtime/00:00:00:ff:ff:ff",
|
||||
remoteAddr: "10.5.1.1:9873",
|
||||
result: parsedRequest{
|
||||
hwaddr: "00:00:00:ff:ff:ff",
|
||||
ipaddr: "10.5.1.1",
|
||||
remoteport: 9873,
|
||||
stage: "runtime",
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "stage=grub via provision route serves config (new semantics)",
|
||||
url: "/provision/00:00:00:ff:ff:ff",
|
||||
rawQuery: "stage=grub",
|
||||
remoteAddr: "10.5.1.1:9873",
|
||||
result: parsedRequest{
|
||||
hwaddr: "00:00:00:ff:ff:ff",
|
||||
ipaddr: "10.5.1.1",
|
||||
remoteport: 9873,
|
||||
stage: "grub",
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "path efifile takes priority over file query param",
|
||||
url: "/efiboot/shim.efi",
|
||||
rawQuery: "wwid=00%3A00%3A00%3Aff%3Aff%3Aff&file=grub.cfg",
|
||||
remoteAddr: "10.5.1.1:9873",
|
||||
result: parsedRequest{
|
||||
hwaddr: "00:00:00:ff:ff:ff",
|
||||
ipaddr: "10.5.1.1",
|
||||
remoteport: 9873,
|
||||
stage: "efiboot",
|
||||
efifile: "shim.efi",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func Test_ParseReq(t *testing.T) {
|
||||
func Test_ParseRequest(t *testing.T) {
|
||||
for _, tt := range parseReqTests {
|
||||
t.Run(tt.description, func(t *testing.T) {
|
||||
req := &http.Request{
|
||||
URL: &url.URL{Path: tt.url},
|
||||
URL: &url.URL{Path: tt.url, RawQuery: tt.rawQuery},
|
||||
RemoteAddr: tt.remoteAddr,
|
||||
}
|
||||
result, err := parseReq(req)
|
||||
result, err := parseRequest(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.result, result)
|
||||
})
|
||||
|
||||
@@ -1,24 +1,9 @@
|
||||
package warewulfd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/Masterminds/sprig/v3"
|
||||
warewulfconf "github.com/warewulf/warewulf/internal/pkg/config"
|
||||
"github.com/warewulf/warewulf/internal/pkg/image"
|
||||
"github.com/warewulf/warewulf/internal/pkg/kernel"
|
||||
"github.com/warewulf/warewulf/internal/pkg/node"
|
||||
"github.com/warewulf/warewulf/internal/pkg/overlay"
|
||||
"github.com/warewulf/warewulf/internal/pkg/util"
|
||||
"github.com/warewulf/warewulf/internal/pkg/wwlog"
|
||||
)
|
||||
|
||||
@@ -39,343 +24,41 @@ type templateVars struct {
|
||||
KernelArgs string
|
||||
KernelVersion string
|
||||
Root string
|
||||
TLS bool
|
||||
Tags map[string]string
|
||||
NetDevs map[string]*node.NetDev
|
||||
}
|
||||
|
||||
func ProvisionSend(w http.ResponseWriter, req *http.Request) {
|
||||
wwlog.Debug("Requested URL: %s", req.URL.String())
|
||||
conf := warewulfconf.Get()
|
||||
rinfo, err := parseReq(req)
|
||||
func HandleProvision(w http.ResponseWriter, req *http.Request) {
|
||||
// Parse just enough to determine the stage
|
||||
rinfo, err := parseRequest(req)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
wwlog.ErrorExc(err, "Bad status")
|
||||
return
|
||||
}
|
||||
|
||||
wwlog.Debug("stage: %s", rinfo.stage)
|
||||
|
||||
wwlog.Info("request from hwaddr:%s ipaddr:%s | stage:%s", rinfo.hwaddr, req.RemoteAddr, rinfo.stage)
|
||||
|
||||
if (rinfo.stage == "runtime" || len(rinfo.overlay) > 0) && conf.Warewulf.Secure() {
|
||||
if rinfo.remoteport >= 1024 {
|
||||
wwlog.Denied("Non-privileged port: %s", req.RemoteAddr)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
status_stages := map[string]string{
|
||||
"efiboot": "EFI",
|
||||
"ipxe": "IPXE",
|
||||
"kernel": "KERNEL",
|
||||
"system": "SYSTEM_OVERLAY",
|
||||
"runtime": "RUNTIME_OVERLAY",
|
||||
"initramfs": "INITRAMFS"}
|
||||
|
||||
status_stage := status_stages[rinfo.stage]
|
||||
var stage_file string
|
||||
|
||||
// TODO: when module version is upgraded to go1.18, should be 'any' type
|
||||
var tmpl_data *templateVars
|
||||
|
||||
remoteNode, err := GetNodeOrSetDiscoverable(rinfo.hwaddr, conf.Warewulf.AutobuildOverlays())
|
||||
if err != nil && err != node.ErrNoUnconfigured {
|
||||
wwlog.ErrorExc(err, "")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
if remoteNode.AssetKey != "" && remoteNode.AssetKey != rinfo.assetkey {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
wwlog.Denied("incorrect asset key: node %s: %s", remoteNode.Id(), rinfo.assetkey)
|
||||
updateStatus(remoteNode.Id(), status_stage, "BAD_ASSET", rinfo.ipaddr)
|
||||
return
|
||||
}
|
||||
|
||||
if !remoteNode.Valid() {
|
||||
wwlog.Error("%s (unknown/unconfigured node)", rinfo.hwaddr)
|
||||
if rinfo.stage == "ipxe" {
|
||||
stage_file = path.Join(conf.Paths.Sysconfdir, "/warewulf/ipxe/unconfigured.ipxe")
|
||||
tmpl_data = &templateVars{
|
||||
Hwaddr: rinfo.hwaddr}
|
||||
}
|
||||
|
||||
} else if rinfo.stage == "ipxe" {
|
||||
template := remoteNode.Ipxe
|
||||
if template == "" {
|
||||
template = "default"
|
||||
}
|
||||
stage_file = path.Join(conf.Paths.Sysconfdir, "warewulf/ipxe", template+".ipxe")
|
||||
kernelArgs := ""
|
||||
kernelVersion := ""
|
||||
if remoteNode.Kernel != nil {
|
||||
kernelArgs = strings.Join(remoteNode.Kernel.Args, " ")
|
||||
kernelVersion = remoteNode.Kernel.Version
|
||||
}
|
||||
if kernelVersion == "" {
|
||||
if kernel_ := kernel.FromNode(&remoteNode); kernel_ != nil {
|
||||
kernelVersion = kernel_.Version()
|
||||
}
|
||||
}
|
||||
authority := fmt.Sprintf("%s:%d", conf.Ipaddr, conf.Warewulf.Port)
|
||||
ipaddr6 := ""
|
||||
if confIpaddr6, err := netip.ParseAddr(conf.Ipaddr6); err == nil {
|
||||
ipaddr6 = confIpaddr6.String()
|
||||
}
|
||||
if rinfoIpaddr, err := netip.ParseAddr(rinfo.ipaddr); err == nil {
|
||||
if rinfoIpaddr.Is6() {
|
||||
if ipaddr6 != "" {
|
||||
authority = fmt.Sprintf("[%s]:%d", ipaddr6, conf.Warewulf.Port)
|
||||
} else {
|
||||
wwlog.Error("No valid IPv6 address configured, but request is IPv6")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
wwlog.Error("Could not parse request IP address: %s", rinfo.ipaddr)
|
||||
}
|
||||
tmpl_data = &templateVars{
|
||||
Id: remoteNode.Id(),
|
||||
Cluster: remoteNode.ClusterName,
|
||||
Fqdn: remoteNode.Id(),
|
||||
Ipaddr: conf.Ipaddr,
|
||||
Ipaddr6: ipaddr6,
|
||||
Port: strconv.Itoa(conf.Warewulf.Port),
|
||||
Authority: authority,
|
||||
Hostname: remoteNode.Id(),
|
||||
Hwaddr: rinfo.hwaddr,
|
||||
ImageName: remoteNode.ImageName,
|
||||
KernelArgs: kernelArgs,
|
||||
KernelVersion: kernelVersion,
|
||||
Root: remoteNode.Root,
|
||||
NetDevs: remoteNode.NetDevs,
|
||||
Tags: remoteNode.Tags}
|
||||
} else if rinfo.stage == "kernel" {
|
||||
kernel_ := kernel.FromNode(&remoteNode)
|
||||
if kernel_ == nil {
|
||||
wwlog.Error("No kernel found for node %s", remoteNode.Id())
|
||||
} else {
|
||||
stage_file = kernel_.FullPath()
|
||||
if stage_file == "" {
|
||||
wwlog.Error("No kernel path found for node %s", remoteNode.Id())
|
||||
}
|
||||
}
|
||||
|
||||
} else if rinfo.stage == "image" {
|
||||
if remoteNode.ImageName != "" {
|
||||
stage_file = image.ImageFile(remoteNode.ImageName)
|
||||
} else {
|
||||
wwlog.Warn("No image set for node %s", remoteNode.Id())
|
||||
}
|
||||
|
||||
} else if rinfo.stage == "system" || rinfo.stage == "runtime" {
|
||||
var context string
|
||||
var request_overlays []string
|
||||
|
||||
if len(rinfo.overlay) > 0 {
|
||||
request_overlays = strings.Split(rinfo.overlay, ",")
|
||||
} else {
|
||||
context = rinfo.stage
|
||||
}
|
||||
stage_file, err = getOverlayFile(
|
||||
remoteNode,
|
||||
context,
|
||||
request_overlays,
|
||||
conf.Warewulf.AutobuildOverlays())
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, overlay.ErrDoesNotExist) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
wwlog.ErrorExc(err, "")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
wwlog.ErrorExc(err, "")
|
||||
return
|
||||
}
|
||||
} else if rinfo.stage == "efiboot" {
|
||||
wwlog.Debug("requested method: %s", req.Method)
|
||||
imageName := remoteNode.ImageName
|
||||
switch rinfo.efifile {
|
||||
case "shim.efi":
|
||||
stage_file = image.ShimFind(imageName)
|
||||
if stage_file == "" {
|
||||
wwlog.Error("couldn't find shim.efi for %s", imageName)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
case "grub.efi", "grub-tpm.efi", "grubx64.efi", "grubia32.efi", "grubaa64.efi", "grubarm.efi":
|
||||
stage_file = image.GrubFind(imageName)
|
||||
if stage_file == "" {
|
||||
wwlog.Error("could't find grub*.efi for %s", imageName)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
case "grub.cfg":
|
||||
stage_file = path.Join(conf.Paths.Sysconfdir, "warewulf/grub/grub.cfg.ww")
|
||||
kernelArgs := ""
|
||||
kernelVersion := ""
|
||||
if remoteNode.Kernel != nil {
|
||||
kernelArgs = strings.Join(remoteNode.Kernel.Args, " ")
|
||||
kernelVersion = remoteNode.Kernel.Version
|
||||
}
|
||||
if kernelVersion == "" {
|
||||
if kernel_ := kernel.FromNode(&remoteNode); kernel_ != nil {
|
||||
kernelVersion = kernel_.Version()
|
||||
}
|
||||
}
|
||||
authority := fmt.Sprintf("%s:%d", conf.Ipaddr, conf.Warewulf.Port)
|
||||
ipaddr6 := ""
|
||||
if confIpaddr6, err := netip.ParseAddr(conf.Ipaddr6); err == nil {
|
||||
ipaddr6 = confIpaddr6.String()
|
||||
}
|
||||
if rinfoIpaddr, err := netip.ParseAddr(rinfo.ipaddr); err == nil {
|
||||
if rinfoIpaddr.Is6() {
|
||||
if ipaddr6 != "" {
|
||||
authority = fmt.Sprintf("[%s]:%d", ipaddr6, conf.Warewulf.Port)
|
||||
} else {
|
||||
wwlog.Error("No valid IPv6 address configured, but request is IPv6")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
wwlog.Error("Could not parse request IP address: %s", rinfo.ipaddr)
|
||||
}
|
||||
tmpl_data = &templateVars{
|
||||
Id: remoteNode.Id(),
|
||||
Cluster: remoteNode.ClusterName,
|
||||
Fqdn: remoteNode.Id(),
|
||||
Ipaddr: conf.Ipaddr,
|
||||
Ipaddr6: ipaddr6,
|
||||
Port: strconv.Itoa(conf.Warewulf.Port),
|
||||
Authority: authority,
|
||||
Hostname: remoteNode.Id(),
|
||||
Hwaddr: rinfo.hwaddr,
|
||||
ImageName: remoteNode.ImageName,
|
||||
Ipxe: remoteNode.Ipxe,
|
||||
KernelArgs: kernelArgs,
|
||||
KernelVersion: kernelVersion,
|
||||
Root: remoteNode.Root,
|
||||
NetDevs: remoteNode.NetDevs,
|
||||
Tags: remoteNode.Tags}
|
||||
if stage_file == "" {
|
||||
wwlog.Error("could't find grub.cfg template for %s", imageName)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
default:
|
||||
wwlog.ErrorExc(fmt.Errorf("could't find efiboot file: %s", rinfo.efifile), "")
|
||||
}
|
||||
} else if rinfo.stage == "shim" {
|
||||
if remoteNode.ImageName != "" {
|
||||
stage_file = image.ShimFind(remoteNode.ImageName)
|
||||
|
||||
if stage_file == "" {
|
||||
wwlog.Error("No kernel found for image %s", remoteNode.ImageName)
|
||||
}
|
||||
} else {
|
||||
wwlog.Warn("No image set for this %s", remoteNode.Id())
|
||||
}
|
||||
} else if rinfo.stage == "grub" {
|
||||
if remoteNode.ImageName != "" {
|
||||
stage_file = image.GrubFind(remoteNode.ImageName)
|
||||
if stage_file == "" {
|
||||
wwlog.Error("No grub found for image %s", remoteNode.ImageName)
|
||||
}
|
||||
} else {
|
||||
wwlog.Warn("No conainer set for node %s", remoteNode.Id())
|
||||
}
|
||||
} else if rinfo.stage == "initramfs" {
|
||||
if kernel_ := kernel.FromNode(&remoteNode); kernel_ != nil {
|
||||
if kver := kernel_.Version(); kver != "" {
|
||||
if initramfs := image.FindInitramfs(remoteNode.ImageName, kver); initramfs != nil {
|
||||
stage_file = initramfs.FullPath()
|
||||
} else {
|
||||
wwlog.Error("No initramfs found for kernel %s in image %s", kver, remoteNode.ImageName)
|
||||
}
|
||||
} else {
|
||||
wwlog.Error("No initramfs found: unable to determine kernel version for node %s", remoteNode.Id())
|
||||
}
|
||||
} else {
|
||||
wwlog.Error("No initramfs found: unable to find kernel for node %s", remoteNode.Id())
|
||||
}
|
||||
}
|
||||
|
||||
wwlog.Serv("stage_file '%s'", stage_file)
|
||||
|
||||
if util.IsFile(stage_file) {
|
||||
|
||||
if tmpl_data != nil {
|
||||
if rinfo.compress != "" {
|
||||
wwlog.Error("Unsupported %s compressed version for file: %s",
|
||||
rinfo.compress, stage_file)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Create a template with the Sprig functions.
|
||||
tmpl := template.New(filepath.Base(stage_file)).Funcs(sprig.TxtFuncMap())
|
||||
|
||||
// Parse the template.
|
||||
parsedTmpl, err := tmpl.ParseFiles(stage_file)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
wwlog.ErrorExc(err, "")
|
||||
return
|
||||
}
|
||||
|
||||
// template engine writes file to buffer in case rendering fails
|
||||
var buf bytes.Buffer
|
||||
|
||||
err = parsedTmpl.Execute(&buf, tmpl_data)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
wwlog.ErrorExc(err, "")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(buf.Len()))
|
||||
_, err = buf.WriteTo(w)
|
||||
if err != nil {
|
||||
wwlog.ErrorExc(err, "")
|
||||
}
|
||||
|
||||
wwlog.Info("send %s -> %s", stage_file, remoteNode.Id())
|
||||
|
||||
} else {
|
||||
if rinfo.compress == "gz" {
|
||||
stage_file += ".gz"
|
||||
|
||||
if !util.IsFile(stage_file) {
|
||||
wwlog.Error("unprepared for compressed version of file %s",
|
||||
stage_file)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
} else if rinfo.compress != "" {
|
||||
wwlog.Error("unsupported %s compressed version of file %s",
|
||||
rinfo.compress, stage_file)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
|
||||
err = sendFile(w, req, stage_file, remoteNode.Id())
|
||||
if err != nil {
|
||||
wwlog.ErrorExc(err, "")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
updateStatus(remoteNode.Id(), status_stage, path.Base(stage_file), rinfo.ipaddr)
|
||||
|
||||
} else if stage_file == "" {
|
||||
// Dispatch to the appropriate stage handler
|
||||
var handler http.HandlerFunc
|
||||
switch rinfo.stage {
|
||||
case "ipxe":
|
||||
handler = HandleIpxe
|
||||
case "kernel":
|
||||
handler = HandleKernel
|
||||
case "image":
|
||||
handler = HandleImage
|
||||
case "system":
|
||||
handler = HandleSystemOverlay
|
||||
case "runtime":
|
||||
handler = HandleRuntimeOverlay
|
||||
case "grub":
|
||||
handler = HandleGrub
|
||||
case "initramfs":
|
||||
handler = HandleInitramfs
|
||||
default:
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
wwlog.Error("No resource selected")
|
||||
updateStatus(remoteNode.Id(), status_stage, "BAD_REQUEST", rinfo.ipaddr)
|
||||
|
||||
} else {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
wwlog.Error("Not found: %s", stage_file)
|
||||
updateStatus(remoteNode.Id(), status_stage, "NOT_FOUND", rinfo.ipaddr)
|
||||
wwlog.Error("Unknown stage: %s", rinfo.stage)
|
||||
return
|
||||
}
|
||||
|
||||
handler(w, req)
|
||||
}
|
||||
|
||||
@@ -21,18 +21,15 @@ var provisionSendTests = []struct {
|
||||
status int
|
||||
ip string
|
||||
}{
|
||||
{"system overlay", "/overlay-system/00:00:00:ff:ff:ff", "system overlay", 200, "10.10.10.10:9873"},
|
||||
{"runtime overlay", "/overlay-runtime/00:00:00:ff:ff:ff", "runtime overlay", 200, "10.10.10.10:9873"},
|
||||
{"fake overlay", "/overlay-system/00:00:00:ff:ff:ff?overlay=fake", "", 404, "10.10.10.10:9873"},
|
||||
{"specific overlay", "/overlay-system/00:00:00:ff:ff:ff?overlay=o1", "specific overlay", 200, "10.10.10.10:9873"},
|
||||
{"find shim", "/efiboot/shim.efi", "", 200, "10.10.10.10:9873"},
|
||||
{"find shim", "/efiboot/shim.efi", "", 404, "10.10.10.11:9873"},
|
||||
{"find grub", "/efiboot/grub.efi", "", 200, "10.10.10.10:9873"},
|
||||
{"find grub", "/efiboot/grub.efi", "", 404, "10.10.10.11:9873"},
|
||||
{"system overlay", "/provision/00:00:00:ff:ff:ff?stage=system", "system overlay", 200, "10.10.10.10:9873"},
|
||||
{"runtime overlay", "/provision/00:00:00:ff:ff:ff?stage=runtime", "runtime overlay", 200, "10.10.10.10:9873"},
|
||||
{"fake overlay", "/provision/00:00:00:ff:ff:ff?stage=system&overlay=fake", "", 404, "10.10.10.10:9873"},
|
||||
{"specific overlay", "/provision/00:00:00:ff:ff:ff?stage=system&overlay=o1", "specific overlay", 200, "10.10.10.10:9873"},
|
||||
{"grub config", "/provision/00:00:00:ff:ff:ff?stage=grub", "", 200, "10.10.10.10:9873"},
|
||||
{"grub config rendered", "/provision/00:00:00:00:ff:ff?stage=grub", "dracut 10.10.0.1:9873", 200, "10.10.10.11:9873"},
|
||||
{"find initramfs", "/provision/00:00:00:ff:ff:ff?stage=initramfs", "", 200, "10.10.10.10:9873"},
|
||||
{"ipxe test with NetDevs, KernelVersion, and Authority", "/provision/00:00:00:00:00:ff?stage=ipxe", "1.1.1 ifname=net:00:00:00:00:00:ff 10.10.0.1 fd00:10::1 10.10.0.1:9873", 200, "10.10.10.12:9873"},
|
||||
{"ipxe ipv6", "/provision/00:00:00:00:00:ff?stage=ipxe", "1.1.1 ifname=net:00:00:00:00:00:ff 10.10.0.1 fd00:10::1 [fd00:10::1]:9873", 200, "[fd00:10::10:12]:9873"},
|
||||
{"find grub.cfg", "/efiboot/grub.cfg", "dracut 10.10.0.1:9873", 200, "10.10.10.11:9873"},
|
||||
}
|
||||
|
||||
func Test_ProvisionSend(t *testing.T) {
|
||||
@@ -100,7 +97,7 @@ nodes:
|
||||
req := httptest.NewRequest(http.MethodGet, tt.url, nil)
|
||||
req.RemoteAddr = tt.ip
|
||||
w := httptest.NewRecorder()
|
||||
ProvisionSend(w, req)
|
||||
HandleProvision(w, req)
|
||||
res := w.Result()
|
||||
defer res.Body.Close()
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// Package server starts the Warewulf HTTP(S) server, registers provisioning
|
||||
// and API routes, and handles TLS configuration and SIGHUP-triggered reloads.
|
||||
//
|
||||
// See userdocs/server/routes.rst for more information.
|
||||
package server
|
||||
|
||||
import (
|
||||
@@ -5,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
@@ -36,17 +41,40 @@ func (h *slashFix) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
h.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func defaultHandler() *slashFix {
|
||||
func requireTLS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.TLS == nil {
|
||||
wwlog.Denied("API request over insecure connection")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func configureRootHandler(apiHandler http.Handler) *slashFix {
|
||||
var wwHandler http.ServeMux
|
||||
wwHandler.HandleFunc("/provision/", warewulfd.ProvisionSend)
|
||||
wwHandler.HandleFunc("/ipxe/", warewulfd.ProvisionSend)
|
||||
wwHandler.HandleFunc("/efiboot/", warewulfd.ProvisionSend)
|
||||
wwHandler.HandleFunc("/kernel/", warewulfd.ProvisionSend)
|
||||
wwHandler.HandleFunc("/container/", warewulfd.ProvisionSend)
|
||||
wwHandler.HandleFunc("/overlay-system/", warewulfd.ProvisionSend)
|
||||
wwHandler.HandleFunc("/overlay-runtime/", warewulfd.ProvisionSend)
|
||||
wwHandler.HandleFunc("/overlay-file/", warewulfd.OverlaySend)
|
||||
wwHandler.HandleFunc("/status", warewulfd.StatusSend)
|
||||
wwHandler.HandleFunc("/provision/", warewulfd.HandleProvision)
|
||||
wwHandler.HandleFunc("/ipxe/", warewulfd.HandleIpxe)
|
||||
wwHandler.HandleFunc("/efiboot/", warewulfd.HandleEfiBoot)
|
||||
wwHandler.HandleFunc("/grub/", warewulfd.HandleGrub)
|
||||
wwHandler.HandleFunc("/kernel/", warewulfd.HandleKernel)
|
||||
wwHandler.HandleFunc("/image/", warewulfd.HandleImage)
|
||||
wwHandler.HandleFunc("/initramfs/", warewulfd.HandleInitramfs)
|
||||
wwHandler.HandleFunc("/system/", warewulfd.HandleSystemOverlay)
|
||||
wwHandler.HandleFunc("/runtime/", warewulfd.HandleRuntimeOverlay)
|
||||
wwHandler.HandleFunc("/status", warewulfd.HandleStatus)
|
||||
|
||||
/* Deprecated */
|
||||
wwHandler.HandleFunc("/container/", warewulfd.HandleImage)
|
||||
wwHandler.HandleFunc("/overlay-system/", warewulfd.HandleSystemOverlay)
|
||||
wwHandler.HandleFunc("/overlay-runtime/", warewulfd.HandleRuntimeOverlay)
|
||||
wwHandler.HandleFunc("/overlay-file/", warewulfd.HandleOverlayFile)
|
||||
|
||||
if apiHandler != nil {
|
||||
wwHandler.Handle("/api/", apiHandler)
|
||||
}
|
||||
|
||||
return &slashFix{&wwHandler}
|
||||
}
|
||||
|
||||
@@ -73,18 +101,40 @@ func RunServer() error {
|
||||
}
|
||||
}
|
||||
|
||||
apiHandler := api.Handler(auth, conf.API.AllowedIPNets())
|
||||
defaultHandler := defaultHandler()
|
||||
dispatchHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api") && conf.API != nil && conf.API.Enabled() {
|
||||
apiHandler.ServeHTTP(w, r)
|
||||
} else {
|
||||
defaultHandler.ServeHTTP(w, r)
|
||||
var apiHandler http.Handler
|
||||
if conf.API != nil && conf.API.Enabled() {
|
||||
apiHandler = api.Handler(auth, conf.API.AllowedIPNets())
|
||||
if conf.API.TLSEnabled() {
|
||||
apiHandler = requireTLS(apiHandler)
|
||||
}
|
||||
})
|
||||
if err := http.ListenAndServe(":"+strconv.Itoa(daemonPort), dispatchHandler); err != nil {
|
||||
return fmt.Errorf("could not start listening service: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
httpHandler := configureRootHandler(apiHandler)
|
||||
|
||||
errChan := make(chan error, 2)
|
||||
|
||||
if conf.Warewulf.TLSEnabled() {
|
||||
key := path.Join(conf.Paths.Sysconfdir, "warewulf", "tls", "warewulf.key")
|
||||
crt := path.Join(conf.Paths.Sysconfdir, "warewulf", "tls", "warewulf.crt")
|
||||
|
||||
if !util.IsFile(key) || !util.IsFile(crt) {
|
||||
return fmt.Errorf("TLS enabled but keys not found in %s, run 'wwctl configure tls' to generate keys", path.Join(conf.Paths.Sysconfdir, "warewulf", "tls"))
|
||||
}
|
||||
httpsHandler := configureRootHandler(apiHandler)
|
||||
go func() {
|
||||
wwlog.Info("Starting HTTPS service on port %d", conf.Warewulf.TlsPort)
|
||||
if err := http.ListenAndServeTLS(":"+strconv.Itoa(conf.Warewulf.TlsPort), crt, key, httpsHandler); err != nil {
|
||||
errChan <- fmt.Errorf("could not start HTTPS service: %w", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
wwlog.Info("Starting HTTP service on port %d", daemonPort)
|
||||
if err := http.ListenAndServe(":"+strconv.Itoa(daemonPort), httpHandler); err != nil {
|
||||
errChan <- fmt.Errorf("could not start HTTP service: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return <-errChan
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ func statusJSON() ([]byte, error) {
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func StatusSend(w http.ResponseWriter, req *http.Request) {
|
||||
func HandleStatus(w http.ResponseWriter, req *http.Request) {
|
||||
|
||||
status, err := statusJSON()
|
||||
if err != nil {
|
||||
|
||||
@@ -15,6 +15,8 @@ func Test_wwinitOverlay(t *testing.T) {
|
||||
defer env.RemoveAll()
|
||||
env.ImportFile("etc/warewulf/warewulf.conf", "warewulf.conf")
|
||||
env.ImportFile("etc/warewulf/nodes.conf", "nodes.conf")
|
||||
env.Configure() // Reload configuration to pick up the changes to warewulf.conf and nodes.conf
|
||||
|
||||
env.ImportFile("var/lib/warewulf/overlays/wwinit/rootfs/etc/warewulf/warewulf.conf.ww", "../rootfs/etc/warewulf/warewulf.conf.ww")
|
||||
env.ImportFile("var/lib/warewulf/overlays/wwinit/rootfs/warewulf/config.ww", "../rootfs/warewulf/config.ww")
|
||||
env.ImportFile("var/lib/warewulf/overlays/wwinit/rootfs/warewulf/init.d/50-ipmi.ww", "../rootfs/warewulf/init.d/50-ipmi.ww")
|
||||
@@ -100,6 +102,9 @@ WWIPMI_GATEWAY="192.168.4.1"
|
||||
WWIPMI_USER="user"
|
||||
WWIPMI_PASSWORD="password"
|
||||
WWIPMI_WRITE="true"
|
||||
WWTLS=false
|
||||
WWTLSPORT=9874
|
||||
WWIPADDR=192.168.0.1
|
||||
`
|
||||
|
||||
const wwinit_50_ipmi string = `backupFile: true
|
||||
|
||||
@@ -52,9 +52,10 @@ else
|
||||
else
|
||||
mount ${WWROOT} /newroot
|
||||
fi
|
||||
mountpoint -q /newroot || die "warewulf: ERROR: failed to mount ${WWROOT} at /newroot"
|
||||
|
||||
info "warewulf: copying image to /newroot..."
|
||||
tar -cf - --exclude ./proc --exclude ./sys --exclude ./dev --exclude --exclude ./newroot . | tar -xf - -C /newroot
|
||||
tar -cf - --exclude ./proc --exclude ./sys --exclude ./dev --exclude ./newroot . | tar -xf - -C /newroot
|
||||
|
||||
mkdir /newroot/proc /newroot/dev /newroot/sys 2>/dev/null
|
||||
|
||||
|
||||
@@ -11,3 +11,6 @@ WWIPMI_WRITE="{{$.Ipmi.Write.Bool}}"
|
||||
{{- if $.Ipmi.Tags.vlan }}
|
||||
WWIPMI_VLAN="{{$.Ipmi.Tags.vlan}}"
|
||||
{{- end }}
|
||||
WWTLS={{$.Warewulf.TLSEnabled}}
|
||||
WWTLSPORT={{$.Warewulf.TlsPort}}
|
||||
WWIPADDR={{$.Ipaddr}}
|
||||
|
||||
6
overlays/wwinit/rootfs/warewulf/tls/warewulf.crt.ww
Normal file
6
overlays/wwinit/rootfs/warewulf/tls/warewulf.crt.ww
Normal file
@@ -0,0 +1,6 @@
|
||||
{{- $crt := Include "tls/warewulf.crt" -}}
|
||||
{{- if eq $crt "" -}}
|
||||
{{ abort }}
|
||||
{{- else -}}
|
||||
{{ $crt }}
|
||||
{{- end -}}
|
||||
@@ -23,6 +23,7 @@ Welcome to the Warewulf User Guide!
|
||||
Server Installation <server/installation>
|
||||
Controlling Warewulf (wwctl) <server/wwctl>
|
||||
Server Configuration <server/configuration>
|
||||
Server Routes <server/routes>
|
||||
Using dnsmasq <server/dnsmasq>
|
||||
Security <server/security>
|
||||
Bootloaders <server/bootloaders>
|
||||
|
||||
325
userdocs/server/routes.rst
Normal file
325
userdocs/server/routes.rst
Normal 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.
|
||||
@@ -40,6 +40,10 @@ There are multiple ways to secure the Warewulf provisioning process:
|
||||
This means that the nodes only boot the kernel which is provided by the
|
||||
distributor and also custom complied modules can't be loaded.
|
||||
|
||||
* TLS (transport layer security) can be enabled for the Warewulf server. When
|
||||
enabled, HTTPS is used when transferring runtime overlays. The kernel and
|
||||
system image are *always* transferred unencrypted.
|
||||
|
||||
SELinux
|
||||
=======
|
||||
|
||||
@@ -82,3 +86,36 @@ with the Warewulf server.
|
||||
nft add rule inet filter input tcp dport 9873 accept
|
||||
nft list ruleset >/etc/nftables.conf
|
||||
systemctl restart nftables
|
||||
|
||||
TLS / HTTPS
|
||||
===========
|
||||
|
||||
TLS can be enabled by setting ``tls: true`` in the Warewulf server
|
||||
configuration.
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
warewulf:
|
||||
tls: true
|
||||
tls port: 9874
|
||||
|
||||
This enables an HTTPS server on the TLS port (default: ``9874``). A key and
|
||||
self-signed certificate can be created with ``wwctl configure tls``.
|
||||
|
||||
By default, the key and certificate are stored in ``/etc/warewulf/tls/``.
|
||||
|
||||
You can also import your own keys with ``wwctl configure tls --import``.
|
||||
|
||||
If HTTPS is enabled the delivery of the runtime overlay is disabled over HTTP,
|
||||
and the runtime overlay is only retrieved by ``wwclient``.
|
||||
|
||||
To additionally require TLS for access to the REST API, set ``tls: true`` under
|
||||
the ``api:`` section:
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
api:
|
||||
enabled: true
|
||||
tls: true
|
||||
|
||||
When ``api: tls`` is set, the REST API rejects plain-HTTP requests.
|
||||
|
||||
Reference in New Issue
Block a user