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