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) 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) 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 } hasNext := len(events) > limit if hasNext { events = events[:limit] } writeJSON(w, http.StatusOK, model.EventPage{ Items: events, Page: page, Limit: limit, HasPrev: page > 1, HasNext: hasNext, }) } 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 }}

{{ .Title }}

Local-only review and enforcement console
Total events
Tracked IPs
Blocked
Review
Allowed
Observed

Activity

Requests per 10-minute bucket
Loading activity…

Methods

Last 24 hours
Loading methods…

Bots

Last 24 hours
Loading bot distribution…

Top bot IPs by events

Last 24 hours
  1. Loading…
  2. Loading…
  3. Loading…

Top non-bot IPs by events

Last 24 hours
  1. Loading…
  2. Loading…
  3. Loading…

Top bot IPs by traffic

Last 24 hours
  1. Loading…
  2. Loading…
  3. Loading…

Top non-bot IPs by traffic

Last 24 hours
  1. Loading…
  2. Loading…
  3. Loading…

Top sources by events

Last 24 hours
  1. Loading…
  2. Loading…
  3. Loading…

Top URLs by events

Last 24 hours
  1. Loading…
  2. Loading…
  3. Loading…
` const queryLogHTML = ` {{ .Title }}

{{ .Title }}

Last 24 hours, newest first
Filters, sorting, and pagination
Use exact values, or 4xx / 5xx for HTTP status classes. Click a column header to sort directly from the table.
No active filters.

Recent requests

Click an IP to open its detail page
Page 1
Time IP Method Source Request Status State Reason Actions
Loading requests log…
Use the controls above to tune the view.
Page 1
` const ipDetailsHTML = ` {{ .Title }}
← Back

{{ .IP }}

State

Clear override removes the local manual override only. It does not change the current OPNsense alias entry.

Investigation

Decisions

TimeKindActionReasonActor

Requests from this IP

TimeSourceHostMethodURIStatusDecisionUser agent

Backend actions

TimeActionResultMessage
`