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:
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user