Files
flod/main.go
2025-11-08 22:54:16 +01:00

795 lines
23 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"net/netip"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/redis/go-redis/v9"
)
// Redis + Context
var ctx = context.Background()
// ──────────────────────────────────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────────────────────────────────
// ExportListJSON schreibt die Map als prettified JSONDatei.
func ExportListJSON(path string, m map[string]string) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
enc := json.NewEncoder(f)
enc.SetIndent("", " ")
return enc.Encode(m)
}
// ImportListJSON liest eine JSONDatei und gibt map[string]string zurück.
func ImportListJSON(path string) (map[string]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
var m map[string]string
if err := json.NewDecoder(f).Decode(&m); err != nil {
return nil, err
}
return m, nil
}
// URLs der Blocklisten
var blocklistURLs = map[string]string{
"bitwire": "https://raw.githubusercontent.com/bitwire-it/ipblocklist/refs/heads/main/ip-list.txt",
}
// Präfix-Cache
type prefixCacheEntry struct {
prefixes []netip.Prefix
expireAt time.Time
}
var (
prefixCache = map[string]prefixCacheEntry{}
prefixCacheMu sync.RWMutex
)
// Prometheus Metriken
var (
checkRequests = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ipcheck_requests_total",
Help: "Total IP check requests",
})
checkBlocked = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ipcheck_blocked_total",
Help: "Total blocked IPs",
})
checkWhitelist = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ipcheck_whitelisted_total",
Help: "Total whitelisted IPs",
})
blocklistHashSizes = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "ipcheck_blocklist_hash_size",
Help: "Number of entries in each category",
},
[]string{"category"},
)
)
func init() {
prometheus.MustRegister(checkRequests, checkBlocked, checkWhitelist, blocklistHashSizes)
}
// Main
func main() {
// Import Blocklisten
if err := importBlocklists(); err != nil {
fmt.Println("Blocklisten-Import FEHLGESCHLAGEN:", err)
return
}
// Server
http.HandleFunc("/", handleGUI)
http.HandleFunc("/download/", handleDownload)
http.HandleFunc("/whitelist", handleWhitelist)
http.HandleFunc("/check/", handleCheck)
http.HandleFunc("/traefik", handleTraefik)
http.Handle("/metrics", promhttp.Handler())
go func() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
updateBlocklistMetrics()
<-ticker.C
}
}()
fmt.Println("Server läuft auf :8080")
http.ListenAndServe(":8080", nil)
}
func clientIPFromHeaders(r *http.Request) (netip.Addr, error) {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
parts := strings.Split(xff, ",")
s := strings.TrimSpace(parts[0])
if a, err := netip.ParseAddr(s); err == nil {
return a.Unmap(), nil
}
}
if xr := r.Header.Get("X-Real-Ip"); xr != "" {
if a, err := netip.ParseAddr(strings.TrimSpace(xr)); err == nil {
return a.Unmap(), nil
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err == nil {
if a, err := netip.ParseAddr(host); err == nil {
return a.Unmap(), nil
}
}
return netip.Addr{}, fmt.Errorf("cannot determine client ip")
}
func updateBlocklistMetrics() {
var rdb = redis.NewClient(&redis.Options{
Addr: os.Getenv("REDIS_ADDR"),
DB: 0,
Username: os.Getenv("REDIS_USER"),
Password: os.Getenv("REDIS_PASS"),
})
for cat := range blocklistURLs {
key := "bl:" + cat
count, err := rdb.HLen(ctx, key).Result()
if err != nil {
fmt.Printf("❌ Redis HLen Error for %s: %v\n", key, err)
continue
}
blocklistHashSizes.WithLabelValues(cat).Set(float64(count))
}
}
type target struct {
Name, URL string
}
func fetchAndSave(client *http.Client, t target, outDir string) error {
fileName := filepath.Base(t.URL)
if fileName == "" {
fileName = strings.ReplaceAll(strings.ToLower(strings.ReplaceAll(t.Name, " ", "_")), "..", "")
}
dst := filepath.Join(outDir, fileName)
log.Printf("Downloading %-40s → %s", t.Name, dst)
resp, err := client.Get(t.URL)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("bad HTTP status: %s", resp.Status)
}
tmp := dst + ".part"
f, err := os.Create(tmp)
if err != nil {
return err
}
if _, err := io.Copy(f, resp.Body); err != nil {
f.Close()
os.Remove(tmp)
return err
}
f.Close()
return os.Rename(tmp, dst)
}
// Import-Logik
func importBlocklists() error {
client := &http.Client{Timeout: 60 * time.Second}
t := target{Name: "Catalog", URL: os.Getenv("FLOD_IMPORT_URL") + "/lists.json"}
if err := os.MkdirAll("./lists/", 0o755); err != nil {
fmt.Println("creating output dir", err)
}
if err := fetchAndSave(client, t, "./lists/"); err != nil {
log.Printf("ERROR %s → %v", t.URL, err)
}
blocklistURLs, _ = ImportListJSON("./lists/lists.json")
var wg sync.WaitGroup
errCh := make(chan error, len(blocklistURLs))
for cat, url := range blocklistURLs {
wg.Add(1)
go func(c, u string) {
defer wg.Done()
if err := importCategory(c, u); err != nil {
errCh <- fmt.Errorf("%s: %v", c, err)
}
}(cat, url)
}
wg.Wait()
close(errCh)
for err := range errCh {
fmt.Println("❌", err)
}
if len(errCh) > 0 {
return fmt.Errorf("Blocklisten-Import teilweise fehlgeschlagen")
}
fmt.Println("✅ Blocklisten-Import abgeschlossen")
fmt.Println(blocklistURLs)
blocklistURLs["flodpod"] = "null"
return nil
}
func importCategory(cat, url string) error {
var rdb = redis.NewClient(&redis.Options{
Addr: os.Getenv("REDIS_ADDR"),
DB: 0,
Username: os.Getenv("REDIS_USER"),
Password: os.Getenv("REDIS_PASS"),
})
fmt.Printf("⬇️ Lade %s (%s)\n", cat, url)
resp, err := http.Get(url)
if err != nil {
return fmt.Errorf("HTTP-Fehler: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("HTTP %d", resp.StatusCode)
}
scanner := bufio.NewScanner(resp.Body)
pipe := rdb.Pipeline()
count, batchCount := 0, 0
const batchSize = 500
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
prefix, valid := normalizePrefix(line)
if !valid {
fmt.Printf("⚠️ Ungültig %s: %s\n", cat, line)
continue
}
pipe.HSet(ctx, "bl:"+cat, prefix, 1)
count++
batchCount++
if batchCount >= batchSize {
if _, err := pipe.Exec(ctx); err != nil {
return fmt.Errorf("Redis-Fehler: %v", err)
}
batchCount = 0
}
if count%1000 == 0 {
fmt.Printf("📈 [%s] %d Einträge\n", cat, count)
}
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("lesefehler: %v", err)
}
if batchCount > 0 {
if _, err := pipe.Exec(ctx); err != nil {
return fmt.Errorf("Redis-Fehler final: %v", err)
}
}
fmt.Printf("✅ [%s] %d Einträge importiert\n", cat, count)
return nil
}
func normalizePrefix(s string) (string, bool) {
if !strings.Contains(s, "/") {
ip := net.ParseIP(s)
if ip == nil {
return "", false
}
if ip.To4() != nil {
s += "/32"
} else {
s += "/128"
}
}
s = strings.TrimSpace(s)
_, err := netip.ParsePrefix(s)
return s, err == nil
}
func handleWhitelist(w http.ResponseWriter, r *http.Request) {
var rdb = redis.NewClient(&redis.Options{
Addr: os.Getenv("REDIS_ADDR"),
DB: 0,
Username: os.Getenv("REDIS_USER"),
Password: os.Getenv("REDIS_PASS"),
})
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body struct {
IP string `json:"ip"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
addr, err := netip.ParseAddr(body.IP)
if err != nil {
http.Error(w, "invalid IP", http.StatusBadRequest)
return
}
key := "wl:" + addr.String()
if err := rdb.Set(ctx, key, "1", 0).Err(); err != nil {
http.Error(w, "redis error", http.StatusInternalServerError)
return
}
// Optional: Cache leeren für die IP
prefixCacheMu.Lock()
defer prefixCacheMu.Unlock()
// Kein spezifischer IP-Cache in deinem Design, aber hier könnte man Cache invalidieren falls nötig
writeJSON(w, map[string]string{
"status": "whitelisted",
"ip": addr.String(),
})
}
// Check-Handler
func handleCheck(w http.ResponseWriter, r *http.Request) {
var rdb = redis.NewClient(&redis.Options{
Addr: os.Getenv("REDIS_ADDR"),
DB: 0,
Username: os.Getenv("REDIS_USER"),
Password: os.Getenv("REDIS_PASS"),
})
checkRequests.Inc()
ipStr := strings.TrimPrefix(r.URL.Path, "/check/")
ip, err := netip.ParseAddr(ipStr)
if err != nil {
http.Error(w, "invalid IP", http.StatusBadRequest)
return
}
var cats []string
for a := range blocklistURLs {
cats = append(cats, a)
}
//cats := []string{"firehol", "bitwire", "RU", "CN"}
matches, err := checkIP(ip, cats)
if err != nil {
http.Error(w, "server error", http.StatusInternalServerError)
return
}
if len(matches) > 0 {
checkBlocked.Inc()
} else {
wl, _ := rdb.Exists(ctx, "wl:"+ip.String()).Result()
if wl > 0 {
checkWhitelist.Inc()
}
}
writeJSON(w, map[string]any{
"ip": ip.String(),
"blocked": len(matches) > 0,
"categories": matches,
})
}
// Check-Handler
func handleTraefik(w http.ResponseWriter, r *http.Request) {
rdb := redis.NewClient(&redis.Options{
Addr: os.Getenv("REDIS_ADDR"),
DB: 0,
Username: os.Getenv("REDIS_USER"),
Password: os.Getenv("REDIS_PASS"),
})
checkRequests.Inc()
ip, err := clientIPFromHeaders(r)
if err != nil {
http.Error(w, "invalid IP", http.StatusBadRequest)
return
}
// Kategorien dynamisch aus blocklistURLs
cats := make([]string, 0, len(blocklistURLs))
for c := range blocklistURLs {
cats = append(cats, c)
}
matches, err := checkIP(ip, cats)
if err != nil {
http.Error(w, "server error", http.StatusInternalServerError)
return
}
// Whitelist check (wie gehabt)
if len(matches) == 0 {
wl, _ := rdb.Exists(ctx, "wl:"+ip.String()).Result()
if wl > 0 {
checkWhitelist.Inc()
}
}
if len(matches) > 0 {
checkBlocked.Inc()
errorhtml(w, r)
//http.Error(w, "blocked", http.StatusTooManyRequests)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("OK"))
}
// Check-Logik
func checkIP(ip netip.Addr, cats []string) ([]string, error) {
var rdb = redis.NewClient(&redis.Options{
Addr: os.Getenv("REDIS_ADDR"),
DB: 0,
Username: os.Getenv("REDIS_USER"),
Password: os.Getenv("REDIS_PASS"),
})
wl, err := rdb.Exists(ctx, "wl:"+ip.String()).Result()
if err != nil {
return nil, err
}
if wl > 0 {
return []string{}, nil
}
matches := []string{}
for _, cat := range cats {
prefixes, err := loadCategoryPrefixes(cat)
if err != nil {
return nil, err
}
for _, pfx := range prefixes {
if pfx.Contains(ip) {
fmt.Printf("💡 MATCH: %s in %s (%s)\n", ip, cat, pfx)
matches = append(matches, cat)
break
}
}
}
return matches, nil
}
func loadCategoryPrefixes(cat string) ([]netip.Prefix, error) {
var rdb = redis.NewClient(&redis.Options{
Addr: os.Getenv("REDIS_ADDR"),
DB: 0,
Username: os.Getenv("REDIS_USER"),
Password: os.Getenv("REDIS_PASS"),
})
prefixCacheMu.Lock()
defer prefixCacheMu.Unlock()
entry, ok := prefixCache[cat]
if ok && time.Now().Before(entry.expireAt) {
return entry.prefixes, nil
}
keys, err := rdb.HKeys(ctx, "bl:"+cat).Result()
if err != nil {
return nil, err
}
var prefixes []netip.Prefix
for _, k := range keys {
k = strings.TrimSpace(k)
pfx, err := netip.ParsePrefix(k)
if err == nil {
prefixes = append(prefixes, pfx)
} else {
fmt.Printf("⚠️ Ungültiger Redis-Prefix %s: %s\n", cat, k)
}
}
prefixCache[cat] = prefixCacheEntry{
prefixes: prefixes,
expireAt: time.Now().Add(10 * time.Minute),
//Hier geändert von 1 * time.Second
}
return prefixes, nil
}
// JSON-Helfer
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
func handleDownload(w http.ResponseWriter, r *http.Request) {
var rdb = redis.NewClient(&redis.Options{
Addr: os.Getenv("REDIS_ADDR"),
DB: 0,
Username: os.Getenv("REDIS_USER"),
Password: os.Getenv("REDIS_PASS"),
})
cat := strings.TrimPrefix(r.URL.Path, "/download/")
if cat == "" {
http.Error(w, "category missing", http.StatusBadRequest)
return
}
// Prüfen, ob Kategorie existiert
if _, ok := blocklistURLs[cat]; !ok {
http.Error(w, "unknown category", http.StatusNotFound)
return
}
// Alle Einträge holen
keys, err := rdb.HKeys(ctx, "bl:"+cat).Result()
if err != nil {
http.Error(w, "redis error", http.StatusInternalServerError)
return
}
// Header für Download setzen
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.txt\"", cat))
// Zeilenweise schreiben
for _, k := range keys {
_, _ = fmt.Fprintln(w, k)
}
}
func handleGUI(w http.ResponseWriter, r *http.Request) {
html := `
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>IP Checker GUI</title>
<style>
body { font-family: sans-serif; max-width: 1000px; margin: auto; padding: 2em; background: #f9fafb; }
h1 { font-size: 1.5em; margin-bottom: 1em; }
input, button { padding: 0.7em; margin: 0.3em 0; width: 100%; border-radius: 0.4em; border: 1px solid #ccc; box-sizing: border-box; }
button { background: #2563eb; color: white; border: none; cursor: pointer; }
button:hover { background: #1d4ed8; }
#result, #metrics, #history { background: white; border: 1px solid #ddd; padding: 1em; border-radius: 0.4em; margin-top: 1em; white-space: pre-wrap; }
</style>
</head>
<body>
<h1>IP Checker + Whitelist GUI</h1>
<input id="ipInput" type="text" placeholder="Enter IP-Address...">
<button onclick="checkIP()">Check IP</button>
<button onclick="whitelistIP()">Whitelist IP</button>
<h2>Ergebnis</h2>
<div id="result">No Request</div>
<h2>Check History</h2>
<div id="history">No history</div>
<h2>Prometheus Metrics</h2>
<div id="metrics">Loading...</div>
<script>
async function checkIP() {
const ip = document.getElementById('ipInput').value.trim();
if (!ip) { alert("Please enter IP!"); return; }
const res = await fetch('/check/' + ip);
const data = await res.json();
document.getElementById('result').innerText = JSON.stringify(data, null, 2);
addHistory(ip, data);
}
async function whitelistIP() {
const ip = document.getElementById('ipInput').value.trim();
if (!ip) { alert("Please enter IP!"); return; }
const res = await fetch('/whitelist', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ip})
});
const data = await res.json();
document.getElementById('result').innerText = JSON.stringify(data, null, 2);
addHistory(ip, data);
}
function addHistory(ip, data) {
let history = JSON.parse(localStorage.getItem('ipHistory') || '[]');
history.unshift({ip, data, ts: new Date().toLocaleString()});
if (history.length > 10) history = history.slice(0, 10);
localStorage.setItem('ipHistory', JSON.stringify(history));
renderHistory();
}
function renderHistory() {
let history = JSON.parse(localStorage.getItem('ipHistory') || '[]');
if (history.length === 0) {
document.getElementById('history').innerText = 'Nothing checked yet';
return;
}
document.getElementById('history').innerText = history.map(e =>
e.ts + ": " + e.ip + " → blocked=" + (e.data.blocked ? "yes" : "no") +
(e.data.categories ? " [" + e.data.categories.join(", ") + "]" : "")
).join("\n");
}
async function loadMetrics() {
const res = await fetch('/metrics');
const text = await res.text();
const lines = text.split('\n').filter(l => l.includes('ipcheck_'));
document.getElementById('metrics').innerText = lines.join('\n') || 'No Data';
}
renderHistory();
setInterval(loadMetrics, 3000);
loadMetrics();
</script>
</body>
</html>
`
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(html))
}
func errorhtml(w http.ResponseWriter, r *http.Request) {
html := `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Webserver ist nicht erreichbar - Fehler 521</title>
<style>
:root{
--bg:#f6f7f9;--text:#1f2937;--muted:#6b7280;--card:#ffffff;
--success:#22c55e;--danger:#ef4444;--accent:#2563eb;--border:#e5e7eb;
}
*{box-sizing:border-box}
html,body{height:100%}
body{margin:0;background:var(--bg);color:var(--text);font:16px/1.5 system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,"Helvetica Neue",Arial}
.wrap{max-width:980px;margin:0 auto;padding:40px 16px 64px}
header{display:flex;align-items:center;gap:12px;flex-wrap:wrap;}
h1{font-size:clamp(24px,4vw,38px);font-weight:700;margin:0}
.pill{display:inline-flex;align-items:center;gap:8px;padding:6px 10px;border-radius:999px;border:1px solid var(--border);background:#fff;font-weight:600;font-size:14px;color:#111}
.pill small{font-weight:500;color:var(--muted)}
p.lead{margin:12px 0 24px;color:var(--muted)}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:18px;margin-top:16px}
.node{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:18px;display:flex;flex-direction:column;align-items:center;gap:10px;box-shadow:0 6px 18px rgba(0,0,0,.04)}
.icon{position:relative;width:104px;height:80px;display:grid;place-items:center}
.status{position:absolute;right:-8px;bottom:-8px;width:30px;height:30px;border-radius:999px;display:grid;place-items:center;color:#fff;font-weight:800;font-size:14px}
.status.ok{background:var(--success)}
.status.err{background:var(--danger)}
.node h3{margin:6px 0 0;font-size:16px}
.node .sub{margin:0;color:var(--muted);font-size:14px}
.node .state{margin:4px 0 0;font-weight:700}
.state.ok{color:var(--success)}
.state.err{color:var(--danger)}
.actions{margin-top:28px;display:flex;gap:12px;flex-wrap:wrap}
.btn{border:1px solid var(--border);background:#fff;font-weight:600;padding:10px 14px;border-radius:10px;cursor:pointer}
.btn.primary{background:var(--accent);border-color:var(--accent);color:#fff}
.meta{margin-top:24px;color:var(--muted);font-size:13px}
footer{margin-top:40px;color:var(--muted);font-size:13px}
/* Simple, friendly SVG look */
svg{display:block}
.dim{fill:#e5e7eb}
.stroke{stroke:#9ca3af}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>Webserver ist nicht erreichbar</h1>
<span class="pill">Fehlercode&nbsp;521 <small>(Origin Server verweigert Verbindung)</small></span>
</header>
<p class="lead">
Dein Browser funktioniert, der Edge-Proxy ist erreichbar - aber der Origin-Server antwortet nicht oder die Verbindung wird verweigert.
</p>
<section class="grid" aria-label="Diagnose-Kette">
<!-- You / Browser -->
<article class="node" aria-label="Browser Status">
<div class="icon" aria-hidden="true">
<!-- Browser icon -->
<svg width="88" height="62" viewBox="0 0 88 62" xmlns="http://www.w3.org/2000/svg" role="img">
<rect x="1" y="6" width="86" height="55" rx="8" fill="#fff" stroke="#d1d5db"/>
<rect x="1" y="1" width="86" height="14" rx="8" fill="#f3f4f6" stroke="#d1d5db"/>
<circle cx="10" cy="8" r="2.5" fill="#ef4444"/>
<circle cx="18" cy="8" r="2.5" fill="#f59e0b"/>
<circle cx="26" cy="8" r="2.5" fill="#22c55e"/>
</svg>
<div class="status ok" title="Funktioniert">✓</div>
</div>
<h3>Du</h3>
<p class="sub">Browser</p>
<p class="state ok">Funktioniert</p>
</article>
<!-- Edge / Proxy -->
<article class="node" aria-label="Edge/Proxy Status">
<div class="icon" aria-hidden="true">
<!-- Cloud icon -->
<svg width="96" height="64" viewBox="0 0 96 64" xmlns="http://www.w3.org/2000/svg" role="img">
<path d="M33 44h32a14 14 0 0 0 0-28 18 18 0 0 0-34-5 16 16 0 0 0-4 31z" fill="#e5e7eb" stroke="#d1d5db"/>
</svg>
<div class="status ok" title="Funktioniert">✓</div>
</div>
<h3>Edge-Proxy</h3>
<p class="sub">Zwischenschicht</p>
<p class="state ok">Funktioniert</p>
</article>
<!-- Host / Origin -->
<article class="node" aria-label="Origin/Host Status">
<div class="icon" aria-hidden="true">
<!-- Server icon -->
<svg width="88" height="62" viewBox="0 0 88 62" xmlns="http://www.w3.org/2000/svg" role="img">
<rect x="6" y="10" width="76" height="18" rx="4" fill="#f3f4f6" stroke="#d1d5db"/>
<circle cx="16" cy="19" r="3" fill="#9ca3af"/>
<rect x="6" y="34" width="76" height="18" rx="4" fill="#f3f4f6" stroke="#d1d5db"/>
<circle cx="16" cy="43" r="3" fill="#9ca3af"/>
</svg>
<div class="status err" title="Fehler">✕</div>
</div>
<h3>Host</h3>
<p class="sub">Origin-Server</p>
<p class="state err">Fehler</p>
</article>
</section>
<div class="actions">
<button class="btn primary" onclick="location.reload()">Erneut versuchen</button>
<button class="btn" onclick="document.getElementById('details').toggleAttribute('open')">Details anzeigen</button>
</div>
<details id="details" class="meta">
<summary><strong>Technische Details</strong></summary>
<ul>
<li>Fehler: <strong>521</strong> - Origin verweigert die Verbindung (z.&nbsp;B. Firewall, blockierter Port, kein Dienst am Ziel).</li>
<li>Zeit: <span id="now">-</span></li>
</ul>
<p>Tipps: Prüfe, ob dein Backend erreichbar ist (TCP/443 oder 80), ob der Dienst läuft und ob eine Firewall/Fail2ban/Rate-Limit die Proxy-IP blockiert.</p>
</details>
<footer>
<span>Wenn das Problem fortbesteht, kontaktiere den Betreiber der Website.</span>
</footer>
</div>
<script>
// Kleine Komfortfunktionen für die Detailbox
(function(){
const now = new Date()
document.getElementById('now').textContent = now.toLocaleString()
// Client IP lässt sich hier ohne Backend nicht sauber ermitteln - Platzhalter
})()
</script>
</body>
</html>`
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(html))
}