506c856046
Previously the orchestrator ran a full DHCP server on a dedicated br-vetting bridge (10.77.0.0/24), which required a hypervisor-level bridge + physical cabling onto that bridge for every repaired host. Real-world bite: the LXC's br-vetting had no L2 path to the target host's PXE NIC, so DHCPDISCOVERs never reached eth1 and PXE silently timed out. dnsmasq's proxy-DHCP mode is the idiomatic answer: it coexists with the LAN's existing DHCP server (UniFi, etc.), never assigns an IP itself, and only supplements the PXE options. No dedicated bridge, no VLAN, no cabling changes \u2014 dnsmasq binds to the LAN interface and layers option 66/67 + the PXE BINL on top of the real DHCP exchange. The MAC allowlist still gates replies, so random LAN clients booting from network get nothing. Template switches dhcp-range=<start,end,lease> to dhcp-range=<cidr>,proxy and replaces dhcp-boot= for first-boot ROM clients with pxe-service= directives (the correct proxy-mode chainload form). Validation drops the dhcp_range regex for a net.ParseCIDR check on pxe.subnet. Config, production/example yaml, and pxe-setup.sh swap --dhcp-range for --subnet. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
192 lines
5.4 KiB
Go
192 lines
5.4 KiB
Go
package pxe
|
|
|
|
import (
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// existingInterface returns any real interface on the host so the
|
|
// Validate tests can exercise the happy path without hardcoding
|
|
// "lo" (which exists on Linux but might be gated elsewhere).
|
|
func existingInterface(t *testing.T) string {
|
|
t.Helper()
|
|
ifaces, err := net.Interfaces()
|
|
if err != nil || len(ifaces) == 0 {
|
|
t.Skipf("no network interfaces: %v", err)
|
|
}
|
|
return ifaces[0].Name
|
|
}
|
|
|
|
// seedTFTP drops zero-byte ipxe.efi + undionly.kpxe into dir so the
|
|
// stat check passes. Callers can omit a name to simulate "missing".
|
|
func seedTFTP(t *testing.T, dir string, names ...string) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir tftp: %v", err)
|
|
}
|
|
for _, name := range names {
|
|
if err := os.WriteFile(filepath.Join(dir, name), nil, 0o644); err != nil {
|
|
t.Fatalf("seed %s: %v", name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func goodCfg(t *testing.T, tftpRoot string) SupervisorConfig {
|
|
t.Helper()
|
|
return SupervisorConfig{
|
|
Enabled: true,
|
|
Interface: existingInterface(t),
|
|
Subnet: "192.168.1.0/24",
|
|
OrchestratorURL: "http://192.168.1.2:8080",
|
|
TFTPRoot: tftpRoot,
|
|
}
|
|
}
|
|
|
|
func TestValidate_DisabledSkipsChecks(t *testing.T) {
|
|
s := NewSupervisor(SupervisorConfig{Enabled: false})
|
|
if err := s.Validate(); err != nil {
|
|
t.Fatalf("disabled supervisor should skip validation, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidate_HappyPath(t *testing.T) {
|
|
tftp := t.TempDir()
|
|
seedTFTP(t, tftp, "ipxe.efi", "undionly.kpxe")
|
|
s := NewSupervisor(goodCfg(t, tftp))
|
|
if err := s.Validate(); err != nil {
|
|
t.Fatalf("happy-path validate: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidate_MissingIPXEBinary(t *testing.T) {
|
|
tftp := t.TempDir()
|
|
// Only seed one of the two required files.
|
|
seedTFTP(t, tftp, "undionly.kpxe")
|
|
s := NewSupervisor(goodCfg(t, tftp))
|
|
err := s.Validate()
|
|
if err == nil {
|
|
t.Fatalf("expected error for missing ipxe.efi")
|
|
}
|
|
if !strings.Contains(err.Error(), "ipxe.efi") {
|
|
t.Fatalf("error should name the missing file, got: %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "pxe-setup.sh") {
|
|
t.Fatalf("error should point operator at pxe-setup.sh, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidate_MissingUndionly(t *testing.T) {
|
|
tftp := t.TempDir()
|
|
seedTFTP(t, tftp, "ipxe.efi")
|
|
s := NewSupervisor(goodCfg(t, tftp))
|
|
err := s.Validate()
|
|
if err == nil || !strings.Contains(err.Error(), "undionly.kpxe") {
|
|
t.Fatalf("expected undionly.kpxe error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidate_MissingInterface(t *testing.T) {
|
|
tftp := t.TempDir()
|
|
seedTFTP(t, tftp, "ipxe.efi", "undionly.kpxe")
|
|
cfg := goodCfg(t, tftp)
|
|
cfg.Interface = "definitely-not-a-real-iface-9999"
|
|
s := NewSupervisor(cfg)
|
|
err := s.Validate()
|
|
if err == nil || !strings.Contains(err.Error(), "pxe.interface") {
|
|
t.Fatalf("expected interface error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidate_MissingLiveImage(t *testing.T) {
|
|
tftp := t.TempDir()
|
|
seedTFTP(t, tftp, "ipxe.efi", "undionly.kpxe")
|
|
cfg := goodCfg(t, tftp)
|
|
cfg.LiveDir = t.TempDir() // empty dir; vmlinuz + initrd.img missing
|
|
s := NewSupervisor(cfg)
|
|
err := s.Validate()
|
|
if err == nil {
|
|
t.Fatalf("expected live image error")
|
|
}
|
|
for _, want := range []string{"vmlinuz", "initrd.img"} {
|
|
if !strings.Contains(err.Error(), want) {
|
|
t.Fatalf("error should name %s, got: %v", want, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidate_LiveDirEmptySkipsLiveChecks(t *testing.T) {
|
|
tftp := t.TempDir()
|
|
seedTFTP(t, tftp, "ipxe.efi", "undionly.kpxe")
|
|
cfg := goodCfg(t, tftp)
|
|
cfg.LiveDir = "" // explicit opt-out; HTTP /live just 404s
|
|
s := NewSupervisor(cfg)
|
|
if err := s.Validate(); err != nil {
|
|
t.Fatalf("empty LiveDir should not trigger live checks, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidate_MalformedSubnet(t *testing.T) {
|
|
tftp := t.TempDir()
|
|
seedTFTP(t, tftp, "ipxe.efi", "undionly.kpxe")
|
|
cases := []struct {
|
|
name string
|
|
subnet string
|
|
}{
|
|
{"no mask", "192.168.1.0"},
|
|
{"bad ip", "hello/24"},
|
|
{"bad mask", "192.168.1.0/99"},
|
|
{"leftover dhcp_range form", "192.168.1.100,192.168.1.200,12h"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
cfg := goodCfg(t, tftp)
|
|
cfg.Subnet = tc.subnet
|
|
s := NewSupervisor(cfg)
|
|
err := s.Validate()
|
|
if err == nil || !strings.Contains(err.Error(), "pxe.subnet") {
|
|
t.Fatalf("expected pxe.subnet error for %q, got: %v", tc.subnet, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidate_BadOrchestratorURL(t *testing.T) {
|
|
tftp := t.TempDir()
|
|
seedTFTP(t, tftp, "ipxe.efi", "undionly.kpxe")
|
|
cases := []string{"", "not a url", "ftp://10.0.0.1", "http://"}
|
|
for _, u := range cases {
|
|
t.Run(u, func(t *testing.T) {
|
|
cfg := goodCfg(t, tftp)
|
|
cfg.OrchestratorURL = u
|
|
s := NewSupervisor(cfg)
|
|
err := s.Validate()
|
|
if err == nil || !strings.Contains(err.Error(), "orchestrator_url") {
|
|
t.Fatalf("expected orchestrator_url error for %q, got: %v", u, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidate_AggregatesErrors(t *testing.T) {
|
|
// Multiple problems at once: Validate must report them all in
|
|
// one pass so the operator sees the full picture instead of
|
|
// whack-a-mole-ing one error per restart.
|
|
cfg := SupervisorConfig{
|
|
Enabled: true,
|
|
// Everything else zero.
|
|
}
|
|
s := NewSupervisor(cfg)
|
|
err := s.Validate()
|
|
if err == nil {
|
|
t.Fatalf("expected aggregated error")
|
|
}
|
|
for _, want := range []string{"pxe.interface", "pxe.tftp_root", "pxe.subnet", "pxe.orchestrator_url"} {
|
|
if !strings.Contains(err.Error(), want) {
|
|
t.Fatalf("expected %q in aggregated error, got: %v", want, err)
|
|
}
|
|
}
|
|
}
|