software steht so weit.

jetzt build + docker build und testen
This commit is contained in:
br committed 2026-10-02 13:25:34 +02:00
1 parent 42f0c0accb
commit ca6cdcca63
90 files changed
+10298 -480

No files matched your search

+332
View File
@@ -0,0 +1,332 @@
// 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
}