package web import ( "crypto/subtle" "html/template" "net/http" "regexp" "strings" "sync" "time" "gitea.dooplex.hu/admin/felhom-hub/internal/configgen" "gitea.dooplex.hu/admin/felhom-hub/internal/store" "gitea.dooplex.hu/admin/felhom-hub/internal/i18n" ) // selfbind.go — the CUSTOMER side of self-bind (v0.66.0, R-27 slice 1): the PUBLIC /bind/ page. // A logged-out customer opens the emailed capability link and binds their own freshly-installed // appliance by proving TWO factors — the console pairing code (physical possession of the box) and // their retrieval passphrase (customer identity). No hub login exists; the URL token IS the auth. // // THE TRAP (spec §9.2): /bind/ is the ONE new public prefix, exempted from operator auth + CSRF at // the two gate sites the /login exemption occupies. isPublicBindPath is the SINGLE definition of that // prefix — both gate sites and the route dispatch call it, so widening it is one visible change (and // the red-proof targets exactly this function). It is matched TIGHTLY (trailing slash → no sibling // prefix like /bindsecret; the ServeMux cleans .. before we see the path → no traversal reach). // // No-oracle rules on this surface: the page NEVER renders/enumerates any appliance data; a wrong code // and a wrong passphrase produce ONE identical generic failure; both factors are compared // unconditionally before the decision; and only attempt COUNTS are logged (never the secrets, never // the raw token — a hash prefix at most). // isPublicBindPath reports whether a path is the public customer self-bind surface. THE single // definition of the /bind/ public prefix (THE TRAP §9.2) — do not inline a second copy anywhere. func isPublicBindPath(path string) bool { return strings.HasPrefix(path, "/bind/") } // --- per-IP rate limiter (web-package sibling of api.ipRateLimiter; the type there is unexported) --- type bindBucket struct { tokens float64 last time.Time } type bindRateLimiter struct { mu sync.Mutex perMinute float64 buckets map[string]*bindBucket now func() time.Time } func newBindRateLimiter(perMinute int) *bindRateLimiter { if perMinute <= 0 { perMinute = 30 } return &bindRateLimiter{perMinute: float64(perMinute), buckets: make(map[string]*bindBucket), now: time.Now} } func (rl *bindRateLimiter) allow(ip string) bool { rl.mu.Lock() defer rl.mu.Unlock() now := rl.now() b, ok := rl.buckets[ip] if !ok { rl.buckets[ip] = &bindBucket{tokens: rl.perMinute - 1, last: now} return true } b.tokens += now.Sub(b.last).Seconds() * (rl.perMinute / 60.0) if b.tokens > rl.perMinute { b.tokens = rl.perMinute } b.last = now if b.tokens < 1 { return false } b.tokens-- return true } // bindClientIP extracts the client IP behind the ingress (first XFF hop, else RemoteAddr) — buckets // only; the ingress geo-gate is the real access control. func bindClientIP(r *http.Request) string { if xff := r.Header.Get("X-Forwarded-For"); xff != "" { if i := strings.IndexByte(xff, ','); i > 0 { return strings.TrimSpace(xff[:i]) } return strings.TrimSpace(xff) } if i := strings.LastIndexByte(r.RemoteAddr, ':'); i > 0 { return r.RemoteAddr[:i] } return r.RemoteAddr } // --- the page --- type bindPageData struct { State string // "form" | "success" | "expired" | "consumed" | "locked" | "resent" Token string // echoed into the form action (the capability itself; already in the URL) Failed bool // generic factor-check failure (form state only) // Lang is the language to render in. It is the CUSTOMER'S CREATION-TIME language and nothing // else: no box has reported yet at bind time, and this page deliberately offers no switch — // the person opening it is a stranger holding a capability URL, exactly like the guest share // pages, and a language control here would be a setting a stranger could touch. Lang string } // bindTemplates holds ONE PARSED TEMPLATE PER LANGUAGE, with the bundle's text substituted into the // markup BEFORE html/template parses it. // // This is the controller's design (10-localisation.md rule 3) and it is chosen for the same reason: // the Hungarian set is parsed from exactly the bytes the page carried before it was converted, in // the same escaping contexts, so the Hungarian page is byte-identical by construction rather than by // inspection. A runtime T function would route every string through the contextual escaper and move // bytes (`—`, quotes) in ways nobody would notice until a customer saw them. var bindTemplates = buildBindTemplates() func buildBindTemplates() map[string]*template.Template { out := make(map[string]*template.Template, len(i18n.Supported)) b := i18n.Shared() for _, lang := range i18n.Supported { html := i18nMarkerRe.ReplaceAllStringFunc(bindPageHTML, func(m string) string { return b.Msg(lang, i18nMarkerRe.FindStringSubmatch(m)[1]) }) // Must: a template that fails to parse is a broken build, not a broken request. It fails // at startup and in every test, rather than serving a stranger a blank page. out[lang] = template.Must(template.New("bind-" + lang).Parse(html)) } return out } // i18nMarkerRe matches a {{T "key"}} marker. Strict on purpose: a malformed marker is NOT // substituted, so it reaches html/template as a call to an undefined function and the build fails // loudly — never a marker shown to a customer. var i18nMarkerRe = regexp.MustCompile(`\{\{\s*T\s+"([A-Za-z0-9_.\-]+)"\s*\}\}`) func (s *Server) renderBind(w http.ResponseWriter, status int, data bindPageData) { tmpl, ok := bindTemplates[i18n.Normalize(data.Lang)] if !ok { tmpl = bindTemplates[i18n.Default] } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(status) if err := tmpl.Execute(w, data); err != nil { s.logger.Printf("[ERROR] rendering /bind page: %v", err) } } // handleBind serves the public self-bind page. GET renders the current link state; POST validates // both factors and, on success, stages the bind (same BindAppliance the operator uses; provenance = // customer self-bind). Reached only via isPublicBindPath — auth + CSRF exempt at the gate sites. func (s *Server) handleBind(w http.ResponseWriter, r *http.Request) { if s.bindLimiter != nil && !s.bindLimiter.allow(bindClientIP(r)) { s.renderBind(w, http.StatusTooManyRequests, bindPageData{State: "expired", Lang: i18n.Default}) return } token := strings.TrimPrefix(r.URL.Path, "/bind/") // R-719 (v0.126.0): „Új linket kérek" on an expired or used link. if t, ok := strings.CutSuffix(token, "/resend"); ok && r.Method == http.MethodPost && t != "" && !strings.Contains(t, "/") { s.handleBindResend(w, t) return } // A trailing segment only — reject anything with further path structure (defence in depth atop // the ServeMux path-clean; the token is a flat hex string). if token == "" || strings.Contains(token, "/") { s.renderBind(w, http.StatusNotFound, bindPageData{State: "expired", Lang: i18n.Default}) return } hash := selfBindHash(token) tok, err := s.store.SelfBindTokenByHash(hash) if err != nil { s.logger.Printf("[ERROR] /bind lookup failed: %v", err) http.Error(w, "Internal error", http.StatusInternalServerError) return } now := time.Now() // THE LANGUAGE IS ITSELF AN ORACLE, so it is resolved with the same care as the text (R-558). // // This page folds an UNKNOWN token into "expired" precisely so a stranger cannot learn whether a // link was ever real. If the page then rendered a real customer's token in English and an unknown // one in Hungarian, the LANGUAGE would answer the question the TEXT refuses to — for every // customer who is not Hungarian. So: // // - the "expired" state ALWAYS renders in the default language, because that is the state an // unknown token lands in and the two must be indistinguishable; // - every other state already discloses that the token is real (its text says so), so those // may follow the customer. // // Pinned by TestBindExpiredIsAlwaysDefaultLanguage. lang := i18n.Default if tok != nil { lang = s.store.CustomerLanguage(tok.CustomerID) } // Terminal link states — identical for GET and POST, no factor check attempted. An unknown token // (nil) is folded into "expired": no oracle for "was this link ever real". switch { case tok == nil || tok.Expired(now): // The token is echoed for the resend form whether or not it is real: the page must not differ. s.renderBind(w, http.StatusOK, bindPageData{State: "expired", Lang: i18n.Default, Token: token}) return case tok.Consumed(): s.renderBind(w, http.StatusOK, bindPageData{State: "consumed", Lang: lang, Token: token}) return case tok.Locked: s.renderBind(w, http.StatusOK, bindPageData{State: "locked", Lang: lang}) return } if r.Method != http.MethodPost { s.renderBind(w, http.StatusOK, bindPageData{State: "form", Token: token, Lang: lang}) return } // --- POST: validate BOTH factors unconditionally, then decide (no oracle) --- normCode := configgen.NormalizePairingCode(r.FormValue("pairing_code")) normPass := configgen.NormalizePassphrase(r.FormValue("passphrase")) // Factor 2 (passphrase) — the customer's retrieval passphrase, constant-time compared. Computed // even when the customer/appliance is absent so the two paths are indistinguishable by timing. var storedPass string if cc, cerr := s.store.GetCustomerConfig(tok.CustomerID); cerr == nil && cc != nil { storedPass = configgen.NormalizePassphrase(cc.RetrievalPassword) } passOK := storedPass != "" && subtle.ConstantTimeCompare([]byte(normPass), []byte(storedPass)) == 1 // Factor 1 (pairing code) — the ONE bindable appliance carrying that console code. appliance, aerr := s.store.ApplianceByPairingCode(normCode) if aerr != nil { s.logger.Printf("[ERROR] /bind appliance lookup failed: %v", aerr) http.Error(w, "Internal error", http.StatusInternalServerError) return } codeOK := appliance != nil if !codeOK || !passOK { attempts, locked, rerr := s.store.RecordSelfBindAttempt(hash) if rerr != nil { s.logger.Printf("[ERROR] /bind recording attempt: %v", rerr) } // COUNTS only — never which factor failed, never the secrets, never the raw token. s.logger.Printf("[WARN] self-bind attempt %d/%d failed for token %s… (customer %s)", attempts, store.SelfBindMaxAttempts, hash[:8], tok.CustomerID) if locked { s.renderBind(w, http.StatusOK, bindPageData{State: "locked", Lang: lang}) return } s.renderBind(w, http.StatusOK, bindPageData{State: "form", Token: token, Failed: true, Lang: lang}) return } // Both factors passed. Consume one-shot FIRST (atomic gate against a double-bind race). consumed, cerr := s.store.ConsumeSelfBindToken(hash) if cerr != nil { s.logger.Printf("[ERROR] /bind consuming token: %v", cerr) http.Error(w, "Internal error", http.StatusInternalServerError) return } if !consumed { // Lost the race (a concurrent request consumed it) — it is already being bound. s.renderBind(w, http.StatusOK, bindPageData{State: "consumed", Lang: lang}) return } if err := s.store.BindAppliance(appliance.ID, tok.CustomerID, "appliance", ""); err != nil { // Rare: the appliance became unbindable (operator discarded it) between lookup and bind. The // token is spent; surface a neutral generic failure rather than an appliance-state oracle. s.logger.Printf("[WARN] self-bind: BindAppliance %d → %s failed after factor match: %v", appliance.ID, tok.CustomerID, err) s.renderBind(w, http.StatusOK, bindPageData{State: "form", Failed: true, Lang: lang}) return } if _, err := s.store.SaveEvent(tok.CustomerID, "appliance_bound", "info", "Az ügyfél saját maga kötötte össze az új eszközt (bare-metal telepítés); a hozzáférést a doboz a következő lekérdezéskor megkapja.", "", "customer_selfbind"); err != nil { s.logger.Printf("[WARN] self-bind: save event for %s: %v", tok.CustomerID, err) } s.logger.Printf("[INFO] self-bind SUCCESS: appliance %d bound to customer %s by customer self-service (token %s…)", appliance.ID, tok.CustomerID, hash[:8]) s.renderBind(w, http.StatusOK, bindPageData{State: "success", Lang: lang}) } // bindPageHTML is the self-contained public page. It CANNOT link /style.css (that route is // operator-auth gated), so all styling is inline — mirroring the login page. Design tokens: navy // surface, 2px radius, hairline rules, exception color for the failure banner. Hungarian, adult tone, // no emoji. It renders NO appliance data in any state. const bindPageHTML = ` {{T "bind.title"}}

