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) 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 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 } 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 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

Recent IPs

IPStateOverrideEventsLast seenReason

Recent events

TimeSourceIPHostMethodPathStatusDecision
` const ipDetailsHTML = ` {{ .Title }}
← Back

{{ .IP }}

State

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

Investigation

Requests from this IP

TimeSourceHostMethodURIStatusDecisionUser agent

Decisions

TimeKindActionReasonActor

Backend actions

TimeActionResultMessage
`