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
300 lines
12 KiB
Go
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")
|
|
}
|