333 lines
9.7 KiB
Go
333 lines
9.7 KiB
Go
// 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
|
||
}
|