Files
felhom-controller/controller/internal/web/share_handlers.go
T
admin 1e8d045815 R-753: the box tells visitors apart — cloudflared at a fixed address, traefik trusts only it, the controller reads the hop traefik saw
- 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
2026-10-01 21:01:42 +02:00

300 lines
12 KiB
Go

package web
import (
"net/http"
"strings"
"github.com/skip2/go-qrcode"
"golang.org/x/crypto/bcrypt"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// Guest launcher share handlers (v0.165.0). See share.go for the security model. This file holds the
// HTTP surface: the pre-auth guest pages (/s/<token>) and the admin share-management POSTs
// (/launcher/share/*, session-authed).
// shareMinPassword is the minimum length of the OPTIONAL per-share password.
const shareMinPassword = 8
// setGuestHeaders stamps the guest launcher responses so search engines never index a capability URL,
// referrers never leak the token to an opened app, and no intermediary caches the page.
func (s *Server) setGuestHeaders(w http.ResponseWriter) {
h := w.Header()
h.Set("X-Robots-Tag", "noindex, nofollow")
h.Set("Referrer-Policy", "no-referrer")
h.Set("Cache-Control", "no-store")
}
// share404 answers exactly like the mux default 404 (http.NotFound) so a wrong or disabled token is
// byte-for-byte indistinguishable from any unknown route. The token is redacted from the log.
func (s *Server) share404(w http.ResponseWriter, r *http.Request) {
s.logger.Printf("[WARN] [web] 404 Not Found: %s /s/<redacted>", r.Method)
http.NotFound(w, r)
}
// GuestLauncherApp is one tile on the standalone guest launcher. It carries ONLY what a guest may see
// — never the internal state vocabulary (stopped/exited/degraded/unhealthy). Clickable apps render as
// links to their public URL; the rest are greyed with a calm, non-technical Hungarian label.
type GuestLauncherApp struct {
DisplayName string
Slug string
BrandColor string
Clickable bool
Href string // set only when Clickable
Label string // set only when NOT Clickable
}
// guestLauncherApps maps the shared launcherApps() slice into the guest view. Clickable ⇒ the app is
// operational AND its public route is actually published (a healthy, reachable app). isOperational
// alone would let an unhealthy/restarting/degraded app through — its URL 404s at Traefik, so a guest
// tap would dead-end; routeUnpublished screens exactly those. The label rides the v0.164.0 ruling:
// StateStopped is a deliberate owner action ("A tulajdonos leállította"); any other non-clickable
// state is a transient the guest need not understand ("Átmenetileg nem elérhető").
func (s *Server) guestLauncherApps() []GuestLauncherApp {
return buildGuestApps(s.launcherApps(), s.cfg.Customer.Domain)
}
// buildGuestApps is the pure mapping from the shared launcher slice to the guest view (no manager, no
// request) — the tested seam for the clickability rule and the guest label vocabulary.
func buildGuestApps(apps []LauncherApp, domain string) []GuestLauncherApp {
out := make([]GuestLauncherApp, 0, len(apps))
for _, a := range apps {
g := GuestLauncherApp{DisplayName: a.DisplayName, Slug: a.Slug, BrandColor: a.BrandColor}
switch {
case isOperationalState(a.State) && !routeUnpublished(a.State):
g.Clickable = true
g.Href = "https://" + a.Subdomain + "." + domain + a.OpenPath
case a.State == stacks.StateStopped:
g.Label = "A tulajdonos leállította"
default:
g.Label = "Átmenetileg nem elérhető"
}
out = append(out, g)
}
return out
}
// shareGuestHandler serves GET /s/<token>: the standalone read-only launcher, or the password gate
// when a share password is set and no valid cookie is present. An unknown token → share404.
func (s *Server) shareGuestHandler(w http.ResponseWriter, r *http.Request) {
token := strings.TrimPrefix(r.URL.Path, "/s/")
stored := s.settings.GetLauncherShareToken()
if !shareTokenMatches(stored, token) {
s.share404(w, r)
return
}
pwHash := s.settings.GetLauncherSharePasswordHash()
if pwHash != "" && !s.shareCookieValid(r, stored, pwHash) {
s.renderSharePasswordPage(w, r, "")
return
}
s.renderShareGuestPage(w, r)
}
// shareGuestPasswordHandler handles POST /s/<token>: the optional share-password gate. On success it
// sets the signed, ~30-day gate cookie (bound to token|passwordHash). Pre-auth HMAC CSRF + a per-IP
// 5/1-min limiter (own map) protect it.
func (s *Server) shareGuestPasswordHandler(w http.ResponseWriter, r *http.Request) {
token := strings.TrimPrefix(r.URL.Path, "/s/")
stored := s.settings.GetLauncherShareToken()
if !shareTokenMatches(stored, token) {
s.share404(w, r)
return
}
pwHash := s.settings.GetLauncherSharePasswordHash()
if pwHash == "" {
// No gate — a stray POST just returns to the page (renders directly).
s.renderShareGuestPage(w, r)
return
}
_ = r.ParseForm()
if !s.validShareCSRF(r) {
s.renderSharePasswordPage(w, r, "Érvénytelen űrlap — töltse újra az oldalt.")
return
}
ip := rateKey(r)
if s.shareRateLimited(ip) {
s.logger.Printf("[WARN] [web] share password rate limited for %s", ip)
s.renderSharePasswordPage(w, r, "Túl sok sikertelen próbálkozás, próbálja újra 1 perc múlva")
return
}
if bcrypt.CompareHashAndPassword([]byte(pwHash), []byte(r.FormValue("password"))) != nil {
s.shareRegisterFailure(ip)
s.renderSharePasswordPage(w, r, "Hibás jelszó")
return
}
s.shareClearFailures(ip)
http.SetCookie(w, &http.Cookie{
Name: shareCookieName,
Value: s.shareCookieValue(stored, pwHash),
Path: "/s/",
MaxAge: int(shareCookieMaxAge.Seconds()),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https",
})
// Redirect back to the same URL so a reload/back does not re-POST; the cookie now passes the gate.
http.Redirect(w, r, r.URL.Path, http.StatusSeeOther)
}
// renderShareGuestPage renders the standalone guest launcher (own minimal <html>, no admin chrome).
func (s *Server) renderShareGuestPage(w http.ResponseWriter, r *http.Request) {
s.setGuestHeaders(w)
data := map[string]interface{}{
"Domain": s.cfg.Customer.Domain,
"Apps": s.guestLauncherApps(),
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.executeTemplateLang(w, r, "launcher_shared", data); err != nil {
s.logger.Printf("[ERROR] [web] Template error (launcher_shared): %v", err)
http.Error(w, "Internal error", http.StatusInternalServerError)
}
}
// renderSharePasswordPage renders the standalone one-field password gate and sets the pre-auth CSRF
// cookie the POST validates.
func (s *Server) renderSharePasswordPage(w http.ResponseWriter, r *http.Request, errMsg string) {
s.setGuestHeaders(w)
csrf := s.setShareCSRFCookie(w, r)
data := map[string]interface{}{
"Action": r.URL.Path, // /s/<token> — the guest already holds this token in their URL bar
"CSRF": csrf,
"Error": errMsg,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.executeTemplateLang(w, r, "launcher_share_password", data); err != nil {
s.logger.Printf("[ERROR] [web] Template error (launcher_share_password): %v", err)
http.Error(w, "Internal error", http.StatusInternalServerError)
}
}
// ── admin share management (session-authed via RequireAuth + session CSRF via CsrfProtect) ─────────
// launcherShareRedirect returns to /launcher with a flash.
//
// `key` is a bundle KEY, not a sentence (v0.252.0, R-557): the redirect is read by a DIFFERENT
// request, and only that request knows the household's language. The launcher resolves it with
// s.flashText, which shows a sentence minted by an older controller verbatim — so a link already in
// a customer's tab still reads correctly.
func (s *Server) launcherShareRedirect(w http.ResponseWriter, r *http.Request, key string) {
http.Redirect(w, r, "/launcher?"+flashQuery("flash", key), http.StatusSeeOther)
}
// launcherShareQRHandler serves the share link as a ~256px PNG QR code (admin-authed; not exempted, so
// an unauthenticated request → login redirect). The token is never logged.
func (s *Server) launcherShareQRHandler(w http.ResponseWriter, r *http.Request) {
token := s.settings.GetLauncherShareToken()
if token == "" {
http.NotFound(w, r)
return
}
png, err := qrcode.Encode("https://"+r.Host+"/s/"+token, qrcode.Medium, 256)
if err != nil {
s.logger.Printf("[ERROR] [web] share: QR encode failed: %v", err)
http.Error(w, "QR error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Robots-Tag", "noindex, nofollow")
_, _ = w.Write(png)
}
// launcherShareEnableHandler mints the first token (POST /launcher/share/enable).
func (s *Server) launcherShareEnableHandler(w http.ResponseWriter, r *http.Request) {
if s.settings.GetLauncherShareToken() != "" {
s.launcherShareRedirect(w, r, "flash.share.already_on")
return
}
tok, err := newShareToken()
if err != nil {
s.logger.Printf("[ERROR] [web] share: token generation failed: %v", err)
s.launcherShareRedirect(w, r, "flash.share.enable_failed")
return
}
if err := s.settings.SetLauncherShareToken(tok); err != nil {
s.logger.Printf("[ERROR] [web] share: saving token failed: %v", err)
s.launcherShareRedirect(w, r, "flash.share.enable_failed")
return
}
s.logger.Printf("[INFO] [web] launcher share link enabled")
s.launcherShareRedirect(w, r, "flash.share.enabled")
}
// launcherShareRotateHandler mints a fresh token (POST /launcher/share/rotate). The old link 404s and
// every outstanding guest cookie is invalidated (both are bound to the token).
func (s *Server) launcherShareRotateHandler(w http.ResponseWriter, r *http.Request) {
if s.settings.GetLauncherShareToken() == "" {
s.launcherShareRedirect(w, r, "flash.share.not_on")
return
}
tok, err := newShareToken()
if err != nil {
s.logger.Printf("[ERROR] [web] share: token generation failed: %v", err)
s.launcherShareRedirect(w, r, "flash.share.relink_failed")
return
}
if err := s.settings.SetLauncherShareToken(tok); err != nil {
s.logger.Printf("[ERROR] [web] share: saving token failed: %v", err)
s.launcherShareRedirect(w, r, "flash.share.relink_failed")
return
}
s.logger.Printf("[INFO] [web] launcher share link rotated")
s.launcherShareRedirect(w, r, "flash.share.relinked")
}
// launcherShareDisableHandler clears the token AND the share password (POST /launcher/share/disable) —
// a clean slate so a later re-enable never inherits a stale gate. All /s/ paths then 404.
func (s *Server) launcherShareDisableHandler(w http.ResponseWriter, r *http.Request) {
if err := s.settings.SetLauncherShareToken(""); err != nil {
s.logger.Printf("[ERROR] [web] share: clearing token failed: %v", err)
s.launcherShareRedirect(w, r, "flash.share.disable_failed")
return
}
if err := s.settings.SetLauncherSharePasswordHash(""); err != nil {
s.logger.Printf("[WARN] [web] share: clearing share password on disable failed: %v", err)
}
s.logger.Printf("[INFO] [web] launcher share link disabled")
s.launcherShareRedirect(w, r, "flash.share.disabled")
}
// launcherSharePasswordHandler sets or clears the OPTIONAL per-share password (POST
// /launcher/share/password). action=clear removes it; otherwise a min-length password is bcrypt-hashed
// into its OWN settings field (never the admin hash). Either change invalidates outstanding cookies.
func (s *Server) launcherSharePasswordHandler(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
if s.settings.GetLauncherShareToken() == "" {
s.launcherShareRedirect(w, r, "flash.share.not_on")
return
}
if r.FormValue("action") == "clear" {
if err := s.settings.SetLauncherSharePasswordHash(""); err != nil {
s.logger.Printf("[ERROR] [web] share: clearing share password failed: %v", err)
s.launcherShareRedirect(w, r, "flash.share.pw_clear_failed")
return
}
s.logger.Printf("[INFO] [web] launcher share password cleared")
s.launcherShareRedirect(w, r, "flash.share.pw_cleared")
return
}
pw := r.FormValue("password")
if len(pw) < shareMinPassword {
s.launcherShareRedirect(w, r, "flash.share.pw_too_short")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(pw), 10)
if err != nil {
s.logger.Printf("[ERROR] [web] share: hashing share password failed: %v", err)
s.launcherShareRedirect(w, r, "flash.share.pw_set_failed")
return
}
if err := s.settings.SetLauncherSharePasswordHash(string(hash)); err != nil {
s.logger.Printf("[ERROR] [web] share: saving share password failed: %v", err)
s.launcherShareRedirect(w, r, "flash.share.pw_set_failed")
return
}
s.logger.Printf("[INFO] [web] launcher share password set")
s.launcherShareRedirect(w, r, "flash.share.pw_set")
}