Add automated PXE installation via ISO GRUB modification and auto-answer endpoint
build-and-push / test (push) Successful in 35s
build-and-push / build-and-push (push) Successful in 1m20s

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 14:28:10 -04:00
parent 994152bedf
commit f58ab9fab3
6 changed files with 257 additions and 14 deletions
+1 -1
View File
@@ -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()
+65
View File
@@ -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, "-", ":")
+1
View File
@@ -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)
+169
View File
@@ -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")
}
+16
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"io"
"log"
"os"
"path/filepath"
"regexp"
@@ -15,6 +16,7 @@ import (
type Service struct {
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)
+3 -11
View File
@@ -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)
}