controller: customer-claim password gate v0.122.0 (closes DRILL-day0-vm F-4/F-5)

The customer sets + owns the dashboard password via a hub-emailed one-time
claim code. An unclaimed box (code hash present, no password) serves ONLY the
claim page — every other route → claim page (302) or 401, so a Day-0 box is
never open on the internet. A set password disables the gate (auth wins).
Reset rides the same code engine (login "Elfelejtett jelszó"). Legacy-open
(no password, no hash) shows a red transition banner until the hub delivers a
hash. Report ACK caches the code state idempotently by generation; report
carries claimed (set-only). --print-reset-code root escape hatch. Requires
hub v0.50.0. Gate-coverage signature test + 4 red-proofs proven.
This commit is contained in:
2026-07-12 18:42:39 +02:00
parent dec6fef20d
commit 3cf49c7fd5
18 changed files with 1117 additions and 1 deletions
+25 -1
View File
@@ -51,7 +51,24 @@ func (s *Server) authEnabled() bool {
// RequireAuth returns middleware that checks for valid session or shows login.
func (s *Server) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip auth if no password is configured
// Customer-claim gate (v0.122.0, F-4): an unclaimed box with a delivered code hash and no
// password serves ONLY the claim page + its assets; everything else → claim page / 401.
// The claim routes (/claim, /claim/request-new-code) are handled by the mux — let them
// through so serveClaimGate only intercepts the GATED paths. A set password disables the
// gate entirely (claimGateActive returns false → the normal auth path below runs).
if s.claimGateActive() {
if claimPageAllowedPath(r.URL.Path) {
next.ServeHTTP(w, r)
return
}
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] claim gate: intercepting %s %s (unclaimed)", r.Method, r.URL.Path)
}
s.serveClaimGate(w, r)
return
}
// Skip auth if no password is configured (legacy-open transition state, or claim disabled).
if !s.authEnabled() {
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] auth: no password configured, passing through %s %s", r.Method, r.URL.Path)
@@ -65,6 +82,13 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler {
return
}
// Claim/reset routes stay reachable pre-auth even on a claimed box: they are the RESET
// entry (code-gated internally). Static assets for the page too.
if r.URL.Path == "/claim" || r.URL.Path == "/claim/request-new-code" || strings.HasPrefix(r.URL.Path, "/static/") {
next.ServeHTTP(w, r)
return
}
if r.URL.Path == "/login" && r.Method == http.MethodPost {
s.handleLogin(w, r)
return
+471
View File
@@ -0,0 +1,471 @@
package web
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
// Customer-claim password arc (v0.122.0, DRILL-day0-vm F-4). The customer OWNS the dashboard
// password: an unclaimed box serves ONLY the claim page (code → set own password → claimed);
// everything else answers the claim page (HTML) or 401 (API). A set password always wins (the
// gate never shows once effectivePasswordHash != ""). Reset rides the same code engine. A box
// with a code hash but no password and not-yet-claimed is GATED; a box with neither hash nor
// password is legacy-open with a red transition banner (transitional only).
const (
claimCodeTTL = 72 * time.Hour
claimMinPassword = 12
claimMaxAttempts = 5
claimLockoutWindow = 15 * time.Minute
claimCSRFCookie = "felhom_claim_csrf"
)
// claimAttempt tracks failed claim-code attempts for the per-source + global limiter.
type claimAttempt struct {
count int
lockedTill time.Time
}
// effectiveClaimCode returns the freshest hub-delivered claim-code state: the ACK-cached
// settings value when its generation is at least the config-baked one (fresher), else the
// controller.yaml bake. Returns ("", 0, "") when neither carries a code.
func (s *Server) effectiveClaimCode() (hash string, generation int, issuedAt string) {
var sHash, sIssued string
var sGen int
if s.settings != nil {
sHash, sGen, sIssued = s.settings.GetClaimCode()
}
cHash := s.cfg.Web.ClaimCodeHash
cGen := s.cfg.Web.ClaimCodeGeneration
cIssued := s.cfg.Web.ClaimCodeIssuedAt
if sHash != "" && sGen >= cGen {
return sHash, sGen, sIssued
}
return cHash, cGen, cIssued
}
// claimGateActive reports whether the unclaimed-gate applies: no password set anywhere, a claim
// code hash is present, and the box has not been claimed. A set password (settings or config)
// disables the gate entirely — password auth wins.
func (s *Server) claimGateActive() bool {
if s.authEnabled() {
return false // a password beats the gate (claimed boxes, or an operator-set one)
}
hash, _, _ := s.effectiveClaimCode()
if hash == "" {
return false // legacy-open (transition state) — no code to gate on
}
if s.settings != nil && s.settings.GetClaimed() {
return false // claimed but password somehow cleared — don't re-gate; treat as legacy-open
}
return true
}
// claimLegacyOpen reports the transitional open state: no password, no code hash — the red
// banner is shown until the hub delivers a code hash. NOT the fresh-box state (that is gated).
func (s *Server) claimLegacyOpen() bool {
if s.authEnabled() {
return false
}
hash, _, _ := s.effectiveClaimCode()
return hash == ""
}
// ── pre-auth CSRF for the claim form (closes CTRL-007: HMAC with the server-side session
// secret, not a bare double-submit) ────────────────────────────────────────────────────────
func (s *Server) claimCSRFToken() string {
mac := hmac.New(sha256.New, []byte(s.cfg.Web.SessionSecret))
mac.Write([]byte("felhom-claim-csrf-v1"))
return hex.EncodeToString(mac.Sum(nil))
}
func (s *Server) setClaimCSRFCookie(w http.ResponseWriter, r *http.Request) string {
tok := s.claimCSRFToken()
http.SetCookie(w, &http.Cookie{
Name: claimCSRFCookie,
Value: tok,
Path: "/",
HttpOnly: false, // read back only by the form on the same page; SameSite blocks cross-site
SameSite: http.SameSiteStrictMode,
Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https",
MaxAge: int(claimCodeTTL.Seconds()),
})
return tok
}
func (s *Server) validClaimCSRF(r *http.Request) bool {
want := s.claimCSRFToken()
form := r.FormValue(csrfFormField)
if subtle.ConstantTimeCompare([]byte(form), []byte(want)) != 1 {
return false
}
c, err := r.Cookie(claimCSRFCookie)
if err != nil {
return false
}
return subtle.ConstantTimeCompare([]byte(c.Value), []byte(want)) == 1
}
// ── the limiter (per-source IP + a global counter; both must be clear) ───────────────────────
func (s *Server) claimRateLocked() (locked bool, till time.Time) {
s.claimMu.Lock()
defer s.claimMu.Unlock()
now := s.claimNow()
if s.claimGlobal.lockedTill.After(now) {
return true, s.claimGlobal.lockedTill
}
return false, time.Time{}
}
func (s *Server) claimSourceLocked(ip string) (locked bool, till time.Time) {
s.claimMu.Lock()
defer s.claimMu.Unlock()
now := s.claimNow()
a := s.claimAttempts[ip]
if a != nil && a.lockedTill.After(now) {
return true, a.lockedTill
}
return false, time.Time{}
}
// claimRegisterFailure bumps the per-IP + global counters; on hitting the cap it locks that
// scope for claimLockoutWindow and returns locked=true (the caller reports the lockout event).
// An EXPIRED lock resets its scope's counter first, so a fresh attempt after the window starts
// clean rather than re-locking on a stale count.
func (s *Server) claimRegisterFailure(ip string) (locked bool) {
s.claimMu.Lock()
defer s.claimMu.Unlock()
now := s.claimNow()
if s.claimAttempts == nil {
s.claimAttempts = make(map[string]*claimAttempt)
}
a := s.claimAttempts[ip]
if a == nil {
a = &claimAttempt{}
s.claimAttempts[ip] = a
}
if !a.lockedTill.IsZero() && !a.lockedTill.After(now) {
*a = claimAttempt{} // per-IP lock expired → clean slate
}
if !s.claimGlobal.lockedTill.IsZero() && !s.claimGlobal.lockedTill.After(now) {
s.claimGlobal = claimAttempt{} // global lock expired → clean slate
}
a.count++
s.claimGlobal.count++
if a.count >= claimMaxAttempts {
a.lockedTill = now.Add(claimLockoutWindow)
locked = true
}
if s.claimGlobal.count >= claimMaxAttempts {
s.claimGlobal.lockedTill = now.Add(claimLockoutWindow)
locked = true
}
return locked
}
func (s *Server) claimClearFailures(ip string) {
s.claimMu.Lock()
defer s.claimMu.Unlock()
delete(s.claimAttempts, ip)
s.claimGlobal = claimAttempt{}
}
// claimNow is the clock seam (tests inject a fake). Defaults to time.Now.
func (s *Server) claimNow() time.Time {
if s.claimClock != nil {
return s.claimClock()
}
return time.Now()
}
func requestIP(r *http.Request) string {
ip := r.RemoteAddr
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
ip = strings.Split(fwd, ",")[0]
}
return strings.TrimSpace(ip)
}
// ── the pages ────────────────────────────────────────────────────────────────────────────────
// claimPageAllowedPath reports the paths reachable while the unclaimed gate is active (the claim
// page itself, its static assets, health). Everything else is gated.
func claimPageAllowedPath(path string) bool {
switch path {
case "/claim", "/claim/request-new-code", "/api/health":
return true
}
return strings.HasPrefix(path, "/static/")
}
// serveClaimGate is invoked by RequireAuth when the unclaimed gate is active and the request is
// NOT an allowed path: render the claim page (HTML) or a 401 (API / mutating).
func (s *Server) serveClaimGate(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprint(w, `{"ok":false,"error":"dashboard not yet claimed"}`)
return
}
http.Redirect(w, r, "/claim", http.StatusFound)
}
// handleClaimPage renders the claim/reset code-entry page (GET). Reachable pre-auth: the code is
// the strong factor. For a claimed box (password set) it doubles as the reset-code entry.
func (s *Server) handleClaimPage(w http.ResponseWriter, r *http.Request, errorMsg, flashMsg string) {
csrf := s.setClaimCSRFCookie(w, r)
hash, _, _ := s.effectiveClaimCode()
reset := s.authEnabled() // a set password means this is the reset flow, not first-claim
data := map[string]interface{}{
"Title": "A szerver beállítása",
"CustomerName": s.cfg.Customer.Name,
"Domain": s.cfg.Customer.Domain,
"Version": s.version,
"Error": errorMsg,
"Flash": flashMsg,
"ClaimCSRF": csrf,
"IsReset": reset,
"HasCode": hash != "",
"MinPassword": claimMinPassword,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tmpl.ExecuteTemplate(w, "claim", data); err != nil {
s.logger.Printf("[ERROR] [web] Template error (claim): %v", err)
http.Error(w, "Internal error", http.StatusInternalServerError)
}
}
// handleClaimSubmit verifies the code and sets the customer's password (POST /claim). On success
// the box is claimed (or the password reset), the code generation is consumed (single-use), all
// sessions are invalidated and a fresh one is issued.
func (s *Server) handleClaimSubmit(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
if !s.validClaimCSRF(r) {
s.handleClaimPage(w, r, "Érvénytelen űrlap — töltsd újra az oldalt.", "")
return
}
wasReset := s.authEnabled() // a password already set → this is a reset, not a first-claim
ip := requestIP(r)
if locked, _ := s.claimRateLocked(); locked {
s.handleClaimPage(w, r, "Túl sok próbálkozás — próbáld újra 15 perc múlva.", "")
return
}
if locked, _ := s.claimSourceLocked(ip); locked {
s.handleClaimPage(w, r, "Túl sok próbálkozás — próbáld újra 15 perc múlva.", "")
return
}
code := strings.TrimSpace(r.FormValue("code"))
newPassword := r.FormValue("new_password")
confirm := r.FormValue("confirm_password")
hash, generation, issuedAt := s.effectiveClaimCode()
if hash == "" {
s.handleClaimPage(w, r, "Nincs aktív kód — kérj újat az alábbi gombbal.", "")
return
}
// Code checks: not expired, not an already-consumed generation, hash matches. A failure of
// ANY of these counts toward the lockout (they are indistinguishable to a guesser).
valid := true
if consumed := s.settings.GetClaimConsumedGeneration(); generation <= consumed {
valid = false // this code was already used (single-use)
}
if valid && issuedAt != "" {
if t, err := time.Parse(time.RFC3339, issuedAt); err == nil && s.claimNow().Sub(t) > claimCodeTTL {
valid = false // expired
}
}
if valid && bcrypt.CompareHashAndPassword([]byte(hash), []byte(code)) != nil {
valid = false // wrong code
}
if !valid {
if s.claimRegisterFailure(ip) {
s.reportClaimLockout(ip)
s.handleClaimPage(w, r, "Túl sok próbálkozás — próbáld újra 15 perc múlva.", "")
return
}
s.handleClaimPage(w, r, "Hibás vagy lejárt kód", "")
return
}
// Password rules (min length, match).
if len(newPassword) < claimMinPassword {
s.handleClaimPage(w, r, fmt.Sprintf("A jelszónak legalább %d karakter hosszúnak kell lennie", claimMinPassword), "")
return
}
if newPassword != confirm {
s.handleClaimPage(w, r, "A két jelszó nem egyezik", "")
return
}
pwHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), 10)
if err != nil {
s.logger.Printf("[ERROR] [web] claim: hashing new password: %v", err)
s.handleClaimPage(w, r, "Belső hiba a jelszó mentésekor", "")
return
}
if err := s.settings.SetPasswordHash(string(pwHash)); err != nil {
s.logger.Printf("[ERROR] [web] claim: saving password: %v", err)
s.handleClaimPage(w, r, "Belső hiba a jelszó mentésekor", "")
return
}
// Consume the generation (single-use) + mark claimed (set-only). Order: consume BEFORE
// claimed so a crash between them can't leave a reusable code on a claimed box.
if err := s.settings.SetClaimConsumedGeneration(generation); err != nil {
s.logger.Printf("[WARN] [web] claim: recording consumed generation failed: %v", err)
}
if err := s.settings.SetClaimed(); err != nil {
s.logger.Printf("[WARN] [web] claim: marking claimed failed: %v", err)
}
s.claimClearFailures(ip)
s.invalidateAllSessions() // reset: kill old sessions; first-claim: none exist
action := "claimed"
if wasReset {
action = "password reset"
}
s.logger.Printf("[INFO] [web] dashboard %s by the customer from %s (code generation %d consumed)", action, ip, generation)
// Issue a fresh session so the customer lands logged-in.
token := s.createSession()
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: token,
Path: "/",
MaxAge: int(sessionMaxAge.Seconds()),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https",
})
http.Redirect(w, r, "/", http.StatusFound)
}
// handleClaimRequestNewCode forwards a "kérj új kódot" / "Elfelejtett jelszó" to the hub, which
// emails a FRESH code to the REGISTERED address only (the requester never chooses the
// destination). The response is always the neutral confirmation page.
func (s *Server) handleClaimRequestNewCode(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
if !s.validClaimCSRF(r) {
s.handleClaimPage(w, r, "Érvénytelen űrlap — töltsd újra az oldalt.", "")
return
}
go s.requestHubResetCode() // fire-and-forget; the neutral response never reveals the outcome
s.handleClaimPage(w, r, "", "Ha az e-mail cím regisztrálva van, elküldtük a kódot.")
}
// requestHubResetCode calls POST /api/v1/claim/reset-request with the box's own report key.
func (s *Server) requestHubResetCode() {
if s.cfg.Hub.URL == "" || s.cfg.Hub.APIKey == "" {
s.logger.Printf("[WARN] [web] claim: cannot request a new code — hub URL/key not configured")
return
}
body, _ := json.Marshal(map[string]string{"customer_id": s.cfg.Customer.ID})
req, err := http.NewRequest(http.MethodPost, strings.TrimRight(s.cfg.Hub.URL, "/")+"/api/v1/claim/reset-request", strings.NewReader(string(body)))
if err != nil {
s.logger.Printf("[ERROR] [web] claim: building reset-request: %v", err)
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+s.cfg.Hub.APIKey)
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
s.logger.Printf("[ERROR] [web] claim: reset-request to hub failed: %v", err)
return
}
resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
s.logger.Printf("[WARN] [web] claim: hub reset-request returned HTTP %d", resp.StatusCode)
return
}
s.logger.Printf("[INFO] [web] claim: requested a fresh code from the hub for %s", s.cfg.Customer.ID)
}
// PrintLocalResetCode is the root escape hatch (v0.122.0, --print-reset-code): generate a fresh
// local claim/reset code, install its hash at a generation ABOVE any cached/consumed one (so the
// gate accepts it), persist to settings.json, and print the plaintext ONCE to stdout. Same gate
// consumes it (single-use). Root-gated by reachability (docker exec into the container). Returns
// a process exit code.
func PrintLocalResetCode(sett ClaimHatchSettings, cfg ClaimHatchConfig) int {
code, err := localCode()
if err != nil {
fmt.Fprintf(os.Stderr, "print-reset-code: generating code: %v\n", err)
return 1
}
hash, err := bcrypt.GenerateFromPassword([]byte(code), 10)
if err != nil {
fmt.Fprintf(os.Stderr, "print-reset-code: hashing code: %v\n", err)
return 1
}
_, cachedGen, _ := sett.GetClaimCode()
nextGen := cachedGen
if cfg.WebClaimGeneration() > nextGen {
nextGen = cfg.WebClaimGeneration()
}
if c := sett.GetClaimConsumedGeneration(); c >= nextGen {
nextGen = c
}
nextGen++ // strictly above cached, baked, and consumed → the gate treats it as fresh + unused
if err := sett.SetClaimCode(string(hash), nextGen, time.Now().UTC().Format(time.RFC3339)); err != nil {
fmt.Fprintf(os.Stderr, "print-reset-code: saving code: %v\n", err)
return 1
}
fmt.Printf("Egyszer használható helyi beállító/visszaállító kód (generation %d):\n\n %s\n\nAdd meg a vezérlőpult beállító oldalán (/claim), majd válassz új jelszót.\n", nextGen, code)
return 0
}
// ClaimHatchSettings / ClaimHatchConfig are the minimal seams the escape hatch needs (satisfied
// by *settings.Settings and *config.Config respectively — kept as interfaces so cmd/ wires them
// without this package importing config for a one-off).
type ClaimHatchSettings interface {
GetClaimCode() (hash string, generation int, issuedAt string)
GetClaimConsumedGeneration() int
SetClaimCode(hash string, generation int, issuedAt string) error
}
type ClaimHatchConfig interface {
WebClaimGeneration() int
}
// localCode makes a readable one-time code (three 4-char base32-ish groups) without needing the
// hub's Hungarian word list — it is typed once, locally, by the operator.
func localCode() (string, error) {
const alphabet = "abcdefghjkmnpqrstuvwxyz23456789" // no ambiguous 0/1/i/l/o
b := make([]byte, 12)
if _, err := rand.Read(b); err != nil {
return "", err
}
out := make([]byte, 0, 14)
for i, v := range b {
if i > 0 && i%4 == 0 {
out = append(out, '-')
}
out = append(out, alphabet[int(v)%len(alphabet)])
}
return string(out), nil
}
// reportClaimLockout pushes the allowlisted claim_lockout event (operator + customer visibility).
func (s *Server) reportClaimLockout(ip string) {
s.logger.Printf("[WARN] [web] claim: code lockout tripped (source %s) — 15 min", ip)
if s.notifier != nil {
s.notifier.PushEvent("claim_lockout", "warning",
"Túl sok hibás beállító/visszaállító kód — a beállító oldal 15 percre zárolva",
map[string]interface{}{"source": ip})
}
}
+245
View File
@@ -0,0 +1,245 @@
package web
import (
"io"
"log"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
"golang.org/x/crypto/bcrypt"
)
// claimTestServer builds a Server with a claim code installed (unclaimed, no password) and the
// full mux (RequireAuth+CsrfProtect wired exactly as main.go does), so route-level gating is
// exercised end-to-end. Returns the server, the plaintext code, and the settings.
func claimTestServer(t *testing.T) (*Server, string, *settings.Settings) {
t.Helper()
lg := log.New(io.Discard, "", 0)
dir := t.TempDir()
cfg := &config.Config{}
cfg.Customer.ID = "c1"
cfg.Customer.Name = "Teszt"
cfg.Customer.Domain = "example.hu"
cfg.Paths.StacksDir = filepath.Join(dir, "stacks")
cfg.Paths.DataDir = filepath.Join(dir, "data")
cfg.Stacks.ComposeCommand = "docker compose"
cfg.Web.SessionSecret = "test-session-secret-abcdef"
sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg)
if err != nil {
t.Fatalf("settings: %v", err)
}
mgr, err := stacks.NewManager(cfg, lg)
if err != nil {
t.Fatalf("stacks: %v", err)
}
s := &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg, version: "test"}
s.loadTemplates()
code := "alma-korte-szilva"
hash, _ := bcrypt.GenerateFromPassword([]byte(code), 10)
if err := sett.SetClaimCode(string(hash), 1, time.Now().UTC().Format(time.RFC3339)); err != nil {
t.Fatalf("SetClaimCode: %v", err)
}
return s, code, sett
}
// fullMux replicates main.go's handler composition so the gate is tested where it actually runs.
func (s *Server) fullMux() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })
mux.Handle("/", s.RequireAuth(s.CsrfProtect(http.HandlerFunc(s.ServeHTTP))))
return mux
}
// §10 THE SIGNATURE TEST — every route of an unclaimed box (with a code hash) answers the claim
// page (redirect to /claim) or a 401 JSON; NOTHING else is reachable, and a mutating POST reaches
// NO handler. Red-proof: remove the claim-gate block in RequireAuth → these assertions fail.
func TestClaimGate_EveryRouteGated(t *testing.T) {
s, _, _ := claimTestServer(t)
mux := s.fullMux()
// A representative sweep of the real route surface (pages + APIs + a mutating deploy POST).
htmlRoutes := []string{"/", "/dashboard", "/stacks", "/backups", "/monitoring", "/settings", "/settings/security", "/storage", "/apps/vaultwarden", "/import", "/debug"}
for _, p := range htmlRoutes {
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, p, nil))
if rr.Code != http.StatusFound || rr.Header().Get("Location") != "/claim" {
t.Errorf("GET %s: got %d loc=%q, want 302→/claim", p, rr.Code, rr.Header().Get("Location"))
}
}
apiRoutes := []string{"/api/disks", "/api/storage/x", "/api/host-metrics", "/api/backup/restore-status"}
for _, p := range apiRoutes {
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, p, nil))
if rr.Code != http.StatusUnauthorized || !strings.Contains(rr.Body.String(), "not yet claimed") {
t.Errorf("GET %s: got %d body=%q, want 401 not-yet-claimed", p, rr.Code, rr.Body.String())
}
}
// A mutating deploy POST must be REFUSED before any handler runs (401, no side effect).
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, httptest.NewRequest(http.MethodPost, "/api/stacks/vaultwarden/deploy", strings.NewReader("{}")))
if rr.Code != http.StatusUnauthorized {
t.Errorf("POST deploy on unclaimed box: got %d, want 401 (no mutation reachable)", rr.Code)
}
// The claim page + its assets + health ARE reachable.
for _, p := range []string{"/claim", "/api/health", "/static/style.css"} {
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, p, nil))
if rr.Code != http.StatusOK {
t.Errorf("GET %s on unclaimed box: got %d, want 200 (allowed)", p, rr.Code)
}
}
}
// The happy-path claim: correct code + password → password set, claimed, code consumed, session
// issued; a second use of the SAME code is refused (single-use via consumed generation).
func TestClaimSubmit_HappyPathThenReuseRefused(t *testing.T) {
s, code, sett := claimTestServer(t)
do := func(codeVal, pw string) *httptest.ResponseRecorder {
form := url.Values{"_csrf": {s.claimCSRFToken()}, "code": {codeVal}, "new_password": {pw}, "confirm_password": {pw}}
req := httptest.NewRequest(http.MethodPost, "/claim", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: claimCSRFCookie, Value: s.claimCSRFToken()})
rr := httptest.NewRecorder()
s.handleClaimSubmit(rr, req)
return rr
}
rr := do(code, "a-strong-passphrase-12")
if rr.Code != http.StatusFound || rr.Header().Get("Location") != "/" {
t.Fatalf("claim submit: got %d loc=%q, want 302→/", rr.Code, rr.Header().Get("Location"))
}
if !sett.GetClaimed() {
t.Fatal("box not marked claimed after a successful claim")
}
if !s.authEnabled() {
t.Fatal("password not set after claim (authEnabled false)")
}
if s.claimGateActive() {
t.Fatal("gate still active after claim")
}
if bcrypt.CompareHashAndPassword([]byte(s.effectivePasswordHash()), []byte("a-strong-passphrase-12")) != nil {
t.Fatal("stored password does not verify the chosen password")
}
// A session cookie was issued.
if len(rr.Result().Cookies()) == 0 {
t.Fatal("no session cookie issued on claim")
}
// Reuse the SAME code (generation 1, now consumed) → refused even though the hash matches.
rr = do(code, "another-strong-pass-12")
if rr.Code == http.StatusFound {
t.Fatal("consumed code was accepted again — single-use broken")
}
if !strings.Contains(rr.Body.String(), "Hibás vagy lejárt kód") {
t.Errorf("reuse should show the wrong/expired-code error, body=%q", claimFirstLine(rr.Body.String()))
}
}
// Wrong codes lock the endpoint after 5 attempts (fake clock); the window then reopens.
func TestClaimSubmit_LockoutAndWindowReopen(t *testing.T) {
s, _, _ := claimTestServer(t)
now := time.Date(2026, 7, 12, 12, 0, 0, 0, time.UTC)
s.claimClock = func() time.Time { return now }
submitWrong := func() *httptest.ResponseRecorder {
form := url.Values{"_csrf": {s.claimCSRFToken()}, "code": {"wrong-wrong-wrong"}, "new_password": {"x-really-long-pass"}, "confirm_password": {"x-really-long-pass"}}
req := httptest.NewRequest(http.MethodPost, "/claim", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.RemoteAddr = "203.0.113.7:5000"
req.AddCookie(&http.Cookie{Name: claimCSRFCookie, Value: s.claimCSRFToken()})
rr := httptest.NewRecorder()
s.handleClaimSubmit(rr, req)
return rr
}
for i := 0; i < claimMaxAttempts; i++ {
submitWrong()
}
// The 6th (post-cap) attempt is locked out.
rr := submitWrong()
if !strings.Contains(rr.Body.String(), "Túl sok próbálkozás") {
t.Fatalf("expected lockout after %d failures, body=%q", claimMaxAttempts, claimFirstLine(rr.Body.String()))
}
// Advance past the window → unlocked (a wrong code shows the normal error again, not lockout).
now = now.Add(claimLockoutWindow + time.Minute)
rr = submitWrong()
if strings.Contains(rr.Body.String(), "Túl sok próbálkozás") {
t.Fatal("still locked after the window elapsed")
}
if !strings.Contains(rr.Body.String(), "Hibás vagy lejárt kód") {
t.Errorf("post-window wrong code should show the normal error, body=%q", claimFirstLine(rr.Body.String()))
}
}
// An expired code (issued > 72h ago) is refused.
func TestClaimSubmit_ExpiredCodeRefused(t *testing.T) {
s, code, sett := claimTestServer(t)
// Re-issue the code with an old issued_at.
hash, _ := bcrypt.GenerateFromPassword([]byte(code), 10)
sett.SetClaimCode(string(hash), 2, time.Now().Add(-73*time.Hour).UTC().Format(time.RFC3339))
form := url.Values{"_csrf": {s.claimCSRFToken()}, "code": {code}, "new_password": {"a-strong-passphrase-12"}, "confirm_password": {"a-strong-passphrase-12"}}
req := httptest.NewRequest(http.MethodPost, "/claim", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: claimCSRFCookie, Value: s.claimCSRFToken()})
rr := httptest.NewRecorder()
s.handleClaimSubmit(rr, req)
if rr.Code == http.StatusFound {
t.Fatal("expired code was accepted")
}
if !strings.Contains(rr.Body.String(), "Hibás vagy lejárt kód") {
t.Errorf("expected expired-code error, body=%q", claimFirstLine(rr.Body.String()))
}
}
// Legacy-open (no password, no code hash) passes through with the red banner flag; a box with a
// set password is entirely ungated (no claim page ever).
func TestClaimGate_LegacyOpenAndPasswordSet(t *testing.T) {
// Legacy-open: fresh server, no claim code, no password.
lg := log.New(io.Discard, "", 0)
dir := t.TempDir()
cfg := &config.Config{}
cfg.Customer.Domain = "example.hu"
cfg.Paths.StacksDir = filepath.Join(dir, "s")
cfg.Paths.DataDir = filepath.Join(dir, "d")
cfg.Stacks.ComposeCommand = "docker compose"
sett, _ := settings.Load(filepath.Join(dir, "settings.json"), lg)
mgr, _ := stacks.NewManager(cfg, lg)
s := &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg, version: "test"}
s.loadTemplates()
if s.claimGateActive() {
t.Fatal("no code hash → gate must NOT be active (legacy-open)")
}
if !s.claimLegacyOpen() {
t.Fatal("no password + no code → expected legacy-open")
}
// Password set → neither gated nor legacy-open.
pw, _ := bcrypt.GenerateFromPassword([]byte("existing-strong-pass"), 10)
sett.SetPasswordHash(string(pw))
if s.claimGateActive() || s.claimLegacyOpen() {
t.Fatal("a set password must disable both the gate and the legacy banner")
}
}
func claimFirstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}
+7
View File
@@ -32,6 +32,13 @@ func (s *Server) CsrfProtect(next http.Handler) http.Handler {
return
}
// Claim/reset POSTs carry their OWN pre-auth HMAC CSRF (validated in the handler) — the
// customer resetting a claimed box has no session yet, so the session-CSRF path can't apply.
if r.URL.Path == "/claim" || r.URL.Path == "/claim/request-new-code" {
next.ServeHTTP(w, r)
return
}
// Skip CSRF for Bearer-token authenticated requests.
// Validate the token against the configured API key before skipping.
if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") {
+3
View File
@@ -121,6 +121,9 @@ func (s *Server) baseData(page, title string) map[string]interface{} {
"Version": s.version,
"AuthEnabled": s.authEnabled(),
"DebugMode": s.isDebug(),
// Customer-claim arc (v0.122.0, F-4): the transitional legacy-open banner — no password,
// no code hash yet. Cleared the moment the hub delivers a code hash (gate flips on).
"ClaimLegacyOpen": s.claimLegacyOpen(),
}
if s.alertManager != nil {
data["Alerts"] = s.alertManager.GetAlerts()
+15
View File
@@ -50,6 +50,13 @@ type Server struct {
done chan struct{}
closeOnce sync.Once
// Customer-claim arc (v0.122.0, F-4): the claim/reset code brute-force limiter. Per-source
// (IP) + a global counter; both must be clear. claimClock is the test clock seam (nil → time.Now).
claimMu sync.Mutex
claimAttempts map[string]*claimAttempt
claimGlobal claimAttempt
claimClock func() time.Time
// Guard for FileBrowser sync — prevents concurrent file writes (H5 fix)
fileBrowserMu sync.Mutex
@@ -272,6 +279,14 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
switch {
// Customer-claim arc (v0.122.0, F-4): the code-entry page + its handlers. Reachable pre-auth
// (code-gated internally); CSRF via the pre-auth HMAC token (validated inside the handlers).
case path == "/claim" && r.Method == http.MethodGet:
s.handleClaimPage(w, r, "", r.URL.Query().Get("flash"))
case path == "/claim" && r.Method == http.MethodPost:
s.handleClaimSubmit(w, r)
case path == "/claim/request-new-code" && r.Method == http.MethodPost:
s.handleClaimRequestNewCode(w, r)
case path == "/" || path == "/dashboard":
s.dashboardHandler(w, r)
case path == "/stacks":
@@ -0,0 +1,56 @@
{{define "claim"}}
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{if .IsReset}}Jelszó visszaállítása{{else}}A szerver beállítása{{end}} — Felhom</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="login-body">
<div class="login-card">
<img src="/static/felhom-logo.svg" alt="Felhom.eu" class="login-logo">
<h1 class="login-title">{{if .IsReset}}Jelszó <span class="title-accent">visszaállítása</span>{{else}}A szerver <span class="title-accent">beállítása</span>{{end}}</h1>
<p class="login-subtitle">{{.CustomerName}}</p>
{{if .Flash}}<div class="alert alert-info">{{.Flash}}</div>{{end}}
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
{{if .HasCode}}
<p style="font-size:0.85rem;color:var(--text-muted,#8a94a6);margin:0 0 1rem">
{{if .IsReset}}Add meg az e-mailben kapott visszaállító kódot, majd válassz új jelszót.{{else}}Add meg az e-mailben kapott beállító kódot, majd válassz saját jelszót a vezérlőpult védelméhez.{{end}}
</p>
<form method="POST" action="/claim">
<input type="hidden" name="_csrf" value="{{.ClaimCSRF}}">
<div class="form-group">
<label for="code">{{if .IsReset}}Visszaállító kód{{else}}Beállító kód{{end}}</label>
<input type="text" id="code" name="code" required autofocus autocomplete="off"
placeholder="szó-szó-szó" class="form-control">
</div>
<div class="form-group">
<label for="new_password">Új jelszó (min. {{.MinPassword}} karakter)</label>
<input type="password" id="new_password" name="new_password" required minlength="{{.MinPassword}}"
placeholder="Legalább {{.MinPassword}} karakter" class="form-control">
</div>
<div class="form-group">
<label for="confirm_password">Új jelszó megerősítése</label>
<input type="password" id="confirm_password" name="confirm_password" required minlength="{{.MinPassword}}"
placeholder="Jelszó mégegyszer" class="form-control">
</div>
<button type="submit" class="btn btn-primary btn-full">{{if .IsReset}}Jelszó beállítása{{else}}Beállítás és belépés{{end}}</button>
</form>
{{else}}
<div class="alert alert-info">Jelenleg nincs aktív kód ehhez a szerverhez. Kérj egy újat az alábbi gombbal — az e-mailben érkezik a regisztrált címre.</div>
{{end}}
<form method="POST" action="/claim/request-new-code" style="margin-top:1rem">
<input type="hidden" name="_csrf" value="{{.ClaimCSRF}}">
<button type="submit" class="btn btn-outline btn-full">{{if .IsReset}}Visszaállító kód kérése{{else}}Nem kaptad meg a kódot? Új kód kérése{{end}}</button>
</form>
<p class="login-footer">Felhom — Otthoni szerver kezelés<br>
<a href="https://felhom.eu" target="_blank">felhom.eu</a></p>
</div>
</body>
</html>
{{end}}
@@ -47,6 +47,14 @@
</div>
</nav>
<main class="content">
{{if .ClaimLegacyOpen}}
<div class="alerts-container">
<div class="alert-banner alert-banner-error">
<span class="alert-icon"><svg class="ico"><use href="#i-triangle-alert"/></svg></span>
<span class="alert-message">A vezérlőpult még nincs jelszóval védve — a beállító kódot hamarosan e-mailben küldjük.</span>
</div>
</div>
{{end}}
{{if .Alerts}}
<div class="alerts-container">
{{range .Alerts}}
@@ -23,6 +23,7 @@
</div>
<button type="submit" class="btn btn-primary btn-full">Bejelentkezés</button>
</form>
<p style="text-align:center;margin:0.75rem 0 0;font-size:0.85rem"><a href="/claim">Elfelejtett jelszó</a></p>
<p class="login-footer">Felhom — Otthoni szerver kezelés<br>
<a href="https://felhom.eu" target="_blank">felhom.eu</a></p>
</div>