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

930 lines
35 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, limit int) (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)
overview, err := h.app.GetOverview(r.Context(), limit)
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 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; }
.toolbar { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; margin-bottom: .75rem; }
.toolbar .meta { font-size: .95rem; color: #94a3b8; }
.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">
<div class="toolbar">
<h2>Recent IPs</h2>
<div class="meta">Last 24 hours · click a column to sort</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 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 = { key: 'events', direction: 'desc' };
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 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 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 updateSortButtons() {
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 rows = sortItems(items).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(''));
document.getElementById('ips-body').innerHTML = rows.length > 0 ? rows.join('') : '<tr><td colspan="7" class="muted">No IPs seen in the last 24 hours.</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';
}
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 refresh() {
const [overviewResponse, recentResponse] = await Promise.all([
fetch('/api/overview?limit=50'),
fetch('/api/recent-ips?hours=' + recentHours + '&limit=250')
]);
const overviewPayload = await overviewResponse.json().catch(() => ({}));
const recentPayload = await recentResponse.json().catch(() => []);
if (overviewResponse.ok) {
renderStats(overviewPayload || {});
}
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>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>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>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().then(() => investigate());
</script>
</body>
</html>`