You've already forked caddy-opnsense-blocker
Build initial caddy-opnsense-blocker daemon
This commit is contained in:
583
internal/web/handler.go
Normal file
583
internal/web/handler.go
Normal file
@@ -0,0 +1,583 @@
|
||||
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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("' + escapeHtml(item.ip) + '", "block")">Block</button>',
|
||||
' <button onclick="sendAction("' + escapeHtml(item.ip) + '", "unblock")">Unblock</button>',
|
||||
' <button class="secondary" onclick="sendAction("' + escapeHtml(item.ip) + '", "reset")">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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>`
|
||||
124
internal/web/handler_test.go
Normal file
124
internal/web/handler_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.dern.ovh/infrastructure/caddy-opnsense-blocker/internal/model"
|
||||
)
|
||||
|
||||
func TestHandlerServesOverviewAndManualActions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := &stubApp{}
|
||||
handler := NewHandler(app)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/overview?limit=10", nil)
|
||||
handler.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected overview status: %d", recorder.Code)
|
||||
}
|
||||
var overview model.Overview
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &overview); err != nil {
|
||||
t.Fatalf("decode overview payload: %v", err)
|
||||
}
|
||||
if overview.TotalEvents != 1 || len(overview.RecentIPs) != 1 {
|
||||
t.Fatalf("unexpected overview payload: %+v", overview)
|
||||
}
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodPost, "/api/ips/203.0.113.10/block", strings.NewReader(`{"reason":"test reason","actor":"tester"}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
handler.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected block status: %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if app.lastAction != "block:203.0.113.10:tester:test reason" {
|
||||
t.Fatalf("unexpected recorded action: %q", app.lastAction)
|
||||
}
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
handler.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected overview page status: %d", recorder.Code)
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), "Local-only review and enforcement console") {
|
||||
t.Fatalf("overview page did not render expected content")
|
||||
}
|
||||
}
|
||||
|
||||
type stubApp struct {
|
||||
lastAction string
|
||||
}
|
||||
|
||||
func (s *stubApp) GetOverview(context.Context, int) (model.Overview, error) {
|
||||
now := time.Now().UTC()
|
||||
return model.Overview{
|
||||
TotalEvents: 1,
|
||||
TotalIPs: 1,
|
||||
BlockedIPs: 1,
|
||||
RecentIPs: []model.IPState{{
|
||||
IP: "203.0.113.10",
|
||||
State: model.IPStateBlocked,
|
||||
ManualOverride: model.ManualOverrideNone,
|
||||
TotalEvents: 1,
|
||||
LastSeenAt: now,
|
||||
}},
|
||||
RecentEvents: []model.Event{{
|
||||
ID: 1,
|
||||
SourceName: "main",
|
||||
ClientIP: "203.0.113.10",
|
||||
OccurredAt: now,
|
||||
Decision: model.DecisionActionBlock,
|
||||
CurrentState: model.IPStateBlocked,
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *stubApp) ListEvents(ctx context.Context, limit int) ([]model.Event, error) {
|
||||
overview, _ := s.GetOverview(ctx, limit)
|
||||
return overview.RecentEvents, nil
|
||||
}
|
||||
|
||||
func (s *stubApp) ListIPs(ctx context.Context, limit int, state string) ([]model.IPState, error) {
|
||||
overview, _ := s.GetOverview(ctx, limit)
|
||||
return overview.RecentIPs, nil
|
||||
}
|
||||
|
||||
func (s *stubApp) GetIPDetails(context.Context, string) (model.IPDetails, error) {
|
||||
now := time.Now().UTC()
|
||||
return model.IPDetails{
|
||||
State: model.IPState{
|
||||
IP: "203.0.113.10",
|
||||
State: model.IPStateBlocked,
|
||||
ManualOverride: model.ManualOverrideNone,
|
||||
TotalEvents: 1,
|
||||
LastSeenAt: now,
|
||||
},
|
||||
RecentEvents: []model.Event{{ID: 1, ClientIP: "203.0.113.10", OccurredAt: now, Decision: model.DecisionActionBlock}},
|
||||
Decisions: []model.DecisionRecord{{ID: 1, IP: "203.0.113.10", Action: model.DecisionActionBlock, CreatedAt: now}},
|
||||
BackendActions: []model.OPNsenseAction{{ID: 1, IP: "203.0.113.10", Action: "block", Result: "added", CreatedAt: now}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *stubApp) ForceBlock(_ context.Context, ip string, actor string, reason string) error {
|
||||
s.lastAction = "block:" + ip + ":" + actor + ":" + reason
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubApp) ForceAllow(_ context.Context, ip string, actor string, reason string) error {
|
||||
s.lastAction = "allow:" + ip + ":" + actor + ":" + reason
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubApp) ClearOverride(_ context.Context, ip string, actor string, reason string) error {
|
||||
s.lastAction = "reset:" + ip + ":" + actor + ":" + reason
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user