Files
flod/main.go
jbergner 19143fd8c1
All checks were successful
release-tag / release-image (push) Successful in 1m47s
updated template
2025-11-09 15:36:01 +01:00

790 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>Too Many Requests - Error 429</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>Too Many Requests - HTTP-ERROR 429</h1>
<span class="pill">ERROR&nbsp;429 <small>(Edge-Proxy denied connection)</small></span>
</header>
<p class="lead">
Your browser is working, the Edge proxy is reachable but blocking your connection - the Origin server is responding and functional.
</p>
<section class="grid" aria-label="Diagnostic chain">
<article class="node" aria-label="Browser Status">
<div class="icon" aria-hidden="true">
<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="Functional">✓</div>
</div>
<h3>You</h3>
<p class="sub">Browser</p>
<p class="state ok">Functional</p>
</article>
<!-- Edge / Proxy -->
<article class="node" aria-label="Edge/Proxy Status">
<div class="icon" aria-hidden="true">
<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 err" title="Blocked">✕</div>
</div>
<h3>Edge-Proxy</h3>
<p class="sub">Intermediate</p>
<p class="state err">Blocked your request</p>
</article>
<!-- Host / Origin -->
<article class="node" aria-label="Origin/Host Status">
<div class="icon" aria-hidden="true">
<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 ok" title="Functional">✓</div>
</div>
<h3>Host</h3>
<p class="sub">Origin-Server</p>
<p class="state ok">Functional</p>
</article>
</section>
<div class="actions">
<button class="btn primary" onclick="location.reload()">Try again</button>
<button class="btn" onclick="document.getElementById('details').toggleAttribute('open')">Show details</button>
</div>
<details id="details" class="meta">
<summary><strong>Technical details</strong></summary>
<ul>
<li>Error: <strong>429</strong> - The client (e.g., a browser, bot, script, or API integration) sends more requests in a short period of time than the server or its protection layer (proxy, WAF, rate limiter) allows. To protect itself, the server temporarily rejects further requests.</li>
<li>Time: <span id="now">-</span></li>
</ul>
<p>Tips: You have sent too many requests in a short period of time. Please wait a moment and try again.</p>
</details>
<footer>
<span>If the problem persists, contact the website operator.</span>
</footer>
</div>
<script>
(function(){
const now = new Date()
document.getElementById('now').textContent = now.toLocaleString()
})()
</script>
</body>
</html>`
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(html))
}