Files
br ca6cdcca63 software steht so weit.
jetzt build + docker build und testen
2026-10-02 13:25:34 +02:00

333 lines
9.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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("<!doctype html><title>" + stage + "</title>")
}
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
}