base dashboard and login

This commit is contained in:
2026-05-17 08:28:16 +00:00
parent 64f4f3c5d4
commit 317a7f3f13
40 changed files with 3327 additions and 72 deletions
+272
View File
@@ -0,0 +1,272 @@
package config
import (
"bufio"
"crypto/rand"
"encoding/hex"
"fmt"
"log"
"os"
"strconv"
"strings"
"golang.org/x/crypto/bcrypt"
)
const defaultConfigFile = "app_config.conf"
// ConfigFile returns the active config file path.
// Override with CONFIG_FILE env var (path only, no secrets in env).
func ConfigFile() string {
if v := os.Getenv("CONFIG_FILE"); v != "" {
return v
}
return defaultConfigFile
}
// Config holds all runtime settings loaded from app_config.conf.
type Config struct {
Port string
CrowdSecAPIURL string
CrowdSecAPILogin string
CrowdSecAPIPassword string // sent as-is to LAPI — never hashed
CscliPath string
UIUsername string
UIPassword string // bcrypt hash after first run
UISessionSecret string
PollIntervalSec int
}
// FirstRunError is returned when the config file was just created and needs editing.
type FirstRunError struct{ Path string }
func (e *FirstRunError) Error() string {
return fmt.Sprintf(
"%s created — fill in crowdsec_api_login and crowdsec_api_password, then restart",
e.Path,
)
}
// HashPassword generates a bcrypt hash suitable for use as ui_password in the config.
func HashPassword(plaintext string) (string, error) {
h, err := bcrypt.GenerateFromPassword([]byte(plaintext), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(h), nil
}
// VerifyUIPassword checks plaintext against the stored ui_password (bcrypt or plaintext fallback).
func (c *Config) VerifyUIPassword(plaintext string) bool {
if isBcryptHash(c.UIPassword) {
return bcrypt.CompareHashAndPassword([]byte(c.UIPassword), []byte(plaintext)) == nil
}
// Fallback: constant-time plaintext compare (should not happen after first startup)
a := []byte(plaintext)
b := []byte(c.UIPassword)
if len(a) != len(b) {
return false
}
var diff byte
for i := range a {
diff |= a[i] ^ b[i]
}
return diff == 0
}
// Load reads app_config.conf (or CONFIG_FILE path), creating it on first run.
// If ui_password is plaintext, it is hashed with bcrypt and written back to the file.
func Load() (*Config, error) {
path := ConfigFile()
if _, err := os.Stat(path); os.IsNotExist(err) {
if err := writeDefault(path); err != nil {
return nil, fmt.Errorf("create %s: %w", path, err)
}
return nil, &FirstRunError{Path: path}
}
vals, err := parseFile(path)
if err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
c := &Config{
Port: strVal(vals, "port", ":8080"),
CrowdSecAPIURL: strVal(vals, "crowdsec_api_url", "http://localhost:8080"),
CrowdSecAPILogin: vals["crowdsec_api_login"],
CrowdSecAPIPassword: vals["crowdsec_api_password"],
CscliPath: strVal(vals, "cscli_path", "/usr/local/bin/cscli"),
UIUsername: strVal(vals, "ui_username", "admin"),
UIPassword: strVal(vals, "ui_password", "changeme"),
UISessionSecret: vals["ui_session_secret"],
PollIntervalSec: intVal(vals, "poll_interval_sec", 15),
}
if c.CrowdSecAPILogin == "" {
return nil, fmt.Errorf("crowdsec_api_login is required in %s", path)
}
if c.CrowdSecAPIPassword == "" {
return nil, fmt.Errorf("crowdsec_api_password is required in %s", path)
}
if len(c.UISessionSecret) < 32 {
return nil, fmt.Errorf("ui_session_secret must be at least 32 characters in %s", path)
}
// Auto-hash ui_password if stored as plaintext.
if c.UIPassword != "" && !isBcryptHash(c.UIPassword) {
hash, err := bcrypt.GenerateFromPassword([]byte(c.UIPassword), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("hash ui_password: %w", err)
}
c.UIPassword = string(hash)
if err := updateConfigValue(path, "ui_password", c.UIPassword); err != nil {
log.Printf("[WARN] could not save hashed ui_password to %s: %v", path, err)
} else {
log.Printf("ui_password hashed and saved to %s", path)
}
}
return c, nil
}
// CscliAvailable returns true if the cscli binary exists at the configured path.
func (c *Config) CscliAvailable() bool {
_, err := os.Stat(c.CscliPath)
return err == nil
}
func isBcryptHash(s string) bool {
return strings.HasPrefix(s, "$2a$") ||
strings.HasPrefix(s, "$2b$") ||
strings.HasPrefix(s, "$2y$")
}
// updateConfigValue rewrites a single key's value in the config file, preserving all other lines.
func updateConfigValue(path, key, value string) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
lines := strings.Split(string(data), "\n")
updated := false
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
idx := strings.IndexByte(trimmed, '=')
if idx < 0 {
continue
}
if strings.TrimSpace(trimmed[:idx]) == key {
lines[i] = key + " = " + value
updated = true
break
}
}
if !updated {
return fmt.Errorf("key %q not found in %s", key, path)
}
return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0600)
}
// writeDefault creates the config file with a random session secret and usage notes.
func writeDefault(path string) error {
secret, err := randomHex(32)
if err != nil {
return err
}
content := fmt.Sprintf(`# CrowdSec Dashy — Configuration
# Auto-generated on first run. Edit required fields and restart.
# Lines starting with # are comments.
# ---------------------------------------------------------------
# Network — address:port the web UI listens on
port = :8080
# CrowdSec Local API
crowdsec_api_url = http://localhost:8080
crowdsec_api_login =
crowdsec_api_password =
# Path to cscli binary (required for bouncers, machines, hub, metrics)
# In Docker: bind-mount the binary into the container at this path.
# Leave empty or point to a missing path to disable CLI features gracefully.
cscli_path = /usr/local/bin/cscli
# Web UI — HTTP Basic Auth
# ui_password is auto-hashed with bcrypt on first startup.
# To pre-hash a password: crowdsec-dashy -pwhash "your-password"
ui_username = admin
ui_password = changeme
# Session signing secret — auto-generated, keep private
ui_session_secret = %s
# Dashboard live-poll interval (seconds)
poll_interval_sec = 15
`, secret)
return os.WriteFile(path, []byte(content), 0600)
}
// parseFile reads key = value pairs, ignoring blank lines and # comments.
func parseFile(path string) (map[string]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
vals := make(map[string]string)
scanner := bufio.NewScanner(f)
lineNum := 0
for scanner.Scan() {
lineNum++
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
idx := strings.IndexByte(line, '=')
if idx < 0 {
return nil, fmt.Errorf("line %d: expected key = value", lineNum)
}
key := strings.TrimSpace(line[:idx])
val := strings.TrimSpace(line[idx+1:])
if key == "" {
return nil, fmt.Errorf("line %d: empty key", lineNum)
}
vals[key] = val
}
return vals, scanner.Err()
}
func strVal(m map[string]string, key, def string) string {
if v, ok := m[key]; ok && v != "" {
return v
}
return def
}
func intVal(m map[string]string, key string, def int) int {
v, ok := m[key]
if !ok || v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
return def
}
return n
}
func randomHex(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("generate random bytes: %w", err)
}
return hex.EncodeToString(b), nil
}
+3 -2
View File
@@ -328,8 +328,9 @@ func (c *CLIClient) listHubItems(ctx context.Context, kind string) ([]HubItem, e
}
func (c *CLIClient) hubAction(ctx context.Context, kind, action, name string) error {
if err := validateName(name); err != nil {
return err
// Hub item names can contain slashes (e.g. crowdsecurity/ssh-bf); use safeArg not validateName.
if !safeArg.MatchString(name) || name == "" {
return fmt.Errorf("invalid hub item name: %q", name)
}
_, err := c.run(ctx, kind, action, name)
return err
+137
View File
@@ -0,0 +1,137 @@
package crowdsec
// -----------------------------------------------------------------------
// LAPI types
// -----------------------------------------------------------------------
type LoginRequest struct {
MachineID string `json:"machine_id"`
Password string `json:"password"`
Scenarios []string `json:"scenarios,omitempty"`
}
type LoginResponse struct {
Token string `json:"token"`
Expire string `json:"expire"`
}
type Decision struct {
ID int64 `json:"id"`
Origin string `json:"origin"`
Type string `json:"type"`
Scope string `json:"scope"`
Value string `json:"value"`
Duration string `json:"duration"`
Scenario string `json:"scenario"`
Simulated bool `json:"simulated"`
Until string `json:"until,omitempty"`
}
type DecisionInput struct {
Duration string `json:"duration"`
Origin string `json:"origin"`
Scenario string `json:"scenario"`
Scope string `json:"scope"`
Simulated bool `json:"simulated"`
Type string `json:"type"`
Value string `json:"value"`
}
type AlertSource struct {
AsName string `json:"as_name,omitempty"`
AsNumber int `json:"as_number,omitempty"`
CN string `json:"cn,omitempty"`
IP string `json:"ip,omitempty"`
Latitude float64 `json:"latitude,omitempty"`
Longitude float64 `json:"longitude,omitempty"`
Range string `json:"range,omitempty"`
Scope string `json:"scope"`
Value string `json:"value"`
}
type AlertEvent struct {
Meta []MetaItem `json:"meta"`
Timestamp string `json:"timestamp"`
}
type MetaItem struct {
Key string `json:"key"`
Value string `json:"value"`
}
type Alert struct {
ID int64 `json:"id"`
UUID string `json:"uuid,omitempty"`
Capacity int32 `json:"capacity"`
CreatedAt string `json:"created_at"`
Decisions []Decision `json:"decisions"`
Events []AlertEvent `json:"events"`
EventsCount int32 `json:"events_count"`
Leakspeed string `json:"leakspeed"`
MachineID string `json:"machine_id,omitempty"`
Message string `json:"message"`
Remediation bool `json:"remediation"`
Scenario string `json:"scenario"`
ScenarioHash string `json:"scenario_hash"`
ScenarioVersion string `json:"scenario_version"`
Simulated bool `json:"simulated"`
Source AlertSource `json:"source"`
StartAt string `json:"start_at"`
StopAt string `json:"stop_at"`
}
// -----------------------------------------------------------------------
// CLI types (cscli output)
// -----------------------------------------------------------------------
type Bouncer struct {
APIKey string `json:"api_key"`
AuthType string `json:"auth_type"`
AutoCreated bool `json:"auto_created"`
CreatedAt string `json:"created_at"`
IPAddress string `json:"ip_address"`
LastPull string `json:"last_pull"`
Name string `json:"name"`
Revoked bool `json:"revoked"`
Type string `json:"type"`
Version string `json:"version"`
}
type AddBouncerResult struct {
Name string
APIKey string
}
type Machine struct {
AuthType string `json:"auth_type"`
CreatedAt string `json:"createdAt"`
IPAddress string `json:"ipAddress"`
IsValidated bool `json:"isValidated"`
LastHeartbeat string `json:"last_heartbeat"`
LastPush string `json:"lastPush"`
MachineID string `json:"machineId"`
Scenarios string `json:"scenarios"`
Status string `json:"status"`
Version string `json:"version"`
}
type HubItem struct {
Author string `json:"author"`
Description string `json:"description"`
Downloaded bool `json:"downloaded"`
Installed bool `json:"installed"`
Local bool `json:"local"`
Name string `json:"name"`
Path string `json:"path"`
Stage string `json:"stage"`
Status string `json:"status"`
Tainted bool `json:"tainted"`
UpToDate bool `json:"up_to_date"`
Version string `json:"version"`
}
type MetricsSection struct {
Title string
Headers []string
Rows [][]string
}
+102
View File
@@ -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))
}
+73
View File
@@ -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)
}
}
+70
View File
@@ -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)
}
+124
View File
@@ -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))
}
+41
View File
@@ -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),
})
}
+217
View File
@@ -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
}
+49
View File
@@ -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
}
+207
View File
@@ -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)
}
+117
View File
@@ -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))
}
+42
View File
@@ -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})
}
+30 -13
View File
@@ -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),
}
}
+256
View File
@@ -0,0 +1,256 @@
package middleware
import (
"context"
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"fmt"
"log"
"net/http"
"runtime/debug"
"strconv"
"strings"
"time"
)
// Middleware wraps an http.Handler.
type Middleware func(http.Handler) http.Handler
// Chain applies middlewares in order (first = outermost).
func Chain(mws ...Middleware) Middleware {
return func(next http.Handler) http.Handler {
for i := len(mws) - 1; i >= 0; i-- {
next = mws[i](next)
}
return next
}
}
// -----------------------------------------------------------------------
// Context keys
// -----------------------------------------------------------------------
type contextKey int
const (
sessionContextKey contextKey = iota
csrfContextKey
)
// -----------------------------------------------------------------------
// Session constants
// -----------------------------------------------------------------------
const (
sessionCookieName = "cs_session"
sessionTTL = 8 * time.Hour
)
// -----------------------------------------------------------------------
// SessionAuth middleware
// -----------------------------------------------------------------------
// SessionAuth validates the session cookie on every request.
// /login and /static/ are exempt — all other paths redirect to /login when unauthenticated.
// On success, the CSRF token is stored in the request context.
func SessionAuth(secret string) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/login" || strings.HasPrefix(r.URL.Path, "/static/") {
next.ServeHTTP(w, r)
return
}
cookie, err := r.Cookie(sessionCookieName)
if err != nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
username, ok := validateSessionValue(secret, cookie.Value)
if !ok {
http.SetCookie(w, expiredCookie())
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
csrf := deriveCSRF(secret, cookie.Value)
ctx := context.WithValue(r.Context(), sessionContextKey, username)
ctx = context.WithValue(ctx, csrfContextKey, csrf)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// -----------------------------------------------------------------------
// Cookie helpers (exported for use in auth handler)
// -----------------------------------------------------------------------
// NewSessionCookie returns a signed, HttpOnly session cookie.
func NewSessionCookie(secret, username string) *http.Cookie {
exp := time.Now().Add(sessionTTL).Unix()
payload := fmt.Sprintf("%d.%s", exp, username)
mac := computeHMAC(secret, payload)
return &http.Cookie{
Name: sessionCookieName,
Value: payload + "." + mac,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
MaxAge: int(sessionTTL.Seconds()),
}
}
// ClearSessionCookie returns an expired cookie that clears the session.
func ClearSessionCookie() *http.Cookie {
return expiredCookie()
}
func expiredCookie() *http.Cookie {
return &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
HttpOnly: true,
MaxAge: -1,
}
}
// -----------------------------------------------------------------------
// Context accessors (exported for handlers)
// -----------------------------------------------------------------------
// CSRFFromContext returns the CSRF token stored by SessionAuth, or "".
func CSRFFromContext(r *http.Request) string {
v, _ := r.Context().Value(csrfContextKey).(string)
return v
}
// -----------------------------------------------------------------------
// Internal helpers
// -----------------------------------------------------------------------
// validateSessionValue parses and verifies a session cookie value.
// Format: "<exp_unix>.<username>.<hmac-hex>"
// HMAC covers "<exp_unix>.<username>".
func validateSessionValue(secret, value string) (username string, ok bool) {
lastDot := strings.LastIndex(value, ".")
if lastDot < 0 {
return "", false
}
payload := value[:lastDot]
mac := value[lastDot+1:]
if subtle.ConstantTimeCompare([]byte(mac), []byte(computeHMAC(secret, payload))) != 1 {
return "", false
}
firstDot := strings.Index(payload, ".")
if firstDot < 0 {
return "", false
}
expStr := payload[:firstDot]
username = payload[firstDot+1:]
exp, err := strconv.ParseInt(expStr, 10, 64)
if err != nil || time.Now().Unix() > exp || username == "" {
return "", false
}
return username, true
}
func deriveCSRF(secret, sessionValue string) string {
h := computeHMAC(secret, "csrf:"+sessionValue)
if len(h) > 32 {
return h[:32]
}
return h
}
func computeHMAC(secret, data string) string {
h := hmac.New(sha256.New, []byte(secret))
h.Write([]byte(data))
return hex.EncodeToString(h.Sum(nil))
}
// -----------------------------------------------------------------------
// Logger
// -----------------------------------------------------------------------
func Logger() Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rw := &responseWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rw, r)
log.Printf("%s %s %d %s", r.Method, r.URL.Path, rw.status, time.Since(start))
})
}
}
// -----------------------------------------------------------------------
// SecureHeaders
// -----------------------------------------------------------------------
func SecureHeaders() Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("X-XSS-Protection", "1; mode=block")
h.Set("Referrer-Policy", "same-origin")
h.Set("Content-Security-Policy",
"default-src 'self'; "+
"script-src 'self' https://cdn.tailwindcss.com 'unsafe-inline'; "+
"style-src 'self' https://fonts.googleapis.com 'unsafe-inline'; "+
"font-src 'self' https://fonts.gstatic.com; "+
"img-src 'self' data:; "+
"connect-src 'self'; "+
"frame-ancestors 'none'")
next.ServeHTTP(w, r)
})
}
}
// -----------------------------------------------------------------------
// Recovery
// -----------------------------------------------------------------------
func Recovery() Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
log.Printf("panic: %v\n%s", rec, debug.Stack())
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
}
// -----------------------------------------------------------------------
// responseWriter — captures status code for logging
// -----------------------------------------------------------------------
type responseWriter struct {
http.ResponseWriter
status int
written bool
}
func (rw *responseWriter) WriteHeader(code int) {
if !rw.written {
rw.status = code
rw.written = true
rw.ResponseWriter.WriteHeader(code)
}
}
func (rw *responseWriter) Write(b []byte) (int, error) {
if !rw.written {
rw.written = true
}
return rw.ResponseWriter.Write(b)
}
+94
View File
@@ -0,0 +1,94 @@
package router
import (
"io/fs"
"net/http"
"crowdsec-dashy/internal/config"
"crowdsec-dashy/internal/crowdsec"
"crowdsec-dashy/internal/handlers"
"crowdsec-dashy/internal/middleware"
)
// New constructs the full HTTP handler: renderer, deps, routes, and middleware chain.
func New(cfg *config.Config, lapi *crowdsec.LAPIClient, webFS fs.FS) (http.Handler, error) {
renderer, err := handlers.NewRenderer(webFS)
if err != nil {
return nil, err
}
deps := handlers.Deps{
Renderer: renderer,
LAPI: lapi,
CLI: crowdsec.NewCLIClient(cfg.CscliPath),
CLIAvailable: cfg.CscliAvailable(),
PollInterval: cfg.PollIntervalSec,
}
mux := http.NewServeMux()
// Static files served from embedded FS sub-tree.
staticFS, err := fs.Sub(webFS, "static")
if err != nil {
return nil, err
}
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
// Auth (exempt from session check — SessionAuth skips /login internally)
auth := handlers.NewAuthHandler(renderer, cfg.UISessionSecret, cfg.UIUsername, cfg.VerifyUIPassword)
mux.HandleFunc("GET /login", auth.Login)
mux.HandleFunc("POST /login", auth.Login)
mux.HandleFunc("POST /logout", auth.Logout)
// Dashboard
dash := handlers.NewDashboardHandler(deps)
mux.HandleFunc("GET /{$}", dash.ServeHTTP)
// Decisions
dec := handlers.NewDecisionsHandler(deps)
mux.HandleFunc("GET /decisions", dec.List)
mux.HandleFunc("POST /decisions/add", dec.Add)
mux.HandleFunc("POST /decisions/delete", dec.Delete)
// Alerts
alrt := handlers.NewAlertsHandler(deps)
mux.HandleFunc("GET /alerts", alrt.List)
mux.HandleFunc("POST /alerts/delete", alrt.Delete)
// Bouncers
bnc := handlers.NewBouncersHandler(deps)
mux.HandleFunc("GET /bouncers", bnc.List)
mux.HandleFunc("POST /bouncers/add", bnc.Add)
mux.HandleFunc("POST /bouncers/delete", bnc.Delete)
// Machines
mch := handlers.NewMachinesHandler(deps)
mux.HandleFunc("GET /machines", mch.List)
mux.HandleFunc("POST /machines/delete", mch.Delete)
mux.HandleFunc("POST /machines/validate", mch.Validate)
// Hub
hub := handlers.NewHubHandler(deps)
mux.HandleFunc("GET /hub", hub.List)
mux.HandleFunc("POST /hub/install", hub.Install)
mux.HandleFunc("POST /hub/remove", hub.Remove)
mux.HandleFunc("POST /hub/update", hub.Update)
// Metrics
met := handlers.NewMetricsHandler(deps)
mux.HandleFunc("GET /metrics-ui", met.ServeHTTP)
// Internal JSON API
api := handlers.NewAPIHandler(deps)
mux.HandleFunc("GET /api/v1/stats", api.Stats)
mux.HandleFunc("GET /api/v1/health", api.Health)
chain := middleware.Chain(
middleware.SessionAuth(cfg.UISessionSecret),
middleware.Logger(),
middleware.SecureHeaders(),
middleware.Recovery(),
)
return chain(mux), nil
}