package pxe import ( "context" "fmt" "log" "os" "os/exec" "path/filepath" "strings" "text/template" "provisioning/internal/model" ) type SupervisorConfig struct { Enabled bool Interface string Subnet string RuntimeDir string TFTPRoot string DnsmasqBin string PublicURL string } type Supervisor struct { cfg SupervisorConfig cmd *exec.Cmd cancel context.CancelFunc } func NewSupervisor(cfg SupervisorConfig) *Supervisor { return &Supervisor{cfg: cfg} } func (s *Supervisor) Start(ctx context.Context, hosts []model.Host) error { if !s.cfg.Enabled { log.Printf("pxe: dnsmasq disabled") return nil } if err := os.MkdirAll(s.cfg.RuntimeDir, 0o755); err != nil { return fmt.Errorf("pxe: create runtime dir: %w", err) } if err := s.writeConfig(hosts); err != nil { return err } return s.startProcess(ctx) } func (s *Supervisor) Reload(hosts []model.Host) error { if !s.cfg.Enabled { return nil } if err := s.writeConfig(hosts); err != nil { return err } if s.cmd != nil && s.cmd.Process != nil { return signalReload(s.cmd.Process) } return nil } func (s *Supervisor) Shutdown() error { if s.cancel != nil { s.cancel() } if s.cmd != nil && s.cmd.Process != nil { return s.cmd.Process.Kill() } return nil } func (s *Supervisor) writeConfig(hosts []model.Host) error { confPath := filepath.Join(s.cfg.RuntimeDir, "dnsmasq.conf") hostsPath := filepath.Join(s.cfg.RuntimeDir, "dhcp-hostsfile") var macs []string for _, h := range hosts { macs = append(macs, h.MAC) } if err := os.WriteFile(hostsPath, []byte(strings.Join(macs, "\n")+"\n"), 0o644); err != nil { return fmt.Errorf("pxe: write dhcp-hostsfile: %w", err) } conf := dnsmasqConf{ Interface: s.cfg.Interface, TFTPRoot: s.cfg.TFTPRoot, HostsFile: hostsPath, PublicURL: s.cfg.PublicURL, RuntimeDir: s.cfg.RuntimeDir, } f, err := os.Create(confPath) if err != nil { return fmt.Errorf("pxe: create dnsmasq.conf: %w", err) } defer f.Close() if err := dnsmasqTmpl.Execute(f, conf); err != nil { return fmt.Errorf("pxe: render dnsmasq.conf: %w", err) } return nil } func (s *Supervisor) startProcess(ctx context.Context) error { confPath := filepath.Join(s.cfg.RuntimeDir, "dnsmasq.conf") procCtx, cancel := context.WithCancel(ctx) s.cancel = cancel s.cmd = exec.CommandContext(procCtx, s.cfg.DnsmasqBin, "--keep-in-foreground", "--conf-file="+confPath) s.cmd.Stdout = os.Stdout s.cmd.Stderr = os.Stderr if err := s.cmd.Start(); err != nil { cancel() return fmt.Errorf("pxe: start dnsmasq: %w", err) } go func() { if err := s.cmd.Wait(); err != nil { select { case <-procCtx.Done(): default: log.Printf("pxe: dnsmasq exited: %v", err) } } }() log.Printf("pxe: dnsmasq started (pid %d)", s.cmd.Process.Pid) return nil } type dnsmasqConf struct { Interface string TFTPRoot string HostsFile string PublicURL string RuntimeDir string } var dnsmasqTmpl = template.Must(template.New("dnsmasq.conf").Parse(` port=0 interface={{.Interface}} bind-interfaces dhcp-range=tag:known,192.168.1.0,proxy dhcp-hostsfile={{.HostsFile}} dhcp-ignore=tag:!known enable-tftp tftp-root={{.TFTPRoot}} # Legacy BIOS dhcp-match=set:bios,option:client-arch,0 dhcp-boot=tag:bios,undionly.kpxe # UEFI dhcp-match=set:efi64,option:client-arch,7 dhcp-boot=tag:efi64,ipxe.efi # iPXE user-class: chain to HTTP script dhcp-match=set:ipxe,option:user-class,iPXE dhcp-boot=tag:ipxe,{{.PublicURL}}/ipxe/${mac:hexhyp} log-dhcp log-facility={{.RuntimeDir}}/dnsmasq.log `))