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
}
+374
View File
@@ -0,0 +1,374 @@
package lander
import (
"bytes"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func request(t *testing.T, l *Lander, stage, tier, target string, header ...string) *httptest.ResponseRecorder {
t.Helper()
r := httptest.NewRequest(http.MethodGet, target, nil)
r.Host = "kunde.de"
if stage != "" {
r.Header.Set("X-Sultan-Stage", stage)
}
r.Header.Set("X-Sultan-Tenant", "kunde")
if tier != "" {
r.Header.Set("X-Sultan-Tier", tier)
}
for i := 0; i+1 < len(header); i += 2 {
r.Header.Set(header[i], header[i+1])
}
rec := httptest.NewRecorder()
l.ServeHTTP(rec, r)
return rec
}
func newLander(t *testing.T) *Lander {
return &Lander{Logger: slog.New(slog.DiscardHandler), HosterName: "Beispiel-Hosting", HosterURL: "https://hoster.example", ProURL: "https://hoster.example/pro"}
}
func TestStages(t *testing.T) {
tests := []struct {
stage string
status int
refresh string // erwarteter meta-refresh-Inhalt, leer = keiner
cookie bool
}{
{"watermark", 200, "5;url=/shop?x=1", true},
{"firemark", 200, "10;url=/shop?x=1", true},
{"paywall", 402, "", false},
{"landing", 402, "", false},
{"maintenance", 503, "", false},
}
for _, tt := range tests {
t.Run(tt.stage, func(t *testing.T) {
rec := request(t, newLander(t), tt.stage, "pro", "/shop?x=1")
if rec.Code != tt.status {
t.Errorf("Status %d, erwartet %d", rec.Code, tt.status)
}
if ct := rec.Header().Get("Content-Type"); ct != "text/html; charset=utf-8" {
t.Errorf("Content-Type %q", ct)
}
if rec.Header().Get("Cache-Control") != "no-store" {
t.Error("Seite darf nicht gecacht werden")
}
body := rec.Body.String()
if tt.refresh != "" && !strings.Contains(body, `http-equiv="refresh" content="`+tt.refresh+`"`) {
t.Errorf("meta refresh %q fehlt:\n%s", tt.refresh, body)
}
if tt.refresh == "" && strings.Contains(body, "refresh") {
t.Error("Stufe ohne Durchlass darf nicht weiterleiten")
}
cookie := rec.Header().Get("Set-Cookie")
if tt.cookie {
for _, want := range []string{"sultan_ack=" + tt.stage, "Max-Age=3600", "HttpOnly", "SameSite=Lax", "Path=/"} {
if !strings.Contains(cookie, want) {
t.Errorf("Cookie %q ohne %q", cookie, want)
}
}
} else if cookie != "" {
t.Errorf("unerwartetes Cookie %q", cookie)
}
if !strings.Contains(body, `lang="de"`) || !strings.Contains(body, `lang="en"`) {
t.Error("Text muss auf Deutsch und Englisch vorliegen")
}
})
}
}
func TestMaintenanceRetryAfterAndProHint(t *testing.T) {
l := newLander(t)
rec := request(t, l, "maintenance", "community", "/")
if rec.Header().Get("Retry-After") != "60" {
t.Errorf("Retry-After %q", rec.Header().Get("Retry-After"))
}
if !strings.Contains(rec.Body.String(), "https://hoster.example/pro") {
t.Error("Community-Wartungsseite ohne Pro-Hinweis")
}
if strings.Contains(request(t, l, "maintenance", "pro", "/").Body.String(), "https://hoster.example/pro") {
t.Error("Pro-Kunde bekommt Pro-Werbung")
}
}
func TestPaywallInvitesToHoster(t *testing.T) {
body := request(t, newLander(t), "paywall", "pro", "/").Body.String()
if !strings.Contains(body, "Beispiel-Hosting") || !strings.Contains(body, "https://hoster.example") {
t.Errorf("Einladung des Hosters fehlt:\n%s", body)
}
}
func TestSecureCookieBehindHTTPS(t *testing.T) {
rec := request(t, newLander(t), "watermark", "pro", "/", "X-Forwarded-Proto", "https")
if !strings.Contains(rec.Header().Get("Set-Cookie"), "Secure") {
t.Errorf("Cookie ohne Secure: %q", rec.Header().Get("Set-Cookie"))
}
}
func TestConfigurableDelays(t *testing.T) {
l := newLander(t)
l.WatermarkDelay, l.FiremarkDelay, l.AckTTL = 2*time.Second, 7*time.Second, 15*time.Minute
if body := request(t, l, "watermark", "pro", "/").Body.String(); !strings.Contains(body, `content="2;url=/"`) {
t.Errorf("Wartezeit Stufe 1:\n%s", body)
}
rec := request(t, l, "firemark", "pro", "/")
if !strings.Contains(rec.Body.String(), `content="7;url=/"`) || !strings.Contains(rec.Header().Get("Set-Cookie"), "Max-Age=900") {
t.Errorf("Stufe 2: %q / %s", rec.Header().Get("Set-Cookie"), rec.Body.String())
}
}
func TestUnknownOrMissingStage(t *testing.T) {
for _, stage := range []string{"", "normal", "kaputt"} {
if rec := request(t, newLander(t), stage, "pro", "/"); rec.Code != http.StatusBadRequest {
t.Errorf("Stufe %q: Status %d", stage, rec.Code)
}
}
}
// Werte aus Headern und URL werden nie roh ausgegeben.
func TestEscaping(t *testing.T) {
rec := request(t, newLander(t), "watermark", "pro", `/a"><script>alert(1)</script>`,
"X-Sultan-Tenant", "<script>böse</script>")
body := rec.Body.String()
if strings.Contains(body, "<script>") {
t.Errorf("ungefilterte Ausgabe:\n%s", body)
}
}
func writeFile(t *testing.T, dir, name, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func TestCustomTemplateWithInlinedAssets(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "maintenance.html",
`<html><head><style>{{.CSS}}</style></head><body><img src="{{.Image}}"><p>Wartung bei {{.Tenant}} ({{.Tier}})</p></body></html>`)
writeFile(t, dir, "maintenance.css", "body { color: red; }")
writeFile(t, dir, "maintenance.svg", `<svg xmlns="http://www.w3.org/2000/svg"/>`)
l := newLander(t)
l.Dir = dir
rec := request(t, l, "maintenance", "pro", "/")
body := rec.Body.String()
if rec.Code != 503 || !strings.Contains(body, "Wartung bei kunde (pro)") {
t.Errorf("eigene Vorlage nicht verwendet: %d\n%s", rec.Code, body)
}
if !strings.Contains(body, "body { color: red; }") {
t.Error("CSS nicht eingebettet")
}
// html/template schreibt "+" im Attribut als &#43; – der Browser liest es als "+".
if !strings.Contains(body, `src="data:image/svg&#43;xml;base64,`) {
t.Errorf("Grafik nicht als data-URI eingebettet:\n%s", body)
}
// Andere Stufen ohne eigene Vorlage: eingebaut.
if body := request(t, l, "paywall", "pro", "/").Body.String(); !strings.Contains(body, "Beispiel-Hosting") {
t.Error("eingebaute Seite für paywall fehlt")
}
}
func TestCustomTemplateWebP(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "landing.html", `<img src="{{.Image}}">`)
writeFile(t, dir, "landing.webp", "RIFF0000WEBP")
l := newLander(t)
l.Dir = dir
if body := request(t, l, "landing", "pro", "/").Body.String(); !strings.Contains(body, `src="data:image/webp;base64,`) {
t.Errorf("WebP nicht eingebettet: %s", body)
}
}
// Eine kaputte Vorlage darf die Hinweisseite nie ausfallen lassen.
func TestBrokenTemplateFallsBackToBuiltin(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "maintenance.html", `{{.GibtsNicht`)
var logs bytes.Buffer
l := newLander(t)
l.Dir, l.Logger = dir, slog.New(slog.NewTextHandler(&logs, nil))
rec := request(t, l, "maintenance", "pro", "/")
if rec.Code != 503 || !strings.Contains(rec.Body.String(), `lang="de"`) {
t.Errorf("keine eingebaute Seite: %d %s", rec.Code, rec.Body.String())
}
if !strings.Contains(logs.String(), "maintenance.html") {
t.Errorf("Fehler nicht protokolliert: %s", logs.String())
}
// Ausführungsfehler (unbekanntes Feld) ebenfalls.
writeFile(t, dir, "maintenance.html", `{{.GibtsNicht}}`)
rec = request(t, l, "maintenance", "pro", "/")
if rec.Code != 503 || !strings.Contains(rec.Body.String(), `lang="de"`) {
t.Errorf("Ausführungsfehler: %d %s", rec.Code, rec.Body.String())
}
}
func TestOversizedAssetIgnored(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "maintenance.html", `<img src="{{.Image}}">ok`)
writeFile(t, dir, "maintenance.svg", strings.Repeat("x", MaxAssetSize+1))
l := newLander(t)
l.Dir = dir
body := request(t, l, "maintenance", "pro", "/").Body.String()
if strings.Contains(body, "base64") || !strings.Contains(body, "ok") {
t.Errorf("zu große Grafik eingebettet: %.200s", body)
}
}
func TestTemplatesReloadedOnChange(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "maintenance.html", `Version 1`)
l := newLander(t)
l.Dir = dir
if body := request(t, l, "maintenance", "pro", "/").Body.String(); body != "Version 1" {
t.Fatalf("body = %q", body)
}
writeFile(t, dir, "maintenance.html", `Version 2 – länger`)
if body := request(t, l, "maintenance", "pro", "/").Body.String(); body != "Version 2 – länger" {
t.Errorf("Änderung nicht übernommen: %q", body)
}
}
func TestDump(t *testing.T) {
dir := filepath.Join(t.TempDir(), "landing")
files, err := Dump(dir, false)
if err != nil {
t.Fatalf("Dump: %v", err)
}
for _, stage := range Stages {
if _, err := os.Stat(filepath.Join(dir, stage+".html")); err != nil {
t.Errorf("%s.html fehlt", stage)
}
}
if _, err := os.Stat(filepath.Join(dir, "README.txt")); err != nil {
t.Error("README.txt fehlt")
}
if len(files) != len(Stages)+1 {
t.Errorf("geschriebene Dateien: %v", files)
}
// Ausgegebene Vorlagen rendern genauso wie die eingebauten.
builtin := newLander(t)
custom := newLander(t)
custom.Dir = dir
for _, stage := range Stages {
a := request(t, builtin, stage, "community", "/x").Body.String()
b := request(t, custom, stage, "community", "/x").Body.String()
if a != b {
t.Errorf("%s: Ausgabe weicht ab", stage)
}
}
if _, err := Dump(dir, false); !errors.Is(err, os.ErrExist) {
t.Errorf("Überschreiben ohne force: %v", err)
}
writeFile(t, dir, "maintenance.html", "eigen")
if _, err := Dump(dir, true); err != nil {
t.Fatalf("Dump mit force: %v", err)
}
if b, _ := os.ReadFile(filepath.Join(dir, "maintenance.html")); string(b) == "eigen" {
t.Error("force hat nicht überschrieben")
}
}
func TestAnyMethodGetsPage(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/formular", strings.NewReader("a=b"))
r.Header.Set("X-Sultan-Stage", "maintenance")
rec := httptest.NewRecorder()
newLander(t).ServeHTTP(rec, r)
if rec.Code != 503 {
t.Errorf("POST: %d", rec.Code)
}
}
// Das Weiterleitungsziel ist immer ein lokaler Pfad: keine fremden Hosts
// (//evil), keine Schemata (javascript:), keine Backslash-Tricks.
func TestRefreshTargetIsLocal(t *testing.T) {
tests := map[string]string{
"/shop?x=1": "/shop?x=1",
"//evil.example/x": "/",
"/\\evil.example": "/",
"/a%2F%2Fb": "/a%2F%2Fb",
"/pfad mit leer": "/pfad%20mit%20leer",
}
for target, want := range tests {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.URL.Path, r.URL.RawPath, r.URL.RawQuery = "", "", ""
u, _ := r.URL.Parse(target)
if strings.HasPrefix(target, "//") {
u.Host, u.Path = "", target // so wie es bei einem Request "GET //evil.example/x" ankommt
}
r.URL = u
r.Header.Set("X-Sultan-Stage", "watermark")
rec := httptest.NewRecorder()
newLander(t).ServeHTTP(rec, r)
if !strings.Contains(rec.Body.String(), `content="5;url=`+want+`"`) {
t.Errorf("%q: erwartet Ziel %q in\n%s", target, want, rec.Body.String())
}
}
if got := localTarget(nil); got != "/" {
t.Errorf("localTarget(nil) = %q", got)
}
}
func TestMaintenancePublicReasonAndUntil(t *testing.T) {
berlin, _ := time.LoadLocation("Europe/Berlin")
l := newLander(t)
l.Location = berlin
now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
l.now = func() time.Time { return now }
until := now.Add(90 * time.Minute) // 15:30 in Berlin (MESZ)
rec := request(t, l, "maintenance", "pro", "/",
"X-Sultan-Public-Reason", url.QueryEscape("Sicherheitsupdate (CVE-1234) – Sicherheit nehmen wir ernst."),
"X-Sultan-Until", until.Format(time.RFC3339))
body := rec.Body.String()
if !strings.Contains(body, "Sicherheitsupdate (CVE-1234) – Sicherheit nehmen wir ernst.") {
t.Errorf("öffentlicher Grund fehlt:\n%s", body)
}
if strings.Contains(body, "Bitte versuchen Sie es in einer Minute") {
t.Error("Standardtext trotz öffentlichem Grund")
}
if !strings.Contains(body, "02.10.2026 15:30") || !strings.Contains(body, "2026-10-02 15:30") {
t.Errorf("voraussichtliches Ende fehlt:\n%s", body)
}
if rec.Header().Get("Retry-After") != "5400" {
t.Errorf("Retry-After %q, erwartet Sekunden bis zum Ende", rec.Header().Get("Retry-After"))
}
}
func TestMaintenanceHeaderHandling(t *testing.T) {
l := newLander(t)
now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
l.now = func() time.Time { return now }
tests := []struct {
name, until, retry string
}{
{"kein Ende", "", "60"},
{"unlesbar", "morgen", "60"},
{"vergangen", now.Add(-time.Hour).Format(time.RFC3339), "60"},
{"sehr fern", now.Add(30 * 24 * time.Hour).Format(time.RFC3339), "86400"},
}
for _, tt := range tests {
rec := request(t, l, "maintenance", "pro", "/", "X-Sultan-Until", tt.until)
if got := rec.Header().Get("Retry-After"); got != tt.retry {
t.Errorf("%s: Retry-After %q, erwartet %q", tt.name, got, tt.retry)
}
}
// Eingeschleustes HTML im Grund wird maskiert, überlange Gründe gekürzt.
rec := request(t, l, "maintenance", "pro", "/", "X-Sultan-Public-Reason", url.QueryEscape("<script>x</script>"+strings.Repeat("a", 400)))
body := rec.Body.String()
if strings.Contains(body, "<script>x") || strings.Contains(body, strings.Repeat("a", 301)) {
t.Errorf("Grund nicht maskiert/gekürzt: %.300s", body)
}
}
+28
View File
@@ -0,0 +1,28 @@
Sultan – eigene Lander-Seiten
=============================
Pro Stufe eine Vorlage <stufe>.html (Go html/template), optional <stufe>.css
und eine Grafik <stufe>.svg oder <stufe>.webp (je höchstens 512 KiB).
CSS und Grafik werden in die Seite eingebettet – es gibt keine weiteren URLs.
Stufen: watermark, firemark, paywall, landing, maintenance
Fehlt eine Vorlage oder ist sie fehlerhaft, gilt die eingebaute Seite.
Änderungen wirken sofort (kein Neustart nötig).
Verfügbare Werte:
{{.Stage}} Stufe
{{.Tenant}} Mandanten-ID
{{.Tier}} community | pro | enterprise
{{.Community}} true bei Tier community
{{.Target}} lokaler Pfad der ursprünglichen Seite (für Weiterleitung/Link)
{{.RefreshSeconds}} Wartezeit der Weiterleitung (watermark/firemark)
{{.HosterName}} Name des Hosting-Anbieters (sultan server --hoster-name)
{{.HosterURL}} Angebotsseite des Anbieters (--hoster-url)
{{.ProURL}} Pro-Angebot für Community (--pro-url)
{{.PublicReason}} öffentlicher Wartungsgrund (maintenance, falls angegeben)
{{.UntilDE}} voraussichtliches Ende, z. B. 02.10.2026 15:30 (maintenance)
{{.UntilEN}} dasselbe, z. B. 2026-10-02 15:30
{{.CSS}} Inhalt von <stufe>.css – in <style>{{.CSS}}</style>
{{.Image}} Grafik als data:-URI – in <img src="{{.Image}}">
Syntax: https://pkg.go.dev/html/template (z. B. {{if .ProURL}}…{{end}})
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<meta http-equiv="refresh" content="{{.RefreshSeconds}};url={{.Target}}">
<title>Wichtiger Hinweis / Important notice</title>
{{if .CSS}}<style>{{.CSS}}</style>{{end}}
</head>
<body>
{{if .Image}}<img src="{{.Image}}" alt="">{{end}}
<section lang="de">
<h1>Wichtiger Hinweis</h1>
<p>Für diese Website ist beim Hosting-Anbieter noch etwas offen. Wenn der Betreiber es nicht bald klärt,
wird die Website abgeschaltet. Bis dahin ist sie erreichbar – Sie werden in {{.RefreshSeconds}} Sekunden weitergeleitet.</p>
<p><a href="{{.Target}}">Weiter zur Website</a></p>
</section>
<section lang="en">
<h1>Important notice</h1>
<p>Something is still open with the hosting provider of this website. If the owner does not sort it out soon,
the website will be switched off. Until then it is available – you will be redirected in {{.RefreshSeconds}} seconds.</p>
<p><a href="{{.Target}}">Continue to the website</a></p>
</section>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>Abgelaufen / Expired</title>
{{if .CSS}}<style>{{.CSS}}</style>{{end}}
</head>
<body>
{{if .Image}}<img src="{{.Image}}" alt="">{{end}}
<section lang="de">
<h1>Das Hosting dieser Website ist abgelaufen</h1>
<p>Die Website ist deshalb gerade nicht erreichbar. Sobald der Betreiber verlängert, ist sie wieder da.
{{if .HosterName}}{{.HosterName}}{{else}}Der Hosting-Anbieter{{end}} freut sich, wenn Sie sich in der Zwischenzeit umsehen.</p>
{{if .HosterURL}}<p><a href="{{.HosterURL}}">Zu den Angeboten</a></p>{{end}}
</section>
<section lang="en">
<h1>The hosting of this website has expired</h1>
<p>That is why the website is not available right now. As soon as the owner renews, it will be back.
{{if .HosterName}}{{.HosterName}}{{else}}The hosting provider{{end}} would be happy if you have a look around in the meantime.</p>
{{if .HosterURL}}<p><a href="{{.HosterURL}}">See the offers</a></p>{{end}}
</section>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>Gleich wieder da / Back soon</title>
{{if .CSS}}<style>{{.CSS}}</style>{{end}}
</head>
<body>
{{if .Image}}<img src="{{.Image}}" alt="">{{end}}
<section lang="de">
<h1>Gleich wieder da</h1>
{{if .PublicReason}}<p>{{.PublicReason}}</p>{{else}}<p>Diese Website ist gerade nicht erreichbar – vermutlich wird sie gewartet oder neu gestartet.
Bitte versuchen Sie es in einer Minute noch einmal.</p>{{end}}
{{if .UntilDE}}<p>Voraussichtlich wieder da: {{.UntilDE}}</p>{{end}}
{{if and .Community .ProURL}}<p>Sie möchten so etwas gar nicht erst erleben? <a href="{{.ProURL}}">Hier gibt es den Pro-Service</a>.</p>{{end}}
</section>
<section lang="en">
<h1>Back in a moment</h1>
{{if .PublicReason}}<p>{{.PublicReason}}</p>{{else}}<p>This website is not available right now – it is probably being maintained or restarted.
Please try again in a minute.</p>{{end}}
{{if .UntilEN}}<p>Expected back: {{.UntilEN}}</p>{{end}}
{{if and .Community .ProURL}}<p>Want to avoid this altogether? <a href="{{.ProURL}}">Get the pro service here</a>.</p>{{end}}
</section>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>Derzeit offline / Currently offline</title>
{{if .CSS}}<style>{{.CSS}}</style>{{end}}
</head>
<body>
{{if .Image}}<img src="{{.Image}}" alt="">{{end}}
<section lang="de">
<h1>Diese Website ist derzeit offline</h1>
<p>Der Betreiber hat sein Hosting nicht verlängert. Schade – aber vielleicht passt ja etwas für Sie:
{{if .HosterName}}{{.HosterName}}{{else}}Der Hosting-Anbieter{{end}} lädt Sie herzlich ein, sich umzusehen, was hier alles möglich ist.</p>
{{if .HosterURL}}<p><a href="{{.HosterURL}}">Zu den Angeboten</a></p>{{end}}
</section>
<section lang="en">
<h1>This website is currently offline</h1>
<p>The owner has not renewed their hosting. A pity – but maybe there is something for you:
{{if .HosterName}}{{.HosterName}}{{else}}The hosting provider{{end}} warmly invites you to have a look at what is possible here.</p>
{{if .HosterURL}}<p><a href="{{.HosterURL}}">See the offers</a></p>{{end}}
</section>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<meta http-equiv="refresh" content="{{.RefreshSeconds}};url={{.Target}}">
<title>Hinweis / Notice</title>
{{if .CSS}}<style>{{.CSS}}</style>{{end}}
</head>
<body>
{{if .Image}}<img src="{{.Image}}" alt="">{{end}}
<section lang="de">
<h1>Kurzer Hinweis</h1>
<p>Der Betreiber dieser Website hat eine Nachricht von seinem Hosting-Anbieter erhalten, um die er sich kümmern sollte.
Die Website ist ganz normal erreichbar – Sie werden in {{.RefreshSeconds}} Sekunden automatisch weitergeleitet.</p>
<p><a href="{{.Target}}">Gleich weiter zur Website</a></p>
</section>
<section lang="en">
<h1>Just a short notice</h1>
<p>The owner of this website has received a message from their hosting provider that needs attention.
The website works as usual – you will be redirected automatically in {{.RefreshSeconds}} seconds.</p>
<p><a href="{{.Target}}">Continue to the website now</a></p>
</section>
</body>
</html>