f58ab9fab3
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>
242 lines
6.5 KiB
Go
242 lines
6.5 KiB
Go
package api
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
|
|
"provisioning/internal/config"
|
|
"provisioning/internal/model"
|
|
"provisioning/internal/orchestrator"
|
|
"provisioning/internal/pxe"
|
|
"provisioning/internal/statemachine"
|
|
"provisioning/internal/store"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
type BootAPI struct {
|
|
Hosts *store.Hosts
|
|
Images *store.Images
|
|
Runner *orchestrator.Runner
|
|
Orchestrator *orchestrator.HostOrchestrator
|
|
Config *config.Config
|
|
ServerTypes *config.ServerTypeRegistry
|
|
}
|
|
|
|
func (a *BootAPI) IPXEScript(w http.ResponseWriter, r *http.Request) {
|
|
mac := normalizeMAC(chi.URLParam(r, "mac"))
|
|
host, err := a.Hosts.GetByMAC(r.Context(), mac)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
http.Error(w, "#!ipxe\nexit", http.StatusNotFound)
|
|
return
|
|
}
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
img, err := a.Images.GetDefault(r.Context())
|
|
if err != nil {
|
|
http.Error(w, "#!ipxe\necho No default image configured\nshell", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
if host.State == model.StatePXEReady {
|
|
a.Runner.Transition(r.Context(), host.ID, statemachine.TriggerPXEScriptServed)
|
|
a.Runner.LogActivity(r.Context(), host.ID, model.LogInfo, "pxe", "iPXE script served — kernel + initrd delivered")
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
w.Write([]byte(pxe.BuildIPXEScript(a.Config.Server.PublicURL, img, mac)))
|
|
}
|
|
|
|
func (a *BootAPI) AnswerFile(w http.ResponseWriter, r *http.Request) {
|
|
var sysInfo struct {
|
|
MAC string `json:"mac"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&sysInfo); err != nil {
|
|
http.Error(w, "invalid json", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
mac := normalizeMAC(sysInfo.MAC)
|
|
host, err := a.Hosts.GetByMAC(r.Context(), mac)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
http.Error(w, "unknown host", http.StatusForbidden)
|
|
return
|
|
}
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
st, ok := a.ServerTypes.Get(host.ServerType)
|
|
if !ok {
|
|
http.Error(w, "unknown server type", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if host.State == model.StatePXEBooted {
|
|
a.Runner.Transition(r.Context(), host.ID, statemachine.TriggerAnswerServed)
|
|
a.Runner.LogActivity(r.Context(), host.ID, model.LogInfo, "pxe", "Answer file served — installation starting")
|
|
}
|
|
|
|
_, pubKey, _ := a.Hosts.GetEphemeralKey(r.Context(), host.ID)
|
|
if pubKey == "" {
|
|
http.Error(w, "no ephemeral key for host", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
answer := pxe.GenerateAnswerFile(host, st, a.Config, pubKey)
|
|
w.Header().Set("Content-Type", "application/toml")
|
|
w.Write([]byte(answer))
|
|
}
|
|
|
|
func (a *BootAPI) InstallComplete(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := idFromURL(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
host, err := a.Hosts.Get(r.Context(), id)
|
|
if err != nil {
|
|
writeJSONErr(w, http.StatusNotFound, "host not found")
|
|
return
|
|
}
|
|
|
|
if host.State == model.StateInstalling {
|
|
a.Runner.LogActivity(r.Context(), host.ID, model.LogInfo, "pxe", "Install-complete webhook received")
|
|
if _, err := a.Runner.Transition(r.Context(), host.ID, statemachine.TriggerInstallWebhook); err != nil {
|
|
log.Printf("host %d: install-complete transition failed: %v", host.ID, err)
|
|
}
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
func (a *BootAPI) FirstBootScript(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := idFromURL(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
host, err := a.Hosts.Get(r.Context(), id)
|
|
if err != nil {
|
|
http.Error(w, "host not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
st, ok := a.ServerTypes.Get(host.ServerType)
|
|
if !ok {
|
|
http.Error(w, "unknown server type", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
script := pxe.GenerateFirstBootScript(host, st, a.Config)
|
|
w.Header().Set("Content-Type", "text/x-shellscript")
|
|
w.Write([]byte(script))
|
|
}
|
|
|
|
func (a *BootAPI) PhoneHome(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := idFromURL(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
IP string `json:"ip"`
|
|
HardwareID string `json:"hardware_id"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSONErr(w, http.StatusBadRequest, "invalid json")
|
|
return
|
|
}
|
|
|
|
host, err := a.Hosts.Get(r.Context(), id)
|
|
if err != nil {
|
|
writeJSONErr(w, http.StatusNotFound, "host not found")
|
|
return
|
|
}
|
|
|
|
log.Printf("host %d (%s): phone-home from %s, hwid=%s", host.ID, host.Hostname, req.IP, req.HardwareID)
|
|
a.Runner.LogActivity(r.Context(), host.ID, model.LogInfo, "pxe", fmt.Sprintf("Phone-home received from %s", req.IP))
|
|
a.Orchestrator.HandlePhoneHome(r.Context(), host.ID, req.IP, req.HardwareID)
|
|
|
|
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, "-", ":")
|
|
return m
|
|
}
|