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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ") {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user