From e2dd78d8f9bc1ff978b0f40b9b198dd6e0a841c4 Mon Sep 17 00:00:00 2001 From: josh Date: Thu, 14 May 2026 12:11:18 -0400 Subject: [PATCH] Embed ISO in initrd for PXE boot with loop-mount wrapper Instead of sanboot (which can't pass kernel params for automation), switch back to kernel/initrd boot. The ISO is embedded in the initrd as a CPIO append. A pxe-init wrapper script loop-mounts the ISO before handing off to the original init, so the installer finds it as a block device. Uses rdinit=/pxe-init kernel parameter. Co-Authored-By: Claude Opus 4.6 --- internal/image/cpio.go | 121 ++++++++++++++++++++++++++++++++++++++ internal/image/service.go | 18 +++++- internal/pxe/ipxe.go | 13 ++-- 3 files changed, 147 insertions(+), 5 deletions(-) create mode 100644 internal/image/cpio.go diff --git a/internal/image/cpio.go b/internal/image/cpio.go new file mode 100644 index 0000000..2fb8b42 --- /dev/null +++ b/internal/image/cpio.go @@ -0,0 +1,121 @@ +package image + +import ( + "fmt" + "io" + "os" +) + +const pxeInitScript = `#!/bin/sh +mkdir -p /dev +mount -t devtmpfs devtmpfs /dev 2>/dev/null +losetup /dev/loop0 /proxmox.iso 2>/dev/null +umount /dev 2>/dev/null +exec /init "$@" +` + +func CreatePXEInitrd(origInitrdPath, isoPath, outputPath string) error { + out, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("create output: %w", err) + } + defer out.Close() + + orig, err := os.Open(origInitrdPath) + if err != nil { + return fmt.Errorf("open initrd: %w", err) + } + if _, err := io.Copy(out, orig); err != nil { + orig.Close() + return fmt.Errorf("copy initrd: %w", err) + } + orig.Close() + + if err := writeCPIOEntry(out, "pxe-init", []byte(pxeInitScript), 0o100755, 1); err != nil { + return err + } + + isoStat, err := os.Stat(isoPath) + if err != nil { + return fmt.Errorf("stat iso: %w", err) + } + isoFile, err := os.Open(isoPath) + if err != nil { + return fmt.Errorf("open iso: %w", err) + } + defer isoFile.Close() + if err := writeCPIOEntryFromReader(out, "proxmox.iso", isoFile, isoStat.Size(), 0o100644, 2); err != nil { + return err + } + + return writeCPIOTrailer(out) +} + +func writeCPIOEntry(w io.Writer, name string, data []byte, mode uint32, ino uint32) error { + if err := writeCPIOHeader(w, name, int64(len(data)), mode, ino); err != nil { + return err + } + if _, err := w.Write(data); err != nil { + return err + } + if pad := (4 - len(data)%4) % 4; pad > 0 { + if _, err := w.Write(make([]byte, pad)); err != nil { + return err + } + } + return nil +} + +func writeCPIOEntryFromReader(w io.Writer, name string, r io.Reader, size int64, mode uint32, ino uint32) error { + if err := writeCPIOHeader(w, name, size, mode, ino); err != nil { + return err + } + if _, err := io.Copy(w, r); err != nil { + return err + } + if pad := (4 - int(size%4)) % 4; pad > 0 { + if _, err := w.Write(make([]byte, pad)); err != nil { + return err + } + } + return nil +} + +func writeCPIOHeader(w io.Writer, name string, fileSize int64, mode uint32, ino uint32) error { + nameLen := len(name) + 1 + hdr := fmt.Sprintf("070701%08X%08X%08X%08X%08X%08X%08X%08X%08X%08X%08X%08X%08X", + ino, // ino + mode, // mode + 0, // uid + 0, // gid + 1, // nlink + 0, // mtime + fileSize, // filesize + 0, // devmajor + 0, // devminor + 0, // rdevmajor + 0, // rdevminor + nameLen, // namesize + 0, // check + ) + if _, err := io.WriteString(w, hdr); err != nil { + return err + } + if _, err := io.WriteString(w, name); err != nil { + return err + } + if _, err := w.Write([]byte{0}); err != nil { + return err + } + totalHeader := 110 + nameLen + if pad := (4 - totalHeader%4) % 4; pad > 0 { + if _, err := w.Write(make([]byte, pad)); err != nil { + return err + } + } + return nil +} + +func writeCPIOTrailer(w io.Writer) error { + return writeCPIOHeader(w, "TRAILER!!!", 0, 0, 0) +} diff --git a/internal/image/service.go b/internal/image/service.go index 7e86000..b01868e 100644 --- a/internal/image/service.go +++ b/internal/image/service.go @@ -53,8 +53,24 @@ func (s *Service) Upload(ctx context.Context, p UploadParams) (*model.Image, err return nil, fmt.Errorf("extract ISO: %w", err) } + report := func(stage, detail string) { + if p.OnProgress != nil { + p.OnProgress(stage, detail) + } + } + report("packaging", "Creating PXE boot image (embedding ISO in initrd)...") + + origInitrdPath := filepath.Join(destDir, result.InitrdFilename) + origISOPath := filepath.Join(destDir, result.ISOFilename) + pxeInitrdFile := "initrd-pxe.img" + pxeInitrdPath := filepath.Join(destDir, pxeInitrdFile) + if err := CreatePXEInitrd(origInitrdPath, origISOPath, pxeInitrdPath); err != nil { + os.RemoveAll(destDir) + return nil, fmt.Errorf("create PXE initrd: %w", err) + } + kernelPath := filepath.Join(p.Name, result.KernelFilename) - initrdPath := filepath.Join(p.Name, result.InitrdFilename) + initrdPath := filepath.Join(p.Name, pxeInitrdFile) isoPath := filepath.Join(p.Name, result.ISOFilename) id, err := s.Store.Create(ctx, model.Image{ diff --git a/internal/pxe/ipxe.go b/internal/pxe/ipxe.go index 74f8c71..b0ad6a4 100644 --- a/internal/pxe/ipxe.go +++ b/internal/pxe/ipxe.go @@ -7,11 +7,16 @@ import ( ) func BuildIPXEScript(publicURL string, img *model.Image, mac string) string { - isoURL := fmt.Sprintf("%s/images/boot/%s", publicURL, img.ISOPath) + kernelURL := fmt.Sprintf("%s/images/boot/%s", publicURL, img.KernelPath) + initrdURL := fmt.Sprintf("%s/images/boot/%s", publicURL, img.InitrdPath) return fmt.Sprintf(`#!ipxe echo Provisioning: booting %s on ${mac} -echo Booting ISO via HTTP SAN... -sanboot %s -`, img.Name, isoURL) +echo Loading kernel... +kernel %s ramdisk_size=16777216 vga=791 video=vesafb:lfb:on rw quiet splash=verbose rdinit=/pxe-init initrd=initrd +echo Loading initrd (this may take a few minutes)... +initrd --name initrd %s +echo Booting... +boot +`, img.Name, kernelURL, initrdURL) }