hub v0.62.0 + scripts v1.19.0 — R-21 slice C: the universal secret-free ISO
A generic ISO carries NO customer secret. The box registers itself at the hub as an unclaimed appliance; the operator binds it to a customer; the hub delivers the customer-id + retrieval passphrase ONCE; day-0 completes via the slice-A path. Hub (v0.62.0): - store/appliance.go: appliance_registrations keyed by (uuid, mac_set) — MAC set is the tiebreaker (duplicate SMBIOS UUIDs); token stored as sha256 only. Idempotent register (sticky-discard), atomic one-shot delivery, bind/discard. - api/appliance.go: POST /appliance/register (the one unauth endpoint, per-IP rate-limited, 256-bit token); GET /appliance/poll (404 no-oracle / 204 unbound / 200 deliver-once / 410 delivered). Passphrase read live, never logged. - web/appliances.go: Hosts-page "Unclaimed appliances" section + BIND (customer picker, host count display-only) + DISCARD; SSH host-key fingerprints; events. - Red-proofs: one-shot delivery + register idempotency (both proven red); 404-no-oracle, sticky-discard, bind staging, render. Green + confirm gate. Scripts (v1.19.0): - felhom-bootstrap.sh: ONE unit, TWO modes. Direct (env has customer/passphrase) = slice-A path, byte-identical, only branched around. Pairing (generic) = register + poll (RestartSec=30 is the poll timer); on delivery write the env 0600 and fall through to direct. Secrets + token shredded on success. - build-felhom-iso.sh --pairing: generic secret-free ISO, -generic filename, manifest mode=pairing. profiles/generic.profile (new). - test/bootstrap-modes.sh: Scenario D (direct = zero appliance calls) + pairing register/poll + delivery handoff — all green in a debian container.
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
// R-21 slice C — the operator surface for unclaimed appliances (a box booted from the GENERIC ISO
|
||||
// that registered itself and is polling for a bind). Lives on the Hosts page: an "Unclaimed
|
||||
// appliances" section, plus BIND (to a customer) and DISCARD actions.
|
||||
|
||||
const applianceStaleAfter = 7 * 24 * time.Hour // no poll in 7 days → badge as stale
|
||||
|
||||
// applianceRow is the per-appliance view model.
|
||||
type applianceRow struct {
|
||||
ID int64
|
||||
UUID string
|
||||
MACs []string
|
||||
Product string
|
||||
CPU string
|
||||
MemGB string
|
||||
SSHFingerprints []string
|
||||
FirstSeen *time.Time
|
||||
LastSeen *time.Time
|
||||
Stale bool
|
||||
Bound bool
|
||||
BoundCustomer string
|
||||
}
|
||||
|
||||
// customerPickerOption is one entry in the BIND customer picker. HostCount is DISPLAYED (multi-host
|
||||
// customers are real — Peti) but never gates the bind.
|
||||
type customerPickerOption struct {
|
||||
CustomerID string
|
||||
CustomerName string
|
||||
HostCount int
|
||||
}
|
||||
|
||||
// sshFingerprint returns the OpenSSH SHA256 fingerprint of one authorized_keys-format line, or "" if
|
||||
// unparseable. Format: "<type> <base64 blob> [comment]".
|
||||
func sshFingerprint(line string) string {
|
||||
f := strings.Fields(line)
|
||||
if len(f) < 2 {
|
||||
return ""
|
||||
}
|
||||
blob, err := base64.StdEncoding.DecodeString(f[1])
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256(blob)
|
||||
return f[0] + " SHA256:" + base64.RawStdEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// applianceToRow builds the view model (parses hw_summary + computes SSH fingerprints).
|
||||
func applianceToRow(a store.ApplianceRegistration, now time.Time, customerName func(string) string) applianceRow {
|
||||
row := applianceRow{
|
||||
ID: a.ID,
|
||||
UUID: a.UUID,
|
||||
Bound: a.Status == store.ApplianceBound,
|
||||
}
|
||||
if a.MACSet != "" {
|
||||
row.MACs = strings.Split(a.MACSet, ",")
|
||||
}
|
||||
fs := a.FirstSeen
|
||||
row.FirstSeen = &fs
|
||||
ls := a.LastSeen
|
||||
row.LastSeen = &ls
|
||||
row.Stale = now.Sub(a.LastSeen) > applianceStaleAfter
|
||||
for _, k := range strings.Split(a.SSHHostPubkeys, "\n") {
|
||||
if fp := sshFingerprint(k); fp != "" {
|
||||
row.SSHFingerprints = append(row.SSHFingerprints, fp)
|
||||
}
|
||||
}
|
||||
if a.HWSummary != "" {
|
||||
var hw struct {
|
||||
Product string `json:"product"`
|
||||
CPU string `json:"cpu"`
|
||||
MemKB int64 `json:"mem_kb"`
|
||||
}
|
||||
if json.Unmarshal([]byte(a.HWSummary), &hw) == nil {
|
||||
row.Product = hw.Product
|
||||
row.CPU = hw.CPU
|
||||
if hw.MemKB > 0 {
|
||||
row.MemGB = fmt.Sprintf("%.1f GB", float64(hw.MemKB)/1024.0/1024.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
if row.Bound {
|
||||
row.BoundCustomer = customerName(a.CustomerID)
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
// gatherUnclaimed builds the Unclaimed-appliances rows + the customer picker (with host counts).
|
||||
func (s *Server) gatherUnclaimed(now time.Time) ([]applianceRow, []customerPickerOption, error) {
|
||||
appls, err := s.store.ListUnclaimedAppliances()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
rows := make([]applianceRow, 0, len(appls))
|
||||
for _, a := range appls {
|
||||
rows = append(rows, applianceToRow(a, now, s.customerName))
|
||||
}
|
||||
var picker []customerPickerOption
|
||||
if len(rows) > 0 { // only pay for the customer list when there's something to bind
|
||||
cfgs, err := s.store.ListCustomerConfigs()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, c := range cfgs {
|
||||
hosts, _ := s.store.ListHostsByCustomer(c.CustomerID)
|
||||
name := c.CustomerName
|
||||
if name == "" {
|
||||
name = c.CustomerID
|
||||
}
|
||||
picker = append(picker, customerPickerOption{CustomerID: c.CustomerID, CustomerName: name, HostCount: len(hosts)})
|
||||
}
|
||||
}
|
||||
return rows, picker, nil
|
||||
}
|
||||
|
||||
// handleApplianceBind — POST /appliances/{id}/bind. Stages the delivery for that appliance's token.
|
||||
// Does NOT gate on the customer's host count (multi-host customers are real).
|
||||
func (s *Server) handleApplianceBind(w http.ResponseWriter, r *http.Request, id int64) {
|
||||
customerID := strings.TrimSpace(r.FormValue("customer_id"))
|
||||
mode := strings.TrimSpace(r.FormValue("mode"))
|
||||
if mode == "" {
|
||||
mode = "appliance"
|
||||
}
|
||||
extraArgs := strings.TrimSpace(r.FormValue("extra_args"))
|
||||
if customerID == "" {
|
||||
http.Error(w, "customer_id is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
cc, err := s.store.GetCustomerConfig(customerID)
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] appliance bind %d: customer lookup: %v", id, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if cc == nil {
|
||||
http.Error(w, "Unknown customer_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := s.store.BindAppliance(id, customerID, mode, extraArgs); err != nil {
|
||||
s.logger.Printf("[WARN] appliance bind %d → %s refused: %v", id, customerID, err)
|
||||
http.Error(w, "Bind failed: "+err.Error(), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
// Provenance is the appliance row (bound_at); audit event now that a customer scopes it.
|
||||
if _, err := s.store.SaveEvent(customerID, "appliance_bound", "info",
|
||||
"Egy új eszközt (bare-metal telepítés) ehhez az ügyfélhez rendeltünk; a hozzáférést a következő lekérdezéskor megkapja.", "", "hub"); err != nil {
|
||||
s.logger.Printf("[WARN] appliance bind %d: save event: %v", id, err)
|
||||
}
|
||||
s.logger.Printf("[INFO] appliance %d BOUND to customer %s (mode=%s) — delivery staged for its next poll", id, customerID, mode)
|
||||
http.Redirect(w, r, "/hosts?flash=appliance_bound", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleApplianceDiscard — POST /appliances/{id}/discard. Ignores the registration + invalidates its
|
||||
// token. Provenance is the appliance row (discarded_at); no customer to scope an event to.
|
||||
func (s *Server) handleApplianceDiscard(w http.ResponseWriter, r *http.Request, id int64) {
|
||||
if err := s.store.DiscardAppliance(id); err != nil {
|
||||
s.logger.Printf("[WARN] appliance discard %d: %v", id, err)
|
||||
http.Error(w, "Discard failed: "+err.Error(), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] appliance %d DISCARDED (token invalidated; polls now 404)", id)
|
||||
http.Redirect(w, r, "/hosts?flash=appliance_discarded", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// parseApplianceID extracts the {id} from /appliances/{id}/<action>.
|
||||
func parseApplianceID(path, action string) (int64, bool) {
|
||||
rest := strings.TrimPrefix(path, "/appliances/")
|
||||
rest = strings.TrimSuffix(rest, "/"+action)
|
||||
id, err := strconv.ParseInt(rest, 10, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
// R-21 slice C — the operator unclaimed-appliance surface: render + bind/discard.
|
||||
|
||||
func seedAppliance(t *testing.T, st *store.Store, uuid, macSet string) int64 {
|
||||
t.Helper()
|
||||
// a real ed25519 host key line so the fingerprint helper has something to parse
|
||||
sshKey := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHVBv+9slP74+1/vNhiI0OJDrXQ2nvb8iwmIxMfUZn36 host"
|
||||
hw := `{"product":"Intel N100 mini","cpu":"Intel(R) N100","mem_kb":16150372}`
|
||||
if _, err := st.RegisterAppliance(uuid, macSet, sshKey, hw, "hash-"+uuid); err != nil {
|
||||
t.Fatalf("register appliance: %v", err)
|
||||
}
|
||||
list, err := st.ListUnclaimedAppliances()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, a := range list {
|
||||
if a.UUID == uuid && a.MACSet == macSet {
|
||||
return a.ID
|
||||
}
|
||||
}
|
||||
t.Fatal("seeded appliance not found")
|
||||
return 0
|
||||
}
|
||||
|
||||
func renderHosts(t *testing.T, s *Server) string {
|
||||
t.Helper()
|
||||
rr := httptest.NewRecorder()
|
||||
s.handleHostsList(rr, httptest.NewRequest("GET", "/hosts", nil))
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("hosts page = %d", rr.Code)
|
||||
}
|
||||
return rr.Body.String()
|
||||
}
|
||||
|
||||
func TestAppliances_UnclaimedSectionRenders(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "acme", CustomerName: "Acme Kft", APIKey: "k", RetrievalPassword: "pw"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seedAppliance(t, st, "uuid-vis", "bc:24:11:98:10:0e,bc:24:11:98:10:0f")
|
||||
|
||||
html := renderHosts(t, s)
|
||||
for _, want := range []string{
|
||||
"Unclaimed appliances", "uuid-vis", "bc:24:11:98:10:0e",
|
||||
"Intel N100 mini", "SHA256:", // hw + a computed SSH fingerprint
|
||||
`action="/appliances/`, "/bind", "/discard",
|
||||
`Acme Kft (0 hosts)`, // the picker shows host counts (display only)
|
||||
} {
|
||||
if !strings.Contains(html, want) {
|
||||
t.Errorf("unclaimed section missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppliances_BindStagesDelivery(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "acme", CustomerName: "Acme", APIKey: "k", RetrievalPassword: "pw"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := seedAppliance(t, st, "uuid-bind", "bc:24:11:98:10:0e")
|
||||
|
||||
form := url.Values{"customer_id": {"acme"}, "mode": {"appliance"}, "extra_args": {"--cores 4"}}
|
||||
req := httptest.NewRequest("POST", "/appliances/x/bind", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rr := httptest.NewRecorder()
|
||||
s.handleApplianceBind(rr, req, id)
|
||||
if rr.Code != http.StatusSeeOther {
|
||||
t.Fatalf("bind = %d (%s), want 303", rr.Code, rr.Body.String())
|
||||
}
|
||||
// The appliance is now bound with the staged delivery.
|
||||
a, _ := st.GetAppliance(id)
|
||||
if a.Status != store.ApplianceBound || a.CustomerID != "acme" || a.InstallMode != "appliance" || a.ExtraArgs != "--cores 4" {
|
||||
t.Fatalf("bind did not stage the delivery: %+v", a)
|
||||
}
|
||||
// Audit event recorded (a customer scopes it now).
|
||||
if ev, _ := st.GetLatestEventByType("acme", "appliance_bound"); ev == nil {
|
||||
t.Error("no appliance_bound event recorded")
|
||||
}
|
||||
// It leaves the unclaimed section as a bound row (still shown until delivered).
|
||||
if !strings.Contains(renderHosts(t, s), "bound → Acme") {
|
||||
t.Error("bound appliance not shown as bound in the UI")
|
||||
}
|
||||
}
|
||||
|
||||
// Bind must NOT gate on the customer's host count (a post-RESET / drill customer is hostless).
|
||||
func TestAppliances_BindDoesNotGateOnHostCount(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "hostless", APIKey: "k", RetrievalPassword: "pw"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := seedAppliance(t, st, "uuid-h", "bc:24:11:98:10:0e")
|
||||
form := url.Values{"customer_id": {"hostless"}}
|
||||
req := httptest.NewRequest("POST", "/appliances/x/bind", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rr := httptest.NewRecorder()
|
||||
s.handleApplianceBind(rr, req, id)
|
||||
if rr.Code != http.StatusSeeOther {
|
||||
t.Fatalf("bind to hostless customer = %d, want 303 (host count is display-only)", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppliances_BindUnknownCustomerRejected(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
id := seedAppliance(t, st, "uuid-u", "bc:24:11:98:10:0e")
|
||||
form := url.Values{"customer_id": {"ghost"}}
|
||||
req := httptest.NewRequest("POST", "/appliances/x/bind", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rr := httptest.NewRecorder()
|
||||
s.handleApplianceBind(rr, req, id)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("bind to unknown customer = %d, want 400", rr.Code)
|
||||
}
|
||||
if a, _ := st.GetAppliance(id); a.Status != store.ApplianceRegistered {
|
||||
t.Error("a rejected bind still mutated the appliance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppliances_Discard(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
id := seedAppliance(t, st, "uuid-d", "bc:24:11:98:10:0e")
|
||||
req := httptest.NewRequest("POST", "/appliances/x/discard", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
s.handleApplianceDiscard(rr, req, id)
|
||||
if rr.Code != http.StatusSeeOther {
|
||||
t.Fatalf("discard = %d, want 303", rr.Code)
|
||||
}
|
||||
a, _ := st.GetAppliance(id)
|
||||
if a.Status != store.ApplianceDiscarded {
|
||||
t.Fatalf("discard did not set status: %+v", a)
|
||||
}
|
||||
// No longer in the unclaimed list.
|
||||
list, _ := st.ListUnclaimedAppliances()
|
||||
if len(list) != 0 {
|
||||
t.Errorf("discarded appliance still unclaimed: %d", len(list))
|
||||
}
|
||||
}
|
||||
@@ -334,8 +334,19 @@ func (s *Server) handleHostsList(w http.ResponseWriter, r *http.Request) {
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
// R-21 slice C: the unclaimed-appliance section + bind picker.
|
||||
unclaimed, picker, err := s.gatherUnclaimed(time.Now())
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] Hosts list: unclaimed appliances: %v", err)
|
||||
// non-fatal: still render the host list
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"Hosts": rows,
|
||||
"Hosts": rows,
|
||||
"Unclaimed": unclaimed,
|
||||
"CustomerPicker": picker,
|
||||
"Flash": r.URL.Query().Get("flash"),
|
||||
"CSRFToken": s.getCSRFToken(r),
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "hosts.html", data); err != nil {
|
||||
s.logger.Printf("[ERROR] hosts.html template: %v", err)
|
||||
|
||||
@@ -316,6 +316,19 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Hosts — read-only fleet view (audit F-M1) + the v0.46.0 log-bundle actions.
|
||||
case path == "/hosts" || path == "/hosts/":
|
||||
s.handleHostsList(w, r)
|
||||
// R-21 slice C — unclaimed-appliance operator actions (bind/discard). POST only.
|
||||
case strings.HasPrefix(path, "/appliances/") && strings.HasSuffix(path, "/bind"):
|
||||
if id, ok := parseApplianceID(path, "bind"); ok && r.Method == http.MethodPost {
|
||||
s.handleApplianceBind(w, r, id)
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
case strings.HasPrefix(path, "/appliances/") && strings.HasSuffix(path, "/discard"):
|
||||
if id, ok := parseApplianceID(path, "discard"); ok && r.Method == http.MethodPost {
|
||||
s.handleApplianceDiscard(w, r, id)
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
// v0.47.0 stale host removal — suffix routes BEFORE the bare /hosts/ catch-all
|
||||
// (mirroring the request-logs placement).
|
||||
case strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/delete-impact"):
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
</head>
|
||||
<body>
|
||||
{{template "icon_sprite"}}
|
||||
{{template "inline_confirm_js"}}
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>Felhom <span>Hub</span></h1>
|
||||
@@ -23,6 +24,58 @@
|
||||
|
||||
<h2 style="margin-bottom: 1rem;">Hosts</h2>
|
||||
|
||||
{{if .Flash}}
|
||||
<div class="flash flash-success" style="margin-bottom: 1rem;">
|
||||
{{if eq .Flash "appliance_bound"}}Appliance bound — its credentials are delivered on its next poll (within ~30s); it then completes day-0 install.
|
||||
{{else if eq .Flash "appliance_discarded"}}Appliance discarded — its token is invalidated; further polls are ignored.
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Unclaimed}}
|
||||
<section class="card" style="margin-bottom: 1.5rem; border-color: var(--warn);">
|
||||
<h2 style="margin-top: 0;">Unclaimed appliances <span class="text-muted" style="font-size: 0.8em; font-weight: normal;">(booted from the generic ISO, awaiting a bind)</span></h2>
|
||||
<p class="text-muted" style="margin-top: 0;">A box that installed from the universal secret-free ISO and registered itself. <strong>Bind</strong> it to a customer to deliver its retrieval passphrase once; <strong>discard</strong> to ignore it.</p>
|
||||
<div style="overflow-x: auto;">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr><th>Appliance</th><th>MACs</th><th>Hardware</th><th>SSH host keys</th><th>Seen</th><th>Bind to customer</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Unclaimed}}
|
||||
<tr>
|
||||
<td><code style="font-size: 0.8em;">{{.UUID}}</code>
|
||||
{{if .Stale}}<br><span class="status-badge status-warn" title="No poll in over 7 days">stale</span>{{end}}
|
||||
{{if .Bound}}<br><span class="status-badge status-ok" title="Bound — awaiting the box's next poll">bound → {{.BoundCustomer}}</span>{{end}}
|
||||
</td>
|
||||
<td style="font-size: 0.78em; font-family: var(--font-mono)">{{range .MACs}}{{.}}<br>{{end}}</td>
|
||||
<td style="font-size: 0.8em;">{{if .Product}}{{.Product}}<br>{{end}}{{if .CPU}}<span class="text-muted">{{.CPU}}</span><br>{{end}}{{if .MemGB}}<span class="text-muted">{{.MemGB}}</span>{{end}}</td>
|
||||
<td style="font-size: 0.72em; font-family: var(--font-mono)">{{range .SSHFingerprints}}{{.}}<br>{{end}}</td>
|
||||
<td style="font-size: 0.78em;">{{if .FirstSeen}}first {{timeAgoPtr .FirstSeen}}<br>{{end}}{{if .LastSeen}}last {{timeAgoPtr .LastSeen}}{{end}}</td>
|
||||
<td>
|
||||
<form method="POST" action="/appliances/{{.ID}}/bind" style="display: flex; flex-direction: column; gap: 0.3rem;">
|
||||
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
|
||||
<select name="customer_id" required style="max-width: 16em;">
|
||||
<option value="">— pick a customer —</option>
|
||||
{{range $.CustomerPicker}}<option value="{{.CustomerID}}">{{.CustomerName}} ({{.HostCount}} host{{if ne .HostCount 1}}s{{end}})</option>{{end}}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-sm" style="border-color: var(--warn); color: var(--warn);">Bind & deliver</button>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<form method="POST" action="/appliances/{{.ID}}/discard">
|
||||
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
|
||||
<button type="submit" class="btn btn-sm btn-outline" data-confirm="Discard this appliance? Its token is invalidated and further polls are ignored.">Discard</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{if .Hosts}}
|
||||
<section class="card" style="padding: 0; overflow: hidden;">
|
||||
<table class="data-table">
|
||||
|
||||
Reference in New Issue
Block a user