592818492c
Let a customer bind their own freshly-installed appliance without the operator: operator "Send self-bind link" mints a 7-day tokenized capability link, emailed (Hungarian, sibling sender) to the customer, who opens a public /bind/<token> page and proves two factors — the console pairing code shown on the box screen + their retrieval passphrase — and the hub stages the bind via the same BindAppliance (provenance customer_selfbind). The box's ~30s appliance poll delivers. Viktor's three rulings verbatim: console pairing code (no appliance list ever rendered), operator-sent tokenized link, 5-attempt lockout -> "call support". Wrong code == wrong passphrase (one generic failure, no oracle, both factors compared unconditionally); expiry falls back to operator-bind unchanged. THE TRAP: one public prefix /bind/, exempt from auth+CSRF at both /login gate sites via a single isPublicBindPath predicate (tight trailing-slash match; ServeMux ..-cleans; handler rejects '/' in token). 9 tests (Scenarios A-F + F1/F2); 4 red-proofs verified red-then-green (lockout, oracle, widened-prefix, single-active). GC verdict: no appliance GC -> the 7-day TTL stands alone. Controller/agent untouched; R-27b deferred. Green: full hub build/vet/test (17 ok) + bash -n + hub confirm gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017qDiBqKKQ5vPB5fXBqu7Kp
191 lines
6.5 KiB
Go
191 lines
6.5 KiB
Go
package web
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-hub/internal/configgen"
|
|
"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
|
|
PairingCode string // v0.66.0 (R-27): the code the customer reads off the box console + types on /bind
|
|
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,
|
|
PairingCode: configgen.FormatPairingCode(a.PairingCode),
|
|
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
|
|
}
|