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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user