// Package lander ist der Sultan-Interceptor (Spec 5.2): Traefik leitet // Router, deren Mandant bzw. Dienst nicht "normal" ist, hierher um und // teilt die Stufe per Header mit (X-Sultan-Stage, -Tenant, -Tier). // // Je Stufe gibt es eine kleine HTML-Seite (Deutsch und Englisch), eingebaut // oder als eigene html/template-Vorlage aus Dir. CSS und Grafik einer // eigenen Vorlage werden eingebettet: Bei watermark/firemark ist das Cookie // sultan_ack schon gesetzt, ein separater Request für Assets ginge über den // Durchlass-Router ans Backend des Kunden. package lander import ( "bytes" "embed" "encoding/base64" "fmt" "html/template" "log/slog" "net/http" "net/url" "os" "path/filepath" "strconv" "strings" "sync" "time" ) //go:embed templates var builtinFS embed.FS // Stages sind die Stufen mit eigener Seite. var Stages = []string{"watermark", "firemark", "paywall", "landing", "maintenance"} // MaxAssetSize begrenzt CSS und Grafik einer eigenen Vorlage. const MaxAssetSize = 512 << 10 // AckCookie muss zu policy.AckCookie passen (Durchlass-Router). const AckCookie = "sultan_ack" var builtin = template.Must(template.ParseFS(builtinFS, "templates/*.html")) // Lander liefert die Seiten aus. Der Nullwert funktioniert (eingebaute // Seiten, Default-Zeiten); Felder nach der ersten Verwendung nicht ändern. type Lander struct { Dir string // eigene Vorlagen (z. B. ./suldata/landing); leer = nur eingebaut HosterName string // für Einladung (paywall/landing) HosterURL string // Angebotsseite des Hosters ProURL string // Pro-Hinweis auf Community-Wartungsseiten WatermarkDelay time.Duration // Default 5s FiremarkDelay time.Duration // Default 10s AckTTL time.Duration // Default 60min: Gültigkeit des Cookies sultan_ack Location *time.Location // Zeitzone für das angezeigte Wartungsende (Default: lokal) Logger *slog.Logger now func() time.Time mu sync.Mutex cache map[string]*customPage } // PageData sind die Werte, die Vorlagen nutzen können (siehe README.txt). type PageData struct { Stage string Tenant string Tier string Community bool Target string RefreshSeconds int HosterName string HosterURL string ProURL string PublicReason string // Wartung: ersetzt den Standardtext UntilDE string // Wartung: voraussichtliches Ende, z. B. 02.10.2026 15:30 UntilEN string // dasselbe, z. B. 2026-10-02 15:30 CSS template.CSS Image template.URL } type stageInfo struct { status int delay func(*Lander) time.Duration // nil = keine Weiterleitung, kein Cookie headers map[string]string } var stages = map[string]stageInfo{ "watermark": {http.StatusOK, func(l *Lander) time.Duration { return or(l.WatermarkDelay, 5*time.Second) }, nil}, "firemark": {http.StatusOK, func(l *Lander) time.Duration { return or(l.FiremarkDelay, 10*time.Second) }, nil}, "paywall": {http.StatusPaymentRequired, nil, nil}, "landing": {http.StatusPaymentRequired, nil, nil}, "maintenance": {http.StatusServiceUnavailable, nil, map[string]string{"Retry-After": "60"}}, } func or(d, def time.Duration) time.Duration { if d > 0 { return d } return def } func (l *Lander) log() *slog.Logger { if l.Logger != nil { return l.Logger } return slog.Default() } func (l *Lander) ServeHTTP(w http.ResponseWriter, r *http.Request) { stage := r.Header.Get("X-Sultan-Stage") info, ok := stages[stage] if !ok { http.Error(w, "unbekannte Stufe", http.StatusBadRequest) return } tier := r.Header.Get("X-Sultan-Tier") data := PageData{ Stage: stage, Tenant: r.Header.Get("X-Sultan-Tenant"), Tier: tier, Community: tier == "community", Target: localTarget(r.URL), HosterName: l.HosterName, HosterURL: l.HosterURL, ProURL: l.ProURL, } if info.delay != nil { data.RefreshSeconds = int(info.delay(l) / time.Second) } retryAfter := "" if stage == "maintenance" { retryAfter = l.maintenanceDetails(r, &data) } body := l.render(stage, data) h := w.Header() h.Set("Content-Type", "text/html; charset=utf-8") h.Set("Cache-Control", "no-store") for k, v := range info.headers { h.Set(k, v) } if retryAfter != "" { h.Set("Retry-After", retryAfter) } if info.delay != nil { http.SetCookie(w, &http.Cookie{ Name: AckCookie, Value: stage, Path: "/", MaxAge: int(or(l.AckTTL, time.Hour) / time.Second), HttpOnly: true, Secure: r.Header.Get("X-Forwarded-Proto") == "https", SameSite: http.SameSiteLaxMode, }) } w.WriteHeader(info.status) w.Write(body) } const maxPublicReason = 300 // maintenanceDetails übernimmt öffentlichen Grund und Ende aus den Headern // der Policy und liefert Retry-After (Sekunden bis zum Ende, 60 s bis 1 Tag). func (l *Lander) maintenanceDetails(r *http.Request, data *PageData) string { if raw := r.Header.Get("X-Sultan-Public-Reason"); raw != "" { reason, err := url.QueryUnescape(raw) if err != nil { reason = raw } reason = strings.TrimSpace(reason) if runes := []rune(reason); len(runes) > maxPublicReason { reason = string(runes[:maxPublicReason]) + "…" } data.PublicReason = reason } now := time.Now() if l.now != nil { now = l.now() } until, err := time.Parse(time.RFC3339, r.Header.Get("X-Sultan-Until")) if err != nil || !until.After(now) { return "" } loc := l.Location if loc == nil { loc = time.Local } data.UntilDE = until.In(loc).Format("02.01.2006 15:04") data.UntilEN = until.In(loc).Format("2006-01-02 15:04") secs := int(until.Sub(now) / time.Second) return strconv.Itoa(min(max(secs, 60), 86400)) } // localTarget liefert den Pfad (+Query) des ursprünglichen Requests als // Weiterleitungsziel – nur lokal: kein Host, kein Schema, kein "//" oder // "\" am Anfang (sonst offene Weiterleitung auf fremde Seiten). func localTarget(u *url.URL) string { if u == nil { return "/" } p := u.EscapedPath() if !strings.HasPrefix(p, "/") || strings.HasPrefix(p, "//") || strings.Contains(u.Path, `\`) { return "/" } if u.RawQuery != "" { return p + "?" + u.RawQuery } return p } // render nutzt die eigene Vorlage der Stufe, sonst (oder bei jedem Fehler) // die eingebaute. func (l *Lander) render(stage string, data PageData) []byte { if page := l.customPage(stage); page != nil { data.CSS, data.Image = page.css, page.image var buf bytes.Buffer err := page.tmpl.Execute(&buf, data) if err == nil { return buf.Bytes() } l.log().Error("Lander-Vorlage fehlerhaft – eingebaute Seite verwendet", "stage", stage, "err", err) data.CSS, data.Image = "", "" } var buf bytes.Buffer if err := builtin.ExecuteTemplate(&buf, stage+".html", data); err != nil { l.log().Error("eingebaute Lander-Seite", "stage", stage, "err", err) return []byte("