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}
|
images := &store.Images{DB: database}
|
||||||
activity := &store.Activity{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()
|
hub := events.NewHub()
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"provisioning/internal/config"
|
"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})
|
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 {
|
func normalizeMAC(m string) string {
|
||||||
m = strings.ToLower(strings.TrimSpace(m))
|
m = strings.ToLower(strings.TrimSpace(m))
|
||||||
m = strings.ReplaceAll(m, "-", ":")
|
m = strings.ReplaceAll(m, "-", ":")
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ func NewRouter(d Deps) http.Handler {
|
|||||||
|
|
||||||
// Boot / PXE endpoints
|
// Boot / PXE endpoints
|
||||||
r.Get("/ipxe/{mac}", d.BootAPI.IPXEScript)
|
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/boot/answer", d.BootAPI.AnswerFile)
|
||||||
r.Post("/api/hosts/{id}/installed", d.BootAPI.InstallComplete)
|
r.Post("/api/hosts/{id}/installed", d.BootAPI.InstallComplete)
|
||||||
r.Get("/api/hosts/{id}/first-boot-script", d.BootAPI.FirstBootScript)
|
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"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -15,6 +16,7 @@ import (
|
|||||||
type Service struct {
|
type Service struct {
|
||||||
Store *store.Images
|
Store *store.Images
|
||||||
ImageDir string
|
ImageDir string
|
||||||
|
PublicURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
type UploadParams struct {
|
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)
|
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)
|
kernelPath := filepath.Join(p.Name, result.KernelFilename)
|
||||||
initrdPath := filepath.Join(p.Name, result.InitrdFilename)
|
initrdPath := filepath.Join(p.Name, result.InitrdFilename)
|
||||||
isoPath := filepath.Join(p.Name, result.ISOFilename)
|
isoPath := filepath.Join(p.Name, result.ISOFilename)
|
||||||
|
|||||||
+3
-11
@@ -7,19 +7,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func BuildIPXEScript(publicURL string, img *model.Image, mac string) string {
|
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)
|
isoURL := fmt.Sprintf("%s/images/boot/%s", publicURL, img.ISOPath)
|
||||||
|
|
||||||
return fmt.Sprintf(`#!ipxe
|
return fmt.Sprintf(`#!ipxe
|
||||||
echo Provisioning: booting %s on ${mac}
|
echo Provisioning: booting %s on ${mac}
|
||||||
echo Connecting ISO via SAN...
|
echo Booting ISO...
|
||||||
sanhook %s
|
sanboot %s
|
||||||
echo Loading kernel...
|
`, img.Name, isoURL)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user