You've already forked caddy-opnsense-blocker
669 lines
24 KiB
Go
669 lines
24 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)
|
|
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/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) 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 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.5rem; }
|
|
h1, h2 { margin: 0 0 .75rem 0; }
|
|
.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; }
|
|
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; }
|
|
a { color: #93c5fd; text-decoration: none; }
|
|
a:hover { text-decoration: underline; }
|
|
.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; }
|
|
</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">
|
|
<h2>Recent IPs</h2>
|
|
<table>
|
|
<thead>
|
|
<tr><th>IP</th><th>State</th><th>Override</th><th>Events</th><th>Last seen</th><th>Reason</th></tr>
|
|
</thead>
|
|
<tbody id="ips-body"></tbody>
|
|
</table>
|
|
</section>
|
|
<section class="panel">
|
|
<h2>Recent events</h2>
|
|
<table>
|
|
<thead>
|
|
<tr><th>Time</th><th>Source</th><th>IP</th><th>Host</th><th>Method</th><th>Path</th><th>Status</th><th>Decision</th></tr>
|
|
</thead>
|
|
<tbody id="events-body"></tbody>
|
|
</table>
|
|
</section>
|
|
</main>
|
|
<script>
|
|
function escapeHtml(value) {
|
|
return String(value ?? '').replace(/[&<>"']/g, character => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[character]));
|
|
}
|
|
|
|
function formatDate(value) {
|
|
if (!value) {
|
|
return '—';
|
|
}
|
|
return new Date(value).toLocaleString();
|
|
}
|
|
|
|
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 renderIPs(items) {
|
|
document.getElementById('ips-body').innerHTML = items.map(item => [
|
|
'<tr>',
|
|
' <td class="mono"><a href="/ips/' + encodeURIComponent(item.ip) + '">' + escapeHtml(item.ip) + '</a></td>',
|
|
' <td><span class="status ' + escapeHtml(item.state) + '">' + escapeHtml(item.state) + '</span></td>',
|
|
' <td>' + escapeHtml(item.manual_override) + '</td>',
|
|
' <td>' + escapeHtml(item.total_events) + '</td>',
|
|
' <td>' + escapeHtml(formatDate(item.last_seen_at)) + '</td>',
|
|
' <td>' + escapeHtml(item.state_reason) + '</td>',
|
|
'</tr>'
|
|
].join('')).join('');
|
|
}
|
|
|
|
function renderEvents(items) {
|
|
document.getElementById('events-body').innerHTML = items.map(item => [
|
|
'<tr>',
|
|
' <td>' + escapeHtml(formatDate(item.occurred_at)) + '</td>',
|
|
' <td>' + escapeHtml(item.source_name) + '</td>',
|
|
' <td class="mono"><a href="/ips/' + encodeURIComponent(item.client_ip) + '">' + escapeHtml(item.client_ip) + '</a></td>',
|
|
' <td>' + escapeHtml(item.host) + '</td>',
|
|
' <td>' + escapeHtml(item.method) + '</td>',
|
|
' <td class="mono">' + escapeHtml(item.path) + '</td>',
|
|
' <td>' + escapeHtml(item.status) + '</td>',
|
|
' <td>' + escapeHtml(item.decision) + (item.enforced ? ' · enforced' : '') + '</td>',
|
|
'</tr>'
|
|
].join('')).join('');
|
|
}
|
|
|
|
async function refresh() {
|
|
const response = await fetch('/api/overview?limit=50');
|
|
const data = await response.json();
|
|
renderStats(data);
|
|
renderIPs(data.recent_ips || []);
|
|
renderEvents(data.recent_events || []);
|
|
}
|
|
|
|
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; }
|
|
.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>
|
|
<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 = {{ printf "%q" .IP }};
|
|
|
|
function escapeHtml(value) {
|
|
return String(value ?? '').replace(/[&<>"']/g, character => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[character]));
|
|
}
|
|
|
|
function formatDate(value) {
|
|
if (!value) {
|
|
return '—';
|
|
}
|
|
return new Date(value).toLocaleString();
|
|
}
|
|
|
|
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>Bot</strong>: <span class="badge">' + escapeHtml(investigation.bot.icon || '🤖') + ' ' + escapeHtml(investigation.bot.name) + '</span> via ' + escapeHtml(investigation.bot.method) + '</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 ? ('listed (' + (investigation.reputation.spamhaus_codes || []).join(', ') + ')') : 'not listed';
|
|
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>`
|