{{T "bind.heading"}}

{{if eq .State "form"}}

{{T "bind.lead"}}

{{if .Failed}}{{end}}

{{T "bind.hint.pairing"}}

{{T "bind.hint.passphrase"}}

{{T "bind.note"}}

{{else if eq .State "success"}}

{{T "bind.success.lead"}}

{{T "bind.success.body"}}

{{else if eq .State "consumed"}}

{{T "bind.consumed.lead"}}

{{T "bind.consumed.body"}}

{{T "bind.resend.hint"}}

{{else if eq .State "resent"}}

{{T "bind.resent.lead"}}

{{T "bind.resent.body"}}

{{else if eq .State "locked"}}

{{T "bind.locked.lead"}}

{{T "bind.locked.body"}}

{{else}}

{{T "bind.invalid.lead"}}

{{T "bind.invalid.body"}}

{{if .Token}}

{{T "bind.resend.hint"}}

{{end}} {{end}}

Felhom.eu

` // ── R-719 (v0.126.0): a returning customer asks for a fresh link from the old one ───────────────────── // // A box that registers is UNCLAIMED — the hub cannot know whose it is until the bind (measured 2026-09-30: // the registration carries uuid, MACs, host keys and hardware, nothing of a customer). So "send the link // when their box registers" cannot be built without mailing every waiting customer. What the returning // customer DOES have is their old mail: its link now answers „expired" (7-day TTL) or „already used". That // page offers one press; the hub mints and mails a fresh link to the address REGISTERED for that link's // customer, and only when the customer has no box (the same guard as every other auto-send). // // No oracle: the answer is the same „resent" page, in the default language, whether the token was real, // unknown, still live, or the customer already has a box. Limits: the per-IP bind limiter, and one mail // per customer per bindResendEvery. Pinned by internal/web/selfbind_resend_test.go. const bindResendEvery = time.Hour func (s *Server) handleBindResend(w http.ResponseWriter, token string) { defer s.renderBind(w, http.StatusOK, bindPageData{State: "resent", Lang: i18n.Default}) tok, err := s.store.SelfBindTokenByHash(selfBindHash(token)) if err != nil || tok == nil { return } now := time.Now() if !tok.Expired(now) && !tok.Consumed() { return // a live link needs no replacement } s.bindResendMu.Lock() last := s.bindResendAt[tok.CustomerID] if now.Sub(last) < bindResendEvery { s.bindResendMu.Unlock() s.logger.Printf("[INFO] self-bind: fresh link for %s NOT sent — one was sent %s ago (limit %s)", tok.CustomerID, now.Sub(last).Round(time.Second), bindResendEvery) return } if s.bindResendAt == nil { s.bindResendAt = map[string]time.Time{} } s.bindResendAt[tok.CustomerID] = now s.bindResendMu.Unlock() s.autoMintSelfBindIfWaiting(tok.CustomerID, "fresh link asked on an expired or used link") }