// 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("" + stage + "") } return buf.Bytes() } type customPage struct { signature string tmpl *template.Template // nil: keine (gültige) eigene Vorlage css template.CSS image template.URL } // customPage lädt die eigene Vorlage einer Stufe und lädt neu, sobald sich // eine der Dateien ändert (Name, Größe, Zeitstempel). func (l *Lander) customPage(stage string) *customPage { if l.Dir == "" { return nil } files := []string{stage + ".html", stage + ".css", stage + ".svg", stage + ".webp"} var sig strings.Builder for _, f := range files { if info, err := os.Stat(filepath.Join(l.Dir, f)); err == nil { fmt.Fprintf(&sig, "%s:%d:%d;", f, info.Size(), info.ModTime().UnixNano()) } } l.mu.Lock() defer l.mu.Unlock() if c := l.cache[stage]; c != nil && c.signature == sig.String() { if c.tmpl == nil { return nil } return c } page := &customPage{signature: sig.String()} if l.cache == nil { l.cache = map[string]*customPage{} } l.cache[stage] = page htmlPath := filepath.Join(l.Dir, stage+".html") src, err := os.ReadFile(htmlPath) if err != nil { return nil // keine eigene Vorlage } tmpl, err := template.New(stage).Parse(string(src)) if err != nil { l.log().Error("Lander-Vorlage fehlerhaft – eingebaute Seite verwendet", "file", htmlPath, "err", err) return nil } page.tmpl = tmpl if css, ok := l.readAsset(filepath.Join(l.Dir, stage+".css")); ok { page.css = template.CSS(css) } for ext, mime := range map[string]string{".svg": "image/svg+xml", ".webp": "image/webp"} { if img, ok := l.readAsset(filepath.Join(l.Dir, stage+ext)); ok { page.image = template.URL("data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(img)) } } return page } func (l *Lander) readAsset(path string) ([]byte, bool) { info, err := os.Stat(path) if err != nil { return nil, false } if info.Size() > MaxAssetSize { l.log().Warn("Lander-Asset zu groß – ignoriert", "file", path, "max_bytes", MaxAssetSize) return nil, false } data, err := os.ReadFile(path) return data, err == nil } // Dump schreibt die eingebauten Seiten und eine README.txt als Vorlagen nach // dir. Bestehende Dateien werden nur mit force überschrieben (sonst // os.ErrExist, ohne etwas zu schreiben). func Dump(dir string, force bool) ([]string, error) { names := []string{"README.txt"} for _, s := range Stages { names = append(names, s+".html") } if !force { for _, n := range names { if _, err := os.Stat(filepath.Join(dir, n)); err == nil { return nil, fmt.Errorf("%s existiert bereits (überschreiben mit --force): %w", filepath.Join(dir, n), os.ErrExist) } } } if err := os.MkdirAll(dir, 0o755); err != nil { return nil, err } var written []string for _, n := range names { data, err := builtinFS.ReadFile("templates/" + n) if err != nil { return written, err } path := filepath.Join(dir, n) if err := os.WriteFile(path, data, 0o644); err != nil { return written, err } written = append(written, path) } return written, nil }