v0.165.0: Indítópult megosztása — guest launcher via capability URL (+ optional password, QR)

Mint a 160-bit capability URL (/s/<token>) serving a standalone read-only guest
launcher: same tiles, opens apps in new tabs, no account, no admin session.
Information only, zero control — every privilege stays behind each app's own auth.

- /s/ pre-auth pass-through (after the claim gate) + session-CSRF exemption; guest
  password POST carries its own pre-auth HMAC CSRF.
- Constant-time token match; empty stored token = disabled = byte-identical mux 404.
- Optional per-share password: separate bcrypt hash + own attempt map; signed cookie
  = HMAC(token|passwordHash) keyed with web.session_secret, so rotate/change invalidates.
- Guest labels ride the v0.164.0 ruling; never expose internal state vocabulary.
- Token redacted in logs (/s/<redacted>); never in CHANGELOG/REPORT/CONTEXT.
- Admin modal: copy-link, QR (go-qrcode), set/clear password, rotate, disable.
- Tests: Groups A-G (14) + 3 red-proofs verified red.
This commit is contained in:
2026-07-24 12:08:43 +02:00
parent 8e5edb2865
commit 15206314ab
19 changed files with 1341 additions and 115 deletions
+5 -2
View File
@@ -83,8 +83,11 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler {
}
// 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/") {
// entry (code-gated internally). Static assets for the page too. The guest launcher share
// (v0.165.0) joins here — /s/<token> is a capability URL with NO admin session; the token
// (or the optional share password) is its own gate. Placed AFTER the claim-gate block above,
// so an unclaimed box never serves the guest page (the claim gate stays supreme).
if r.URL.Path == "/claim" || r.URL.Path == "/claim/request-new-code" || strings.HasPrefix(r.URL.Path, "/static/") || strings.HasPrefix(r.URL.Path, "/s/") {
next.ServeHTTP(w, r)
return
}
+4 -1
View File
@@ -34,7 +34,10 @@ func (s *Server) CsrfProtect(next http.Handler) http.Handler {
// 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" {
// The guest launcher share password POST (/s/<token>, v0.165.0) is the same shape: no admin
// session, own pre-auth HMAC CSRF (validShareCSRF). The admin share-management POSTs live
// under /launcher/share/* and are NOT exempted — they ride the normal session CSRF below.
if r.URL.Path == "/claim" || r.URL.Path == "/claim/request-new-code" || strings.HasPrefix(r.URL.Path, "/s/") {
next.ServeHTTP(w, r)
return
}
+13 -8
View File
@@ -84,6 +84,18 @@ func routeUnpublished(state stacks.ContainerState) bool {
}
}
// isOperationalState reports whether a stack has running containers (not stopped/exited/not-deployed).
// Shared by the funcmap "isOperational" and the guest launcher's clickability rule (v0.165.0), so both
// answer "is there something to open here" from a single source.
func isOperationalState(state stacks.ContainerState) bool {
switch state {
case stacks.StateRunning, stacks.StateStarting, stacks.StateUnhealthy, stacks.StateRestarting, stacks.StateDegraded:
return true
default:
return false
}
}
// templateFuncMap returns the FuncMap used by all HTML templates.
func (s *Server) templateFuncMap() template.FuncMap {
loc := getTimezone()
@@ -162,14 +174,7 @@ func (s *Server) templateFuncMap() template.FuncMap {
},
// isOperational returns true for any state where the stack has containers
// and is not stopped/exited — used by templates for showing action buttons
"isOperational": func(state stacks.ContainerState) bool {
switch state {
case stacks.StateRunning, stacks.StateStarting, stacks.StateUnhealthy, stacks.StateRestarting, stacks.StateDegraded:
return true
default:
return false
}
},
"isOperational": isOperationalState,
"routeUnpublished": routeUnpublished,
"logoURL": func(slug string) string {
return s.cfg.AppLogoURL(slug)
+26 -5
View File
@@ -272,18 +272,39 @@ func buildLauncherApps(stackList []stacks.Stack, subdomains map[string]string) [
return apps
}
// launcherHandler renders the Indítópult: a grid of large tappable tiles, one per openable deployed
// app (subdomain presence is the single openability criterion — see buildLauncherApps). Behind
// RequireAuth like every page; the "/" landing page stays the Vezérlőpult.
func (s *Server) launcherHandler(w http.ResponseWriter, r *http.Request) {
// launcherApps assembles the sorted launcher tile list (deployed/protected stacks with a subdomain,
// controller excluded). Extracted from launcherHandler so the guest share page (v0.165.0) renders
// the EXACT same app slice as the admin launcher.
func (s *Server) launcherApps() []LauncherApp {
var eligible []stacks.Stack
for _, st := range s.stackMgr.GetStacks() {
if st.Deployed || st.Protected {
eligible = append(eligible, st)
}
}
return buildLauncherApps(eligible, s.subdomainMap(eligible))
}
// launcherHandler renders the Indítópult: a grid of large tappable tiles, one per openable deployed
// app (subdomain presence is the single openability criterion — see buildLauncherApps). Behind
// RequireAuth like every page; the "/" landing page stays the Vezérlőpult. It also carries the
// "Indítópult megosztása" share state (v0.165.0) for the modal.
func (s *Server) launcherHandler(w http.ResponseWriter, r *http.Request) {
data := s.baseData("launcher", "Indítópult")
data["Apps"] = buildLauncherApps(eligible, s.subdomainMap(eligible))
data["Apps"] = s.launcherApps()
// Share modal state. The share URL is built from the request Host at render time (the canonical
// controller subdomain is not persisted anywhere reachable here); it carries the live token, which
// is fine to show the authed admin inside the modal — the ONE admin surface allowed to reveal it.
token := s.settings.GetLauncherShareToken()
data["ShareEnabled"] = token != ""
if token != "" {
data["ShareURL"] = "https://" + r.Host + "/s/" + token
}
data["SharePasswordSet"] = s.settings.GetLauncherSharePasswordHash() != ""
if f := strings.TrimSpace(r.URL.Query().Get("flash")); f != "" {
data["ShareFlash"] = f
}
s.executeTemplate(w, r, "launcher", data)
}
+33 -1
View File
@@ -50,6 +50,12 @@ type Server struct {
done chan struct{}
closeOnce sync.Once
// Guest launcher share (v0.165.0): its OWN per-IP brute-force limiter for the optional share
// password gate — deliberately separate from loginAttempts (the admin login), so a guest and the
// owner never share a counter. Lazily initialized (struct-literal test servers skip NewServer).
shareAttempts map[string]*loginAttempt
shareAttemptMu sync.Mutex
// 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
@@ -179,6 +185,7 @@ func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *syste
version: version,
sessions: make(map[string]*session),
loginAttempts: make(map[string]*loginAttempt),
shareAttempts: make(map[string]*loginAttempt),
done: make(chan struct{}),
}
s.classifyFSPath = system.ClassifyPathFSTimeout
@@ -331,7 +338,13 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] ServeHTTP: %s %s from %s", r.Method, path, r.RemoteAddr)
// The guest launcher share token (v0.165.0) is a secret — redact it from the request log so
// debug logging never leaks a live capability URL (Scenario G). Method + IP stay intact.
logPath := path
if strings.HasPrefix(path, "/s/") {
logPath = "/s/<redacted>"
}
s.logger.Printf("[DEBUG] [web] ServeHTTP: %s %s from %s", r.Method, logPath, r.RemoteAddr)
}
switch {
@@ -347,6 +360,25 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.dashboardHandler(w, r)
case path == "/launcher":
s.launcherHandler(w, r)
// Guest launcher share (v0.165.0). /s/<token> is the pre-auth capability URL (RequireAuth lets
// the /s/ prefix through after the claim gate). A GET renders the guest launcher (or the password
// gate); a POST submits the optional share password. An unknown/disabled token falls through to a
// byte-identical 404 (share404), so nothing distinguishes a wrong token from an unknown route.
case strings.HasPrefix(path, "/s/") && r.Method == http.MethodGet:
s.shareGuestHandler(w, r)
case strings.HasPrefix(path, "/s/") && r.Method == http.MethodPost:
s.shareGuestPasswordHandler(w, r)
// Admin share management (session-authed via RequireAuth + session CSRF via CsrfProtect).
case path == "/launcher/share/qr.png" && r.Method == http.MethodGet:
s.launcherShareQRHandler(w, r)
case path == "/launcher/share/enable" && r.Method == http.MethodPost:
s.launcherShareEnableHandler(w, r)
case path == "/launcher/share/rotate" && r.Method == http.MethodPost:
s.launcherShareRotateHandler(w, r)
case path == "/launcher/share/disable" && r.Method == http.MethodPost:
s.launcherShareDisableHandler(w, r)
case path == "/launcher/share/password" && r.Method == http.MethodPost:
s.launcherSharePasswordHandler(w, r)
case path == "/stacks":
s.stacksHandler(w, r)
case path == "/backups":
+147
View File
@@ -0,0 +1,147 @@
package web
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"net/http"
"time"
)
// Guest launcher share (v0.165.0). The "Indítópult megosztása" capability link: the admin mints a
// ≥160-bit random token; https://<host>/s/<token> then serves a standalone, read-only guest launcher
// with NO account and NO admin session. The link grants INFORMATION ONLY — app names + public URLs;
// every privilege stays behind each app's own auth and the controller admin password.
//
// SECURITY MODEL:
// - The token IS the secret. 160 bits of entropy is the whole defence for the token GET — the path
// is never rate-limited or CAPTCHA'd, and the value is never logged (redacted as /s/<redacted>).
// - Constant-time comparison only (shareTokenMatches); an empty stored token matches nothing, so
// "sharing disabled" and "wrong token" are indistinguishable from any unknown route (Scenario B).
// - The OPTIONAL per-share password is a SEPARATE credential (its own bcrypt hash, its own attempt
// map). Passing it once mints a signed cookie bound to token|passwordHash, so rotating the token
// OR changing the password invalidates every outstanding cookie with zero bookkeeping.
const (
shareCookieName = "felhom_share" // the signed guest gate cookie (password shares only)
shareCSRFCookie = "felhom_share_csrf" // pre-auth HMAC CSRF for the guest password POST
shareCookieMaxAge = 30 * 24 * time.Hour
)
// newShareToken returns a fresh ≥160-bit capability token: 20 random bytes (160 bits) as
// base64.RawURLEncoding (27 URL-safe chars, no padding).
func newShareToken() (string, error) {
b := make([]byte, 20)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// shareTokenMatches reports whether the presented token equals the stored one, in constant time. An
// empty stored token never matches (sharing disabled ⇒ every /s/ path is a 404). ConstantTimeCompare
// returns 0 for differing lengths, so no length oracle leaks.
func shareTokenMatches(stored, presented string) bool {
if stored == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(stored), []byte(presented)) == 1
}
// shareCookieValue is the signed guest gate cookie: HMAC-SHA256 over token|passwordHash, keyed with
// the box's persisted, box-scoped web.session_secret (the SAME secret the claim pre-auth CSRF already
// trusts — reusing it introduces no new assumption; it is stable across restarts, not per-boot and
// not claim-generation-scoped, so it fits the reuse branch of the Part-2 decision rule). Binding the
// share-password hash into the MAC means a password change invalidates the cookie; binding the token
// means a rotation does too.
func (s *Server) shareCookieValue(token, passwordHash string) string {
mac := hmac.New(sha256.New, []byte(s.cfg.Web.SessionSecret))
mac.Write([]byte("felhom-share-cookie-v1|" + token + "|" + passwordHash))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
// shareCookieValid reports whether the request carries a valid gate cookie for this token+hash.
func (s *Server) shareCookieValid(r *http.Request, token, passwordHash string) bool {
c, err := r.Cookie(shareCookieName)
if err != nil {
return false
}
want := s.shareCookieValue(token, passwordHash)
return subtle.ConstantTimeCompare([]byte(c.Value), []byte(want)) == 1
}
// ── pre-auth CSRF for the guest password form (mirrors the claim pre-auth HMAC pattern: the guest
// has no session, so the session-CSRF path cannot apply) ─────────────────────────────────────────
func (s *Server) shareCSRFToken() string {
mac := hmac.New(sha256.New, []byte(s.cfg.Web.SessionSecret))
mac.Write([]byte("felhom-share-csrf-v1"))
return hex.EncodeToString(mac.Sum(nil))
}
func (s *Server) setShareCSRFCookie(w http.ResponseWriter, r *http.Request) string {
tok := s.shareCSRFToken()
http.SetCookie(w, &http.Cookie{
Name: shareCSRFCookie,
Value: tok,
Path: "/s/",
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(shareCookieMaxAge.Seconds()),
})
return tok
}
func (s *Server) validShareCSRF(r *http.Request) bool {
want := s.shareCSRFToken()
if subtle.ConstantTimeCompare([]byte(r.FormValue(csrfFormField)), []byte(want)) != 1 {
return false
}
c, err := r.Cookie(shareCSRFCookie)
if err != nil {
return false
}
return subtle.ConstantTimeCompare([]byte(c.Value), []byte(want)) == 1
}
// ── the share-password brute-force limiter (own per-IP map; same 5-attempts / 1-min window as the
// admin login, copied — never shared) ─────────────────────────────────────────────────────────────
// shareRateLimited reports whether this IP is currently blocked (>= loginMaxAttempts within the
// window). An expired window resets the counter first, so a fresh burst starts clean.
func (s *Server) shareRateLimited(ip string) bool {
s.shareAttemptMu.Lock()
defer s.shareAttemptMu.Unlock()
a := s.shareAttempts[ip]
if a != nil && time.Since(a.lastFail) > loginWindowDuration {
delete(s.shareAttempts, ip)
a = nil
}
return a != nil && a.count >= loginMaxAttempts
}
// shareRegisterFailure bumps this IP's failed-attempt counter (lazily allocating the map so
// struct-literal test servers that skip NewServer still work).
func (s *Server) shareRegisterFailure(ip string) {
s.shareAttemptMu.Lock()
defer s.shareAttemptMu.Unlock()
if s.shareAttempts == nil {
s.shareAttempts = make(map[string]*loginAttempt)
}
if s.shareAttempts[ip] == nil {
s.shareAttempts[ip] = &loginAttempt{}
}
s.shareAttempts[ip].count++
s.shareAttempts[ip].lastFail = time.Now()
}
// shareClearFailures clears this IP's counter after a successful gate pass.
func (s *Server) shareClearFailures(ip string) {
s.shareAttemptMu.Lock()
defer s.shareAttemptMu.Unlock()
delete(s.shareAttempts, ip)
}
+294
View File
@@ -0,0 +1,294 @@
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 := clientIP(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.tmpl.ExecuteTemplate(w, "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.tmpl.ExecuteTemplate(w, "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 Hungarian flash (reuses urlQueryEscape).
func (s *Server) launcherShareRedirect(w http.ResponseWriter, r *http.Request, flash string) {
http.Redirect(w, r, "/launcher?flash="+urlQueryEscape(flash), 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, "A megosztás már be van kapcsolva.")
return
}
tok, err := newShareToken()
if err != nil {
s.logger.Printf("[ERROR] [web] share: token generation failed: %v", err)
s.launcherShareRedirect(w, r, "A megosztás bekapcsolása nem sikerült.")
return
}
if err := s.settings.SetLauncherShareToken(tok); err != nil {
s.logger.Printf("[ERROR] [web] share: saving token failed: %v", err)
s.launcherShareRedirect(w, r, "A megosztás bekapcsolása nem sikerült.")
return
}
s.logger.Printf("[INFO] [web] launcher share link enabled")
s.launcherShareRedirect(w, r, "A megosztás bekapcsolva.")
}
// 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, "A megosztás nincs bekapcsolva.")
return
}
tok, err := newShareToken()
if err != nil {
s.logger.Printf("[ERROR] [web] share: token generation failed: %v", err)
s.launcherShareRedirect(w, r, "Az új link készítése nem sikerült.")
return
}
if err := s.settings.SetLauncherShareToken(tok); err != nil {
s.logger.Printf("[ERROR] [web] share: saving token failed: %v", err)
s.launcherShareRedirect(w, r, "Az új link készítése nem sikerült.")
return
}
s.logger.Printf("[INFO] [web] launcher share link rotated")
s.launcherShareRedirect(w, r, "Új megosztási link készült. A korábbi link és minden korábbi belépés érvénytelen.")
}
// 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, "A megosztás kikapcsolása nem sikerült.")
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, "A megosztás kikapcsolva.")
}
// 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, "A megosztás nincs bekapcsolva.")
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, "A jelszó törlése nem sikerült.")
return
}
s.logger.Printf("[INFO] [web] launcher share password cleared")
s.launcherShareRedirect(w, r, "A megosztási jelszó törölve.")
return
}
pw := r.FormValue("password")
if len(pw) < shareMinPassword {
s.launcherShareRedirect(w, r, "A jelszónak legalább 8 karakter hosszúnak kell lennie.")
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, "A jelszó beállítása nem sikerült.")
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, "A jelszó beállítása nem sikerült.")
return
}
s.logger.Printf("[INFO] [web] launcher share password set")
s.launcherShareRedirect(w, r, "A megosztási jelszó beállítva. A korábbi belépések érvénytelenek.")
}
+400
View File
@@ -0,0 +1,400 @@
package web
import (
"bytes"
"io"
"log"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"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"
)
// Guest launcher share (v0.165.0). These tests drive the pure core (token match, guest-app mapping),
// the standalone templates, and the real routes through the production mux composition (fullMux =
// RequireAuth + CsrfProtect + ServeHTTP, exactly as main.go wires it). Each security item carries a
// companion red-proof recorded in REPORT.
// shareTestServer builds a CLAIMED box (admin password set → authEnabled, claim gate off) with a
// SessionSecret, so the /s/ pre-auth pass-through and the admin-auth gate on /launcher/share/* are
// both exercised as they run in production.
func shareTestServer(t *testing.T) *Server {
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 = "demo-felhom.eu"
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-share"
ph, _ := bcrypt.GenerateFromPassword([]byte("admin-pw-123456"), bcrypt.MinCost)
cfg.Web.PasswordHash = string(ph)
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: "9.9.9-test",
sessions: map[string]*session{},
loginAttempts: map[string]*loginAttempt{},
shareAttempts: map[string]*loginAttempt{}}
s.loadTemplates()
return s
}
const testShareToken = "TESTTOKEN0AAAAAAAAAAAAAAAAAA"
// ── Group B core: constant-time token match (Scenario B) ──────────────────────────────────────────
// COMPANION red-proof (REPORT): replace subtle.ConstantTimeCompare with strings.HasPrefix(presented,
// stored) → the superstring case ("abcd" vs stored "abc") is accepted and this test FAILS.
func TestShareTokenMatches(t *testing.T) {
if !shareTokenMatches("abc", "abc") {
t.Error("exact match must pass")
}
if shareTokenMatches("", "") || shareTokenMatches("", "anything") {
t.Error("an empty stored token must never match (sharing disabled)")
}
if shareTokenMatches("abc", "ab") {
t.Error("a prefix must not match")
}
if shareTokenMatches("abc", "abcd") {
t.Error("a superstring must not match")
}
if shareTokenMatches("abc", "abx") {
t.Error("a different token must not match")
}
}
func TestNewShareToken_EntropyAndCharset(t *testing.T) {
a, err := newShareToken()
if err != nil {
t.Fatal(err)
}
b, _ := newShareToken()
if a == b {
t.Error("two tokens collided — not random")
}
if len(a) != 27 { // 20 bytes → base64.RawURLEncoding = 27 chars
t.Errorf("token length = %d, want 27 (160 bits, no padding)", len(a))
}
if strings.ContainsAny(a, "+/=") {
t.Errorf("token %q contains non-URL-safe chars", a)
}
}
// ── Group A: guest happy path — headers triple, tiles, no admin chrome (Scenario A) ───────────────
// COMPANION red-proof (REPORT): render the guest page through the admin layout template → the
// no-"nav-links"/no-version absence assertions FAIL.
func TestShareGuest_HeadersTilesNoAdminChrome(t *testing.T) {
s := shareTestServer(t)
if err := s.settings.SetLauncherShareToken(testShareToken); err != nil {
t.Fatal(err)
}
writeStack(t, s.cfg.Paths.StacksDir, "recept-app", "display_name: Receptek\nsubdomain: recept\n", true)
_ = s.stackMgr.ScanStacks()
rr := httptest.NewRecorder()
s.fullMux().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/s/"+testShareToken, nil))
if rr.Code != http.StatusOK {
t.Fatalf("GET /s/<token> = %d: %s", rr.Code, rr.Body.String())
}
if got := rr.Header().Get("X-Robots-Tag"); got != "noindex, nofollow" {
t.Errorf("X-Robots-Tag = %q", got)
}
if got := rr.Header().Get("Referrer-Policy"); got != "no-referrer" {
t.Errorf("Referrer-Policy = %q", got)
}
if got := rr.Header().Get("Cache-Control"); got != "no-store" {
t.Errorf("Cache-Control = %q", got)
}
body := rr.Body.String()
if !strings.Contains(body, "Receptek") {
t.Error("guest page must render the openable app tile")
}
for _, chrome := range []string{`class="sidebar"`, "nav-links", "/logout", "9.9.9-test", "alert-banner"} {
if strings.Contains(body, chrome) {
t.Errorf("guest page leaked admin chrome: %q", chrome)
}
}
}
// ── Group B: wrong / disabled / empty token → byte-identical to the mux default 404 (Scenario B) ──
func TestShareGuest_WrongTokenIs404LikeDefault(t *testing.T) {
s := shareTestServer(t)
if err := s.settings.SetLauncherShareToken(testShareToken); err != nil {
t.Fatal(err)
}
// Reference: the mux default case. /s/ bypasses auth, so both reach ServeHTTP's switch directly;
// calling ServeHTTP is the apples-to-apples comparison against the default 404 branch.
ref := httptest.NewRecorder()
s.ServeHTTP(ref, httptest.NewRequest(http.MethodGet, "/no-such-route-xyz", nil))
wantCode, wantBody := ref.Code, ref.Body.String()
cases := []string{"/s/WRONGTOKEN", "/s/"}
for _, p := range cases {
rr := httptest.NewRecorder()
s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, p, nil))
if rr.Code != wantCode || rr.Body.String() != wantBody {
t.Errorf("GET %s = %d %q; want default-404 %d %q", p, rr.Code, rr.Body.String(), wantCode, wantBody)
}
}
// Disabled (empty stored token): the previously-valid token now 404s identically.
if err := s.settings.SetLauncherShareToken(""); err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/s/"+testShareToken, nil))
if rr.Code != wantCode || rr.Body.String() != wantBody {
t.Errorf("disabled-token GET = %d %q; want default-404 %d %q", rr.Code, rr.Body.String(), wantCode, wantBody)
}
}
// sharePOST posts the guest password form with a valid pre-auth HMAC CSRF pair (form field + cookie).
func (s *Server) sharePOST(mux http.Handler, path, password string, extra ...*http.Cookie) *httptest.ResponseRecorder {
csrf := s.shareCSRFToken()
form := url.Values{"_csrf": {csrf}, "password": {password}}
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: shareCSRFCookie, Value: csrf})
for _, c := range extra {
req.AddCookie(c)
}
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
return rr
}
func cookieNamed(rr *httptest.ResponseRecorder, name string) *http.Cookie {
for _, c := range rr.Result().Cookies() {
if c.Name == name {
return c
}
}
return nil
}
// ── Group C: optional password gate (Scenario C) ──────────────────────────────────────────────────
// COMPANION red-proof (REPORT): drop passwordHash from shareCookieValue's HMAC input → the
// "changing the password invalidates the cookie" assertion FAILS.
func TestShareGuest_PasswordGate(t *testing.T) {
s := shareTestServer(t)
mux := s.fullMux()
s.settings.SetLauncherShareToken(testShareToken)
hash, _ := bcrypt.GenerateFromPassword([]byte("guest-secret"), bcrypt.MinCost)
s.settings.SetLauncherSharePasswordHash(string(hash))
path := "/s/" + testShareToken
// GET with no cookie → the password gate, not the launcher.
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil))
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "Ez az oldal jelszóval védett") {
t.Fatalf("expected password gate, got %d: %s", rr.Code, rr.Body.String())
}
// 5 wrong attempts → "Hibás jelszó"; the 6th within the window → rate-limited.
for i := 0; i < 5; i++ {
rr := s.sharePOST(mux, path, "wrong")
if !strings.Contains(rr.Body.String(), "Hibás jelszó") {
t.Fatalf("wrong attempt %d: want 'Hibás jelszó', got: %s", i+1, rr.Body.String())
}
}
rr = s.sharePOST(mux, path, "wrong")
if !strings.Contains(rr.Body.String(), "Túl sok sikertelen") {
t.Fatalf("6th attempt must be rate-limited, got: %s", rr.Body.String())
}
// New IP, correct password → 303 + a signed gate cookie is set.
s2 := shareTestServer(t)
mux2 := s2.fullMux()
s2.settings.SetLauncherShareToken(testShareToken)
s2.settings.SetLauncherSharePasswordHash(string(hash))
rr = s2.sharePOST(mux2, path, "guest-secret")
if rr.Code != http.StatusSeeOther {
t.Fatalf("correct password: want 303, got %d: %s", rr.Code, rr.Body.String())
}
gate := cookieNamed(rr, shareCookieName)
if gate == nil || gate.Value == "" {
t.Fatal("correct password must set the signed gate cookie")
}
// The cookie lets a subsequent GET through directly (no gate).
req := httptest.NewRequest(http.MethodGet, path, nil)
req.AddCookie(gate)
rr = httptest.NewRecorder()
mux2.ServeHTTP(rr, req)
if strings.Contains(rr.Body.String(), "jelszóval védett") {
t.Error("a valid gate cookie must skip the password page")
}
// Changing the password invalidates the outstanding cookie (it binds the hash).
newHash, _ := bcrypt.GenerateFromPassword([]byte("new-secret"), bcrypt.MinCost)
s2.settings.SetLauncherSharePasswordHash(string(newHash))
req = httptest.NewRequest(http.MethodGet, path, nil)
req.AddCookie(gate)
rr = httptest.NewRecorder()
mux2.ServeHTTP(rr, req)
if !strings.Contains(rr.Body.String(), "jelszóval védett") {
t.Error("changing the password must invalidate the old gate cookie")
}
}
// ── Group D: rotation + disable (Scenario D) ──────────────────────────────────────────────────────
func TestShareGuest_RotateAndDisable(t *testing.T) {
s := shareTestServer(t)
s.settings.SetLauncherShareToken(testShareToken)
hash, _ := bcrypt.GenerateFromPassword([]byte("pw"), bcrypt.MinCost)
s.settings.SetLauncherSharePasswordHash(string(hash))
oldGate := &http.Cookie{Name: shareCookieName, Value: s.shareCookieValue(testShareToken, string(hash))}
// Rotate: mint a new token; the old one 404s, the old gate cookie no longer passes.
newTok, _ := newShareToken()
s.settings.SetLauncherShareToken(newTok)
rr := httptest.NewRecorder()
s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/s/"+testShareToken, nil))
if rr.Code != http.StatusNotFound {
t.Errorf("old token after rotation: want 404, got %d", rr.Code)
}
// New token + old cookie → the cookie is bound to the OLD token, so the gate re-prompts.
req := httptest.NewRequest(http.MethodGet, "/s/"+newTok, nil)
req.AddCookie(oldGate)
rr = httptest.NewRecorder()
s.ServeHTTP(rr, req)
if !strings.Contains(rr.Body.String(), "jelszóval védett") {
t.Error("rotation must invalidate the old gate cookie")
}
// Disable: every /s/ path 404s.
s.settings.SetLauncherShareToken("")
rr = httptest.NewRecorder()
s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/s/"+newTok, nil))
if rr.Code != http.StatusNotFound {
t.Errorf("after disable: want 404, got %d", rr.Code)
}
}
// ── Group E: guest state labels (Scenario E) ──────────────────────────────────────────────────────
func TestBuildGuestApps_Labels(t *testing.T) {
apps := []LauncherApp{
{Name: "a", DisplayName: "Alpha", Slug: "a", Subdomain: "a", State: stacks.StateRunning},
{Name: "b", DisplayName: "Beta", Slug: "b", Subdomain: "b", State: stacks.StateStopped},
{Name: "c", DisplayName: "Gamma", Slug: "c", Subdomain: "c", State: stacks.StateExited},
{Name: "d", DisplayName: "Delta", Slug: "d", Subdomain: "d", State: stacks.StateDegraded},
}
g := buildGuestApps(apps, "demo-felhom.eu")
if !g[0].Clickable || g[0].Href != "https://a.demo-felhom.eu" {
t.Errorf("running app must be clickable with a public href, got %+v", g[0])
}
if g[1].Clickable || g[1].Label != "A tulajdonos leállította" {
t.Errorf("stopped app: want greyed 'A tulajdonos leállította', got %+v", g[1])
}
if g[2].Clickable || g[2].Label != "Átmenetileg nem elérhető" {
t.Errorf("exited app: want 'Átmenetileg nem elérhető', got %+v", g[2])
}
if g[3].Clickable || g[3].Label != "Átmenetileg nem elérhető" {
t.Errorf("degraded app: want 'Átmenetileg nem elérhető', got %+v", g[3])
}
}
// The rendered guest page shows the calm labels and NEVER the internal state vocabulary.
func TestShareGuestTemplate_LabelsNoInternalWords(t *testing.T) {
g := buildGuestApps([]LauncherApp{
{DisplayName: "Alpha", Slug: "a", Subdomain: "a", State: stacks.StateRunning},
{DisplayName: "Beta", Slug: "b", Subdomain: "b", State: stacks.StateStopped},
{DisplayName: "Gamma", Slug: "c", Subdomain: "c", State: stacks.StateExited},
}, "demo-felhom.eu")
html := renderBackupPage(t, "launcher_shared", map[string]interface{}{"Domain": "demo-felhom.eu", "Apps": g})
if !strings.Contains(html, "A tulajdonos leállította") || !strings.Contains(html, "Átmenetileg nem elérhető") {
t.Error("guest labels missing from rendered page")
}
for _, word := range []string{"stopped", "exited", "degraded", "unhealthy"} {
if strings.Contains(html, word) {
t.Errorf("internal state word %q leaked to the guest page", word)
}
}
// Empty state.
empty := renderBackupPage(t, "launcher_shared", map[string]interface{}{"Domain": "demo-felhom.eu", "Apps": []GuestLauncherApp{}})
if !strings.Contains(empty, "Jelenleg nincs elérhető alkalmazás.") {
t.Error("empty guest launcher must show the calm empty-state copy")
}
}
// ── Group F: claim gate supreme + admin surfaces stay admin (Scenario F) ──────────────────────────
func TestShare_ClaimGateInterceptsGuestPage(t *testing.T) {
s, _, _ := claimTestServer(t) // unclaimed: claim code, no password → claimGateActive
s.settings.SetLauncherShareToken(testShareToken)
rr := httptest.NewRecorder()
s.fullMux().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/s/"+testShareToken, nil))
if rr.Code != http.StatusFound || rr.Header().Get("Location") != "/claim" {
t.Errorf("on an unclaimed box /s/<token> must hit the claim gate: got %d loc=%q", rr.Code, rr.Header().Get("Location"))
}
}
func TestShare_AdminSurfacesRequireAuthAndCSRF(t *testing.T) {
s := shareTestServer(t)
mux := s.fullMux()
// Unauthenticated QR + share POSTs → login redirect.
for _, tc := range []struct {
method, path string
}{
{http.MethodGet, "/launcher/share/qr.png"},
{http.MethodPost, "/launcher/share/enable"},
{http.MethodPost, "/launcher/share/rotate"},
{http.MethodPost, "/launcher/share/disable"},
} {
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, httptest.NewRequest(tc.method, tc.path, nil))
if rr.Code != http.StatusFound || !strings.HasPrefix(rr.Header().Get("Location"), "/login") {
t.Errorf("%s %s unauthenticated: want login redirect, got %d loc=%q", tc.method, tc.path, rr.Code, rr.Header().Get("Location"))
}
}
// Authenticated but CSRF-missing POST → 403.
sessTok := s.createSession()
req := httptest.NewRequest(http.MethodPost, "/launcher/share/enable", nil)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: sessTok})
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Errorf("CSRF-missing admin POST: want 403, got %d", rr.Code)
}
}
// ── Group G: the token never reaches the logs (Scenario G) ────────────────────────────────────────
// COMPANION red-proof (REPORT): revert the /s/ redaction in ServeHTTP (log the raw path) → the
// "token absent from logs" assertion FAILS.
func TestShareGuest_TokenNeverLogged(t *testing.T) {
s := shareTestServer(t)
var buf bytes.Buffer
s.logger = log.New(&buf, "", 0)
s.cfg.Logging.Level = "debug"
s.settings.SetLauncherShareToken(testShareToken)
// A valid GET (debug ServeHTTP line) and a wrong-token 404 must both keep the token out of logs.
for _, p := range []string{"/s/" + testShareToken, "/s/WRONGSECRET123"} {
rr := httptest.NewRecorder()
s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, p, nil))
}
logs := buf.String()
if strings.Contains(logs, testShareToken) || strings.Contains(logs, "WRONGSECRET123") {
t.Errorf("a share token leaked into the logs:\n%s", logs)
}
if !strings.Contains(logs, "/s/<redacted>") {
t.Errorf("expected a redacted /s/<redacted> path in the debug log, got:\n%s", logs)
}
}
+102 -10
View File
@@ -1,31 +1,41 @@
{{define "launch_tile"}}
<span class="launch-tile{{if .Off}} launch-tile--off{{end}}" style="background: {{tileColor .Slug .BrandColor}}">
<span class="launch-mono">{{initial .DisplayName}}</span>
<img class="launch-logo" src="{{logoURL .Slug}}" alt=""
onerror="if(!this.dataset.step){this.dataset.step='1';this.src='{{logoPNGURL .Slug}}';}else{this.onerror=null;this.style.display='none';this.parentElement.classList.add('launch-tile--noimg');}">
</span>
{{end}}
{{define "launcher"}}
{{template "layout_start" .}}
<div class="page-header">
<h2>Indítópult</h2>
<span class="domain-badge">{{.Domain}}</span>
<button type="button" class="btn btn-outline share-open-btn" onclick="openShareModal()"><svg class="ico"><use href="#i-share"/></svg>Indítópult megosztása</button>
</div>
{{if .ShareFlash}}
<div class="alerts-container">
<div class="alert-banner alert-banner-info">
<span class="alert-icon"><svg class="ico"><use href="#i-info"/></svg></span>
<span class="alert-message">{{.ShareFlash}}</span>
</div>
</div>
{{end}}
{{if .Apps}}
<div class="launch-grid">
{{range .Apps}}
{{if isOperational .State}}
<a class="launch-cell" href="https://{{.Subdomain}}.{{$.Domain}}{{.OpenPath}}" target="_blank" rel="noopener">
<span class="launch-tile" style="background: {{tileColor .Slug .BrandColor}}">
<span class="launch-mono">{{initial .DisplayName}}</span>
<img class="launch-logo" src="{{logoURL .Slug}}" alt=""
onerror="if(!this.dataset.step){this.dataset.step='1';this.src='{{logoPNGURL .Slug}}';}else{this.onerror=null;this.style.display='none';this.parentElement.classList.add('launch-tile--noimg');}">
</span>
{{template "launch_tile" (dict "Slug" .Slug "BrandColor" .BrandColor "DisplayName" .DisplayName "Off" false)}}
<span class="launch-name">{{.DisplayName}}</span>
{{if ne (stateStr .State) "running"}}<span class="tag tag-{{stateColor .State}}"><span class="dot"></span>{{stateLabel .State}}</span>{{end}}
</a>
{{else}}
<div class="launch-cell launch-cell--off">
<span class="launch-tile launch-tile--off" style="background: {{tileColor .Slug .BrandColor}}">
<span class="launch-mono">{{initial .DisplayName}}</span>
<img class="launch-logo" src="{{logoURL .Slug}}" alt=""
onerror="if(!this.dataset.step){this.dataset.step='1';this.src='{{logoPNGURL .Slug}}';}else{this.onerror=null;this.style.display='none';this.parentElement.classList.add('launch-tile--noimg');}">
</span>
{{template "launch_tile" (dict "Slug" .Slug "BrandColor" .BrandColor "DisplayName" .DisplayName "Off" true)}}
<span class="launch-name">{{.DisplayName}}</span>
<span class="tag tag-{{stateColor .State}}"><span class="dot"></span>{{stateLabel .State}}</span>
</div>
@@ -39,5 +49,87 @@
</div>
{{end}}
<div class="modal-overlay" id="share-modal" style="display:none">
<div class="modal-card">
<h3>Indítópult megosztása</h3>
<p class="share-lede">Egy megosztható link, amely csak megmutatja az alkalmazásokat és új lapon megnyitja őket. Fiók nélkül, felügyeleti hozzáférés nélkül — minden alkalmazás a saját bejelentkezése mögött marad.</p>
{{if .ShareEnabled}}
<div class="share-field">
<label class="share-label">Megosztási link</label>
<div class="share-link-row">
<input id="share-link-input" type="text" readonly value="{{.ShareURL}}">
<button type="button" id="share-copy-btn" class="btn btn-outline" onclick="copyShareLink()">Link másolása</button>
</div>
</div>
<div class="share-qr">
<img src="/launcher/share/qr.png" alt="QR-kód a megosztási linkhez" width="200" height="200">
<span class="share-qr-hint">Olvassa be telefonnal a gyors megnyitáshoz.</span>
</div>
<div class="share-field">
{{if .SharePasswordSet}}
<label class="share-label">Jelszó</label>
<p class="share-note">A megosztás jelszóval védett.</p>
<form method="POST" action="/launcher/share/password">
{{.CSRFField}}
<input type="hidden" name="action" value="clear">
<button type="submit" class="btn btn-outline">Jelszó törlése</button>
</form>
{{else}}
<form method="POST" action="/launcher/share/password">
{{.CSRFField}}
<label class="share-label">Jelszó (nem kötelező)</label>
<div class="share-link-row">
<input type="password" name="password" autocomplete="new-password" placeholder="Legalább 8 karakter">
<button type="submit" class="btn btn-outline">Jelszó beállítása</button>
</div>
</form>
{{end}}
</div>
<div class="share-actions">
<form method="POST" action="/launcher/share/rotate">
{{.CSRFField}}
<button type="submit" class="btn btn-outline" data-confirm="Új link készítése? A régi link és minden korábbi belépés érvénytelenné válik.">Új link készítése</button>
</form>
<form method="POST" action="/launcher/share/disable">
{{.CSRFField}}
<button type="submit" class="btn btn-danger" data-confirm="Biztosan kikapcsolja a megosztást? A link azonnal érvénytelenné válik.">Megosztás kikapcsolása</button>
</form>
</div>
{{else}}
<p class="share-note">A megosztás jelenleg ki van kapcsolva.</p>
<form method="POST" action="/launcher/share/enable">
{{.CSRFField}}
<div class="modal-actions">
<button type="button" class="btn btn-outline" onclick="closeShareModal()">Bezárás</button>
<button type="submit" class="btn btn-primary">Megosztás bekapcsolása</button>
</div>
</form>
{{end}}
{{if .ShareEnabled}}
<div class="modal-actions">
<button type="button" class="btn btn-outline" onclick="closeShareModal()">Bezárás</button>
</div>
{{end}}
</div>
</div>
<script>
function openShareModal(){var m=document.getElementById('share-modal');if(m)m.style.display='flex';}
function closeShareModal(){var m=document.getElementById('share-modal');if(m)m.style.display='none';}
function copyShareLink(){
var inp=document.getElementById('share-link-input');
var btn=document.getElementById('share-copy-btn');
if(!inp)return;
function done(){if(btn){var o=btn.textContent;btn.textContent='Másolva';setTimeout(function(){btn.textContent=o;},1500);}}
if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(inp.value).then(done,function(){inp.select();done();});}
else{inp.select();try{document.execCommand('copy');}catch(e){}done();}
}
(function(){
var m=document.getElementById('share-modal');
if(m){m.addEventListener('click',function(e){if(e.target===m)closeShareModal();});}
{{if .ShareFlash}}openShareModal();{{end}}
})();
</script>
{{template "layout_end"}}
{{end}}
@@ -0,0 +1,27 @@
{{define "launcher_share_password"}}
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title>Indítópult</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="guest-body guest-gate-body">
<main class="guest-gate">
<div class="guest-gate-card">
<h2>Indítópult</h2>
<p>Ez az oldal jelszóval védett.</p>
{{if .Error}}<div class="gate-error">{{.Error}}</div>{{end}}
<form method="POST" action="{{.Action}}">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<input type="password" name="password" placeholder="Jelszó" autocomplete="current-password" autofocus>
<button type="submit" class="btn btn-primary">Belépés</button>
</form>
</div>
</main>
</body>
</html>
{{end}}
@@ -0,0 +1,43 @@
{{define "launcher_shared"}}
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title>Indítópult</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="guest-body">
<main class="guest-content">
<div class="page-header">
<h2>Indítópult</h2>
<span class="domain-badge">{{.Domain}}</span>
</div>
{{if .Apps}}
<div class="launch-grid">
{{range .Apps}}
{{if .Clickable}}
<a class="launch-cell" href="{{.Href}}" target="_blank" rel="noopener noreferrer">
{{template "launch_tile" (dict "Slug" .Slug "BrandColor" .BrandColor "DisplayName" .DisplayName "Off" false)}}
<span class="launch-name">{{.DisplayName}}</span>
</a>
{{else}}
<div class="launch-cell launch-cell--off">
{{template "launch_tile" (dict "Slug" .Slug "BrandColor" .BrandColor "DisplayName" .DisplayName "Off" true)}}
<span class="launch-name">{{.DisplayName}}</span>
<span class="launch-off-label">{{.Label}}</span>
</div>
{{end}}
{{end}}
</div>
{{else}}
<div class="empty-state">
<p>Jelenleg nincs elérhető alkalmazás.</p>
</div>
{{end}}
</main>
</body>
</html>
{{end}}
@@ -1181,6 +1181,28 @@ a.stat-card:hover {
.launch-cell--off { cursor: default; }
.launch-tile--off { opacity: .4; }
.launch-cell--off .launch-name { color: var(--text-3); }
.launch-off-label { font-size: .8rem; color: var(--text-3); text-align: center; }
/* Launcher share modal (v0.165.0) */
.share-open-btn { margin-left: auto; }
.share-lede { color: var(--text-2); font-size: .9rem; margin-bottom: 1rem; }
.share-field { margin-bottom: 1rem; }
.share-label { display: block; font-size: .85rem; color: var(--text-2); margin-bottom: .35rem; }
.share-note { color: var(--text-2); font-size: .9rem; }
.share-link-row { display: flex; gap: .5rem; align-items: center; }
.share-link-row input { flex: 1; min-width: 0; }
.share-qr { display: flex; flex-direction: column; align-items: center; gap: .35rem; margin-bottom: 1rem; }
.share-qr img { border: 1px solid var(--line); border-radius: var(--radius); background: #fff; padding: .35rem; }
.share-qr-hint { color: var(--text-3); font-size: .8rem; }
.share-actions { display: flex; gap: .5rem; flex-wrap: wrap; margin-top: .5rem; }
/* Standalone guest launcher + password gate (v0.165.0) — no sidebar/nav */
.guest-content { max-width: 960px; margin: 0 auto; padding: 2rem 1.25rem; }
.guest-gate-body { display: flex; align-items: center; justify-content: center; min-height: 100vh; }
.guest-gate { width: 100%; display: flex; justify-content: center; padding: 1.25rem; }
.guest-gate-card { width: 100%; max-width: 360px; background: var(--bg-1); border: 1px solid var(--line); border-radius: var(--radius); padding: 1.75rem; text-align: center; }
.guest-gate-card form { display: flex; flex-direction: column; gap: .75rem; margin-top: 1rem; }
.gate-error { color: var(--crit); font-size: .9rem; margin-top: .5rem; }
/* Login page */
.login-body {