software steht so weit.
jetzt build + docker build und testen
This commit is contained in:
1 parent
42f0c0accb
commit
ca6cdcca63
90 files changed
+10298
-480
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user