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, since time.Time, limit int, options model.EventListOptions) ([]model.Event, error)
CountEvents(ctx context.Context, since time.Time, options model.EventListOptions) (int64, error)
ListSourceNames() []string
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
queryLogPage *template.Template
ipDetailsPage *template.Template
}
type pageData struct {
Title string
IP string
Sources []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)),
queryLogPage: template.Must(template.New("query-log").Parse(queryLogHTML)),
ipDetailsPage: template.Must(template.New("ip-details").Parse(ipDetailsHTML)),
}
mux := http.NewServeMux()
mux.HandleFunc("/", h.handleOverviewPage)
mux.HandleFunc("/requests", h.handleQueryLogPage)
mux.HandleFunc("/queries", h.handleQueryLogPage)
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)
mux.Handle("/assets/", assetHandler())
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) handleQueryLogPage(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/queries" {
http.Redirect(w, r, "/requests", http.StatusMovedPermanently)
return
}
if r.URL.Path != "/requests" {
http.NotFound(w, r)
return
}
if r.Method != http.MethodGet {
methodNotAllowed(w)
return
}
renderTemplate(w, h.queryLogPage, pageData{Title: "Requests Log", Sources: h.app.ListSourceNames()})
}
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: true, ShowAllowed: 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)
hours := queryInt(r, "hours", 24)
if hours <= 0 {
hours = 24
}
page := queryInt(r, "page", 1)
if page <= 0 {
page = 1
}
stateFilter := strings.TrimSpace(r.URL.Query().Get("state"))
if stateFilter == "" && queryBool(r, "review_only", false) {
stateFilter = string(model.IPStateReview)
}
since := time.Now().UTC().Add(-time.Duration(hours) * time.Hour)
options := model.EventListOptions{
ShowKnownBots: queryBool(r, "show_known_bots", true),
ShowAllowed: queryBool(r, "show_allowed", true),
ReviewOnly: queryBool(r, "review_only", false),
Offset: (page - 1) * limit,
Source: strings.TrimSpace(r.URL.Query().Get("source")),
Method: strings.TrimSpace(r.URL.Query().Get("method")),
StatusFilter: strings.TrimSpace(r.URL.Query().Get("status")),
State: stateFilter,
BotFilter: strings.TrimSpace(r.URL.Query().Get("bot_filter")),
SortBy: strings.TrimSpace(r.URL.Query().Get("sort_by")),
SortDesc: !strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("sort_dir")), "asc"),
}
events, err := h.app.ListEvents(r.Context(), since, limit+1, options)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
totalItems, err := h.app.CountEvents(r.Context(), since, options)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
hasNext := len(events) > limit
if hasNext {
events = events[:limit]
}
lastPage := 1
if totalItems > 0 {
lastPage = int((totalItems + int64(limit) - 1) / int64(limit))
}
writeJSON(w, http.StatusOK, model.EventPage{
Items: events,
Data: events,
Page: page,
Limit: limit,
HasPrev: page > 1,
HasNext: hasNext,
LastPage: lastPage,
TotalItems: totalItems,
})
}
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 = `
{{ .Title }}
Activity
Requests per 10-minute bucket
Loading activity…
Loading bot distribution…
Top bot IPs by events
Last 24 hours
Loading…—
Loading…—
Loading…—
Top non-bot IPs by events
Last 24 hours
Loading…—
Loading…—
Loading…—
Top bot IPs by traffic
Last 24 hours
Loading…—
Loading…—
Loading…—
Top non-bot IPs by traffic
Last 24 hours
Loading…—
Loading…—
Loading…—
Top sources by events
Last 24 hours
Loading…—
Loading…—
Loading…—
Top URLs by events
Last 24 hours
Loading…—
Loading…—
Loading…—
`
const queryLogHTML = `
{{ .Title }}
Filters, pagination, and columns
Sort by clicking a column header. Filters remain server-side, while Tabulator handles rendering and pagination.
No active filters.
`
const ipDetailsHTML = `
{{ .Title }}
State
Clear override removes the local manual override only. It does not change the current OPNsense alias entry.
Decisions
| Time | Kind | Action | Reason | Actor |
Requests from this IP
| Time | Source | Host | Method | URI | Status | Decision | User agent |
`