base dashboard and login
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"crowdsec-dashy/internal/crowdsec"
|
||||
)
|
||||
|
||||
// AlertsHandler manages the alerts page and its POST actions.
|
||||
type AlertsHandler struct {
|
||||
deps Deps
|
||||
}
|
||||
|
||||
func NewAlertsHandler(deps Deps) *AlertsHandler {
|
||||
return &AlertsHandler{deps: deps}
|
||||
}
|
||||
|
||||
// AlertsData is passed to the alerts template.
|
||||
type AlertsData struct {
|
||||
PageData
|
||||
Alerts []crowdsec.Alert
|
||||
Filter crowdsec.AlertFilter
|
||||
}
|
||||
|
||||
// List renders the alerts list.
|
||||
func (h *AlertsHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
filter := crowdsec.AlertFilter{
|
||||
Limit: 200,
|
||||
Scenario: r.URL.Query().Get("scenario"),
|
||||
IP: r.URL.Query().Get("ip"),
|
||||
Since: r.URL.Query().Get("since"),
|
||||
}
|
||||
|
||||
alerts, err := h.deps.LAPI.ListAlerts(ctx, filter)
|
||||
if err != nil {
|
||||
h.deps.Renderer.RenderError(w, http.StatusBadGateway, "failed to fetch alerts")
|
||||
return
|
||||
}
|
||||
|
||||
pd := NewPageData(r, "Alerts", h.deps.CLIAvailable, h.deps.PollInterval)
|
||||
if f := readFlash(r); f.Message != "" {
|
||||
pd.Flash = f
|
||||
}
|
||||
|
||||
h.deps.Renderer.Render(w, "alerts", AlertsData{
|
||||
PageData: pd,
|
||||
Alerts: alerts,
|
||||
Filter: filter,
|
||||
})
|
||||
}
|
||||
|
||||
// Delete processes alert deletion (POST; `id` field may repeat for bulk).
|
||||
func (h *AlertsHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 4096)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
flashRedirect(w, r, "/alerts", "error", "invalid form data")
|
||||
return
|
||||
}
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
ids := r.Form["id"]
|
||||
if len(ids) == 0 {
|
||||
flashRedirect(w, r, "/alerts", "error", "no alert selected")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var errs []string
|
||||
deleted := 0
|
||||
for _, s := range ids {
|
||||
id, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
errs = append(errs, fmt.Sprintf("invalid id: %q", s))
|
||||
continue
|
||||
}
|
||||
if err := h.deps.LAPI.DeleteAlert(ctx, id); err != nil {
|
||||
errs = append(errs, fmt.Sprintf("id %d: %v", id, err))
|
||||
continue
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
flashRedirect(w, r, "/alerts", "error", strings.Join(errs, "; "))
|
||||
return
|
||||
}
|
||||
|
||||
flashRedirect(w, r, "/alerts", "success", fmt.Sprintf("%d alert(s) deleted", deleted))
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"crowdsec-dashy/internal/crowdsec"
|
||||
)
|
||||
|
||||
// APIHandler serves the internal JSON API consumed by frontend JS.
|
||||
type APIHandler struct {
|
||||
deps Deps
|
||||
}
|
||||
|
||||
func NewAPIHandler(deps Deps) *APIHandler {
|
||||
return &APIHandler{deps: deps}
|
||||
}
|
||||
|
||||
type statsResponse struct {
|
||||
Decisions int `json:"decisions"`
|
||||
Alerts int `json:"alerts"`
|
||||
Bouncers int `json:"bouncers"`
|
||||
Machines int `json:"machines"`
|
||||
Healthy bool `json:"healthy"`
|
||||
}
|
||||
|
||||
type healthResponse struct {
|
||||
Healthy bool `json:"healthy"`
|
||||
CLIAvailable bool `json:"cli_available"`
|
||||
}
|
||||
|
||||
// Stats returns dashboard summary counts.
|
||||
func (h *APIHandler) Stats(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
decisions, _ := h.deps.LAPI.ListDecisions(ctx, crowdsec.DecisionFilter{Limit: 500})
|
||||
alerts, _ := h.deps.LAPI.ListAlerts(ctx, crowdsec.AlertFilter{Limit: 500})
|
||||
|
||||
resp := statsResponse{
|
||||
Decisions: len(decisions),
|
||||
Alerts: len(alerts),
|
||||
Healthy: h.deps.LAPI.IsHealthy(ctx),
|
||||
}
|
||||
|
||||
if h.deps.CLIAvailable {
|
||||
bouncers, _ := h.deps.CLI.ListBouncers(ctx)
|
||||
machines, _ := h.deps.CLI.ListMachines(ctx)
|
||||
resp.Bouncers = len(bouncers)
|
||||
resp.Machines = len(machines)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// Health returns LAPI health and cscli availability.
|
||||
func (h *APIHandler) Health(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(healthResponse{
|
||||
Healthy: h.deps.LAPI.IsHealthy(ctx),
|
||||
CLIAvailable: h.deps.CLIAvailable,
|
||||
}); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"crowdsec-dashy/internal/middleware"
|
||||
)
|
||||
|
||||
// AuthHandler handles login and logout.
|
||||
type AuthHandler struct {
|
||||
renderer *Renderer
|
||||
secret string
|
||||
uiUsername string
|
||||
verifyPassword func(string) bool
|
||||
}
|
||||
|
||||
// NewAuthHandler constructs an AuthHandler.
|
||||
func NewAuthHandler(renderer *Renderer, secret, uiUsername string, verifyPassword func(string) bool) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
renderer: renderer,
|
||||
secret: secret,
|
||||
uiUsername: uiUsername,
|
||||
verifyPassword: verifyPassword,
|
||||
}
|
||||
}
|
||||
|
||||
// LoginData is passed to the login template.
|
||||
type LoginData struct {
|
||||
Title string
|
||||
Error string
|
||||
}
|
||||
|
||||
// Login handles GET (render form) and POST (verify credentials, set cookie).
|
||||
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
if _, err := r.Cookie("cs_session"); err == nil {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
h.renderer.Render(w, "login", LoginData{Title: "Login"})
|
||||
|
||||
case http.MethodPost:
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 2048)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
h.renderer.Render(w, "login", LoginData{Title: "Login", Error: "Invalid request."})
|
||||
return
|
||||
}
|
||||
username := strings.TrimSpace(r.FormValue("username"))
|
||||
password := r.FormValue("password")
|
||||
|
||||
if username != h.uiUsername || !h.verifyPassword(password) {
|
||||
h.renderer.Render(w, "login", LoginData{Title: "Login", Error: "Invalid credentials."})
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(w, middleware.NewSessionCookie(h.secret, username))
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// Logout clears the session cookie and redirects to /login.
|
||||
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, middleware.ClearSessionCookie())
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"crowdsec-dashy/internal/crowdsec"
|
||||
)
|
||||
|
||||
// BouncersHandler manages the bouncers page and its POST actions.
|
||||
type BouncersHandler struct {
|
||||
deps Deps
|
||||
}
|
||||
|
||||
func NewBouncersHandler(deps Deps) *BouncersHandler {
|
||||
return &BouncersHandler{deps: deps}
|
||||
}
|
||||
|
||||
// BouncersData is passed to the bouncers template.
|
||||
type BouncersData struct {
|
||||
PageData
|
||||
Bouncers []crowdsec.Bouncer
|
||||
NewBouncer *crowdsec.AddBouncerResult // set immediately after add; shown exactly once
|
||||
}
|
||||
|
||||
// List renders the bouncers list.
|
||||
func (h *BouncersHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
pd := NewPageData(r, "Bouncers", h.deps.CLIAvailable, h.deps.PollInterval)
|
||||
if f := readFlash(r); f.Message != "" {
|
||||
pd.Flash = f
|
||||
}
|
||||
|
||||
var bouncers []crowdsec.Bouncer
|
||||
if h.deps.CLIAvailable {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
var err error
|
||||
bouncers, err = h.deps.CLI.ListBouncers(ctx)
|
||||
if err != nil {
|
||||
pd.Flash = FlashMessage{Type: "error", Message: fmt.Sprintf("cscli error: %v", err)}
|
||||
}
|
||||
}
|
||||
|
||||
h.deps.Renderer.Render(w, "bouncers", BouncersData{PageData: pd, Bouncers: bouncers})
|
||||
}
|
||||
|
||||
// Add registers a new bouncer and renders the API key reveal page (no redirect).
|
||||
// The key is shown exactly once in the POST response and never stored by the UI.
|
||||
func (h *BouncersHandler) Add(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 4096)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
flashRedirect(w, r, "/bouncers", "error", "invalid form data")
|
||||
return
|
||||
}
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if !h.deps.CLIAvailable {
|
||||
flashRedirect(w, r, "/bouncers", "error", "cscli not available")
|
||||
return
|
||||
}
|
||||
|
||||
name := r.FormValue("name")
|
||||
if ok, _ := matchName(name); !ok {
|
||||
flashRedirect(w, r, "/bouncers", "error", "invalid name: use 1-64 alphanumeric/dash/underscore characters")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := h.deps.CLI.AddBouncer(ctx, name)
|
||||
if err != nil {
|
||||
flashRedirect(w, r, "/bouncers", "error", fmt.Sprintf("failed to add bouncer: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
bouncers, _ := h.deps.CLI.ListBouncers(ctx)
|
||||
|
||||
pd := NewPageData(r, "Bouncers", h.deps.CLIAvailable, h.deps.PollInterval)
|
||||
h.deps.Renderer.Render(w, "bouncers", BouncersData{
|
||||
PageData: pd,
|
||||
Bouncers: bouncers,
|
||||
NewBouncer: result,
|
||||
})
|
||||
}
|
||||
|
||||
// Delete removes a bouncer by name.
|
||||
func (h *BouncersHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 4096)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
flashRedirect(w, r, "/bouncers", "error", "invalid form data")
|
||||
return
|
||||
}
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if !h.deps.CLIAvailable {
|
||||
flashRedirect(w, r, "/bouncers", "error", "cscli not available")
|
||||
return
|
||||
}
|
||||
|
||||
name := r.FormValue("name")
|
||||
if ok, _ := matchName(name); !ok {
|
||||
flashRedirect(w, r, "/bouncers", "error", "invalid bouncer name")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := h.deps.CLI.DeleteBouncer(ctx, name); err != nil {
|
||||
flashRedirect(w, r, "/bouncers", "error", fmt.Sprintf("failed to delete bouncer: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
flashRedirect(w, r, "/bouncers", "success", fmt.Sprintf("Bouncer %q deleted", name))
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"crowdsec-dashy/internal/crowdsec"
|
||||
)
|
||||
|
||||
// DashboardHandler serves the main dashboard page.
|
||||
type DashboardHandler struct {
|
||||
deps Deps
|
||||
}
|
||||
|
||||
func NewDashboardHandler(deps Deps) *DashboardHandler {
|
||||
return &DashboardHandler{deps: deps}
|
||||
}
|
||||
|
||||
// DashboardData is passed to the dashboard template.
|
||||
type DashboardData struct {
|
||||
PageData
|
||||
RecentDecisions []crowdsec.Decision
|
||||
RecentAlerts []crowdsec.Alert
|
||||
LAPIHealthy bool
|
||||
}
|
||||
|
||||
func (h *DashboardHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
decisions, _ := h.deps.LAPI.ListDecisions(ctx, crowdsec.DecisionFilter{Limit: 10})
|
||||
alerts, _ := h.deps.LAPI.ListAlerts(ctx, crowdsec.AlertFilter{Limit: 10})
|
||||
|
||||
h.deps.Renderer.Render(w, "dashboard", DashboardData{
|
||||
PageData: NewPageData(r, "Dashboard", h.deps.CLIAvailable, h.deps.PollInterval),
|
||||
RecentDecisions: decisions,
|
||||
RecentAlerts: alerts,
|
||||
LAPIHealthy: h.deps.LAPI.IsHealthy(ctx),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"crowdsec-dashy/internal/crowdsec"
|
||||
)
|
||||
|
||||
// DecisionsHandler manages the decisions page and its POST actions.
|
||||
type DecisionsHandler struct {
|
||||
deps Deps
|
||||
}
|
||||
|
||||
func NewDecisionsHandler(deps Deps) *DecisionsHandler {
|
||||
return &DecisionsHandler{deps: deps}
|
||||
}
|
||||
|
||||
// DecisionsData is passed to the decisions template.
|
||||
type DecisionsData struct {
|
||||
PageData
|
||||
Decisions []crowdsec.Decision
|
||||
Filter crowdsec.DecisionFilter
|
||||
Page int
|
||||
HasNext bool
|
||||
}
|
||||
|
||||
const decisionsPerPage = 50
|
||||
|
||||
var durationRE = regexp.MustCompile(`^\d+[smhdw]$`)
|
||||
|
||||
// List renders the paginated decisions list.
|
||||
func (h *DecisionsHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
filter := crowdsec.DecisionFilter{
|
||||
Limit: decisionsPerPage + 1,
|
||||
Offset: (page - 1) * decisionsPerPage,
|
||||
Type: r.URL.Query().Get("type"),
|
||||
Scope: r.URL.Query().Get("scope"),
|
||||
Value: r.URL.Query().Get("value"),
|
||||
Origin: r.URL.Query().Get("origin"),
|
||||
}
|
||||
|
||||
decisions, err := h.deps.LAPI.ListDecisions(ctx, filter)
|
||||
if err != nil {
|
||||
h.deps.Renderer.RenderError(w, http.StatusBadGateway, "failed to fetch decisions")
|
||||
return
|
||||
}
|
||||
|
||||
hasNext := len(decisions) > decisionsPerPage
|
||||
if hasNext {
|
||||
decisions = decisions[:decisionsPerPage]
|
||||
}
|
||||
|
||||
pd := NewPageData(r, "Decisions", h.deps.CLIAvailable, h.deps.PollInterval)
|
||||
if f := readFlash(r); f.Message != "" {
|
||||
pd.Flash = f
|
||||
}
|
||||
|
||||
h.deps.Renderer.Render(w, "decisions", DecisionsData{
|
||||
PageData: pd,
|
||||
Decisions: decisions,
|
||||
Filter: filter,
|
||||
Page: page,
|
||||
HasNext: hasNext,
|
||||
})
|
||||
}
|
||||
|
||||
// Add processes the add-decision form (POST).
|
||||
func (h *DecisionsHandler) Add(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 4096)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
flashRedirect(w, r, "/decisions", "error", "invalid form data")
|
||||
return
|
||||
}
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
value := strings.TrimSpace(r.FormValue("value"))
|
||||
scope := r.FormValue("scope")
|
||||
decType := r.FormValue("type")
|
||||
duration := strings.TrimSpace(r.FormValue("duration"))
|
||||
scenario := strings.TrimSpace(r.FormValue("scenario"))
|
||||
|
||||
if err := validateDecisionInput(value, scope, decType, duration); err != nil {
|
||||
flashRedirect(w, r, "/decisions", "error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if scenario == "" {
|
||||
scenario = "manual"
|
||||
}
|
||||
if matched, _ := regexp.MatchString(`^[a-zA-Z0-9_./\-]{1,128}$`, scenario); !matched {
|
||||
scenario = "manual"
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := h.deps.LAPI.AddDecision(ctx, crowdsec.DecisionInput{
|
||||
Duration: duration,
|
||||
Origin: "cscli",
|
||||
Scenario: scenario,
|
||||
Scope: scope,
|
||||
Type: decType,
|
||||
Value: value,
|
||||
}); err != nil {
|
||||
flashRedirect(w, r, "/decisions", "error", fmt.Sprintf("failed to add decision: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
flashRedirect(w, r, "/decisions", "success",
|
||||
fmt.Sprintf("Decision added: %s %s (%s) for %s", decType, value, scope, duration))
|
||||
}
|
||||
|
||||
// Delete processes decision deletion (POST; `id` field may repeat for bulk).
|
||||
func (h *DecisionsHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 4096)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
flashRedirect(w, r, "/decisions", "error", "invalid form data")
|
||||
return
|
||||
}
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
ids := r.Form["id"]
|
||||
if len(ids) == 0 {
|
||||
flashRedirect(w, r, "/decisions", "error", "no decision selected")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var errs []string
|
||||
deleted := 0
|
||||
for _, s := range ids {
|
||||
id, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
errs = append(errs, fmt.Sprintf("invalid id: %q", s))
|
||||
continue
|
||||
}
|
||||
if err := h.deps.LAPI.DeleteDecision(ctx, id); err != nil {
|
||||
errs = append(errs, fmt.Sprintf("id %d: %v", id, err))
|
||||
continue
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
flashRedirect(w, r, "/decisions", "error", strings.Join(errs, "; "))
|
||||
return
|
||||
}
|
||||
|
||||
flashRedirect(w, r, "/decisions", "success", fmt.Sprintf("%d decision(s) deleted", deleted))
|
||||
}
|
||||
|
||||
func validateDecisionInput(value, scope, decType, duration string) error {
|
||||
switch scope {
|
||||
case "Ip", "Range", "Country":
|
||||
default:
|
||||
return fmt.Errorf("invalid scope: must be Ip, Range, or Country")
|
||||
}
|
||||
|
||||
switch decType {
|
||||
case "ban", "captcha", "throttle":
|
||||
default:
|
||||
return fmt.Errorf("invalid type: must be ban, captcha, or throttle")
|
||||
}
|
||||
|
||||
if value == "" {
|
||||
return fmt.Errorf("value is required")
|
||||
}
|
||||
|
||||
switch scope {
|
||||
case "Ip":
|
||||
if net.ParseIP(value) == nil {
|
||||
return fmt.Errorf("invalid IP address: %q", value)
|
||||
}
|
||||
case "Range":
|
||||
if _, _, err := net.ParseCIDR(value); err != nil {
|
||||
return fmt.Errorf("invalid CIDR range: %q", value)
|
||||
}
|
||||
case "Country":
|
||||
if len(value) != 2 {
|
||||
return fmt.Errorf("invalid country code: must be exactly 2 uppercase letters")
|
||||
}
|
||||
for _, c := range value {
|
||||
if c < 'A' || c > 'Z' {
|
||||
return fmt.Errorf("invalid country code: must be uppercase letters")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !durationRE.MatchString(duration) {
|
||||
return fmt.Errorf("invalid duration %q: use format like 24h, 7d, 30m, 3600s", duration)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
|
||||
"crowdsec-dashy/internal/middleware"
|
||||
)
|
||||
|
||||
var validNameRE = regexp.MustCompile(`^[a-zA-Z0-9_\-]{1,64}$`)
|
||||
|
||||
func matchName(name string) (bool, error) {
|
||||
return validNameRE.MatchString(name), nil
|
||||
}
|
||||
|
||||
// flashRedirect redirects with flash type and message as query params.
|
||||
func flashRedirect(w http.ResponseWriter, r *http.Request, to, flashType, msg string) {
|
||||
v := url.Values{}
|
||||
v.Set("flash", flashType)
|
||||
v.Set("msg", msg)
|
||||
http.Redirect(w, r, to+"?"+v.Encode(), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// readFlash extracts a validated flash message from URL query params.
|
||||
func readFlash(r *http.Request) FlashMessage {
|
||||
flash := r.URL.Query().Get("flash")
|
||||
msg := r.URL.Query().Get("msg")
|
||||
if flash == "" || msg == "" {
|
||||
return FlashMessage{}
|
||||
}
|
||||
switch flash {
|
||||
case "success", "error", "warning", "info":
|
||||
return FlashMessage{Type: flash, Message: msg}
|
||||
}
|
||||
return FlashMessage{}
|
||||
}
|
||||
|
||||
// checkCSRF verifies the _csrf form field against the token in the request context.
|
||||
// Must be called after r.ParseForm().
|
||||
func checkCSRF(r *http.Request) bool {
|
||||
expected := middleware.CSRFFromContext(r)
|
||||
if expected == "" {
|
||||
return false
|
||||
}
|
||||
got := r.FormValue("_csrf")
|
||||
return subtle.ConstantTimeCompare([]byte(got), []byte(expected)) == 1
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"crowdsec-dashy/internal/crowdsec"
|
||||
)
|
||||
|
||||
// HubHandler manages the hub page (collections, parsers, scenarios, postoverflows).
|
||||
type HubHandler struct {
|
||||
deps Deps
|
||||
}
|
||||
|
||||
func NewHubHandler(deps Deps) *HubHandler {
|
||||
return &HubHandler{deps: deps}
|
||||
}
|
||||
|
||||
// HubData is passed to the hub template.
|
||||
type HubData struct {
|
||||
PageData
|
||||
Tab string
|
||||
Items []crowdsec.HubItem // current tab's items
|
||||
}
|
||||
|
||||
// List renders the hub page for the active tab.
|
||||
func (h *HubHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
pd := NewPageData(r, "Hub", h.deps.CLIAvailable, h.deps.PollInterval)
|
||||
if f := readFlash(r); f.Message != "" {
|
||||
pd.Flash = f
|
||||
}
|
||||
|
||||
tab := sanitizeTab(r.URL.Query().Get("tab"))
|
||||
data := HubData{PageData: pd, Tab: tab}
|
||||
|
||||
if h.deps.CLIAvailable {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var err error
|
||||
switch tab {
|
||||
case "collections":
|
||||
data.Items, err = h.deps.CLI.ListCollections(ctx)
|
||||
case "parsers":
|
||||
data.Items, err = h.deps.CLI.ListParsers(ctx)
|
||||
case "scenarios":
|
||||
data.Items, err = h.deps.CLI.ListScenarios(ctx)
|
||||
case "postoverflows":
|
||||
data.Items, err = h.deps.CLI.ListPostoverflows(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
data.PageData.Flash = FlashMessage{Type: "error", Message: fmt.Sprintf("cscli error: %v", err)}
|
||||
}
|
||||
}
|
||||
|
||||
h.deps.Renderer.Render(w, "hub", data)
|
||||
}
|
||||
|
||||
// Install installs a hub item.
|
||||
func (h *HubHandler) Install(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 4096)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
flashRedirect(w, r, "/hub", "error", "invalid form data")
|
||||
return
|
||||
}
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if !h.deps.CLIAvailable {
|
||||
flashRedirect(w, r, "/hub", "error", "cscli not available")
|
||||
return
|
||||
}
|
||||
|
||||
kind := r.FormValue("kind")
|
||||
name := r.FormValue("name")
|
||||
tab := sanitizeTab(r.FormValue("tab"))
|
||||
|
||||
if err := validateHubKind(kind); err != nil {
|
||||
flashRedirect(w, r, hubURL(tab), "error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var err error
|
||||
switch kind {
|
||||
case "collections":
|
||||
err = h.deps.CLI.InstallCollection(ctx, name)
|
||||
case "parsers":
|
||||
err = h.deps.CLI.InstallParser(ctx, name)
|
||||
case "scenarios":
|
||||
err = h.deps.CLI.InstallScenario(ctx, name)
|
||||
default:
|
||||
flashRedirect(w, r, hubURL(tab), "error", "unsupported hub type")
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
flashRedirect(w, r, hubURL(tab), "error", fmt.Sprintf("install failed: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
flashRedirect(w, r, hubURL(tab), "success", fmt.Sprintf("Installed %s", name))
|
||||
}
|
||||
|
||||
// Remove uninstalls a hub item.
|
||||
func (h *HubHandler) Remove(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 4096)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
flashRedirect(w, r, "/hub", "error", "invalid form data")
|
||||
return
|
||||
}
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if !h.deps.CLIAvailable {
|
||||
flashRedirect(w, r, "/hub", "error", "cscli not available")
|
||||
return
|
||||
}
|
||||
|
||||
kind := r.FormValue("kind")
|
||||
name := r.FormValue("name")
|
||||
tab := sanitizeTab(r.FormValue("tab"))
|
||||
|
||||
if err := validateHubKind(kind); err != nil {
|
||||
flashRedirect(w, r, hubURL(tab), "error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var err error
|
||||
switch kind {
|
||||
case "collections":
|
||||
err = h.deps.CLI.RemoveCollection(ctx, name)
|
||||
case "parsers":
|
||||
err = h.deps.CLI.RemoveParser(ctx, name)
|
||||
case "scenarios":
|
||||
err = h.deps.CLI.RemoveScenario(ctx, name)
|
||||
default:
|
||||
flashRedirect(w, r, hubURL(tab), "error", "unsupported hub type")
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
flashRedirect(w, r, hubURL(tab), "error", fmt.Sprintf("remove failed: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
flashRedirect(w, r, hubURL(tab), "success", fmt.Sprintf("Removed %s", name))
|
||||
}
|
||||
|
||||
// Update runs cscli hub update + upgrade.
|
||||
func (h *HubHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1024)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
flashRedirect(w, r, "/hub", "error", "invalid form data")
|
||||
return
|
||||
}
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if !h.deps.CLIAvailable {
|
||||
flashRedirect(w, r, "/hub", "error", "cscli not available")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 180*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := h.deps.CLI.HubUpdate(ctx); err != nil {
|
||||
flashRedirect(w, r, "/hub", "error", fmt.Sprintf("hub update failed: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
flashRedirect(w, r, "/hub", "success", "Hub updated and upgraded successfully")
|
||||
}
|
||||
|
||||
func sanitizeTab(tab string) string {
|
||||
switch tab {
|
||||
case "collections", "parsers", "scenarios", "postoverflows":
|
||||
return tab
|
||||
}
|
||||
return "collections"
|
||||
}
|
||||
|
||||
func hubURL(tab string) string {
|
||||
return "/hub?tab=" + tab
|
||||
}
|
||||
|
||||
func validateHubKind(kind string) error {
|
||||
switch kind {
|
||||
case "collections", "parsers", "scenarios", "postoverflows":
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("invalid hub kind: %q", kind)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"crowdsec-dashy/internal/crowdsec"
|
||||
)
|
||||
|
||||
// MachinesHandler manages the machines page and its POST actions.
|
||||
type MachinesHandler struct {
|
||||
deps Deps
|
||||
}
|
||||
|
||||
func NewMachinesHandler(deps Deps) *MachinesHandler {
|
||||
return &MachinesHandler{deps: deps}
|
||||
}
|
||||
|
||||
// MachinesData is passed to the machines template.
|
||||
type MachinesData struct {
|
||||
PageData
|
||||
Machines []crowdsec.Machine
|
||||
}
|
||||
|
||||
var validMachineID = regexp.MustCompile(`^[a-zA-Z0-9_.\-]{1,128}$`)
|
||||
|
||||
// List renders the machines list.
|
||||
func (h *MachinesHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
pd := NewPageData(r, "Machines", h.deps.CLIAvailable, h.deps.PollInterval)
|
||||
if f := readFlash(r); f.Message != "" {
|
||||
pd.Flash = f
|
||||
}
|
||||
|
||||
var machines []crowdsec.Machine
|
||||
if h.deps.CLIAvailable {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
var err error
|
||||
machines, err = h.deps.CLI.ListMachines(ctx)
|
||||
if err != nil {
|
||||
pd.Flash = FlashMessage{Type: "error", Message: fmt.Sprintf("cscli error: %v", err)}
|
||||
}
|
||||
}
|
||||
|
||||
h.deps.Renderer.Render(w, "machines", MachinesData{PageData: pd, Machines: machines})
|
||||
}
|
||||
|
||||
// Delete removes a machine by ID.
|
||||
func (h *MachinesHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 4096)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
flashRedirect(w, r, "/machines", "error", "invalid form data")
|
||||
return
|
||||
}
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if !h.deps.CLIAvailable {
|
||||
flashRedirect(w, r, "/machines", "error", "cscli not available")
|
||||
return
|
||||
}
|
||||
|
||||
id := r.FormValue("id")
|
||||
if !validMachineID.MatchString(id) {
|
||||
flashRedirect(w, r, "/machines", "error", "invalid machine id")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := h.deps.CLI.DeleteMachine(ctx, id); err != nil {
|
||||
flashRedirect(w, r, "/machines", "error", fmt.Sprintf("failed to delete machine: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
flashRedirect(w, r, "/machines", "success", fmt.Sprintf("Machine %q deleted", id))
|
||||
}
|
||||
|
||||
// Validate approves a pending machine registration.
|
||||
func (h *MachinesHandler) Validate(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 4096)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
flashRedirect(w, r, "/machines", "error", "invalid form data")
|
||||
return
|
||||
}
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if !h.deps.CLIAvailable {
|
||||
flashRedirect(w, r, "/machines", "error", "cscli not available")
|
||||
return
|
||||
}
|
||||
|
||||
id := r.FormValue("id")
|
||||
if !validMachineID.MatchString(id) {
|
||||
flashRedirect(w, r, "/machines", "error", "invalid machine id")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := h.deps.CLI.ValidateMachine(ctx, id); err != nil {
|
||||
flashRedirect(w, r, "/machines", "error", fmt.Sprintf("failed to validate machine: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
flashRedirect(w, r, "/machines", "success", fmt.Sprintf("Machine %q validated", id))
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"crowdsec-dashy/internal/crowdsec"
|
||||
)
|
||||
|
||||
// MetricsHandler serves the parsed cscli metrics page.
|
||||
type MetricsHandler struct {
|
||||
deps Deps
|
||||
}
|
||||
|
||||
func NewMetricsHandler(deps Deps) *MetricsHandler {
|
||||
return &MetricsHandler{deps: deps}
|
||||
}
|
||||
|
||||
// MetricsData is passed to the metrics template.
|
||||
type MetricsData struct {
|
||||
PageData
|
||||
Sections []crowdsec.MetricsSection
|
||||
}
|
||||
|
||||
func (h *MetricsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
pd := NewPageData(r, "Metrics", h.deps.CLIAvailable, h.deps.PollInterval)
|
||||
|
||||
var sections []crowdsec.MetricsSection
|
||||
if h.deps.CLIAvailable {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
var err error
|
||||
sections, err = h.deps.CLI.GetMetrics(ctx)
|
||||
if err != nil {
|
||||
pd.Flash = FlashMessage{Type: "error", Message: fmt.Sprintf("cscli error: %v", err)}
|
||||
}
|
||||
}
|
||||
|
||||
h.deps.Renderer.Render(w, "metrics", MetricsData{PageData: pd, Sections: sections})
|
||||
}
|
||||
@@ -5,12 +5,14 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"crowdsec-dashy/internal/crowdsec"
|
||||
"crowdsec-dashy/internal/middleware"
|
||||
)
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -23,33 +25,46 @@ type Renderer struct {
|
||||
funcMap template.FuncMap
|
||||
}
|
||||
|
||||
// NewRenderer parses all page templates against the base layout.
|
||||
func NewRenderer(templateDir string) (*Renderer, error) {
|
||||
// bareLayoutPages lists page names that use base_bare.html instead of base.html.
|
||||
var bareLayoutPages = map[string]bool{
|
||||
"login": true,
|
||||
}
|
||||
|
||||
// NewRenderer parses all page templates. Most pages use the sidebar layout
|
||||
// (base.html); pages listed in bareLayoutPages use the bare layout (base_bare.html).
|
||||
func NewRenderer(fsys fs.FS) (*Renderer, error) {
|
||||
r := &Renderer{
|
||||
templates: make(map[string]*template.Template),
|
||||
funcMap: buildFuncMap(),
|
||||
}
|
||||
|
||||
basePath := filepath.Join(templateDir, "layouts", "base.html")
|
||||
const (
|
||||
basePath = "templates/layouts/base.html"
|
||||
barePath = "templates/layouts/base_bare.html"
|
||||
)
|
||||
|
||||
pages, err := filepath.Glob(filepath.Join(templateDir, "pages", "*.html"))
|
||||
pages, err := fs.Glob(fsys, "templates/pages/*.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("renderer: glob pages: %w", err)
|
||||
}
|
||||
if len(pages) == 0 {
|
||||
return nil, fmt.Errorf("renderer: no page templates found in %s/pages/", templateDir)
|
||||
return nil, fmt.Errorf("renderer: no page templates found")
|
||||
}
|
||||
|
||||
for _, page := range pages {
|
||||
name := templateName(page)
|
||||
tmpl, err := template.New("base").
|
||||
layout := basePath
|
||||
if bareLayoutPages[name] {
|
||||
layout = barePath
|
||||
}
|
||||
tmpl, err := template.New(path.Base(page)).
|
||||
Funcs(r.funcMap).
|
||||
ParseFiles(basePath, page)
|
||||
ParseFS(fsys, layout, page)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("renderer: parse %s: %w", name, err)
|
||||
}
|
||||
r.templates[name] = tmpl
|
||||
log.Printf("renderer: registered template %q", name)
|
||||
log.Printf("renderer: registered template %q (layout: %s)", name, path.Base(layout))
|
||||
}
|
||||
|
||||
return r, nil
|
||||
@@ -64,7 +79,7 @@ func (r *Renderer) Render(w http.ResponseWriter, name string, data any) {
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.ExecuteTemplate(&buf, "base", data); err != nil {
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
log.Printf("renderer: execute %q: %v", name, err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -84,9 +99,9 @@ func (r *Renderer) RenderError(w http.ResponseWriter, code int, msg string) {
|
||||
})
|
||||
}
|
||||
|
||||
func templateName(path string) string {
|
||||
base := filepath.Base(path)
|
||||
return base[:len(base)-len(filepath.Ext(base))]
|
||||
func templateName(p string) string {
|
||||
base := path.Base(p)
|
||||
return base[:len(base)-len(path.Ext(base))]
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -206,6 +221,7 @@ type PageData struct {
|
||||
Flash FlashMessage
|
||||
CLIAvailable bool
|
||||
PollInterval int
|
||||
CSRFToken string
|
||||
}
|
||||
|
||||
// FlashMessage is a one-shot notification shown on the next page load.
|
||||
@@ -229,6 +245,7 @@ func NewPageData(r *http.Request, title string, cliAvail bool, pollSec int) Page
|
||||
Nav: SidebarNav,
|
||||
CLIAvailable: cliAvail,
|
||||
PollInterval: pollSec,
|
||||
CSRFToken: middleware.CSRFFromContext(r),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user