2

Add on-demand IP investigation and richer IP details

This commit is contained in:
2026-03-12 01:53:44 +01:00
parent 33dd9bac76
commit c5e1c4ff36
13 changed files with 1561 additions and 144 deletions

View File

@@ -21,6 +21,7 @@ type App interface {
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
@@ -165,6 +166,16 @@ func (h *handler) handleAPIIP(w http.ResponseWriter, r *http.Request) {
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)
@@ -175,7 +186,7 @@ func (h *handler) handleAPIIP(w http.ResponseWriter, r *http.Request) {
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 "reset":
case "clear-override", "reset":
err = h.app.ClearOverride(r.Context(), ip, payload.Actor, payload.Reason)
default:
http.NotFound(w, r)
@@ -199,26 +210,50 @@ func decodeActionPayload(r *http.Request) (actionPayload, error) {
if r.ContentLength == 0 {
return payload, nil
}
decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
if err := decoder.Decode(&payload); err != nil {
if errors.Is(err, io.EOF) {
return payload, nil
}
return actionPayload{}, fmt.Errorf("decode request body: %w", err)
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
}
rest := strings.TrimPrefix(path, prefix)
rest = strings.Trim(rest, "/")
if rest == "" {
value := strings.Trim(strings.TrimPrefix(path, prefix), "/")
if value == "" {
return "", false
}
decoded, err := url.PathUnescape(rest)
decoded, err := url.PathUnescape(value)
if err != nil {
return "", false
}
@@ -229,48 +264,19 @@ func extractAPIPath(path string) (ip string, action string, ok bool) {
if !strings.HasPrefix(path, "/api/ips/") {
return "", "", false
}
rest := strings.TrimPrefix(path, "/api/ips/")
rest = strings.Trim(rest, "/")
rest := strings.Trim(strings.TrimPrefix(path, "/api/ips/"), "/")
if rest == "" {
return "", "", false
}
parts := strings.Split(rest, "/")
decoded, err := url.PathUnescape(parts[0])
decodedIP, err := url.PathUnescape(parts[0])
if err != nil {
return "", "", false
}
if len(parts) == 1 {
return decoded, "", true
return decodedIP, "", true
}
if len(parts) == 2 {
return decoded, parts[1], true
}
return "", "", false
}
func queryLimit(r *http.Request, fallback int) int {
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 > 500 {
return 500
}
return parsed
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
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()})
return decodedIP, parts[1], true
}
func methodNotAllowed(w http.ResponseWriter) {
@@ -309,10 +315,6 @@ const overviewHTML = `<!doctype html>
.status.review { background: #78350f; }
.status.allowed { background: #14532d; }
.status.observed { background: #1e293b; }
.actions { display: flex; gap: .35rem; flex-wrap: wrap; }
button { background: #2563eb; color: white; border: 0; border-radius: .45rem; padding: .35rem .6rem; cursor: pointer; }
button.secondary { background: #475569; }
button.danger { background: #dc2626; }
.muted { color: #94a3b8; }
.mono { font-family: ui-monospace, monospace; }
.panel { background: #111827; border: 1px solid #334155; border-radius: .75rem; padding: 1rem; overflow: auto; }
@@ -329,13 +331,13 @@ const overviewHTML = `<!doctype html>
<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><th>Actions</th></tr>
<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>
<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>
@@ -349,18 +351,11 @@ const overviewHTML = `<!doctype html>
return String(value ?? '').replace(/[&<>"']/g, character => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[character]));
}
async function sendAction(ip, action) {
const reason = window.prompt('Optional reason', '');
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');
function formatDate(value) {
if (!value) {
return '—';
}
await refresh();
return new Date(value).toLocaleString();
}
function renderStats(data) {
@@ -387,15 +382,8 @@ const overviewHTML = `<!doctype html>
' <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(new Date(item.last_seen_at).toLocaleString()) + '</td>',
' <td>' + escapeHtml(formatDate(item.last_seen_at)) + '</td>',
' <td>' + escapeHtml(item.state_reason) + '</td>',
' <td>',
' <div class="actions">',
' <button class="danger" onclick="sendAction(&quot;' + escapeHtml(item.ip) + '&quot;, &quot;block&quot;)">Block</button>',
' <button onclick="sendAction(&quot;' + escapeHtml(item.ip) + '&quot;, &quot;unblock&quot;)">Unblock</button>',
' <button class="secondary" onclick="sendAction(&quot;' + escapeHtml(item.ip) + '&quot;, &quot;reset&quot;)">Reset</button>',
' </div>',
' </td>',
'</tr>'
].join('')).join('');
}
@@ -403,7 +391,7 @@ const overviewHTML = `<!doctype html>
function renderEvents(items) {
document.getElementById('events-body').innerHTML = items.map(item => [
'<tr>',
' <td>' + escapeHtml(new Date(item.occurred_at).toLocaleString()) + '</td>',
' <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>',
@@ -437,24 +425,29 @@ const ipDetailsHTML = `<!doctype html>
<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; }
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; }
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; }
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; }
.actions { display: flex; gap: .35rem; flex-wrap: wrap; }
.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>
@@ -465,18 +458,19 @@ const ipDetailsHTML = `<!doctype html>
<main>
<section class="panel">
<h2>State</h2>
<div id="state"></div>
<div class="actions">
<button class="danger" onclick="sendAction('block')">Block</button>
<button onclick="sendAction('unblock')">Unblock</button>
<button class="secondary" onclick="sendAction('reset')">Reset</button>
</div>
<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>Recent events</h2>
<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>Method</th><th>Path</th><th>Status</th><th>Decision</th></tr>
<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>
@@ -507,8 +501,18 @@ const ipDetailsHTML = `<!doctype html>
return String(value ?? '').replace(/[&<>"']/g, character => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[character]));
}
async function sendAction(action) {
const reason = window.prompt('Optional reason', '');
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' },
@@ -517,67 +521,148 @@ const ipDetailsHTML = `<!doctype html>
if (!response.ok) {
const payload = await response.json().catch(() => ({ error: response.statusText }));
window.alert(payload.error || 'Request failed');
return;
}
await refresh();
const data = await response.json();
renderAll(data);
}
function renderState(state) {
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(new Date(state.last_seen_at).toLocaleString()) + '</div>',
'<div><strong>Reason</strong>: ' + escapeHtml(state.state_reason) + '</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>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) {
document.getElementById('events-body').innerHTML = items.map(item => [
const rows = items.map(item => [
'<tr>',
' <td>' + escapeHtml(new Date(item.occurred_at).toLocaleString()) + '</td>',
' <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.path) + '</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('')).join('');
].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) {
document.getElementById('decisions-body').innerHTML = items.map(item => [
const rows = items.map(item => [
'<tr>',
' <td>' + escapeHtml(new Date(item.created_at).toLocaleString()) + '</td>',
' <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('')).join('');
].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) {
document.getElementById('backend-body').innerHTML = items.map(item => [
const rows = items.map(item => [
'<tr>',
' <td>' + escapeHtml(new Date(item.created_at).toLocaleString()) + '</td>',
' <td>' + escapeHtml(formatDate(item.created_at)) + '</td>',
' <td>' + escapeHtml(item.action) + '</td>',
' <td>' + escapeHtml(item.result) + '</td>',
' <td>' + escapeHtml(item.message) + '</td>',
'</tr>'
].join('')).join('');
].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();
renderState(data.state || {});
renderEvents(data.recent_events || []);
renderDecisions(data.decisions || []);
renderBackend(data.backend_actions || []);
renderAll(data);
}
refresh();
setInterval(refresh, 2000);
refresh().then(() => investigate());
</script>
</body>
</html>`