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 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
@@ -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, "-", ":")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+3
-11
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user