218 lines
5.3 KiB
Go
218 lines
5.3 KiB
Go
|
|
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
|
||
|
|
}
|