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: " [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}/. 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 }