80aeac71f6
gates / gates (push) Successful in 29s
Red-proofs RP40-RP42. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
393 lines
18 KiB
Go
393 lines
18 KiB
Go
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/<token> 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 = `<!DOCTYPE html>
|
|
<html lang="{{T "bind.htmllang"}}">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<meta name="robots" content="noindex, nofollow">
|
|
<title>{{T "bind.title"}}</title>
|
|
<style>
|
|
:root { --navy:#0A2540; --ink:#0A2540; --muted:#5b6b7d; --line:#d8e0e8; --brand:#0083D8; --exc:#c0392b; --bg:#f4f7fa; }
|
|
* { box-sizing: border-box; }
|
|
body { margin:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; background:var(--bg); color:var(--ink); line-height:1.5; }
|
|
.wrap { max-width:460px; margin:3rem auto; padding:0 1rem; }
|
|
.card { background:#fff; border:1px solid var(--line); border-radius:2px; padding:1.75rem; }
|
|
h1 { font-size:1.25rem; margin:0 0 .25rem; color:var(--navy); }
|
|
h1 span { color:var(--brand); }
|
|
.lead { color:var(--muted); font-size:.92rem; margin:.25rem 0 1.25rem; }
|
|
label { display:block; font-weight:600; font-size:.9rem; margin:1rem 0 .35rem; }
|
|
.hint { color:var(--muted); font-size:.8rem; margin:.15rem 0 0; }
|
|
input[type=text] { width:100%; padding:.6rem .7rem; border:1px solid var(--line); border-radius:2px; font-size:1rem; }
|
|
input.code { text-transform:uppercase; letter-spacing:.12em; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }
|
|
button { margin-top:1.5rem; width:100%; padding:.7rem; background:var(--brand); color:#fff; border:none; border-radius:2px; font-size:1rem; cursor:pointer; }
|
|
button:hover { background:#006cb0; }
|
|
.banner { border:1px solid var(--exc); color:var(--exc); background:#fbeae8; border-radius:2px; padding:.6rem .75rem; font-size:.88rem; margin:0 0 1rem; }
|
|
.note { border-top:1px solid var(--line); margin-top:1.5rem; padding-top:1rem; color:var(--muted); font-size:.82rem; }
|
|
.foot { text-align:center; color:var(--muted); font-size:.75rem; margin-top:1.25rem; }
|
|
.ok { color:#1e7e34; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrap">
|
|
<div class="card">
|
|
<h1>{{T "bind.heading"}}</h1>
|
|
{{if eq .State "form"}}
|
|
<p class="lead">{{T "bind.lead"}}</p>
|
|
{{if .Failed}}<div class="banner">{{T "bind.failed"}}</div>{{end}}
|
|
<form method="POST" action="/bind/{{.Token}}">
|
|
<label for="pairing_code">{{T "bind.label.pairing"}}</label>
|
|
<input class="code" type="text" id="pairing_code" name="pairing_code" autocomplete="off" autocapitalize="characters" spellcheck="false" required autofocus placeholder="{{T "bind.placeholder.pairing"}}">
|
|
<p class="hint">{{T "bind.hint.pairing"}}</p>
|
|
<label for="passphrase">{{T "bind.label.passphrase"}}</label>
|
|
<input type="text" id="passphrase" name="passphrase" autocomplete="off" spellcheck="false" required placeholder="{{T "bind.placeholder.passphrase"}}">
|
|
<p class="hint">{{T "bind.hint.passphrase"}}</p>
|
|
<button type="submit">{{T "bind.submit"}}</button>
|
|
</form>
|
|
<p class="note">{{T "bind.note"}}</p>
|
|
{{else if eq .State "success"}}
|
|
<p class="lead ok">{{T "bind.success.lead"}}</p>
|
|
<p>{{T "bind.success.body"}}</p>
|
|
{{else if eq .State "consumed"}}
|
|
<p class="lead">{{T "bind.consumed.lead"}}</p>
|
|
<p>{{T "bind.consumed.body"}}</p>
|
|
<form method="POST" action="/bind/{{.Token}}/resend"><p class="hint">{{T "bind.resend.hint"}}</p><button type="submit">{{T "bind.resend.button"}}</button></form>
|
|
{{else if eq .State "resent"}}
|
|
<p class="lead">{{T "bind.resent.lead"}}</p>
|
|
<p>{{T "bind.resent.body"}}</p>
|
|
{{else if eq .State "locked"}}
|
|
<p class="lead">{{T "bind.locked.lead"}}</p>
|
|
<p>{{T "bind.locked.body"}}</p>
|
|
{{else}}
|
|
<p class="lead">{{T "bind.invalid.lead"}}</p>
|
|
<p>{{T "bind.invalid.body"}}</p>
|
|
{{if .Token}}<form method="POST" action="/bind/{{.Token}}/resend"><p class="hint">{{T "bind.resend.hint"}}</p><button type="submit">{{T "bind.resend.button"}}</button></form>{{end}}
|
|
{{end}}
|
|
</div>
|
|
<p class="foot">Felhom.eu</p>
|
|
</div>
|
|
</body>
|
|
</html>`
|
|
|
|
// ── 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")
|
|
}
|