Files
felhom.eu/hub/internal/web/appliances.go
T
admin 36c5cd5fdf 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.
2026-07-17 15:07:31 +02:00

188 lines
6.2 KiB
Go

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
}