From 8b3d9a312e795e08440894ada52468c3f065964e Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 17 Apr 2026 22:50:54 -0400 Subject: [PATCH] Add quick-register one-liner for target-host registration Operator pastes `curl -fsSL $ORCH/register/quick.sh | sudo bash` on the target host (pre-wipe). The script probes MAC + CPU/RAM/disks/NICs/GPUs, emits an expected-spec YAML, and POSTs to a new LAN-trusted JSON endpoint /api/v1/hosts. The register page shows the command prefilled with the orchestrator URL; the manual form moves into a collapsible "Register manually" disclosure. --- cmd/vetting/main.go | 1 + internal/api/quick_register_test.go | 256 +++++++++++++++++++ internal/api/ui_handlers.go | 110 +++++++- internal/httpserver/router.go | 6 + internal/web/embed.go | 3 + internal/web/register/quick.sh.tmpl | 146 +++++++++++ internal/web/static/app.css | 35 +++ internal/web/templates/host_tile_templ.go | 32 +-- internal/web/templates/layout_templ.go | 4 +- internal/web/templates/registration.templ | 68 +++-- internal/web/templates/registration_templ.go | 78 +++--- 11 files changed, 663 insertions(+), 76 deletions(-) create mode 100644 internal/api/quick_register_test.go create mode 100644 internal/web/register/quick.sh.tmpl diff --git a/cmd/vetting/main.go b/cmd/vetting/main.go index 54e997a..5ada34a 100644 --- a/cmd/vetting/main.go +++ b/cmd/vetting/main.go @@ -102,6 +102,7 @@ func main() { EventHub: hub, Runner: runner, Tiles: tiles, + PublicURL: cfg.Server.PublicURL, } agentAPI := &api.Agent{ diff --git a/internal/api/quick_register_test.go b/internal/api/quick_register_test.go new file mode 100644 index 0000000..433b3eb --- /dev/null +++ b/internal/api/quick_register_test.go @@ -0,0 +1,256 @@ +package api_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "vetting/internal/api" + "vetting/internal/db" + "vetting/internal/model" + "vetting/internal/store" +) + +func newUI(t *testing.T, publicURL string) (*api.UI, *store.Hosts) { + t.Helper() + tmp := t.TempDir() + conn, err := db.Open(filepath.Join(tmp, "vetting.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + hostStore := &store.Hosts{DB: conn} + return &api.UI{ + Hosts: hostStore, + PublicURL: publicURL, + }, hostStore +} + +func validQuickRegisterBody() []byte { + body, _ := json.Marshal(map[string]any{ + "name": "qr-host-01", + "mac": "aa:bb:cc:dd:ee:01", + "wol_broadcast_ip": "10.0.0.255", + "wol_port": 9, + "expected_spec_yaml": "cpu:\n model: \"Xeon\"\n logical_cores: 8\n" + + "memory:\n total_gib: 16\n", + "notes": "registered via smoke test", + }) + return body +} + +func TestCreateHostJSON_Success(t *testing.T) { + ui, hostStore := newUI(t, "") + req := httptest.NewRequest(http.MethodPost, "/api/v1/hosts", + bytes.NewReader(validQuickRegisterBody())) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + ui.CreateHostJSON(rr, req) + + if rr.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201; body=%q", rr.Code, rr.Body.String()) + } + var resp struct { + ID int64 `json:"id"` + Name string `json:"name"` + MAC string `json:"mac"` + } + if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.ID == 0 || resp.Name != "qr-host-01" || resp.MAC != "aa:bb:cc:dd:ee:01" { + t.Fatalf("response = %+v", resp) + } + + got, err := hostStore.Get(context.Background(), resp.ID) + if err != nil { + t.Fatalf("get host: %v", err) + } + if got.Name != "qr-host-01" || got.MAC != "aa:bb:cc:dd:ee:01" || got.WoLPort != 9 { + t.Fatalf("host row = %+v", got) + } +} + +func TestCreateHostJSON_DefaultsWoLPort(t *testing.T) { + ui, hostStore := newUI(t, "") + body, _ := json.Marshal(map[string]any{ + "name": "qr-host-noport", + "mac": "aa:bb:cc:dd:ee:02", + "wol_broadcast_ip": "10.0.0.255", + "expected_spec_yaml": "cpu:\n logical_cores: 4\n", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/hosts", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + ui.CreateHostJSON(rr, req) + if rr.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201; body=%q", rr.Code, rr.Body.String()) + } + hosts, err := hostStore.List(context.Background()) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(hosts) != 1 || hosts[0].WoLPort != 9 { + t.Fatalf("hosts = %+v", hosts) + } +} + +func TestCreateHostJSON_BadMAC(t *testing.T) { + ui, _ := newUI(t, "") + body, _ := json.Marshal(map[string]any{ + "name": "bad-mac", + "mac": "not-a-mac", + "wol_broadcast_ip": "10.0.0.255", + "expected_spec_yaml": "cpu:\n logical_cores: 1\n", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/hosts", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + ui.CreateHostJSON(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%q", rr.Code, rr.Body.String()) + } + var resp map[string]string + _ = json.NewDecoder(rr.Body).Decode(&resp) + if !strings.Contains(resp["error"], "MAC") { + t.Fatalf("error = %q, want mention of MAC", resp["error"]) + } +} + +func TestCreateHostJSON_BadYAML(t *testing.T) { + ui, _ := newUI(t, "") + body, _ := json.Marshal(map[string]any{ + "name": "bad-yaml", + "mac": "aa:bb:cc:dd:ee:03", + "wol_broadcast_ip": "10.0.0.255", + "expected_spec_yaml": "cpu: [unterminated", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/hosts", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + ui.CreateHostJSON(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%q", rr.Code, rr.Body.String()) + } +} + +func TestCreateHostJSON_DuplicateMAC(t *testing.T) { + ui, hostStore := newUI(t, "") + // Seed a host with the MAC we'll try to re-register. + if _, err := hostStore.Create(context.Background(), model.Host{ + Name: "existing", + MAC: "aa:bb:cc:dd:ee:04", + WoLBroadcastIP: "10.0.0.255", + WoLPort: 9, + ExpectedSpecYAML: "cpu:\n logical_cores: 1\n", + }); err != nil { + t.Fatalf("seed host: %v", err) + } + body, _ := json.Marshal(map[string]any{ + "name": "dupe", + "mac": "aa:bb:cc:dd:ee:04", + "wol_broadcast_ip": "10.0.0.255", + "expected_spec_yaml": "cpu:\n logical_cores: 1\n", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/hosts", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + ui.CreateHostJSON(rr, req) + if rr.Code != http.StatusConflict { + t.Fatalf("status = %d, want 409; body=%q", rr.Code, rr.Body.String()) + } + var resp map[string]string + _ = json.NewDecoder(rr.Body).Decode(&resp) + if !strings.Contains(strings.ToLower(resp["error"]), "mac") { + t.Fatalf("error = %q, want mention of MAC", resp["error"]) + } +} + +func TestCreateHostJSON_BadJSON(t *testing.T) { + ui, _ := newUI(t, "") + req := httptest.NewRequest(http.MethodPost, "/api/v1/hosts", + strings.NewReader("not json")) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + ui.CreateHostJSON(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%q", rr.Code, rr.Body.String()) + } +} + +func TestQuickRegisterScript_UsesPublicURL(t *testing.T) { + ui, _ := newUI(t, "https://vetting.example.lan") + req := httptest.NewRequest(http.MethodGet, "/register/quick.sh", nil) + rr := httptest.NewRecorder() + ui.QuickRegisterScript(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + if ct := rr.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/x-shellscript") { + t.Fatalf("Content-Type = %q, want text/x-shellscript...", ct) + } + body := rr.Body.String() + if !strings.Contains(body, "#!/usr/bin/env bash") { + t.Fatalf("body missing bash shebang: %q", body[:min(120, len(body))]) + } + if !strings.Contains(body, "https://vetting.example.lan") { + t.Fatalf("body missing configured public URL") + } + if !strings.Contains(body, "/api/v1/hosts") { + t.Fatalf("body missing POST target /api/v1/hosts") + } +} + +func TestQuickRegisterScript_FallsBackToRequestHost(t *testing.T) { + ui, _ := newUI(t, "") + req := httptest.NewRequest(http.MethodGet, "/register/quick.sh", nil) + req.Host = "10.0.0.5:8080" + rr := httptest.NewRecorder() + ui.QuickRegisterScript(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d", rr.Code) + } + body := rr.Body.String() + if !strings.Contains(body, "http://10.0.0.5:8080") { + t.Fatalf("body missing request-host fallback URL; body prefix=%q", + body[:min(160, len(body))]) + } +} + +// Verifies the unique-name path also hits friendlyDBError, not just MAC. +func TestCreateHostJSON_DuplicateName(t *testing.T) { + ui, hostStore := newUI(t, "") + if _, err := hostStore.Create(context.Background(), model.Host{ + Name: "already-taken", + MAC: "aa:bb:cc:dd:ee:05", + WoLBroadcastIP: "10.0.0.255", + WoLPort: 9, + ExpectedSpecYAML: "cpu:\n logical_cores: 1\n", + }); err != nil { + t.Fatalf("seed: %v", err) + } + body, _ := json.Marshal(map[string]any{ + "name": "already-taken", + "mac": "aa:bb:cc:dd:ee:99", + "wol_broadcast_ip": "10.0.0.255", + "expected_spec_yaml": "cpu:\n logical_cores: 1\n", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/hosts", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + ui.CreateHostJSON(rr, req) + if rr.Code != http.StatusConflict { + t.Fatalf("status = %d", rr.Code) + } + var resp map[string]string + _ = json.NewDecoder(rr.Body).Decode(&resp) + if !strings.Contains(strings.ToLower(resp["error"]), "name") { + t.Fatalf("error = %q, want friendly name-conflict message", resp["error"]) + } +} + diff --git a/internal/api/ui_handlers.go b/internal/api/ui_handlers.go index 0ba0591..d430fe8 100644 --- a/internal/api/ui_handlers.go +++ b/internal/api/ui_handlers.go @@ -1,12 +1,14 @@ package api import ( + "encoding/json" "errors" "log" "net/http" "regexp" "strconv" "strings" + "text/template" "github.com/go-chi/chi/v5" "gopkg.in/yaml.v3" @@ -15,6 +17,7 @@ import ( "vetting/internal/model" "vetting/internal/orchestrator" "vetting/internal/store" + "vetting/internal/web" "vetting/internal/web/templates" ) @@ -25,10 +28,32 @@ type UI struct { EventHub *events.Hub Runner *orchestrator.Runner Tiles *TileEnricher + PublicURL string // user-visible base URL baked into the quick-register one-liner } var macRe = regexp.MustCompile(`^[0-9a-f]{2}(:[0-9a-f]{2}){5}$`) +// quickRegisterTmpl is parsed once at startup — a malformed template +// should fail the binary at init, not on a visitor's first hit. +var quickRegisterTmpl = template.Must( + template.ParseFS(web.Register, "register/quick.sh.tmpl"), +) + +// baseURL returns the orchestrator URL to bake into generated artefacts +// (the quick-register one-liner, its rendered script). Prefers the +// operator-configured public URL; falls back to the request's own host +// so a dev run on http://127.0.0.1:8080 still produces a working command. +func (u *UI) baseURL(r *http.Request) string { + if u.PublicURL != "" { + return strings.TrimRight(u.PublicURL, "/") + } + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + return scheme + "://" + r.Host +} + func (u *UI) Dashboard(w http.ResponseWriter, r *http.Request) { hosts, err := u.Hosts.List(r.Context()) if err != nil { @@ -92,7 +117,22 @@ func (u *UI) StartRun(w http.ResponseWriter, r *http.Request) { } func (u *UI) NewHostForm(w http.ResponseWriter, r *http.Request) { - _ = templates.Registration(templates.RegistrationForm{}).Render(r.Context(), w) + _ = templates.Registration(templates.RegistrationForm{ + QuickRegisterURL: u.baseURL(r), + }).Render(r.Context(), w) +} + +// QuickRegisterScript renders the bash one-liner an operator pastes on +// the target host: hardware autodetect + POST to /api/v1/hosts. The +// orchestrator URL is substituted in so the script is self-contained. +func (u *UI) QuickRegisterScript(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/x-shellscript; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + if err := quickRegisterTmpl.Execute(w, struct{ OrchestratorURL string }{ + OrchestratorURL: u.baseURL(r), + }); err != nil { + log.Printf("quick-register script render: %v", err) + } } func (u *UI) CreateHost(w http.ResponseWriter, r *http.Request) { @@ -107,6 +147,7 @@ func (u *UI) CreateHost(w http.ResponseWriter, r *http.Request) { WoLPort: r.PostForm.Get("wol_port"), ExpectedSpecYAML: r.PostForm.Get("expected_spec_yaml"), Notes: strings.TrimSpace(r.PostForm.Get("notes")), + QuickRegisterURL: u.baseURL(r), } if errMsg := validateHostForm(&form); errMsg != "" { @@ -138,6 +179,73 @@ func (u *UI) CreateHost(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/", http.StatusSeeOther) } +// quickRegisterPayload is the POST body accepted by /api/v1/hosts — +// the shape the quick-register bash one-liner emits. +type quickRegisterPayload struct { + Name string `json:"name"` + MAC string `json:"mac"` + WoLBroadcastIP string `json:"wol_broadcast_ip"` + WoLPort int `json:"wol_port"` + ExpectedSpecYAML string `json:"expected_spec_yaml"` + Notes string `json:"notes"` +} + +// CreateHostJSON is the API counterpart to CreateHost. Accepts the same +// fields as the form but in JSON, so a target host can POST its own +// registration payload over curl from the quick-register one-liner. +// Same validation as the form; no auth (LAN-only). +func (u *UI) CreateHostJSON(w http.ResponseWriter, r *http.Request) { + var p quickRegisterPayload + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 256*1024)).Decode(&p); err != nil { + writeJSONError(w, http.StatusBadRequest, "bad json: "+err.Error()) + return + } + form := templates.RegistrationForm{ + Name: strings.TrimSpace(p.Name), + MAC: strings.ToLower(strings.TrimSpace(p.MAC)), + WoLBroadcastIP: strings.TrimSpace(p.WoLBroadcastIP), + ExpectedSpecYAML: p.ExpectedSpecYAML, + Notes: strings.TrimSpace(p.Notes), + } + if p.WoLPort > 0 { + form.WoLPort = strconv.Itoa(p.WoLPort) + } + if errMsg := validateHostForm(&form); errMsg != "" { + writeJSONError(w, http.StatusBadRequest, errMsg) + return + } + wolPort := p.WoLPort + if wolPort == 0 { + wolPort = 9 + } + id, err := u.Hosts.Create(r.Context(), model.Host{ + Name: form.Name, + MAC: form.MAC, + WoLBroadcastIP: form.WoLBroadcastIP, + WoLPort: wolPort, + ExpectedSpecYAML: form.ExpectedSpecYAML, + Notes: form.Notes, + }) + if err != nil { + writeJSONError(w, http.StatusConflict, friendlyDBError(err)) + return + } + log.Printf("api: registered host %d (%s, %s)", id, form.Name, form.MAC) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": id, + "name": form.Name, + "mac": form.MAC, + }) +} + +func writeJSONError(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} + // OverrideWipeStorage is the operator's explicit "yes, wipe the disk // even though we found filesystem signatures" button. Only meaningful // when the latest run is FailedHolding with failed_stage=Storage — the diff --git a/internal/httpserver/router.go b/internal/httpserver/router.go index bd9b9b5..389ace4 100644 --- a/internal/httpserver/router.go +++ b/internal/httpserver/router.go @@ -49,6 +49,11 @@ func NewRouter(d Deps) http.Handler { r.Post("/sensor", d.Agent.Sensor) }) + // Quick-register: the bash one-liner fetched from /register/quick.sh + // POSTs here from the target host. LAN-trusted, same threat model + // as the browser UI. + r.Post("/api/v1/hosts", d.UI.CreateHostJSON) + // Browser UI — no auth; bind to loopback or LAN only, or front // with a reverse proxy if you need a password. r.Get("/", d.UI.Dashboard) @@ -58,6 +63,7 @@ func NewRouter(d Deps) http.Handler { r.Post("/hosts/{id}/start", d.UI.StartRun) r.Post("/hosts/{id}/override-wipe", d.UI.OverrideWipeStorage) r.Get("/reports/{runID}", d.UI.Report) + r.Get("/register/quick.sh", d.UI.QuickRegisterScript) r.Get("/events", d.UI.SSE) return r diff --git a/internal/web/embed.go b/internal/web/embed.go index 3347a00..d19c94d 100644 --- a/internal/web/embed.go +++ b/internal/web/embed.go @@ -4,3 +4,6 @@ import "embed" //go:embed static/* var Static embed.FS + +//go:embed register/*.tmpl +var Register embed.FS diff --git a/internal/web/register/quick.sh.tmpl b/internal/web/register/quick.sh.tmpl new file mode 100644 index 0000000..94f74d2 --- /dev/null +++ b/internal/web/register/quick.sh.tmpl @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# Vetting quick-register. +# +# Run on the target host (any Linux with root) before the host is wiped: +# curl -fsSL {{.OrchestratorURL}}/register/quick.sh | sudo bash +# +# Detects the primary NIC's MAC, probes hardware (CPU / RAM / disks / +# NICs / GPUs) into an expected-spec YAML, and POSTs everything to +# {{.OrchestratorURL}}/api/v1/hosts. After registration, go to the +# orchestrator's dashboard and click "Start vetting" for the new host. +# +# Env overrides (all optional): +# NAME Host display name (default: `hostname -s`) +# MAC Force a specific MAC (default: autodetect) +# WOL_BROADCAST WoL broadcast IP (default: primary iface broadcast) +# WOL_PORT WoL UDP port (default: 9) +# NOTES Free-text notes +# ORCH_URL Override orchestrator base URL +set -euo pipefail + +ORCH_URL="${ORCH_URL:-{{.OrchestratorURL}}}" + +if [[ -z "${ORCH_URL}" ]]; then + echo "ERROR: ORCH_URL is empty; pass it via env." >&2 + exit 1 +fi + +primary_iface() { + # Pick the first physical ethernet-style interface that isn't a loopback, + # bridge, veth, docker, virbr, bond, tun, or tap. + ip -o link show 2>/dev/null \ + | awk '$0 ~ /link\/ether/ { + name=$2; sub(":","",name) + if (name ~ /^(lo|docker|br-|veth|virbr|bond|tun|tap|wlan|wlp)/) next + print name; exit + }' +} + +IFACE="$(primary_iface || true)" +if [[ -z "${IFACE}" ]]; then + echo "ERROR: could not pick a primary network interface." >&2 + exit 1 +fi + +NAME="${NAME:-$(hostname -s 2>/dev/null || hostname)}" +MAC="${MAC:-$(cat /sys/class/net/${IFACE}/address 2>/dev/null || true)}" +if [[ -z "${MAC}" ]]; then + echo "ERROR: could not read MAC for ${IFACE}." >&2 + exit 1 +fi + +if [[ -z "${WOL_BROADCAST:-}" ]]; then + WOL_BROADCAST="$(ip -o -4 addr show dev "${IFACE}" 2>/dev/null \ + | awk '{for(i=1;i<=NF;i++) if($i=="brd") {print $(i+1); exit}}')" +fi +WOL_BROADCAST="${WOL_BROADCAST:-255.255.255.255}" +WOL_PORT="${WOL_PORT:-9}" + +# --- Hardware probes --- +cores="$(nproc 2>/dev/null || echo 0)" +cpu_model="$(awk -F: '/^model name/ {sub(/^ */, "", $2); print $2; exit}' /proc/cpuinfo 2>/dev/null || true)" +mem_gib="$(awk '/^MemTotal:/ {printf "%d", ($2/1024/1024) + 0.5; exit}' /proc/meminfo 2>/dev/null || echo 0)" + +disk_yaml="" +while read -r name size serial; do + [[ -z "${name}" ]] && continue + [[ "${name}" =~ ^(sd|nvme|vd|hd) ]] || continue + [[ -z "${serial}" ]] && continue + size_gb=$(( size / 1000 / 1000 / 1000 )) + disk_yaml+=" - serial: \"${serial}\" + size_gb: ${size_gb} +" +done < <(lsblk -dn -b -o NAME,SIZE,SERIAL 2>/dev/null || true) + +nic_yaml="" +for iface_dir in /sys/class/net/*; do + iface_name="$(basename "${iface_dir}")" + [[ "${iface_name}" =~ ^(lo|docker|br-|veth|virbr|bond|tun|tap)$ ]] && continue + nic_mac="$(cat "${iface_dir}/address" 2>/dev/null || true)" + [[ -z "${nic_mac}" || "${nic_mac}" == "00:00:00:00:00:00" ]] && continue + speed_mbps="$(cat "${iface_dir}/speed" 2>/dev/null || echo 0)" + if [[ "${speed_mbps}" =~ ^[0-9]+$ ]] && (( speed_mbps > 0 )); then + speed_gbps=$(( (speed_mbps + 999) / 1000 )) + else + speed_gbps=0 + fi + nic_yaml+=" - mac: \"${nic_mac}\" + speed_gbps: ${speed_gbps} +" +done + +gpu_yaml="" +if command -v lspci >/dev/null 2>&1; then + while IFS= read -r gpu; do + [[ -z "${gpu}" ]] && continue + gpu_yaml+=" - model: \"${gpu}\" +" + done < <(lspci -mm 2>/dev/null | awk -F\" '/"(VGA|3D|Display)/ {print $6}') +fi + +# Assemble the YAML spec. Empty sections are omitted so the diff engine +# skips them rather than demanding empty arrays on the actual side. +spec="cpu: + model: \"${cpu_model}\" + logical_cores: ${cores} +memory: + total_gib: ${mem_gib} +" +[[ -n "${disk_yaml}" ]] && spec+="disks: +${disk_yaml}" +[[ -n "${nic_yaml}" ]] && spec+="nics: +${nic_yaml}" +[[ -n "${gpu_yaml}" ]] && spec+="gpus: +${gpu_yaml}" + +# --- JSON escape (backslash, double-quote, newline, tab, CR) --- +json_escape() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + s="${s//$'\t'/\\t}" + s="${s//$'\r'/\\r}" + s="${s//$'\n'/\\n}" + printf '%s' "${s}" +} + +payload=$(cat <{ form.Error } } -
- - -
+ if form.QuickRegisterURL != "" { +
+

Quick register (recommended)

+

Run this on the target host as root before wiping. It auto-detects MAC and hardware, then registers with this orchestrator:

+
{ "curl -fsSL " + form.QuickRegisterURL + "/register/quick.sh | sudo bash" }
+

After the script prints OK, refresh the dashboard and click Start vetting on the new host.

+
+ } +
+ Register manually + -
- - -
- - Cancel -
-
+
+ + +
+ + +
+ + Cancel +
+ + } } diff --git a/internal/web/templates/registration_templ.go b/internal/web/templates/registration_templ.go index 78db794..63e790e 100644 --- a/internal/web/templates/registration_templ.go +++ b/internal/web/templates/registration_templ.go @@ -16,6 +16,7 @@ type RegistrationForm struct { ExpectedSpecYAML string Notes string Error string + QuickRegisterURL string // base URL (no trailing slash) used in the one-liner } func Registration(form RegistrationForm) templ.Component { @@ -63,7 +64,7 @@ func Registration(form RegistrationForm) templ.Component { var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(form.Error) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 18, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `registration.templ`, Line: 19, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -74,85 +75,104 @@ func Registration(form RegistrationForm) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "