b23ef64ee1
Generate a fresh ed25519 key pair at rebuild time, inject the public key into the Proxmox answer file, use the private key for cluster join over SSH, then remove the key from both the remote host and the database. This eliminates the need to manage static SSH keys in config/secrets. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
172 lines
4.3 KiB
Go
172 lines
4.3 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"log"
|
|
"net/http"
|
|
"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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
_, 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 {
|
|
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.Orchestrator.HandlePhoneHome(r.Context(), host.ID, req.IP, req.HardwareID)
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
|
}
|
|
|
|
func normalizeMAC(m string) string {
|
|
m = strings.ToLower(strings.TrimSpace(m))
|
|
m = strings.ReplaceAll(m, "-", ":")
|
|
return m
|
|
}
|