2
Files
caddy-opnsense-blocker/internal/web/handler.go

1223 lines
46 KiB
Go

package web
import (
"context"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"git.dern.ovh/infrastructure/caddy-opnsense-blocker/internal/model"
)
type App interface {
GetOverview(ctx context.Context, since time.Time, limit int, options model.OverviewOptions) (model.Overview, error)
ListEvents(ctx context.Context, limit int) ([]model.Event, error)
ListIPs(ctx context.Context, limit int, state string) ([]model.IPState, error)
ListRecentIPs(ctx context.Context, since time.Time, limit int) ([]model.RecentIPRow, error)
GetIPDetails(ctx context.Context, ip string) (model.IPDetails, error)
InvestigateIP(ctx context.Context, ip string) (model.IPDetails, error)
ForceBlock(ctx context.Context, ip string, actor string, reason string) error
ForceAllow(ctx context.Context, ip string, actor string, reason string) error
ClearOverride(ctx context.Context, ip string, actor string, reason string) error
}
type handler struct {
app App
overviewPage *template.Template
ipDetailsPage *template.Template
}
type pageData struct {
Title string
IP string
}
type actionPayload struct {
Reason string `json:"reason"`
Actor string `json:"actor"`
}
func NewHandler(app App) http.Handler {
h := &handler{
app: app,
overviewPage: template.Must(template.New("overview").Parse(overviewHTML)),
ipDetailsPage: template.Must(template.New("ip-details").Parse(ipDetailsHTML)),
}
mux := http.NewServeMux()
mux.HandleFunc("/", h.handleOverviewPage)
mux.HandleFunc("/healthz", h.handleHealth)
mux.HandleFunc("/ips/", h.handleIPPage)
mux.HandleFunc("/api/overview", h.handleAPIOverview)
mux.HandleFunc("/api/events", h.handleAPIEvents)
mux.HandleFunc("/api/ips", h.handleAPIIPs)
mux.HandleFunc("/api/recent-ips", h.handleAPIRecentIPs)
mux.HandleFunc("/api/ips/", h.handleAPIIP)
return mux
}
func (h *handler) handleOverviewPage(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
if r.Method != http.MethodGet {
methodNotAllowed(w)
return
}
renderTemplate(w, h.overviewPage, pageData{Title: "Caddy OPNsense Blocker"})
}
func (h *handler) handleIPPage(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w)
return
}
ip, ok := extractPathValue(r.URL.Path, "/ips/")
if !ok {
http.NotFound(w, r)
return
}
renderTemplate(w, h.ipDetailsPage, pageData{Title: "IP details", IP: ip})
}
func (h *handler) handleHealth(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w)
return
}
writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "time": time.Now().UTC()})
}
func (h *handler) handleAPIOverview(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w)
return
}
limit := queryLimit(r, 50)
hours := queryInt(r, "hours", 24)
if hours <= 0 {
hours = 24
}
since := time.Now().UTC().Add(-time.Duration(hours) * time.Hour)
options := model.OverviewOptions{
ShowKnownBots: queryBool(r, "show_known_bots", true),
ShowAllowed: queryBool(r, "show_allowed", true),
}
overview, err := h.app.GetOverview(r.Context(), since, limit, options)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, overview)
}
func (h *handler) handleAPIEvents(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w)
return
}
limit := queryLimit(r, 100)
events, err := h.app.ListEvents(r.Context(), limit)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, events)
}
func (h *handler) handleAPIIPs(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/ips" {
http.NotFound(w, r)
return
}
if r.Method != http.MethodGet {
methodNotAllowed(w)
return
}
limit := queryLimit(r, 100)
state := strings.TrimSpace(r.URL.Query().Get("state"))
items, err := h.app.ListIPs(r.Context(), limit, state)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, items)
}
func (h *handler) handleAPIRecentIPs(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/recent-ips" {
http.NotFound(w, r)
return
}
if r.Method != http.MethodGet {
methodNotAllowed(w)
return
}
limit := queryLimit(r, 200)
hours := queryInt(r, "hours", 24)
if hours <= 0 {
hours = 24
}
since := time.Now().UTC().Add(-time.Duration(hours) * time.Hour)
items, err := h.app.ListRecentIPs(r.Context(), since, limit)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, items)
}
func (h *handler) handleAPIIP(w http.ResponseWriter, r *http.Request) {
ip, action, ok := extractAPIPath(r.URL.Path)
if !ok {
http.NotFound(w, r)
return
}
if action == "" {
if r.Method != http.MethodGet {
methodNotAllowed(w)
return
}
details, err := h.app.GetIPDetails(r.Context(), ip)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, details)
return
}
if r.Method != http.MethodPost {
methodNotAllowed(w)
return
}
if action == "investigate" {
details, err := h.app.InvestigateIP(r.Context(), ip)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, details)
return
}
payload, err := decodeActionPayload(r)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
switch action {
case "block":
err = h.app.ForceBlock(r.Context(), ip, payload.Actor, payload.Reason)
case "unblock":
err = h.app.ForceAllow(r.Context(), ip, payload.Actor, payload.Reason)
case "clear-override", "reset":
err = h.app.ClearOverride(r.Context(), ip, payload.Actor, payload.Reason)
default:
http.NotFound(w, r)
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
details, err := h.app.GetIPDetails(r.Context(), ip)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, details)
}
func decodeActionPayload(r *http.Request) (actionPayload, error) {
defer r.Body.Close()
var payload actionPayload
if r.ContentLength == 0 {
return payload, nil
}
limited := io.LimitReader(r.Body, 1<<20)
if err := json.NewDecoder(limited).Decode(&payload); err != nil {
return actionPayload{}, fmt.Errorf("decode action payload: %w", err)
}
return payload, nil
}
func queryLimit(r *http.Request, fallback int) int {
if fallback <= 0 {
fallback = 50
}
value := strings.TrimSpace(r.URL.Query().Get("limit"))
if value == "" {
return fallback
}
parsed, err := strconv.Atoi(value)
if err != nil || parsed <= 0 {
return fallback
}
if parsed > 1000 {
return 1000
}
return parsed
}
func queryInt(r *http.Request, name string, fallback int) int {
value := strings.TrimSpace(r.URL.Query().Get(name))
if value == "" {
return fallback
}
parsed, err := strconv.Atoi(value)
if err != nil {
return fallback
}
return parsed
}
func queryBool(r *http.Request, name string, fallback bool) bool {
value := strings.TrimSpace(strings.ToLower(r.URL.Query().Get(name)))
if value == "" {
return fallback
}
switch value {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
return fallback
}
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
func writeError(w http.ResponseWriter, status int, err error) {
writeJSON(w, status, map[string]any{"error": err.Error()})
}
func extractPathValue(path string, prefix string) (string, bool) {
if !strings.HasPrefix(path, prefix) {
return "", false
}
value := strings.Trim(strings.TrimPrefix(path, prefix), "/")
if value == "" {
return "", false
}
decoded, err := url.PathUnescape(value)
if err != nil {
return "", false
}
return decoded, true
}
func extractAPIPath(path string) (ip string, action string, ok bool) {
if !strings.HasPrefix(path, "/api/ips/") {
return "", "", false
}
rest := strings.Trim(strings.TrimPrefix(path, "/api/ips/"), "/")
if rest == "" {
return "", "", false
}
parts := strings.Split(rest, "/")
decodedIP, err := url.PathUnescape(parts[0])
if err != nil {
return "", "", false
}
if len(parts) == 1 {
return decodedIP, "", true
}
return decodedIP, parts[1], true
}
func methodNotAllowed(w http.ResponseWriter) {
writeError(w, http.StatusMethodNotAllowed, errors.New("method not allowed"))
}
func renderTemplate(w http.ResponseWriter, tmpl *template.Template, data pageData) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, data); err != nil {
writeError(w, http.StatusInternalServerError, err)
}
}
const overviewHTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ .Title }}</title>
<style>
:root { color-scheme: dark; }
body { font-family: system-ui, sans-serif; margin: 0; background: #0f172a; color: #e2e8f0; }
header { padding: 1rem 1.5rem; border-bottom: 1px solid #334155; position: sticky; top: 0; background: rgba(15,23,42,.97); }
main { padding: 1.5rem; display: grid; gap: 1.25rem; }
h1, h2 { margin: 0 0 .75rem 0; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: .55rem .65rem; border-bottom: 1px solid #1e293b; text-align: left; vertical-align: top; }
th { color: #93c5fd; white-space: nowrap; }
a { color: #93c5fd; text-decoration: none; }
a:hover { text-decoration: underline; }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: .75rem; }
.card { background: #111827; border: 1px solid #334155; border-radius: .75rem; padding: .9rem; }
.stat-value { font-size: 1.7rem; font-weight: 700; }
.status { display: inline-block; padding: .15rem .45rem; border-radius: 999px; font-size: .8rem; background: #1e293b; }
.status.blocked { background: #7f1d1d; }
.status.review { background: #78350f; }
.status.allowed { background: #14532d; }
.status.observed { background: #1e293b; }
.muted { color: #94a3b8; }
.mono { font-family: ui-monospace, monospace; }
.panel { background: #111827; border: 1px solid #334155; border-radius: .75rem; padding: 1rem; overflow: auto; }
.controls { display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap; }
.controls-group { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
.leaders { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; }
.leader-card { background: #111827; border: 1px solid #334155; border-radius: .75rem; padding: 1rem; }
.leader-card h2 { margin-bottom: .35rem; font-size: 1rem; }
.leader-list { list-style: none; margin: .75rem 0 0 0; padding: 0; display: grid; gap: .65rem; }
.leader-item { display: grid; gap: .2rem; }
.leader-main { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
.leader-main .mono { overflow: hidden; text-overflow: ellipsis; }
.leader-value { font-weight: 600; white-space: nowrap; }
@media (max-width: 1100px) { .leaders { grid-template-columns: 1fr; } }
.toolbar { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; margin-bottom: .75rem; }
.toolbar .meta { font-size: .95rem; color: #94a3b8; }
.toolbar-right { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; justify-content: flex-end; }
.toggle { display: inline-flex; align-items: center; gap: .45rem; font-size: .95rem; color: #cbd5e1; }
.toggle input { margin: 0; }
.sort-button { appearance: none; background: transparent; border: 0; color: inherit; cursor: pointer; font: inherit; padding: 0; }
.sort-button[data-active="true"] { color: #dbeafe; }
.actions { display: flex; gap: .35rem; flex-wrap: wrap; }
.action-link, button { display: inline-flex; align-items: center; justify-content: center; gap: .35rem; border-radius: .45rem; padding: .3rem .6rem; font-size: .9rem; }
.action-link { background: #1e293b; color: #e2e8f0; text-decoration: none; }
button { background: #2563eb; color: white; border: 0; cursor: pointer; }
button.secondary { background: #475569; }
button.danger { background: #dc2626; }
.ip-cell { display: flex; align-items: center; gap: .45rem; }
.bot-chip { display: inline-flex; align-items: center; justify-content: center; width: 1.25rem; height: 1.25rem; border-radius: 999px; border: 1px solid #334155; background: #0f172a; color: #e2e8f0; font-size: .72rem; font-weight: 700; cursor: help; }
.bot-chip.verified { border-color: #2563eb; }
.bot-chip.hint { border-style: dashed; }
.bot-chip.google { background: #2563eb; color: white; }
.bot-chip.bing { background: #0284c7; color: white; }
.bot-chip.apple { background: #475569; color: white; }
.bot-chip.meta { background: #2563eb; color: white; }
.bot-chip.duckduckgo { background: #ea580c; color: white; }
.bot-chip.openai { background: #059669; color: white; }
.bot-chip.anthropic { background: #b45309; color: white; }
.bot-chip.perplexity { background: #0f766e; color: white; }
.bot-chip.semrush { background: #db2777; color: white; }
.bot-chip.yandex { background: #dc2626; color: white; }
.bot-chip.baidu { background: #7c3aed; color: white; }
.bot-chip.bytespider { background: #111827; color: white; }
</style>
</head>
<body>
<header>
<h1>{{ .Title }}</h1>
<div class="muted">Local-only review and enforcement console</div>
</header>
<main>
<section class="stats" id="stats"></section>
<section class="panel controls">
<div class="controls-group">
<label class="toggle"><input id="show-bots-toggle" type="checkbox" checked onchange="toggleKnownBots()">Show known bots</label>
<label class="toggle"><input id="show-allowed-toggle" type="checkbox" checked onchange="toggleAllowed()">Show allowed</label>
</div>
<div class="muted">These two filters affect both the leaderboards and the Recent IPs list.</div>
</section>
<section class="leaders" id="leaderboards"></section>
<section class="panel">
<div class="toolbar">
<h2>Recent IPs</h2>
<div class="toolbar-right">
<label class="toggle"><input id="show-review-toggle" type="checkbox" onchange="toggleReviewOnly()">Review only</label>
<div class="meta">Last 24 hours · click a column to sort</div>
</div>
</div>
<table>
<thead>
<tr>
<th><button class="sort-button" data-sort="ip" onclick="setSort('ip')">IP</button></th>
<th><button class="sort-button" data-sort="source" onclick="setSort('source')">Source</button></th>
<th><button class="sort-button" data-sort="state" onclick="setSort('state')">State</button></th>
<th><button class="sort-button" data-sort="events" onclick="setSort('events')">Events</button></th>
<th><button class="sort-button" data-sort="last_seen" onclick="setSort('last_seen')">Last seen</button></th>
<th><button class="sort-button" data-sort="reason" onclick="setSort('reason')">Reason</button></th>
<th>Actions</th>
</tr>
</thead>
<tbody id="ips-body"></tbody>
</table>
</section>
</main>
<script>
const recentHours = 24;
const storageKeys = {
showKnownBots: 'caddy-opnsense-blocker.overview.showKnownBots',
showAllowed: 'caddy-opnsense-blocker.overview.showAllowed',
showReviewOnly: 'caddy-opnsense-blocker.overview.showReviewOnly',
sortKey: 'caddy-opnsense-blocker.overview.sortKey',
sortDirection: 'caddy-opnsense-blocker.overview.sortDirection',
};
const sortLabels = {
ip: 'IP',
source: 'Source',
state: 'State',
events: 'Events',
last_seen: 'Last seen',
reason: 'Reason',
};
const stateOrder = { blocked: 0, review: 1, observed: 2, allowed: 3 };
let currentItems = [];
let currentSort = loadSortPreference();
let showKnownBots = loadShowKnownBotsPreference();
let showAllowed = loadShowAllowedPreference();
let showReviewOnly = loadShowReviewOnlyPreference();
function renderStats(data) {
const stats = [
['Total events', data.total_events],
['Tracked IPs', data.total_ips],
['Blocked', data.blocked_ips],
['Review', data.review_ips],
['Allowed', data.allowed_ips],
['Observed', data.observed_ips],
];
document.getElementById('stats').innerHTML = stats.map(([label, value]) => [
'<div class="card">',
' <div class="muted">' + escapeHtml(label) + '</div>',
' <div class="stat-value">' + escapeHtml(value) + '</div>',
'</div>'
].join('')).join('');
}
function formatBytes(value) {
const bytes = Number(value || 0);
if (!Number.isFinite(bytes) || bytes <= 0) {
return '0 B';
}
const units = ['B', 'kB', 'MB', 'GB', 'TB'];
let current = bytes;
let unitIndex = 0;
while (current >= 1000 && unitIndex < units.length - 1) {
current /= 1000;
unitIndex += 1;
}
const precision = current >= 100 || unitIndex === 0 ? 0 : 1;
return current.toFixed(precision) + ' ' + units[unitIndex];
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, character => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[character]));
}
function loadShowKnownBotsPreference() {
try {
const rawValue = window.localStorage.getItem(storageKeys.showKnownBots);
if (rawValue === null) {
return true;
}
return rawValue !== 'false';
} catch (_) {
return true;
}
}
function saveShowKnownBotsPreference(value) {
try {
window.localStorage.setItem(storageKeys.showKnownBots, value ? 'true' : 'false');
} catch (_) {
}
}
function loadShowAllowedPreference() {
try {
const rawValue = window.localStorage.getItem(storageKeys.showAllowed);
if (rawValue === null) {
return true;
}
return rawValue !== 'false';
} catch (_) {
return true;
}
}
function saveShowAllowedPreference(value) {
try {
window.localStorage.setItem(storageKeys.showAllowed, value ? 'true' : 'false');
} catch (_) {
}
}
function loadShowReviewOnlyPreference() {
try {
const rawValue = window.localStorage.getItem(storageKeys.showReviewOnly);
if (rawValue === null) {
return false;
}
return rawValue === 'true';
} catch (_) {
return false;
}
}
function saveShowReviewOnlyPreference(value) {
try {
window.localStorage.setItem(storageKeys.showReviewOnly, value ? 'true' : 'false');
} catch (_) {
}
}
function loadSortPreference() {
const fallback = { key: 'events', direction: 'desc' };
try {
const key = window.localStorage.getItem(storageKeys.sortKey);
const direction = window.localStorage.getItem(storageKeys.sortDirection);
const validKeys = new Set(['ip', 'source', 'state', 'events', 'last_seen', 'reason']);
if (!validKeys.has(key || '')) {
return fallback;
}
if (direction !== 'asc' && direction !== 'desc') {
return fallback;
}
return { key, direction };
} catch (_) {
return fallback;
}
}
function saveSortPreference() {
try {
window.localStorage.setItem(storageKeys.sortKey, currentSort.key);
window.localStorage.setItem(storageKeys.sortDirection, currentSort.direction);
} catch (_) {
}
}
function formatDate(value) {
if (!value) {
return '—';
}
return new Date(value).toLocaleString();
}
function compareText(left, right) {
return String(left || '').localeCompare(String(right || ''), undefined, { sensitivity: 'base' });
}
function compareNumber(left, right) {
return Number(left || 0) - Number(right || 0);
}
function compareState(left, right) {
const leftRank = Object.prototype.hasOwnProperty.call(stateOrder, left) ? stateOrder[left] : 999;
const rightRank = Object.prototype.hasOwnProperty.call(stateOrder, right) ? stateOrder[right] : 999;
if (leftRank === rightRank) {
return compareText(left, right);
}
return leftRank - rightRank;
}
function botVisual(bot) {
const candidate = String((bot || {}).provider_id || (bot || {}).name || '').toLowerCase();
const catalog = [
{ match: ['google'], short: 'G', className: 'google' },
{ match: ['bing', 'microsoft'], short: 'B', className: 'bing' },
{ match: ['apple'], short: 'A', className: 'apple' },
{ match: ['facebook', 'meta'], short: 'M', className: 'meta' },
{ match: ['duckduckgo', 'duckduckbot'], short: 'D', className: 'duckduckgo' },
{ match: ['gptbot', 'openai'], short: 'O', className: 'openai' },
{ match: ['claudebot', 'anthropic'], short: 'C', className: 'anthropic' },
{ match: ['perplexity'], short: 'P', className: 'perplexity' },
{ match: ['semrush'], short: 'S', className: 'semrush' },
{ match: ['yandex'], short: 'Y', className: 'yandex' },
{ match: ['baidu'], short: 'B', className: 'baidu' },
{ match: ['bytespider', 'tiktok'], short: 'T', className: 'bytespider' },
];
for (const entry of catalog) {
if (entry.match.some(fragment => candidate.includes(fragment))) {
return entry;
}
}
const name = String((bot || {}).name || '').trim();
return { short: (name[0] || '?').toUpperCase(), className: 'generic' };
}
function renderBotChip(bot) {
if (!bot) {
return '';
}
const visual = botVisual(bot);
const statusClass = bot.verified ? 'verified' : 'hint';
const title = (bot.name || 'Bot') + (bot.verified ? '' : ' (possible)');
return '<span class="bot-chip ' + escapeHtml(visual.className) + ' ' + statusClass + '" title="' + escapeHtml(title) + '">' + escapeHtml(visual.short) + '</span>';
}
function renderTopIPs(items, primaryMetric) {
const visibleItems = Array.isArray(items) ? items : [];
if (visibleItems.length === 0) {
return '<div class="muted">No matching IP activity in the selected window.</div>';
}
return '<ol class="leader-list">' + visibleItems.map(item => {
const primaryValue = primaryMetric === 'traffic'
? formatBytes(item.traffic_bytes)
: String(item.events || 0);
return [
'<li class="leader-item">',
' <div class="leader-main">',
' <a class="mono" href="/ips/' + encodeURIComponent(item.ip) + '">' + escapeHtml(item.ip) + '</a>',
' <span class="leader-value">' + escapeHtml(primaryValue) + '</span>',
' </div>',
'</li>'
].join('');
}).join('') + '</ol>';
}
function renderTopSources(items) {
if (!Array.isArray(items) || items.length === 0) {
return '<div class="muted">No source activity in the selected window.</div>';
}
return '<ol class="leader-list">' + items.map(item => [
'<li class="leader-item">',
' <div class="leader-main">',
' <span class="mono">' + escapeHtml(item.source_name || '—') + '</span>',
' <span class="leader-value">' + escapeHtml(String(item.events || 0)) + '</span>',
' </div>',
'</li>'
].join('')).join('') + '</ol>';
}
function renderTopURLs(items) {
if (!Array.isArray(items) || items.length === 0) {
return '<div class="muted">No URL activity in the selected window.</div>';
}
return '<ol class="leader-list">' + items.map(item => {
const label = ((item.host || '') ? (item.host + item.uri) : (item.uri || '—'));
return [
'<li class="leader-item">',
' <div class="leader-main">',
' <span class="mono">' + escapeHtml(label) + '</span>',
' <span class="leader-value">' + escapeHtml(String(item.events || 0)) + '</span>',
' </div>',
'</li>'
].join('');
}).join('') + '</ol>';
}
function renderLeaderboards(data) {
const cards = [
{
title: 'Top IPs by events',
subtitle: 'Last 24 hours',
body: renderTopIPs(data.top_ips_by_events, 'events'),
},
{
title: 'Top IPs by traffic',
subtitle: 'Last 24 hours',
body: renderTopIPs(data.top_ips_by_traffic, 'traffic'),
},
{
title: 'Top sources by events',
subtitle: 'Last 24 hours',
body: renderTopSources(data.top_sources),
},
{
title: 'Top URLs by events',
subtitle: 'Last 24 hours',
body: renderTopURLs(data.top_urls),
},
];
document.getElementById('leaderboards').innerHTML = cards.map(card => [
'<section class="leader-card">',
' <h2>' + escapeHtml(card.title) + '</h2>',
' <div class="muted">' + escapeHtml(card.subtitle) + '</div>',
card.body,
'</section>'
].join('')).join('');
}
function updateSortButtons() {
const botsToggle = document.getElementById('show-bots-toggle');
if (botsToggle) {
botsToggle.checked = showKnownBots;
}
const allowedToggle = document.getElementById('show-allowed-toggle');
if (allowedToggle) {
allowedToggle.checked = showAllowed;
}
const reviewToggle = document.getElementById('show-review-toggle');
if (reviewToggle) {
reviewToggle.checked = showReviewOnly;
}
document.querySelectorAll('button[data-sort]').forEach(button => {
const key = button.dataset.sort;
const active = key === currentSort.key;
button.dataset.active = active ? 'true' : 'false';
button.textContent = sortLabels[key] + (active ? (currentSort.direction === 'asc' ? ' ↑' : ' ↓') : '');
});
}
function sortItems(items) {
return [...items].sort((left, right) => {
let result = 0;
switch (currentSort.key) {
case 'ip':
result = compareText(left.ip, right.ip);
break;
case 'source':
result = compareText(left.source_name, right.source_name);
break;
case 'state':
result = compareState(left.state, right.state);
break;
case 'events':
result = compareNumber(left.events, right.events);
break;
case 'last_seen':
result = compareNumber(left.last_seen_at ? Date.parse(left.last_seen_at) : 0, right.last_seen_at ? Date.parse(right.last_seen_at) : 0);
break;
case 'reason':
result = compareText(left.reason, right.reason);
break;
}
if (result === 0) {
result = compareText(left.ip, right.ip);
}
return currentSort.direction === 'asc' ? result : -result;
});
}
function renderActions(item) {
const actions = item.actions || {};
const buttons = [
'<a class="action-link" href="/ips/' + encodeURIComponent(item.ip) + '">Open</a>'
];
if (actions.can_unblock) {
buttons.push('<button class="secondary" data-ip="' + escapeHtml(item.ip) + '" onclick="sendAction(this.dataset.ip, \'unblock\', \'Reason for manual unblock\')">Unblock</button>');
} else if (actions.can_block) {
buttons.push('<button class="danger" data-ip="' + escapeHtml(item.ip) + '" onclick="sendAction(this.dataset.ip, \'block\', \'Reason for manual block\')">Block</button>');
}
return '<div class="actions">' + buttons.join('') + '</div>';
}
function renderIPs(items) {
const filteredItems = items.filter(item => (showKnownBots || !item.bot) && (showAllowed || item.state !== 'allowed') && (!showReviewOnly || item.state === 'review'));
const rows = sortItems(filteredItems).map(item => [
'<tr>',
' <td class="mono"><div class="ip-cell">' + renderBotChip(item.bot) + '<a href="/ips/' + encodeURIComponent(item.ip) + '">' + escapeHtml(item.ip) + '</a></div></td>',
' <td>' + escapeHtml(item.source_name || '—') + '</td>',
' <td><span class="status ' + escapeHtml(item.state) + '">' + escapeHtml(item.state) + '</span></td>',
' <td>' + escapeHtml(item.events) + '</td>',
' <td>' + escapeHtml(formatDate(item.last_seen_at)) + '</td>',
' <td>' + escapeHtml(item.reason || '—') + '</td>',
' <td>' + renderActions(item) + '</td>',
'</tr>'
].join(''));
let emptyMessage = 'No IPs seen in the last 24 hours.';
if (showReviewOnly) {
emptyMessage = 'No review IPs match the current filters in the last 24 hours.';
} else if (!showKnownBots && !showAllowed) {
emptyMessage = 'No non-bot, non-allowed IPs seen in the last 24 hours.';
} else if (!showKnownBots) {
emptyMessage = 'No non-bot IPs seen in the last 24 hours.';
} else if (!showAllowed) {
emptyMessage = 'No non-allowed IPs seen in the last 24 hours.';
}
document.getElementById('ips-body').innerHTML = rows.length > 0 ? rows.join('') : '<tr><td colspan="7" class="muted">' + escapeHtml(emptyMessage) + '</td></tr>';
}
function render() {
updateSortButtons();
renderIPs(currentItems);
}
function setSort(key) {
if (currentSort.key === key) {
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
} else {
currentSort.key = key;
currentSort.direction = (key === 'events' || key === 'last_seen') ? 'desc' : 'asc';
}
saveSortPreference();
render();
}
function toggleKnownBots() {
const toggle = document.getElementById('show-bots-toggle');
showKnownBots = !toggle || toggle.checked;
saveShowKnownBotsPreference(showKnownBots);
render();
refreshOverview();
}
function toggleAllowed() {
const toggle = document.getElementById('show-allowed-toggle');
showAllowed = !toggle || toggle.checked;
saveShowAllowedPreference(showAllowed);
render();
refreshOverview();
}
function toggleReviewOnly() {
const toggle = document.getElementById('show-review-toggle');
showReviewOnly = !!toggle && toggle.checked;
saveShowReviewOnlyPreference(showReviewOnly);
render();
}
async function sendAction(ip, action, promptLabel) {
const reason = window.prompt(promptLabel, '');
if (reason === null) {
return;
}
const response = await fetch('/api/ips/' + encodeURIComponent(ip) + '/' + action, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason, actor: 'web-ui' }),
});
if (!response.ok) {
const payload = await response.json().catch(() => ({ error: response.statusText }));
window.alert(payload.error || 'Request failed');
return;
}
await refresh();
}
async function refreshOverview() {
const response = await fetch('/api/overview?hours=' + recentHours + '&limit=10&show_known_bots=' + (showKnownBots ? 'true' : 'false') + '&show_allowed=' + (showAllowed ? 'true' : 'false'));
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
return;
}
window.__overviewPayload = payload || {};
renderStats(payload || {});
renderLeaderboards(payload || {});
}
async function refresh() {
const recentResponse = await fetch('/api/recent-ips?hours=' + recentHours + '&limit=250');
const recentPayload = await recentResponse.json().catch(() => []);
refreshOverview();
if (!recentResponse.ok) {
const message = Array.isArray(recentPayload) ? recentResponse.statusText : (recentPayload.error || recentResponse.statusText);
document.getElementById('ips-body').innerHTML = '<tr><td colspan="7" class="muted">' + escapeHtml(message) + '</td></tr>';
return;
}
currentItems = Array.isArray(recentPayload) ? recentPayload : [];
render();
}
render();
refresh();
setInterval(refresh, 2000);
</script>
</body>
</html>`
const ipDetailsHTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ .Title }}</title>
<style>
:root { color-scheme: dark; }
body { font-family: system-ui, sans-serif; margin: 0; background: #020617; color: #e2e8f0; }
header { padding: 1rem 1.5rem; border-bottom: 1px solid #334155; position: sticky; top: 0; background: rgba(2,6,23,.97); }
main { padding: 1.5rem; display: grid; gap: 1.5rem; }
.panel { background: #111827; border: 1px solid #334155; border-radius: .75rem; padding: 1rem; overflow: auto; }
h1, h2 { margin: 0 0 .75rem 0; }
.status { display: inline-block; padding: .15rem .45rem; border-radius: 999px; font-size: .8rem; background: #1e293b; }
.status.blocked { background: #7f1d1d; }
.status.review { background: #78350f; }
.status.allowed { background: #14532d; }
.status.observed { background: #1e293b; }
.muted { color: #94a3b8; }
.badge { display: inline-flex; align-items: center; gap: .35rem; padding: .2rem .55rem; border-radius: 999px; background: #1d4ed8; color: white; font-size: .8rem; }
.bot-badge { background: #0f172a; border: 1px solid #334155; color: #e2e8f0; }
.bot-badge.bot-verified { border-color: #1d4ed8; }
.bot-badge.bot-hint { border-style: dashed; }
.bot-mark { display: inline-flex; align-items: center; justify-content: center; width: 1.15rem; height: 1.15rem; border-radius: 999px; font-size: .72rem; font-weight: 700; color: white; background: #475569; }
.bot-mark.google { background: #2563eb; }
.bot-mark.bing { background: #0284c7; }
.bot-mark.apple { background: #475569; }
.bot-mark.meta { background: #2563eb; }
.bot-mark.duckduckgo { background: #ea580c; }
.bot-mark.openai { background: #059669; }
.bot-mark.anthropic { background: #b45309; }
.bot-mark.perplexity { background: #0f766e; }
.bot-mark.semrush { background: #db2777; }
.bot-mark.yandex { background: #dc2626; }
.bot-mark.baidu { background: #7c3aed; }
.bot-mark.bytespider { background: #111827; }
.kv { display: grid; gap: .45rem; }
.actions { display: flex; gap: .35rem; flex-wrap: wrap; margin-top: .9rem; }
button { background: #2563eb; color: white; border: 0; border-radius: .45rem; padding: .35rem .6rem; cursor: pointer; }
button.secondary { background: #475569; }
button.danger { background: #dc2626; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: .55rem .65rem; border-bottom: 1px solid #1e293b; text-align: left; vertical-align: top; }
th { color: #93c5fd; }
.mono { font-family: ui-monospace, monospace; }
a { color: #93c5fd; text-decoration: none; }
.hint { font-size: .9rem; color: #94a3b8; margin-top: .75rem; }
</style>
</head>
<body data-ip="{{ .IP }}">
<header>
<div><a href="/">← Back</a></div>
<h1 class="mono">{{ .IP }}</h1>
</header>
<main>
<section class="panel">
<h2>State</h2>
<div id="state" class="kv"></div>
<div id="actions" class="actions"></div>
<div class="hint">Clear override removes the local manual override only. It does not change the current OPNsense alias entry.</div>
</section>
<section class="panel">
<h2>Investigation</h2>
<div id="investigation" class="kv"></div>
</section>
<section class="panel">
<h2>Decisions</h2>
<table>
<thead>
<tr><th>Time</th><th>Kind</th><th>Action</th><th>Reason</th><th>Actor</th></tr>
</thead>
<tbody id="decisions-body"></tbody>
</table>
</section>
<section class="panel">
<h2>Requests from this IP</h2>
<table>
<thead>
<tr><th>Time</th><th>Source</th><th>Host</th><th>Method</th><th>URI</th><th>Status</th><th>Decision</th><th>User agent</th></tr>
</thead>
<tbody id="events-body"></tbody>
</table>
</section>
<section class="panel">
<h2>Backend actions</h2>
<table>
<thead>
<tr><th>Time</th><th>Action</th><th>Result</th><th>Message</th></tr>
</thead>
<tbody id="backend-body"></tbody>
</table>
</section>
</main>
<script>
const ip = document.body.dataset.ip || '';
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, character => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[character]));
}
function formatDate(value) {
if (!value) {
return '—';
}
return new Date(value).toLocaleString();
}
function botVisual(bot) {
const candidate = String((bot || {}).provider_id || (bot || {}).name || '').toLowerCase();
const catalog = [
{ match: ['google'], short: 'G', className: 'google' },
{ match: ['bing', 'microsoft'], short: 'B', className: 'bing' },
{ match: ['apple'], short: 'A', className: 'apple' },
{ match: ['facebook', 'meta'], short: 'M', className: 'meta' },
{ match: ['duckduckgo', 'duckduckbot'], short: 'D', className: 'duckduckgo' },
{ match: ['gptbot', 'openai'], short: 'O', className: 'openai' },
{ match: ['claudebot', 'anthropic'], short: 'C', className: 'anthropic' },
{ match: ['perplexity'], short: 'P', className: 'perplexity' },
{ match: ['semrush'], short: 'S', className: 'semrush' },
{ match: ['yandex'], short: 'Y', className: 'yandex' },
{ match: ['baidu'], short: 'B', className: 'baidu' },
{ match: ['bytespider', 'tiktok'], short: 'T', className: 'bytespider' },
];
for (const entry of catalog) {
if (entry.match.some(fragment => candidate.includes(fragment))) {
return entry;
}
}
const name = String((bot || {}).name || '').trim();
return { short: (name[0] || '?').toUpperCase(), className: 'generic' };
}
function renderBotBadge(bot) {
const visual = botVisual(bot);
const badgeClass = bot.verified ? 'bot-verified' : 'bot-hint';
return '<span class="badge bot-badge ' + badgeClass + '"><span class="bot-mark ' + escapeHtml(visual.className) + '">' + escapeHtml(visual.short) + '</span><span>' + escapeHtml(bot.name || 'Bot') + '</span></span>';
}
async function sendAction(action, promptLabel) {
const reason = window.prompt(promptLabel, '');
if (reason === null) {
return;
}
const response = await fetch('/api/ips/' + encodeURIComponent(ip) + '/' + action, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason, actor: 'web-ui' }),
});
if (!response.ok) {
const payload = await response.json().catch(() => ({ error: response.statusText }));
window.alert(payload.error || 'Request failed');
return;
}
const data = await response.json();
renderAll(data);
}
async function investigate() {
document.getElementById('investigation').innerHTML = '<div class="muted">Refreshing investigation…</div>';
const response = await fetch('/api/ips/' + encodeURIComponent(ip) + '/investigate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ actor: 'web-ui' }),
});
if (!response.ok) {
const payload = await response.json().catch(() => ({ error: response.statusText }));
document.getElementById('investigation').innerHTML = '<div class="muted">' + escapeHtml(payload.error || 'Investigation failed') + '</div>';
return;
}
const data = await response.json();
renderAll(data);
}
function renderState(data) {
const state = data.state || {};
const opnsense = data.opnsense || {};
const opnsenseLabel = opnsense.configured ? (opnsense.error ? ('unknown (' + opnsense.error + ')') : (opnsense.present ? 'blocked' : 'not blocked')) : 'disabled';
document.getElementById('state').innerHTML = [
'<div><strong>State</strong>: <span class="status ' + escapeHtml(state.state) + '">' + escapeHtml(state.state) + '</span></div>',
'<div><strong>Override</strong>: ' + escapeHtml(state.manual_override) + '</div>',
'<div><strong>Total events</strong>: ' + escapeHtml(state.total_events) + '</div>',
'<div><strong>Last seen</strong>: ' + escapeHtml(formatDate(state.last_seen_at)) + '</div>',
'<div><strong>Reason</strong>: ' + escapeHtml(state.state_reason) + '</div>',
'<div><strong>OPNsense alias</strong>: ' + escapeHtml(opnsenseLabel) + '</div>'
].join('');
}
function renderActions(data) {
const actions = data.actions || {};
const buttons = [];
if (actions.can_unblock) {
buttons.push('<button onclick="sendAction(&quot;unblock&quot;, &quot;Reason for manual unblock&quot;)">Unblock</button>');
} else if (actions.can_block) {
buttons.push('<button class="danger" onclick="sendAction(&quot;block&quot;, &quot;Reason for manual block&quot;)">Block</button>');
}
if (actions.can_clear_override) {
buttons.push('<button class="secondary" onclick="sendAction(&quot;clear-override&quot;, &quot;Reason for clearing the manual override&quot;)">Clear override</button>');
}
buttons.push('<button class="secondary" onclick="investigate()">Refresh investigation</button>');
document.getElementById('actions').innerHTML = buttons.join('');
}
function renderInvestigation(investigation) {
if (!investigation) {
document.getElementById('investigation').innerHTML = '<div class="muted">No cached investigation yet.</div>';
return;
}
const rows = [];
if (investigation.bot) {
rows.push('<div><strong>' + (investigation.bot.verified ? 'Bot' : 'Possible bot') + '</strong>: ' + renderBotBadge(investigation.bot) + ' via ' + escapeHtml(investigation.bot.method) + (investigation.bot.verified ? '' : ' (not verified)') + '</div>');
} else {
rows.push('<div><strong>Bot</strong>: no verified bot match</div>');
}
if (investigation.reverse_dns) {
rows.push('<div><strong>Reverse DNS</strong>: <span class="mono">' + escapeHtml(investigation.reverse_dns.ptr || '—') + '</span>' + (investigation.reverse_dns.forward_confirmed ? ' · forward-confirmed' : '') + '</div>');
}
if (investigation.registration) {
rows.push('<div><strong>Registration</strong>: ' + escapeHtml(investigation.registration.organization || investigation.registration.name || '—') + '</div>');
rows.push('<div><strong>Prefix</strong>: <span class="mono">' + escapeHtml(investigation.registration.prefix || '—') + '</span></div>');
rows.push('<div><strong>Country</strong>: ' + escapeHtml(investigation.registration.country || '—') + '</div>');
rows.push('<div><strong>Abuse contact</strong>: ' + escapeHtml(investigation.registration.abuse_email || '—') + '</div>');
}
if (investigation.reputation) {
const label = investigation.reputation.spamhaus_listed ? 'Yes' : 'No';
rows.push('<div><strong>Spamhaus</strong>: ' + escapeHtml(label) + '</div>');
if (investigation.reputation.error) {
rows.push('<div><strong>Spamhaus error</strong>: ' + escapeHtml(investigation.reputation.error) + '</div>');
}
}
rows.push('<div><strong>Updated</strong>: ' + escapeHtml(formatDate(investigation.updated_at)) + '</div>');
if (investigation.error) {
rows.push('<div><strong>Lookup warning</strong>: ' + escapeHtml(investigation.error) + '</div>');
}
document.getElementById('investigation').innerHTML = rows.join('');
}
function renderEvents(items) {
const rows = items.map(item => [
'<tr>',
' <td>' + escapeHtml(formatDate(item.occurred_at)) + '</td>',
' <td>' + escapeHtml(item.source_name) + '</td>',
' <td>' + escapeHtml(item.host) + '</td>',
' <td>' + escapeHtml(item.method) + '</td>',
' <td class="mono">' + escapeHtml(item.uri || item.path) + '</td>',
' <td>' + escapeHtml(item.status) + '</td>',
' <td>' + escapeHtml(item.decision) + (item.enforced ? ' · enforced' : '') + '</td>',
' <td>' + escapeHtml(item.user_agent || '—') + '</td>',
'</tr>'
].join(''));
document.getElementById('events-body').innerHTML = rows.length > 0 ? rows.join('') : '<tr><td colspan="8" class="muted">No requests stored for this IP yet.</td></tr>';
}
function renderDecisions(items) {
const rows = items.map(item => [
'<tr>',
' <td>' + escapeHtml(formatDate(item.created_at)) + '</td>',
' <td>' + escapeHtml(item.kind) + '</td>',
' <td>' + escapeHtml(item.action) + '</td>',
' <td>' + escapeHtml(item.reason) + '</td>',
' <td>' + escapeHtml(item.actor) + '</td>',
'</tr>'
].join(''));
document.getElementById('decisions-body').innerHTML = rows.length > 0 ? rows.join('') : '<tr><td colspan="5" class="muted">No decisions recorded for this IP yet.</td></tr>';
}
function renderBackend(items) {
const rows = items.map(item => [
'<tr>',
' <td>' + escapeHtml(formatDate(item.created_at)) + '</td>',
' <td>' + escapeHtml(item.action) + '</td>',
' <td>' + escapeHtml(item.result) + '</td>',
' <td>' + escapeHtml(item.message) + '</td>',
'</tr>'
].join(''));
document.getElementById('backend-body').innerHTML = rows.length > 0 ? rows.join('') : '<tr><td colspan="4" class="muted">No backend actions recorded for this IP yet.</td></tr>';
}
function renderAll(data) {
renderState(data || {});
renderActions(data || {});
renderInvestigation((data || {}).investigation || null);
renderEvents((data || {}).recent_events || []);
renderDecisions((data || {}).decisions || []);
renderBackend((data || {}).backend_actions || []);
}
async function refresh() {
const response = await fetch('/api/ips/' + encodeURIComponent(ip));
const data = await response.json();
renderAll(data);
}
refresh();
</script>
</body>
</html>`