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
+34
View File
@@ -1,5 +1,39 @@
## Changelog
### v0.122.0 — customer-claim password gate (closes DRILL-day0-vm F-4/F-5) (2026-07-12) — MinAgent: 0.81.0
The customer sets + OWNS the dashboard password; the old "no password → open dashboard" is gone.
An unclaimed box (hub-delivered claim-code hash present, no password) serves ONLY the claim page
— every other route answers the claim page (302 → `/claim`) or `401` (API), so a Day-0 box is
never open on the public internet (closes F-4; F-5's unauthenticated geo toggle closes with it).
Requires the hub's v0.50.0 claim engine (code generation + email + ACK/config delivery).
- **`internal/web/claim.go`** — the gate + pages. `claimGateActive()` (no password + code hash +
not claimed), `effectiveClaimCode()` (ACK-cached settings beats the config bake by generation),
the claim page (`GET /claim`), submit (`POST /claim`: verify code → set own password → claimed
→ consume generation → session), and "kérj új kódot / Elfelejtett jelszó" (`POST
/claim/request-new-code` → hub `reset-request`). Code checks: bcrypt match AND generation not
yet consumed (single-use) AND ≤ 72 h old. Per-source + global brute-force limiter (5 tries →
15-min lockout, fake-clock tested); a lockout raises the allowlisted `claim_lockout` event.
Pre-auth CSRF is an HMAC over `web.session_secret` (closes the CTRL-007 bare-double-submit
weakness), min password length 12.
- **Gate wiring** (`auth.go`, `csrf.go`, `server.go`, `cmd`): the gate sits atop `RequireAuth`; a
SET password disables it entirely (password auth wins — claimed boxes never regress). `/claim*`
+ `/static/*` stay reachable pre-auth (the code is the strong factor). Legacy-open (no password,
no hash) passes through with a red transition banner (`layout.html`) until the hub delivers a
hash. Login page gains an "Elfelejtett jelszó" link.
- **`internal/report/claim_sync.go`** — caches the ACK's `claim` {hash, generation} into
settings.json IDEMPOTENTLY BY GENERATION (offsite-descriptor one-way shape: newer generation
advances; same/older/nil never rewrites, a hub outage never clears). The report carries
`claimed` (set-only hub-side). `config.web.claim_code_*` baked by the hub gates from first boot.
- **`internal/settings`** — `Claimed` (set-only), `ClaimCode*` cache, `ClaimConsumedGeneration`
(single-use). **`--print-reset-code`** root escape hatch: prints a one-time local code (a
generation above cached/baked/consumed), the same gate consumes it.
- Tests: gate-coverage signature test (every route → claim/401, a deploy POST mutates nothing) +
happy-path/reuse-refused/expired/lockout+window-reopen; four §10 red-proofs proven
(mutate→FAIL→revert): gate skip-line, single-use generation (hub + controller), reset non-DoS,
rate-limiter.
### v0.121.0 — backups page truth pass (dead sections removed, real Tier-3 state, SQLite-honest DB) (2026-07-12) — MinAgent: 0.81.0
Pure UI/data-plumbing on `/backups`; no backup-engine behavior change, no agent-API change, MinAgent
+27
View File
@@ -1557,6 +1557,33 @@ self_update:
### 8. Authentication & Settings
#### Customer-claim gate (`internal/web/claim.go`, v0.122.0 — closes DRILL-day0-vm F-4/F-5)
The dashboard password is **customer-owned**, set through a one-time claim code the hub emails to
the registered address (no operator-set path, no open-until-set window). This closes the fresh-box
race where a new `felhom.<domain>` cert appears in CT logs minutes before any password exists.
- **States** (precedence): a SET password (settings→config) always wins — the gate never shows.
Else a delivered **claim-code hash + not-yet-claimed** → GATED: every route serves the claim
page (`302 → /claim`) or `401` JSON (API); only `/claim*`, `/static/*`, `/api/health` pass. Else
(no password, no hash) → **legacy-open** with a red transition banner until the hub delivers a
hash (transitional only, never the fresh-box state).
- **Claim/reset flow**: `GET /claim` (code + new password ×2, min 12) → `POST /claim` verifies the
code (bcrypt match AND generation not yet consumed AND ≤ 72 h old), sets the customer's password
via `settings.SetPasswordHash`, marks `Claimed` (set-only), consumes the generation (single-use),
invalidates sessions, issues a fresh one. `POST /claim/request-new-code` (the "Új kód kérése" /
login-page "Elfelejtett jelszó") forwards to the hub, which emails a fresh code to the
registered address only. Reset rides the same page (a claimed box reaches `/claim` pre-auth).
- **Anti-brute-force**: per-source + global counter, 5 failures → 15-minute lockout (both scopes),
raising the allowlisted `claim_lockout` event. Pre-auth CSRF is an HMAC over `web.session_secret`
(fixes the CTRL-007 bare-double-submit weakness).
- **Delivery**: the hub bakes `web.claim_code_{hash,generation,issued_at}` into the Day-0
controller.yaml (gate-from-first-boot) and serves the freshest state in the report ACK
(`report/claim_sync.go` caches it idempotently by generation — newer advances, same/older/nil
never rewrites, a hub outage never clears). The report carries `claimed` (hub ingests set-only).
- **Escape hatch**: `felhom-controller --print-reset-code` prints a one-time local code (generation
above cached/baked/consumed); the same gate consumes it. Root-gated by `docker exec` reachability.
#### Session Auth (`internal/web/auth.go`)
- bcrypt password verification with configurable source priority: `settings.json``controller.yaml` → no auth (open access)
+23
View File
@@ -67,6 +67,7 @@ func main() {
configPath := flag.String("config", "/opt/docker/felhom-controller/controller.yaml", "Path to configuration file")
showVersion := flag.Bool("version", false, "Show version and exit")
printResetCode := flag.Bool("print-reset-code", false, "Customer-claim escape hatch (v0.122.0, F-4): print a fresh one-time local claim/reset code to stdout, then exit. Root-gated by reachability (docker exec). Same gate consumes it.")
flag.Parse()
if *showVersion {
@@ -74,6 +75,20 @@ func main() {
os.Exit(0)
}
if *printResetCode {
cfg, err := config.LoadPermissive(*configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "print-reset-code: loading config: %v\n", err)
os.Exit(1)
}
sett, err := settings.Load(cfg.Paths.DataDir+"/settings.json", log.New(os.Stderr, "", 0))
if err != nil {
fmt.Fprintf(os.Stderr, "print-reset-code: loading settings: %v\n", err)
os.Exit(1)
}
os.Exit(web.PrintLocalResetCode(sett, cfg))
}
startTime := time.Now()
// --- Load configuration ---
@@ -487,6 +502,10 @@ func main() {
report.SetPendingLogTails(resp.LogTailRequests)
// v0.116.0: the controller's OWN ring, same pattern (selftail.go).
report.SetPendingControllerLog(resp.ControllerLogRequested)
// v0.122.0 (F-4): cache the hub-delivered claim-code state (idempotent by
// generation). The web gate reads it on the next request — no restart needed.
claimSync := &report.ClaimSync{Settings: sett, Logger: logger}
claimSync.Reconcile(resp.Claim)
}
// Wire hub push status into alert manager for dashboard alerts
alertMgr.SetHubPushStatus(func() web.HubPushStatusData {
@@ -579,6 +598,7 @@ func main() {
}
sched.Every("hub-report", pushInterval, func(ctx context.Context) error {
r := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), logger)
r.Claimed = sett.GetClaimed() // v0.122.0 (F-4): set-only claim flag for the hub
if err := hubPusher.Push(r); err != nil {
return err
}
@@ -684,6 +704,7 @@ func main() {
if hubPusher != nil {
if cfg.Hub.Enabled {
r := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), logger)
r.Claimed = sett.GetClaimed() // v0.122.0 (F-4): set-only claim flag for the hub
var pushErr error
for attempt := 1; attempt <= 3; attempt++ {
pushErr = hubPusher.Push(r)
@@ -762,6 +783,7 @@ func main() {
apiRouter.SetReportPushTrigger(func() {
go func() {
rep := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), logger)
rep.Claimed = sett.GetClaimed() // v0.122.0 (F-4): set-only claim flag for the hub
if err := hubPusher.Push(rep); err != nil {
logger.Printf("[WARN] [report] Out-of-band geo report push failed: %v", err)
}
@@ -876,6 +898,7 @@ func main() {
if hubPusher != nil {
dc.TriggerHubReportPush = func() error {
r := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), logger)
r.Claimed = sett.GetClaimed() // v0.122.0 (F-4): set-only claim flag for the hub
return hubPusher.Push(r)
}
}
+14
View File
@@ -122,6 +122,14 @@ type WebConfig struct {
SetupListen string `yaml:"setup_listen"` // Plain HTTP listener for setup wizard (only active during setup mode)
PasswordHash string `yaml:"password_hash"`
SessionSecret string `yaml:"session_secret"`
// Customer-claim arc (v0.122.0, DRILL-day0-vm F-4): the hub-baked claim/reset code state —
// bcrypt(code) + monotonic generation + issue time (RFC3339). Day-0 configs carry these so a
// fresh box is claim-gated from FIRST boot; live boxes get fresher values via the report ACK
// (settings.json wins when its generation is newer). A set password always beats the gate.
ClaimCodeHash string `yaml:"claim_code_hash"`
ClaimCodeGeneration int `yaml:"claim_code_generation"`
ClaimCodeIssuedAt string `yaml:"claim_code_issued_at"`
}
type GitConfig struct {
@@ -418,3 +426,9 @@ func (cfg *Config) AppScreenshotURL(slug string, index int) string {
func (cfg *Config) AppPageURL(slug string) string {
return fmt.Sprintf("/apps/%s", slug)
}
// WebClaimGeneration returns the config-baked claim-code generation (0 when none). Satisfies the
// web.ClaimHatchConfig seam for the --print-reset-code escape hatch.
func (cfg *Config) WebClaimGeneration() int {
return cfg.Web.ClaimCodeGeneration
}
+53
View File
@@ -0,0 +1,53 @@
package report
import "log"
// Customer-claim arc (v0.122.0, F-4) — the ACK-side of the claim-code delivery. The hub serves
// the ACTIVE code's bcrypt hash + monotonic generation on every report ACK of a managed
// customer; this reconciler caches it into settings.json IDEMPOTENTLY BY GENERATION (the
// offsite-descriptor guard shape: an unchanged generation never rewrites, a hub outage never
// clears — set/refresh only, exactly like the escrow confirmer's one-way rules). The gate itself
// (internal/web) reads the cached state; nothing here decides gating.
// ClaimStatus mirrors the hub ACK's `claim` object (nil when the hub has no claim row / old hub).
type ClaimStatus struct {
CodeHash string `json:"code_hash"`
Generation int `json:"generation"`
IssuedAt string `json:"issued_at"` // RFC3339
}
// ClaimSettings is the settings surface the sync needs (satisfied by *settings.Settings).
type ClaimSettings interface {
GetClaimCode() (hash string, generation int, issuedAt string)
SetClaimCode(hash string, generation int, issuedAt string) error
}
// ClaimSync applies one ACK's claim status to the settings cache.
type ClaimSync struct {
Settings ClaimSettings
Logger *log.Logger
}
func (c *ClaimSync) logf(f string, a ...any) {
if c.Logger != nil {
c.Logger.Printf(f, a...)
}
}
// Reconcile caches a newer-generation code state; same-or-older generations and nil/empty
// statuses are no-ops (a rotation is the ONLY thing that moves the cache — no write-back, no
// clearing on hub silence).
func (c *ClaimSync) Reconcile(cs *ClaimStatus) {
if cs == nil || cs.CodeHash == "" || cs.Generation <= 0 {
return
}
_, curGen, _ := c.Settings.GetClaimCode()
if cs.Generation <= curGen {
return // idempotent: this generation (or a newer one) is already cached
}
if err := c.Settings.SetClaimCode(cs.CodeHash, cs.Generation, cs.IssuedAt); err != nil {
c.logf("[ERROR] [claim-sync] caching hub claim code (gen %d) failed (retries next ACK): %v", cs.Generation, err)
return
}
c.logf("[INFO] [claim-sync] hub claim code cached (generation %d) — hash first 8: %.8s…", cs.Generation, cs.CodeHash)
}
@@ -0,0 +1,62 @@
package report
import (
"io"
"log"
"testing"
)
type fakeClaimSettings struct {
hash string
gen int
issued string
setCall int
}
func (f *fakeClaimSettings) GetClaimCode() (string, int, string) { return f.hash, f.gen, f.issued }
func (f *fakeClaimSettings) SetClaimCode(hash string, gen int, issued string) error {
f.hash, f.gen, f.issued = hash, gen, issued
f.setCall++
return nil
}
func newSync(f *fakeClaimSettings) *ClaimSync {
return &ClaimSync{Settings: f, Logger: log.New(io.Discard, "", 0)}
}
// A newer generation caches; the same/older generation and nil are no-ops (idempotent, one-way).
func TestClaimSync_IdempotentByGeneration(t *testing.T) {
f := &fakeClaimSettings{}
s := newSync(f)
s.Reconcile(&ClaimStatus{CodeHash: "h1", Generation: 1, IssuedAt: "t1"})
if f.gen != 1 || f.hash != "h1" || f.setCall != 1 {
t.Fatalf("first cache: %+v", f)
}
// Same generation → no write.
s.Reconcile(&ClaimStatus{CodeHash: "h1-again", Generation: 1, IssuedAt: "t1"})
if f.setCall != 1 || f.hash != "h1" {
t.Fatalf("same generation must not rewrite: %+v", f)
}
// Older generation → no write (a lagging ACK can't regress the cache).
s.Reconcile(&ClaimStatus{CodeHash: "h0", Generation: 0, IssuedAt: "t0"})
if f.setCall != 1 {
t.Fatalf("older generation must not rewrite: %+v", f)
}
// Newer generation (a resend) → cache advances.
s.Reconcile(&ClaimStatus{CodeHash: "h2", Generation: 2, IssuedAt: "t2"})
if f.gen != 2 || f.hash != "h2" || f.setCall != 2 {
t.Fatalf("newer generation should advance: %+v", f)
}
// nil / empty / non-positive generation → no-op (old hub, no claim row).
s.Reconcile(nil)
s.Reconcile(&ClaimStatus{CodeHash: "", Generation: 3})
s.Reconcile(&ClaimStatus{CodeHash: "h", Generation: 0})
if f.setCall != 2 {
t.Fatalf("nil/empty/zero-gen must be no-ops: %+v", f)
}
}
+3
View File
@@ -48,6 +48,9 @@ type PushResponse struct {
// ring; the NEXT report ships controller_log_tail (selftail.go). Absent/false on an
// old hub = nothing pending.
ControllerLogRequested bool `json:"controller_log_requested"`
// Claim (v0.122.0, F-4) — the hub's active claim-code state (bcrypt hash + generation) for
// the customer-claim gate. nil on an old hub / no claim row → the cache stays as-is.
Claim *ClaimStatus `json:"claim"`
}
// Pusher sends reports to the central hub.
+5
View File
@@ -43,6 +43,11 @@ type Report struct {
// the cycle right after the ACK's controller_log_requested (selftail.go; additive — the
// app-tail flow above is untouched).
ControllerLogTail *ControllerLogTail `json:"controller_log_tail,omitempty"`
// Claimed (v0.122.0, F-4) — whether the customer has completed the dashboard claim (set
// their own password). The hub ingests it SET-ONLY: a later false (wiped settings.json
// after DR) never un-claims the customer hub-side.
Claimed bool `json:"claimed"`
}
// SystemReport holds host-level system info.
+65
View File
@@ -28,6 +28,17 @@ type Settings struct {
// Auth
PasswordHash string `json:"password_hash,omitempty"` // bcrypt hash, overrides controller.yaml
// Customer-claim arc (v0.122.0, F-4). Claimed is SET-ONLY (a claim or reset completed at
// least once — never cleared). ClaimCode* cache the freshest hub-delivered code state (report
// ACK; beats controller.yaml when its generation is newer). ClaimConsumedGeneration records
// the last code generation successfully consumed — a code of a consumed generation is dead
// even if its hash still matches (single-use).
Claimed bool `json:"claimed,omitempty"`
ClaimCodeHash string `json:"claim_code_hash,omitempty"`
ClaimCodeGeneration int `json:"claim_code_generation,omitempty"`
ClaimCodeIssuedAt string `json:"claim_code_issued_at,omitempty"` // RFC3339
ClaimConsumedGeneration int `json:"claim_consumed_generation,omitempty"`
// Notification preferences (Phase 2 — define struct now, leave empty)
Notifications *NotificationPrefs `json:"notifications,omitempty"`
@@ -405,6 +416,60 @@ func (s *Settings) SetPasswordHash(hash string) error {
return s.save()
}
// ── Customer-claim arc (v0.122.0) ──────────────────────────────────────────────
// GetClaimed reports whether this box has completed a claim (set-only).
func (s *Settings) GetClaimed() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.Claimed
}
// SetClaimed marks the box claimed (never un-claims) and saves.
func (s *Settings) SetClaimed() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.Claimed {
return nil
}
s.Claimed = true
return s.save()
}
// GetClaimCode returns the cached hub-delivered code state (hash, generation, issuedAt RFC3339).
func (s *Settings) GetClaimCode() (hash string, generation int, issuedAt string) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.ClaimCodeHash, s.ClaimCodeGeneration, s.ClaimCodeIssuedAt
}
// SetClaimCode caches a hub-delivered code state (idempotent by generation — the caller guards).
func (s *Settings) SetClaimCode(hash string, generation int, issuedAt string) error {
s.mu.Lock()
defer s.mu.Unlock()
s.ClaimCodeHash = hash
s.ClaimCodeGeneration = generation
s.ClaimCodeIssuedAt = issuedAt
return s.save()
}
// GetClaimConsumedGeneration returns the last successfully consumed code generation.
func (s *Settings) GetClaimConsumedGeneration() int {
s.mu.RLock()
defer s.mu.RUnlock()
return s.ClaimConsumedGeneration
}
// SetClaimConsumedGeneration records a consumed code generation (single-use enforcement).
func (s *Settings) SetClaimConsumedGeneration(gen int) error {
s.mu.Lock()
defer s.mu.Unlock()
if gen > s.ClaimConsumedGeneration {
s.ClaimConsumedGeneration = gen
}
return s.save()
}
// GetDBValidations returns a copy of the cached DB validations.
func (s *Settings) GetDBValidations() map[string]DBValidationCache {
s.mu.RLock()
+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>