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

584 lines
19 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)
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
}
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 "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
}
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)
}
return payload, nil
}
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 == "" {
return "", false
}
decoded, err := url.PathUnescape(rest)
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.TrimPrefix(path, "/api/ips/")
rest = strings.Trim(rest, "/")
if rest == "" {
return "", "", false
}
parts := strings.Split(rest, "/")
decoded, err := url.PathUnescape(parts[0])
if err != nil {
return "", "", false
}
if len(parts) == 1 {
return decoded, "", 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()})
}
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; }
.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; }
</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><th>Actions</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 => ({'&':'&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');
}
await refresh();
}
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(new Date(item.last_seen_at).toLocaleString()) + '</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('');
}
function renderEvents(items) {
document.getElementById('events-body').innerHTML = items.map(item => [
'<tr>',
' <td>' + escapeHtml(new Date(item.occurred_at).toLocaleString()) + '</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: #0f172a; color: #e2e8f0; }
header { padding: 1rem 1.5rem; border-bottom: 1px solid #334155; }
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; }
.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; }
button { background: #2563eb; color: white; border: 0; border-radius: .45rem; padding: .35rem .6rem; cursor: pointer; }
button.secondary { background: #475569; }
button.danger { background: #dc2626; }
.mono { font-family: ui-monospace, monospace; }
a { color: #93c5fd; text-decoration: none; }
</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"></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>
</section>
<section class="panel">
<h2>Recent events</h2>
<table>
<thead>
<tr><th>Time</th><th>Source</th><th>Method</th><th>Path</th><th>Status</th><th>Decision</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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[character]));
}
async function sendAction(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');
}
await refresh();
}
function renderState(state) {
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>'
].join('');
}
function renderEvents(items) {
document.getElementById('events-body').innerHTML = items.map(item => [
'<tr>',
' <td>' + escapeHtml(new Date(item.occurred_at).toLocaleString()) + '</td>',
' <td>' + escapeHtml(item.source_name) + '</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('');
}
function renderDecisions(items) {
document.getElementById('decisions-body').innerHTML = items.map(item => [
'<tr>',
' <td>' + escapeHtml(new Date(item.created_at).toLocaleString()) + '</td>',
' <td>' + escapeHtml(item.kind) + '</td>',
' <td>' + escapeHtml(item.action) + '</td>',
' <td>' + escapeHtml(item.reason) + '</td>',
' <td>' + escapeHtml(item.actor) + '</td>',
'</tr>'
].join('')).join('');
}
function renderBackend(items) {
document.getElementById('backend-body').innerHTML = items.map(item => [
'<tr>',
' <td>' + escapeHtml(new Date(item.created_at).toLocaleString()) + '</td>',
' <td>' + escapeHtml(item.action) + '</td>',
' <td>' + escapeHtml(item.result) + '</td>',
' <td>' + escapeHtml(item.message) + '</td>',
'</tr>'
].join('')).join('');
}
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 || []);
}
refresh();
setInterval(refresh, 2000);
</script>
</body>
</html>`