1e8d045815
- felhom-tunnel network 172.16.253.0/29 (ip-range .4/30): cloudflared alone at .2, traefik at .3; traefik's websecure trusts forwarded headers from 172.16.253.2/32 only, and every request passes felhom-forwarded@file, which removes the client-writable host/path/address headers (X-Forwarded-Host/-Uri/-Method/-Prefix, Forwarded, True-Client-Ip, …) and fixes X-Forwarded-Port to 443 (measured: Cloudflare passes a client's X-Forwarded-Host/-Port). - EnsureBaseStack reconciles a RUNNING traefik/cloudflared whose rendered files changed (recreate), refuses a rewrite that would drop a certificate resolver, and moves cloudflared only once traefik is on the tunnel network. - clientIP: believed only when the TCP peer is traefik; the rightmost X-Forwarded-For entry (the hop traefik saw); the tunnel hop → CF-Connecting-IP (the edge refuses a client-sent one, measured 403). rateKey: IPv6 per /64. Dashboard login, claim, share and escrow counters key on it; the setup gate logs it. - Dashboard login messages: keys, informal voice, both languages. Red-proofs: RP-A1 (leftmost hop), RP-A2 (shared tunnel key), RP-A3 (no reconcile) — felhom.eu audits/visitors-2026-10-01/A. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
408 lines
15 KiB
Go
408 lines
15 KiB
Go
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=<token> → 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.<domain>/__gate/start?rd=<where it was going>;
|
|
// - 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: "<unix expiry>.<mac over host+expiry>".
|
|
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/<slug>/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/<slug>/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/<slug>/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/<slug>/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}, "")
|
|
}
|