package web import ( "crypto/sha256" "encoding/hex" "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/. 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) 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) } } // 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) http.Redirect(w, r, "/customers/"+customerID+"?flash=selfbind-sent#tab=setup", http.StatusSeeOther) } }