You've already forked caddy-opnsense-blocker
1223 lines
46 KiB
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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("unblock", "Reason for manual unblock")">Unblock</button>');
|
|
} else if (actions.can_block) {
|
|
buttons.push('<button class="danger" onclick="sendAction("block", "Reason for manual block")">Block</button>');
|
|
}
|
|
if (actions.can_clear_override) {
|
|
buttons.push('<button class="secondary" onclick="sendAction("clear-override", "Reason for clearing the manual override")">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>`
|