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
|
||||
}
|
||||
@@ -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 + – der Browser liest es als "+".
|
||||
if !strings.Contains(body, `src="data:image/svg+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)
|
||||
}
|
||||
}
|
||||
@@ -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}})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user