From f58ab9fab35626334b5e3c7c4d5077620b9d3063 Mon Sep 17 00:00:00 2001 From: josh Date: Thu, 14 May 2026 14:28:10 -0400 Subject: [PATCH] Add automated PXE installation via ISO GRUB modification and auto-answer endpoint Modifies uploaded ISO's GRUB config in-place to set timeout=0 and inject proxmox-start-auto-installer + answer-url kernel params, enabling fully hands-off installation. Adds /api/boot/auto-answer endpoint that identifies hosts by ARP-resolving the requester's IP to MAC address. Co-Authored-By: Claude Opus 4.6 --- cmd/provisioning/main.go | 2 +- internal/api/boot.go | 65 +++++++++++++ internal/httpserver/router.go | 1 + internal/image/isomod.go | 169 ++++++++++++++++++++++++++++++++++ internal/image/service.go | 20 +++- internal/pxe/ipxe.go | 14 +-- 6 files changed, 257 insertions(+), 14 deletions(-) create mode 100644 internal/image/isomod.go diff --git a/cmd/provisioning/main.go b/cmd/provisioning/main.go index 449d6be..34e52c4 100644 --- a/cmd/provisioning/main.go +++ b/cmd/provisioning/main.go @@ -56,7 +56,7 @@ func main() { images := &store.Images{DB: database} activity := &store.Activity{DB: database} - imageSvc := &image.Service{Store: images, ImageDir: cfg.Images.Dir} + imageSvc := &image.Service{Store: images, ImageDir: cfg.Images.Dir, PublicURL: cfg.Server.PublicURL} hub := events.NewHub() diff --git a/internal/api/boot.go b/internal/api/boot.go index 366d25c..60f2c46 100644 --- a/internal/api/boot.go +++ b/internal/api/boot.go @@ -1,11 +1,14 @@ package api import ( + "bufio" "encoding/json" "errors" "fmt" "log" + "net" "net/http" + "os" "strings" "provisioning/internal/config" @@ -169,6 +172,68 @@ func (a *BootAPI) PhoneHome(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } +func (a *BootAPI) AutoAnswer(w http.ResponseWriter, r *http.Request) { + clientIP, _, _ := net.SplitHostPort(r.RemoteAddr) + if clientIP == "" { + clientIP = r.RemoteAddr + } + + mac := macFromARP(clientIP) + if mac == "" { + log.Printf("auto-answer: no ARP entry for %s", clientIP) + http.Error(w, "could not determine MAC for your IP", http.StatusNotFound) + return + } + + host, err := a.Hosts.GetByMAC(r.Context(), mac) + if err != nil { + log.Printf("auto-answer: MAC %s (IP %s) not registered", mac, clientIP) + http.Error(w, "unknown host", http.StatusNotFound) + return + } + + st, ok := a.ServerTypes.Get(host.ServerType) + if !ok { + http.Error(w, "unknown server type", http.StatusInternalServerError) + return + } + + if host.State == model.StatePXEBooted || host.State == model.StatePXEReady { + a.Runner.Transition(r.Context(), host.ID, statemachine.TriggerPXEScriptServed) + a.Runner.Transition(r.Context(), host.ID, statemachine.TriggerAnswerServed) + a.Runner.LogActivity(r.Context(), host.ID, model.LogInfo, "pxe", "Auto-installer answer file served") + } + + _, pubKey, _ := a.Hosts.GetEphemeralKey(r.Context(), host.ID) + if pubKey == "" { + http.Error(w, "no ephemeral key for host", http.StatusInternalServerError) + return + } + + log.Printf("auto-answer: serving answer for %s (%s) from IP %s", host.Hostname, mac, clientIP) + answer := pxe.GenerateAnswerFile(host, st, a.Config, pubKey) + w.Header().Set("Content-Type", "application/toml") + w.Write([]byte(answer)) +} + +func macFromARP(ip string) string { + f, err := os.Open("/proc/net/arp") + if err != nil { + return "" + } + defer f.Close() + + scanner := bufio.NewScanner(f) + scanner.Scan() // skip header + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) >= 4 && fields[0] == ip && fields[3] != "00:00:00:00:00:00" { + return strings.ToLower(fields[3]) + } + } + return "" +} + func normalizeMAC(m string) string { m = strings.ToLower(strings.TrimSpace(m)) m = strings.ReplaceAll(m, "-", ":") diff --git a/internal/httpserver/router.go b/internal/httpserver/router.go index 048ba35..67d6385 100644 --- a/internal/httpserver/router.go +++ b/internal/httpserver/router.go @@ -73,6 +73,7 @@ func NewRouter(d Deps) http.Handler { // Boot / PXE endpoints r.Get("/ipxe/{mac}", d.BootAPI.IPXEScript) + r.Get("/api/boot/auto-answer", d.BootAPI.AutoAnswer) r.Post("/api/boot/answer", d.BootAPI.AnswerFile) r.Post("/api/hosts/{id}/installed", d.BootAPI.InstallComplete) r.Get("/api/hosts/{id}/first-boot-script", d.BootAPI.FirstBootScript) diff --git a/internal/image/isomod.go b/internal/image/isomod.go new file mode 100644 index 0000000..f6607fc --- /dev/null +++ b/internal/image/isomod.go @@ -0,0 +1,169 @@ +package image + +import ( + "bytes" + "fmt" + "io" + "log" + "os" + "strings" + + "github.com/kdomanski/iso9660" +) + +func ModifyISOForAutoInstall(isoPath, answerURL string) error { + origConfig, err := readGrubCfgFromISO(isoPath) + if err != nil { + return err + } + if origConfig == nil { + log.Printf("image: no grub.cfg found in ISO, skipping auto-install modification") + return nil + } + + newConfig := rewriteGrubConfig(string(origConfig), answerURL) + + if len(newConfig) > len(origConfig) { + return fmt.Errorf("modified GRUB config (%d bytes) exceeds original (%d bytes)", len(newConfig), len(origConfig)) + } + + padded := make([]byte, len(origConfig)) + copy(padded, []byte(newConfig)) + for i := len(newConfig); i < len(padded); i++ { + padded[i] = '\n' + } + + offset, err := findContentInISO(isoPath, origConfig) + if err != nil { + return fmt.Errorf("locate grub.cfg in ISO: %w", err) + } + + f, err := os.OpenFile(isoPath, os.O_WRONLY, 0) + if err != nil { + return fmt.Errorf("open ISO for writing: %w", err) + } + defer f.Close() + + if _, err := f.WriteAt(padded, offset); err != nil { + return fmt.Errorf("write modified grub.cfg: %w", err) + } + + log.Printf("image: modified grub.cfg at offset %d for auto-install", offset) + return nil +} + +func readGrubCfgFromISO(isoPath string) ([]byte, error) { + f, err := os.Open(isoPath) + if err != nil { + return nil, fmt.Errorf("open ISO: %w", err) + } + defer f.Close() + + img, err := iso9660.OpenImage(f) + if err != nil { + return nil, fmt.Errorf("parse ISO: %w", err) + } + + root, err := img.RootDir() + if err != nil { + return nil, fmt.Errorf("read ISO root: %w", err) + } + + grubFile := findFileByName(root, "grub.cfg") + if grubFile == nil { + return nil, nil + } + + reader := grubFile.Reader() + data, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("read grub.cfg: %w", err) + } + return data, nil +} + +func findFileByName(dir *iso9660.File, target string) *iso9660.File { + children, err := dir.GetChildren() + if err != nil { + return nil + } + for _, child := range children { + if child.IsDir() { + if result := findFileByName(child, target); result != nil { + return result + } + } else if strings.EqualFold(child.Name(), target) { + return child + } + } + return nil +} + +func rewriteGrubConfig(original, answerURL string) string { + lines := strings.Split(original, "\n") + var result []string + depth := 0 + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + + if strings.HasPrefix(trimmed, "set timeout=") { + result = append(result, "set timeout=0") + continue + } + if strings.HasPrefix(trimmed, "set default=") { + result = append(result, "set default=0") + continue + } + + if strings.HasPrefix(trimmed, "menuentry") { + depth++ + } + if trimmed == "}" && depth > 0 { + depth-- + } + + if depth == 1 && strings.Contains(trimmed, "linux ") && strings.Contains(trimmed, "/boot/linux") { + if !strings.Contains(line, "proxmox-start-auto-installer") { + line = strings.TrimRight(line, " \t") + " proxmox-start-auto-installer" + } + if answerURL != "" && !strings.Contains(line, "proxmox-auto-installer-answer-url") { + line += " proxmox-auto-installer-answer-url=" + answerURL + } + } + + result = append(result, line) + } + + return strings.Join(result, "\n") +} + +func findContentInISO(isoPath string, content []byte) (int64, error) { + f, err := os.Open(isoPath) + if err != nil { + return 0, err + } + defer f.Close() + + prefix := content + if len(prefix) > 256 { + prefix = prefix[:256] + } + + buf := make([]byte, 2048) + stat, _ := f.Stat() + + for offset := int64(0); offset < stat.Size(); offset += 2048 { + n, err := f.ReadAt(buf, offset) + if n == 0 { + break + } + if err != nil && err != io.EOF { + return 0, err + } + if bytes.HasPrefix(buf[:n], prefix) { + return offset, nil + } + } + return 0, fmt.Errorf("grub.cfg content not found in ISO raw data") +} diff --git a/internal/image/service.go b/internal/image/service.go index 7e86000..c216033 100644 --- a/internal/image/service.go +++ b/internal/image/service.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "log" "os" "path/filepath" "regexp" @@ -13,8 +14,9 @@ import ( ) type Service struct { - Store *store.Images - ImageDir string + Store *store.Images + ImageDir string + PublicURL string } type UploadParams struct { @@ -53,6 +55,20 @@ func (s *Service) Upload(ctx context.Context, p UploadParams) (*model.Image, err return nil, fmt.Errorf("extract ISO: %w", err) } + if s.PublicURL != "" { + report := func(stage, detail string) { + if p.OnProgress != nil { + p.OnProgress(stage, detail) + } + } + report("packaging", "Configuring ISO for automated installation...") + isoFile := filepath.Join(destDir, result.ISOFilename) + answerURL := s.PublicURL + "/api/boot/auto-answer" + if err := ModifyISOForAutoInstall(isoFile, answerURL); err != nil { + log.Printf("image: ISO auto-install modification failed (non-fatal): %v", err) + } + } + kernelPath := filepath.Join(p.Name, result.KernelFilename) initrdPath := filepath.Join(p.Name, result.InitrdFilename) isoPath := filepath.Join(p.Name, result.ISOFilename) diff --git a/internal/pxe/ipxe.go b/internal/pxe/ipxe.go index f476833..723b8b0 100644 --- a/internal/pxe/ipxe.go +++ b/internal/pxe/ipxe.go @@ -7,19 +7,11 @@ import ( ) func BuildIPXEScript(publicURL string, img *model.Image, mac string) string { - kernelURL := fmt.Sprintf("%s/images/boot/%s", publicURL, img.KernelPath) - initrdURL := fmt.Sprintf("%s/images/boot/%s", publicURL, img.InitrdPath) isoURL := fmt.Sprintf("%s/images/boot/%s", publicURL, img.ISOPath) return fmt.Sprintf(`#!ipxe echo Provisioning: booting %s on ${mac} -echo Connecting ISO via SAN... -sanhook %s -echo Loading kernel... -kernel %s ramdisk_size=16777216 vga=791 video=vesafb:lfb:on rw quiet splash=verbose initrd=initrd -echo Loading initrd... -initrd --name initrd %s -echo Booting... -boot -`, img.Name, isoURL, kernelURL, initrdURL) +echo Booting ISO... +sanboot %s +`, img.Name, isoURL) }