package web import ( "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "errors" "net/http" "net/url" "os" "path/filepath" "strconv" "strings" "sync" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" ) // ── The setup gate's answerer (v0.280.0, `09` §3 decision 46) ───────────────────────────────────────── // // traefik asks GET /__felhom_gate/auth (forwardAuth) for every request to an app whose gate is closed // (internal/stacks/setup_gate.go writes that route). The answer: // // - a valid GATE COOKIE for that app host → 200, the request goes to the app; // - /__felhom_gate/cb?t= → the token (minted below, 60 s, one use, bound to the host) is swapped for a // host-only gate cookie, and the browser goes back where it was going; // - a browser GET without one → 302 to https://felhom./__gate/start?rd=; // - anything else → 401 {"error": …}: a script or a phone app gets a plain refusal. // // GET /__gate/start is on the DASHBOARD host, where the household's own session cookie already is. With a // valid session: a token and a 302 to the app's callback. Without: a page that says the app is waiting for its // first setup, with a sign-in link that comes straight back here after the sign-in. // // The dashboard cookie is NEVER widened to the app hosts (it is host-only, auth.go) — the spike measured that // widening it would send the household's session to every app's backend. The gate cookie reaches only its // own app host and opens only that app's gate (HMAC over the host). The key is persisted, so a controller // restart does not re-gate a browser that already passed. // Pinned by internal/web/setup_gate_test.go. const ( gateCookieName = "felhom_gate" gateCookieLife = 7 * 24 * time.Hour gateTokenLife = 60 * time.Second gateAuthPath = "/__felhom_gate/auth" gateCallbackURI = "/__felhom_gate/cb" gateStartPath = "/__gate/start" // signupClosedPath: the sign-up block's replacePath target (internal/stacks/signup_block.go, decision 47). signupClosedPath = "/__felhom_gate/signup-closed" ) type gateState struct { once sync.Once key []byte mu sync.Mutex used map[string]time.Time // token nonces already swapped, until they expire } // gateKey loads the gate's HMAC key from the data dir, or makes one. No data dir → a key for this run only. func (s *Server) gateKey() []byte { s.gate.once.Do(func() { s.gate.used = map[string]time.Time{} p := "" if s.cfg != nil && s.cfg.Paths.DataDir != "" { p = filepath.Join(s.cfg.Paths.DataDir, "setup-gate.key") if b, err := os.ReadFile(p); err == nil { if k, err := hex.DecodeString(strings.TrimSpace(string(b))); err == nil && len(k) == 32 { s.gate.key = k return } } } k := make([]byte, 32) _, _ = rand.Read(k) s.gate.key = k if p != "" { if err := os.WriteFile(p, []byte(hex.EncodeToString(k)), 0o600); err != nil { s.logger.Printf("[WARN] [web] setup gate: the key could not be saved (%v) — a restart will ask browsers to sign in again", err) } } }) return s.gate.key } func (s *Server) gateMAC(parts ...string) string { m := hmac.New(sha256.New, s.gateKey()) m.Write([]byte(strings.Join(parts, "\x00"))) return hex.EncodeToString(m.Sum(nil)) } func (s *Server) gateNow() time.Time { if s.gateClock != nil { return s.gateClock() } return time.Now() } // gateCookieValid: ".". func (s *Server) gateCookieValid(r *http.Request, host string) bool { c, err := r.Cookie(gateCookieName) if err != nil { return false } exp, mac, ok := strings.Cut(c.Value, ".") n, err := strconv.ParseInt(exp, 10, 64) if !ok || err != nil || s.gateNow().Unix() > n { return false } return hmac.Equal([]byte(mac), []byte(s.gateMAC("cookie", host, exp))) } type gateToken struct { Host string `json:"h"` Exp int64 `json:"e"` Nonce string `json:"n"` RD string `json:"r"` MAC string `json:"m"` } func (s *Server) mintGateToken(host, rd string) string { nb := make([]byte, 12) _, _ = rand.Read(nb) t := gateToken{Host: host, Exp: s.gateNow().Add(gateTokenLife).Unix(), Nonce: hex.EncodeToString(nb), RD: rd} t.MAC = s.gateMAC("token", t.Host, strconv.FormatInt(t.Exp, 10), t.Nonce, t.RD) b, _ := json.Marshal(t) return base64.RawURLEncoding.EncodeToString(b) } // takeGateToken checks a token for host and uses it up. Returns where the browser was going. func (s *Server) takeGateToken(raw, host string) (string, error) { b, err := base64.RawURLEncoding.DecodeString(raw) if err != nil { return "", errors.New("malformed") } var t gateToken if json.Unmarshal(b, &t) != nil { return "", errors.New("malformed") } if !hmac.Equal([]byte(t.MAC), []byte(s.gateMAC("token", t.Host, strconv.FormatInt(t.Exp, 10), t.Nonce, t.RD))) { return "", errors.New("bad signature") } if t.Host != host { return "", errors.New("for another app") } now := s.gateNow() if now.Unix() > t.Exp { return "", errors.New("expired") } s.gateKey() s.gate.mu.Lock() defer s.gate.mu.Unlock() for n, until := range s.gate.used { if now.After(until) { delete(s.gate.used, n) } } if _, seen := s.gate.used[t.Nonce]; seen { return "", errors.New("already used") } s.gate.used[t.Nonce] = time.Unix(t.Exp, 0).Add(time.Second) return t.RD, nil } // gateRDHost: the host of a return address that may be used — https, on this household's domain, and an // app whose gate is closed. Anything else is refused (no open redirect through the dashboard). func (s *Server) gateRDHost(rd string) (string, bool) { u, err := url.Parse(rd) if err != nil || u.Scheme != "https" || u.User != nil || u.Host == "" || s.stackMgr == nil { return "", false } host := strings.ToLower(u.Hostname()) if u.Port() != "" || !strings.HasSuffix(host, "."+strings.ToLower(s.cfg.Customer.Domain)) { return "", false } if _, closed, found := s.stackMgr.SetupGateHost(host); !found || !closed { return "", false } return host, true } func gateRefuse(w http.ResponseWriter, code int) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(code) _, _ = w.Write([]byte(`{"error":"this app is waiting for its first setup"}`)) } // ServeGateAuth is traefik's forwardAuth answer. It trusts X-Forwarded-Host/-Uri/-Method, which traefik sets // from the request it is forwarding (the entrypoints trust no client's own X-Forwarded-* headers). Anyone who // calls it directly on the docker network learns only yes or no about a cookie they already hold. func (s *Server) ServeGateAuth(w http.ResponseWriter, r *http.Request) { host := strings.ToLower(r.Header.Get("X-Forwarded-Host")) if i := strings.LastIndex(host, ":"); i != -1 { host = host[:i] } uri := r.Header.Get("X-Forwarded-Uri") if uri == "" { uri = "/" } method := r.Header.Get("X-Forwarded-Method") if s.stackMgr == nil { gateRefuse(w, http.StatusForbidden) return } app, closed, found := s.stackMgr.SetupGateHost(host) if !found { // A host no app claims: fail closed. traefik only asks for hosts a gate file names. s.logger.Printf("[WARN] [web] setup gate: asked about %q, which no gated app owns — refused", host) gateRefuse(w, http.StatusForbidden) return } if !closed { // Opened; traefik has not dropped the file yet (it is removed right after the record is written). w.WriteHeader(http.StatusOK) return } if u, err := url.Parse(uri); err == nil && u.Path == gateCallbackURI { rd, err := s.takeGateToken(u.Query().Get("t"), host) if err != nil { s.logger.Printf("[WARN] [web] setup gate %s: a sign-in token was refused (%v) — visitor %s", app, err, clientIP(r)) gateRefuse(w, http.StatusForbidden) return } exp := strconv.FormatInt(s.gateNow().Add(gateCookieLife).Unix(), 10) http.SetCookie(w, &http.Cookie{ Name: gateCookieName, Value: exp + "." + s.gateMAC("cookie", host, exp), Path: "/", MaxAge: int(gateCookieLife.Seconds()), HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, }) s.logger.Printf("[INFO] [web] setup gate %s: the household passed (a dashboard session vouched for this browser) — visitor %s", app, clientIP(r)) w.Header().Set("Cache-Control", "no-store") http.Redirect(w, r, rd, http.StatusFound) return } if s.gateCookieValid(r, host) { w.WriteHeader(http.StatusOK) return } if (method == "" || method == http.MethodGet) && strings.Contains(r.Header.Get("Accept"), "text/html") { rd := "https://" + host + uri w.Header().Set("Cache-Control", "no-store") http.Redirect(w, r, "https://felhom."+s.cfg.Customer.Domain+gateStartPath+"?"+url.Values{"rd": {rd}}.Encode(), http.StatusFound) return } if s.isDebug() { s.logger.Printf("[DEBUG] [web] setup gate %s: %s %s without a pass — 401 (visitor %s)", app, method, uri, clientIP(r)) } gateRefuse(w, http.StatusUnauthorized) } // ServeGateStart is /__gate/start on the dashboard host. func (s *Server) ServeGateStart(w http.ResponseWriter, r *http.Request) { rd := r.URL.Query().Get("rd") host, ok := s.gateRDHost(rd) if !ok { http.Redirect(w, r, "/", http.StatusFound) return } w.Header().Set("Cache-Control", "no-store") if s.hasSession(r) { http.Redirect(w, r, "https://"+host+gateCallbackURI+"?"+url.Values{"t": {s.mintGateToken(host, rd)}}.Encode(), http.StatusFound) return } next := gateStartPath + "?" + url.Values{"rd": {rd}}.Encode() data := map[string]interface{}{ "LoginURL": "/login?" + url.Values{"next": {next}}.Encode(), "Host": host, } if app, _, found := s.stackMgr.SetupGateHost(host); found { if st, ok := s.stackMgr.GetStack(app); ok { data["AppName"] = st.Meta.DisplayName } } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.executeTemplateLang(w, r, "setupgate", data); err != nil { s.logger.Printf("[ERROR] [web] setup gate page: %v", err) http.Error(w, "Internal error", http.StatusInternalServerError) } } // appSetupGateOpenHandler is the household's "Done, I set it up" (POST /apps//setup-gate/open). func (s *Server) appSetupGateOpenHandler(w http.ResponseWriter, r *http.Request, slug string) { found := s.stackBySlug(slug) if found == nil { escrowJSON(w, http.StatusNotFound, nil, s.msg(r, "escrow.unknown_app")) return } // v0.281.0: an app that can say whether its setup is done is ASKED first — the press never opens an app that // still says "not done" (measured 2026-09-29: uptime-kuma pressed before its setup answered anyone). An // unreadable status refuses too (fail closed). Without a probe the page's confirm says what the press does. if has, done, got, perr := s.stackMgr.SetupGateProbe(found.Name); has && (perr != nil || !done) { s.logger.Printf("[INFO] [web] setup gate %s: the household's press refused — the app's own status says not done (value %q, err %v)", found.Name, got, perr) escrowJSON(w, http.StatusConflict, nil, s.msg(r, "err.setup_gate.probe_not_done")) return } if err := s.stackMgr.OpenSetupGate(found.Name, stacks.SetupGateByHousehold); err != nil { if errors.Is(err, stacks.ErrSetupGateNotClosed) { escrowJSON(w, http.StatusConflict, nil, s.msg(r, "err.setup_gate.not_closed")) return } s.logger.Printf("[ERROR] [web] setup gate %s: the household's open failed: %v", found.Name, err) escrowJSON(w, http.StatusInternalServerError, nil, s.msg(r, "err.setup_gate.open_failed")) return } escrowJSON(w, http.StatusOK, map[string]any{"opened": true}, "") } // appDefaultLoginChangedHandler is the household's "I changed it" under a known default login // (POST /apps//default-login/changed, R-710). func (s *Server) appDefaultLoginChangedHandler(w http.ResponseWriter, r *http.Request, slug string) { found := s.stackBySlug(slug) if found == nil || !found.Deployed { escrowJSON(w, http.StatusNotFound, nil, s.msg(r, "escrow.unknown_app")) return } if err := s.stackMgr.MarkDefaultLoginChanged(found.Name, "household"); err != nil { s.logger.Printf("[ERROR] [web] default login %s: %v", found.Name, err) escrowJSON(w, http.StatusInternalServerError, nil, s.msg(r, "err.setup_gate.open_failed")) return } escrowJSON(w, http.StatusOK, map[string]any{"recorded": true}, "") } // stackBySlug resolves a page slug exactly as appDetailHandler does. func (s *Server) stackBySlug(slug string) *stacks.Stack { if s.stackMgr == nil { return nil } for _, st := range s.stackMgr.GetStacks() { if st.Meta.Slug == slug { st := st return &st } } return nil } // appSignupWindowHandler is the household's "open sign-up for 15 minutes" (POST /apps//signup-window, decision 47). func (s *Server) appSignupWindowHandler(w http.ResponseWriter, r *http.Request, slug string) { found := s.stackBySlug(slug) if found == nil { escrowJSON(w, http.StatusNotFound, nil, s.msg(r, "escrow.unknown_app")) return } until, err := s.stackMgr.OpenSignupWindow(found.Name) if err != nil { if errors.Is(err, stacks.ErrNoSignupBlock) { escrowJSON(w, http.StatusConflict, nil, s.msg(r, "err.setup_gate.no_signup_block")) return } s.logger.Printf("[ERROR] [web] signup window %s: %v", found.Name, err) escrowJSON(w, http.StatusInternalServerError, nil, s.msg(r, "err.setup_gate.open_failed")) return } escrowJSON(w, http.StatusOK, map[string]any{"open_until": until}, "") } // ServeSignupClosed answers the app's own sign-up address while it is closed (the sign-up block's replacePath // sends it here). A browser gets a page, anything else a 403 JSON. It holds no secret and changes nothing. func (s *Server) ServeSignupClosed(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") if !strings.Contains(r.Header.Get("Accept"), "text/html") || r.Method != http.MethodGet { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusForbidden) _, _ = w.Write([]byte(`{"error":"sign-up is closed on this app; its admin adds new accounts"}`)) return } host := strings.ToLower(r.Host) if i := strings.LastIndex(host, ":"); i != -1 { host = host[:i] } data := map[string]interface{}{"Host": host} if s.stackMgr != nil { if app, _, found := s.stackMgr.SetupGateHost(host); found { if st, ok := s.stackMgr.GetStack(app); ok { data["AppName"] = st.Meta.DisplayName } } } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusForbidden) if err := s.executeTemplateLang(w, r, "signupclosed", data); err != nil { s.logger.Printf("[ERROR] [web] signup-closed page: %v", err) } } // appCloseSignupHandler is decision 49's "close sign-up now" (POST /apps//close-signup) for an app installed // before decision 47. It applies exactly what a fresh install gets after its setup; it never gates the app. func (s *Server) appCloseSignupHandler(w http.ResponseWriter, r *http.Request, slug string) { found := s.stackBySlug(slug) if found == nil { escrowJSON(w, http.StatusNotFound, nil, s.msg(r, "escrow.unknown_app")) return } if err := s.stackMgr.CloseSignupNow(found.Name); err != nil { if errors.Is(err, stacks.ErrCloseSignupNotOffered) { escrowJSON(w, http.StatusConflict, nil, s.msg(r, "err.setup_gate.close_signup_not_offered")) return } s.logger.Printf("[ERROR] [web] close sign-up %s: %v", found.Name, err) escrowJSON(w, http.StatusInternalServerError, nil, s.msg(r, "err.setup_gate.open_failed")) return } escrowJSON(w, http.StatusOK, map[string]any{"closed": true}, "") }