Files
felhom.eu/hub/internal/web/selfbind_mint.go
T

197 lines
10 KiB
Go

package web
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/configgen"
)
// selfbind_mint.go — the OPERATOR side of customer self-bind (v0.66.0, R-27 slice 1): the "Send
// self-bind link" button on the customer page mints a 7-day capability token and emails the customer
// a public bind link. The customer side (the public /bind/ page) lives in selfbind.go. No hub
// customer-login exists — the emailed link IS the auth model.
// selfBindTTL is the capability link's lifetime. After it, self-bind falls back to operator-bind
// unchanged (the token simply reads as expired; nothing else regresses).
const selfBindTTL = 7 * 24 * time.Hour
// selfBindBaseURL is the hub's public origin, matching the hardcoded origin used elsewhere
// (configgen). The link is https://hub.felhom.eu/bind/<token>.
const selfBindBaseURL = "https://hub.felhom.eu"
// SelfBindMailer delivers the self-bind capability link to the registered customer address. The
// notify.Dispatcher implements it (a sibling of the claim mailer — NOT routed through claim). The
// signature takes plain strings so the implementation needs no import of this package.
type SelfBindMailer interface {
SendSelfBindEmail(customerID, email, link string) error
}
// SetSelfBindMailer wires the self-bind link sender (v0.66.0). Absent → the button returns 502.
func (s *Server) SetSelfBindMailer(m SelfBindMailer) { s.selfBindMailer = m }
func selfBindHash(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// selfBindOutcome is what mintAndSendSelfBindLink did, so each caller can decide how loud to be.
// The operator BUTTON turns these into flashes; the AUTO-MINT callers (create / RESET completion)
// only log, because neither may fail an otherwise-successful operation over a courtesy email.
type selfBindOutcome int
const (
selfBindSent selfBindOutcome = iota // minted + emailed
selfBindSkippedNoMailer // no mailer wired on this hub
selfBindSkippedNoEmail // F1: customer has no registered address
selfBindSendFailed // F2: send failed, token invalidated
selfBindMintFailed // token generation or DB write failed
)
// mintAndSendSelfBindLink is the shared mint+send core (v0.67.0). It was extracted from
// handleSelfBindLinkSend so the auto-mint callers reuse the SAME honesty rules rather than
// re-implementing them:
//
// - F1: no registered email → nothing is minted (a link nobody can receive is worse than none).
// - F2: send failed → the just-minted token is DELETED, never left silently live.
//
// The plaintext token exists only between minting and the send: never logged (only an 8-char hash
// prefix), never persisted (only its sha256). Callers get the outcome and the underlying error;
// nothing here writes an HTTP response, which is what makes it reusable off the request path.
func (s *Server) mintAndSendSelfBindLink(customerID, email string) (selfBindOutcome, error) {
if s.selfBindMailer == nil {
return selfBindSkippedNoMailer, nil
}
if email == "" {
return selfBindSkippedNoEmail, nil
}
token, err := configgen.RandomHex(32) // 256-bit capability token
if err != nil {
return selfBindMintFailed, err
}
hash := selfBindHash(token)
if err := s.store.MintSelfBindToken(customerID, hash, selfBindTTL); err != nil {
return selfBindMintFailed, err
}
link := selfBindBaseURL + "/bind/" + token
if err := s.selfBindMailer.SendSelfBindEmail(customerID, email, link); err != nil {
if derr := s.store.DeleteSelfBindTokens(customerID); derr != nil {
s.logger.Printf("[ERROR] self-bind link for %s: send failed AND cleanup failed: send=%v cleanup=%v", customerID, err, derr)
} else {
s.logger.Printf("[ERROR] self-bind link for %s: email send failed, token invalidated (hash %s…): %v", customerID, hash[:8], err)
}
return selfBindSendFailed, err
}
if err := s.store.MarkSelfBindEmailed(hash); err != nil {
s.logger.Printf("[WARN] self-bind link sent to %s but emailed_at not recorded: %v", customerID, err)
}
s.logger.Printf("[INFO] self-bind link (hash %s…, valid 7 days) emailed to the registered address of %s", hash[:8], customerID)
return selfBindSent, nil
}
// autoMintSelfBindLink is the fire-and-log wrapper used at customer creation and at RESET
// completion (v0.67.0, R-36 sub-item). The box's console banner tells the customer to open „az
// e-mailben kapott link", so that email should already exist by the time anyone reads the banner —
// previously it existed only once the operator remembered to press the button.
//
// It NEVER fails the caller's operation: a customer create that provisioned Cloudflare, offsite and
// PBS successfully must not 500 because a courtesy email bounced. Every outcome is logged; the
// operator can always re-send from the Setup tab.
func (s *Server) autoMintSelfBindLink(customerID, email, occasion string) {
outcome, err := s.mintAndSendSelfBindLink(customerID, email)
// Invariant both call sites need: once this returns, the only live capability token for this
// customer is one we just minted — or none at all. It matters on the RESET path, because
// PurgeCustomerResetDBState does NOT clear selfbind_tokens, so a link minted BEFORE the reset
// would otherwise stay live across it. A successful mint already replaces it (MintSelfBindToken
// deletes-then-inserts, single-active); the skip paths are the ones that would leave it behind.
if outcome == selfBindSkippedNoMailer || outcome == selfBindSkippedNoEmail {
if derr := s.store.DeleteSelfBindTokens(customerID); derr != nil {
s.logger.Printf("[WARN] self-bind: could not clear stale tokens for %s on %s: %v", customerID, occasion, derr)
}
}
switch outcome {
case selfBindSent:
s.logger.Printf("[INFO] self-bind link auto-minted for %s on %s (the console banner's promised email now exists)", customerID, occasion)
s.recordSelfBindSent(customerID, occasion)
case selfBindSkippedNoMailer:
s.logger.Printf("[INFO] self-bind link NOT auto-minted for %s on %s: no mailer configured on this hub", customerID, occasion)
case selfBindSkippedNoEmail:
s.logger.Printf("[WARN] self-bind link NOT auto-minted for %s on %s: no registered email address (F1) — set one, then send from the Setup tab", customerID, occasion)
case selfBindSendFailed:
s.logger.Printf("[WARN] self-bind link auto-mint for %s on %s FAILED to send; token invalidated — re-send from the Setup tab: %v", customerID, occasion, err)
case selfBindMintFailed:
s.logger.Printf("[ERROR] self-bind link auto-mint for %s on %s failed to mint: %v", customerID, occasion, err)
}
}
// selfBindSentEvent (R-509, v0.114.0) records WHEN and WHY a self-bind link went out, so the customer
// page can say „Kapcsolódó link elküldve: <date> (<occasion>)". Hub-internal: stored, never dispatched.
const selfBindSentEvent = "selfbind_link_sent"
func (s *Server) recordSelfBindSent(customerID, occasion string) {
details, _ := json.Marshal(map[string]string{"occasion": occasion})
if _, err := s.store.SaveEvent(customerID, selfBindSentEvent, "info", "Self-bind link e-mailed ("+occasion+")", string(details), "hub"); err != nil {
s.logger.Printf("[WARN] self-bind: sent-record for %s not stored: %v", customerID, err)
}
}
// autoMintSelfBindIfWaiting (R-509, operator decision A 2026-09-15) sends the link whenever an event
// leaves a customer WAITING FOR A BOX: an e-mail set or changed on a customer with no bound host, and a
// host delete that keeps the customer. Appliance registration is deliberately NOT a trigger — it knows
// no customer (api/appliance.go). No host bound is re-checked here, so a customer who still has a box
// is never mailed a pairing link.
func (s *Server) autoMintSelfBindIfWaiting(customerID, occasion string) {
cfg, err := s.store.GetCustomerConfig(customerID)
if err != nil || cfg == nil {
return
}
if h, herr := s.store.GetHostByCustomer(customerID); herr != nil || h != nil {
if herr != nil {
s.logger.Printf("[WARN] self-bind auto-send for %s on %s skipped: host lookup failed: %v", customerID, occasion, herr)
}
return
}
s.autoMintSelfBindLink(customerID, cfg.Email, occasion)
}
// handleSelfBindLinkSend — POST /customers/{id}/selfbind-link. Mints a single-active capability token
// for the customer and emails the public bind link. Honesty rules:
// - F1: no registered email → nothing is minted, LOUD flash (a link no one can receive is useless).
// - F2: email send fails → the just-minted token is deleted (not left silently live), LOUD flash.
//
// The plaintext token exists only between minting and the send; it is never logged (only an 8-char
// hash prefix) and never persisted (only its sha256).
func (s *Server) handleSelfBindLinkSend(w http.ResponseWriter, r *http.Request, customerID string) {
if s.selfBindMailer == nil {
http.Error(w, "Self-bind mailer is not configured on this hub", http.StatusBadGateway)
return
}
cfg, err := s.store.GetCustomerConfig(customerID)
if err != nil || cfg == nil {
http.NotFound(w, r)
return
}
// The honesty rules (F1/F2) live in the shared core so the button and the auto-mint callers
// cannot drift apart; the button's job is only to turn the outcome into an operator-visible flash.
switch outcome, err := s.mintAndSendSelfBindLink(customerID, cfg.Email); outcome {
case selfBindSkippedNoEmail:
s.logger.Printf("[WARN] self-bind link for %s NOT sent: customer has no registered email", customerID)
http.Redirect(w, r, "/customers/"+customerID+"?flash=selfbind-no-email#tab=setup", http.StatusSeeOther)
case selfBindSendFailed:
http.Redirect(w, r, "/customers/"+customerID+"?flash=selfbind-send-failed#tab=setup", http.StatusSeeOther)
case selfBindMintFailed:
s.logger.Printf("[ERROR] self-bind link for %s: %v", customerID, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
default: // selfBindSent (the no-mailer case was refused above)
s.recordSelfBindSent(customerID, "operator button")
http.Redirect(w, r, "/customers/"+customerID+"?flash=selfbind-sent#tab=setup", http.StatusSeeOther)
}
}