diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..cb9b91a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,136 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Additional Instructions +- personal overrides: @~/.claude/CLAUDE.md + +## Commands + +```bash +# Build +go build -o crowdsec-dashy ./cmd/server + +# Production build (static binary, stripped) +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o crowdsec-dashy ./cmd/server + +# Run +./crowdsec-dashy + +# Lint / vet +go vet ./... + +# Tests (none yet — project is in early build phases) +go test ./... + +# Docker +docker compose up -d +docker compose build +``` + +## Architecture + +**Go 1.26. Zero external Go dependencies.** Stdlib only (`net/http`, `html/template`, `os/exec`, `encoding/json`). + +### Data access — two parallel layers + +| Layer | Package | Coverage | +|---|---|---| +| CrowdSec LAPI (REST) | `internal/crowdsec/lapi.go` | Decisions CRUD, Alerts, health | +| `cscli` exec wrapper | `internal/crowdsec/cli.go` | Bouncers, Machines, Hub, Metrics | + +All shared structs (Decision, Alert, Bouncer, Machine, HubItem, MetricsSection, etc.) live in `internal/crowdsec/types.go`. + +Bouncers, machines, hub, and metrics are **not** exposed via REST — they require `cscli` hitting the database directly. Features that need `cscli` check `Deps.CLIAvailable` and render an inline warning banner when the binary is absent. + +### Package layout + +``` +cmd/server/main.go entrypoint — loads config, authenticates LAPI, starts HTTP server +internal/config/config.go env-var loading; CscliAvailable() stat-checks the binary path +internal/crowdsec/ + types.go all shared structs + lapi.go REST client (JWT auth, auto-retry on 401) + cli.go cscli exec wrapper (strict allow-list) +internal/handlers/ + renderer.go Renderer, PageData, Deps, SidebarNav + dashboard.go / api.go / ... one file per page + JSON API handler +internal/middleware/middleware.go BasicAuth, Logger, SecureHeaders, Recovery +internal/router/router.go constructs Deps, wires all routes +web/templates/layouts/base.html sidebar shell +web/templates/pages/*.html one file per page +web/static/css/app.css all component styles +web/static/js/dashboard.js live stat polling + sparklines +web/static/js/tables.js client-side filter/sort/bulk-select +``` + +### Request flow + +``` +HTTP request + → middleware chain (BasicAuth → Logger → SecureHeaders → Recovery) + → router.go (stdlib mux) + → handler struct (receives Deps: Renderer, LAPI, CLI, CLIAvailable, PollInterval) + → handler renders to bytes.Buffer first, then writes to ResponseWriter +``` + +### Dependency injection + +`Deps` struct (`internal/handlers/renderer.go:246`) is constructed once in `router.New()` and passed to every handler constructor. Handlers are structs, not functions. + +### Template system + +`Renderer` (`internal/handlers/renderer.go`) parses all page templates at startup: each page in `web/templates/pages/*.html` is combined with `web/templates/layouts/base.html` into a named `*template.Template`. Templates are keyed by filename without extension (e.g. `"dashboard"`, `"decisions"`). All render calls go through `Renderer.Render(w, name, data)` using the buffer-then-write pattern. + +Template functions available: `inc`, `dec`, `dict`, `decisionBadgeClass`, `originBadgeClass`, `hubStatusClass`, `safeHTML`, `boolIcon`, `truncate`, `join`. + +### LAPI authentication + +On startup, `lapi.Login()` POSTs to `/v1/watchers/login` → JWT stored in memory. All subsequent requests carry `Authorization: Bearer `. On 401, `doJSON()` auto-retries with a fresh login once before failing. + +### `cscli` exec security + +All args passed as a slice (never through shell). Two allow-lists enforced before `exec.CommandContext`: +- `allowedSubcommands` (first arg): bouncers, machines, collections, parsers, scenarios, postoverflows, hub, metrics, version +- `allowedActions` (second arg): list, add, delete, install, remove, update, upgrade, validate +- All other user-supplied values validated against `safeArg` regex `^[a-zA-Z0-9_./:@\-]+$` + +### POST pattern + +All state-changing POST handlers use PRG (Post/Redirect/Get). Flash messages are attached to `PageData` via `WithFlash(type, msg)` and displayed on the redirected GET. All POST handlers must apply `http.MaxBytesReader` before parsing the body. + +### Internal JSON API + +Used by frontend JS — all return JSON, protected by the same BasicAuth middleware. + +| Method | Path | Description | +|---|---|---| +| GET | `/api/v1/stats` | Dashboard summary counts | +| GET | `/api/v1/health` | LAPI health + cscli availability | + +### Adding a new page + +1. `internal/handlers/mypage.go` — handler struct with `ServeHTTP` or named methods +2. `web/templates/pages/mypage.html` — starts with `{{template "base" .}}` +3. Add `NavItem` to `SidebarNav` in `renderer.go` +4. `router.go` — `mux.Handle("/mypage", handlers.NewMyPageHandler(deps))` + +### UI design + +Dark industrial aesthetic. Palette: near-black surface `#080b10`, blue-grey panels `#0f1520`, cyan accent `#00d4ff`, threat-red `#ff3b3b`, safe-green `#00e676`. Fonts: `JetBrains Mono` for data, `IBM Plex Sans` for labels. All styles in `web/static/css/app.css`. No external JS frameworks — sparklines drawn with inline SVG ``. + +Dashboard live stats poll `/api/v1/stats` via `web/static/js/dashboard.js`. Client-side table filter/sort/bulk-select in `web/static/js/tables.js`. + +### Environment variables + +| Variable | Default | Notes | +|---|---|---| +| `PORT` | `:8080` | Listen address | +| `CROWDSEC_API_URL` | `http://localhost:8080` | LAPI base URL | +| `CROWDSEC_API_LOGIN` | *(required)* | Machine login | +| `CROWDSEC_API_PASSWORD` | *(required)* | Machine password | +| `CSCLI_PATH` | `/usr/local/bin/cscli` | Binary path; CLI features disabled if absent | +| `UI_USERNAME` | `admin` | Basic Auth username | +| `UI_PASSWORD` | `changeme` | Basic Auth password | +| `UI_SESSION_SECRET` | *(required, ≥32 chars)* | HMAC signing key | +| `POLL_INTERVAL_SEC` | `15` | Dashboard poll interval (seconds) | diff --git a/Dockerfile b/Dockerfile index 4c29aaa..10d4f42 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # ----------------------------------------------------------------------- # Stage 1: Build # ----------------------------------------------------------------------- -FROM golang:1.22-alpine AS builder +FROM golang:1.26-alpine AS builder WORKDIR /build @@ -26,27 +26,18 @@ RUN apk --no-cache add ca-certificates tzdata WORKDIR /app -# Copy binary +# Copy binary (web assets embedded at build time) COPY --from=builder /build/crowdsec-dashy . -# Copy web assets (templates + static) -COPY web/ ./web/ - # Non-root user for security -RUN addgroup -S csui && adduser -S csui -G csui +RUN addgroup -S csui && adduser -S csui -G csui && \ + mkdir -p /app/config && chown csui:csui /app/config USER csui EXPOSE 8080 -# Runtime environment — override via docker-compose or -e flags -ENV PORT=:8080 \ - CROWDSEC_API_URL=http://crowdsec:8080 \ - CROWDSEC_API_LOGIN= \ - CROWDSEC_API_PASSWORD= \ - CSCLI_PATH=/usr/local/bin/cscli \ - UI_USERNAME=admin \ - UI_PASSWORD=changeme \ - UI_SESSION_SECRET=please-change-this-to-32-chars!! \ - POLL_INTERVAL_SEC=15 +# All settings live in app_config.conf (auto-generated on first run). +# CONFIG_FILE tells the app where to look — set in docker-compose.yml. +ENV CONFIG_FILE=/app/config/app_config.conf ENTRYPOINT ["/app/crowdsec-dashy"] diff --git a/README.md b/README.md index ce380d3..1e4bc3c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A lightweight, dependency-free web dashboard for monitoring and managing CrowdSec. -**Stack**: Go 1.22 · `html/template` · Tailwind CSS (CDN) · Zero external Go packages +**Stack**: Go 1.26 · `html/template` · Tailwind CSS (CDN) · Zero external Go packages --- @@ -28,10 +28,10 @@ A lightweight, dependency-free web dashboard for monitoring and managing CrowdSe ```bash # On the host running CrowdSec: -sudo cscli machines add crowdsec-dashy --password your-password -a +sudo cscli machines add crowdsec-dashy --password your-password -a -f /etc/crowdsec/dashy_credentials.yaml # Or if CrowdSec is in Docker: -docker exec crowdsec cscli machines add crowdsec-dashy --password your-password -a +docker exec crowdsec cscli machines add crowdsec-dashy --password your-password -a -f /etc/crowdsec/dashy_credentials.yaml ``` ### 2. Configure environment variables diff --git a/app_config.conf b/app_config.conf new file mode 100644 index 0000000..4bfc78d --- /dev/null +++ b/app_config.conf @@ -0,0 +1,29 @@ +# 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 = 10.98.195.1:10080 + +# CrowdSec Local API +crowdsec_api_url = http://localhost:8888 +crowdsec_api_login = crowdsec-dashy +crowdsec_api_password = Snoring-Paddling7-Overfill + +# 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 = $2a$10$u64XyTYnIYFrEyM5Ht4g8.GIEoX.gSlR.bk0cBZuW8dgity.MmrJ. + +# Session signing secret — auto-generated, keep private +ui_session_secret = ff675b56e36e70e6a1a1ad76cd20013b025c66d6df2b48f830608bfd8dfa8eb2 + +# Dashboard live-poll interval (seconds) +poll_interval_sec = 15 diff --git a/cmd/server/main.go b/cmd/server/main.go index 9b2d245..48e5b52 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -2,34 +2,49 @@ package main import ( "context" + "errors" + "flag" + "fmt" "log" "net/http" "os" - "path/filepath" "time" "crowdsec-dashy/internal/config" "crowdsec-dashy/internal/crowdsec" "crowdsec-dashy/internal/router" + webfiles "crowdsec-dashy/web" ) func main() { + // ---------------------------------------------------------------- + // CLI flags + // ---------------------------------------------------------------- + pwhash := flag.String("pwhash", "", "hash a password for use as ui_password in app_config.conf") + flag.Parse() + + if *pwhash != "" { + hash, err := config.HashPassword(*pwhash) + if err != nil { + log.Fatalf("hash password: %v", err) + } + fmt.Println(hash) + os.Exit(0) + } + // ---------------------------------------------------------------- // Configuration // ---------------------------------------------------------------- cfg, err := config.Load() if err != nil { + var firstRun *config.FirstRunError + if errors.As(err, &firstRun) { + log.Println(firstRun.Error()) + os.Exit(0) + } log.Fatalf("configuration error: %v", err) } - // Resolve paths - cwd, err := os.Getwd() - if err != nil { - log.Fatalf("cannot determine working directory: %v", err) - } - staticDir := filepath.Join(cwd, "web", "static") - templateDir := filepath.Join(cwd, "web", "templates") - // ---------------------------------------------------------------- // CrowdSec LAPI — authenticate at startup // ---------------------------------------------------------------- @@ -40,8 +55,9 @@ func main() { if err := lapi.Login(ctx); err != nil { cancel() log.Fatalf("failed to authenticate with CrowdSec LAPI: %v\n"+ - "Ensure CROWDSEC_API_LOGIN and CROWDSEC_API_PASSWORD are correct and\n"+ - "the machine is registered: cscli machines add %s -a", err, cfg.CrowdSecAPILogin) + "Check crowdsec_api_login / crowdsec_api_password in %s\n"+ + "Register the machine first: cscli machines add %s -a", + err, config.ConfigFile(), cfg.CrowdSecAPILogin) } cancel() log.Println("authenticated with CrowdSec LAPI") @@ -56,7 +72,7 @@ func main() { // ---------------------------------------------------------------- // Build router // ---------------------------------------------------------------- - handler, err := router.New(cfg, staticDir, templateDir) + handler, err := router.New(cfg, lapi, webfiles.FS) if err != nil { log.Fatalf("failed to initialise router: %v", err) } diff --git a/docker-compose.yml b/docker-compose.yml index 41d8447..8697445 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,20 +1,24 @@ # ----------------------------------------------------------------------- # CrowdSec Dashy — Docker Compose # -# Prerequisites: -# 1. Register the UI as a CrowdSec machine BEFORE starting this stack: -# docker exec crowdsec cscli machines add crowdsec-dashy --password changeme -a +# First-run setup: +# 1. mkdir -p config +# 2. docker compose up crowdsec-dashy +# The container exits after creating ./config/app_config.conf # -# 2. Edit the environment variables below (especially passwords). +# 3. Edit ./config/app_config.conf +# Set: crowdsec_api_login, crowdsec_api_password, ui_password # -# cscli bind-mount: -# The UI needs the cscli binary for bouncer/machine/hub/metrics management. -# If CrowdSec is running in Docker, extract the binary from the image: -# docker cp crowdsec:/usr/local/bin/cscli /usr/local/bin/cscli -# Then the bind-mount below works automatically. +# 4. Register the UI as a CrowdSec machine: +# docker exec crowdsec cscli machines add crowdsec-dashy \ +# --password -a # -# If cscli is NOT available, those sections will show a degradation banner -# and all LAPI-based features (decisions, alerts) continue to work normally. +# 5. docker compose up -d +# +# cscli (optional): +# Required for bouncers, machines, hub, and metrics pages. +# docker cp crowdsec:/usr/local/bin/cscli /usr/local/bin/cscli +# Then uncomment the cscli volume below. # ----------------------------------------------------------------------- services: @@ -34,26 +38,20 @@ services: crowdsec-dashy: build: . - # Or use a published image: - # image: ghcr.io/your-org/crowdsec-dashy:latest container_name: crowdsec-dashy restart: unless-stopped ports: - "8080:8080" environment: - PORT: ":8080" - CROWDSEC_API_URL: "http://crowdsec:8080" - CROWDSEC_API_LOGIN: "crowdsec-dashy" # match what you registered above - CROWDSEC_API_PASSWORD: "changeme" # CHANGE THIS - CSCLI_PATH: "/usr/local/bin/cscli" - UI_USERNAME: "admin" # UI Basic Auth login - UI_PASSWORD: "changeme" # CHANGE THIS - UI_SESSION_SECRET: "replace-with-32-random-chars-here" # CHANGE THIS - POLL_INTERVAL_SEC: "15" + # CONFIG_FILE points to the mounted config directory. + # All secrets live in the file — not in environment variables. + CONFIG_FILE: /app/config/app_config.conf volumes: - # Bind-mount cscli binary from host (or from crowdsec container) - # See setup instructions above - - /usr/local/bin/cscli:/usr/local/bin/cscli:ro + # Config directory — app_config.conf is auto-generated here on first run. + - ./config:/app/config + # cscli binary — optional; enables bouncers/machines/hub/metrics. + # Uncomment after copying from the crowdsec container (see above). + # - /usr/local/bin/cscli:/usr/local/bin/cscli:ro networks: - cs-internal depends_on: diff --git a/go.mod b/go.mod index 6aba262..1c7b454 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module crowdsec-dashy -go 1.26.2 +go 1.26.3 + +require golang.org/x/crypto v0.51.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0d14826 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..f86ceb6 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/crowdsec/cli.go b/internal/crowdsec/cli.go index 95d7e21..1eef79c 100644 --- a/internal/crowdsec/cli.go +++ b/internal/crowdsec/cli.go @@ -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 diff --git a/internal/crowdsec/types.go b/internal/crowdsec/types.go new file mode 100644 index 0000000..c37f190 --- /dev/null +++ b/internal/crowdsec/types.go @@ -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 +} diff --git a/internal/handlers/alerts.go b/internal/handlers/alerts.go new file mode 100644 index 0000000..86caf3f --- /dev/null +++ b/internal/handlers/alerts.go @@ -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)) +} diff --git a/internal/handlers/api.go b/internal/handlers/api.go new file mode 100644 index 0000000..d35600e --- /dev/null +++ b/internal/handlers/api.go @@ -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) + } +} diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go new file mode 100644 index 0000000..f71e982 --- /dev/null +++ b/internal/handlers/auth.go @@ -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) +} diff --git a/internal/handlers/bouncers.go b/internal/handlers/bouncers.go new file mode 100644 index 0000000..589ce4b --- /dev/null +++ b/internal/handlers/bouncers.go @@ -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)) +} diff --git a/internal/handlers/dashboard.go b/internal/handlers/dashboard.go new file mode 100644 index 0000000..390976d --- /dev/null +++ b/internal/handlers/dashboard.go @@ -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), + }) +} diff --git a/internal/handlers/decisions.go b/internal/handlers/decisions.go new file mode 100644 index 0000000..0c1e249 --- /dev/null +++ b/internal/handlers/decisions.go @@ -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 +} diff --git a/internal/handlers/helpers.go b/internal/handlers/helpers.go new file mode 100644 index 0000000..47d4b31 --- /dev/null +++ b/internal/handlers/helpers.go @@ -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 +} diff --git a/internal/handlers/hub.go b/internal/handlers/hub.go new file mode 100644 index 0000000..2e1aafc --- /dev/null +++ b/internal/handlers/hub.go @@ -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) +} diff --git a/internal/handlers/machines.go b/internal/handlers/machines.go new file mode 100644 index 0000000..a8e8d82 --- /dev/null +++ b/internal/handlers/machines.go @@ -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)) +} diff --git a/internal/handlers/metrics.go b/internal/handlers/metrics.go new file mode 100644 index 0000000..44cf48f --- /dev/null +++ b/internal/handlers/metrics.go @@ -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}) +} diff --git a/internal/handlers/renderer.go b/internal/handlers/renderer.go index acdc081..c13b478 100644 --- a/internal/handlers/renderer.go +++ b/internal/handlers/renderer.go @@ -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), } } diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go new file mode 100644 index 0000000..1a6ecbc --- /dev/null +++ b/internal/middleware/middleware.go @@ -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: ".." +// HMAC covers ".". +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) +} diff --git a/internal/router/router.go b/internal/router/router.go new file mode 100644 index 0000000..1063028 --- /dev/null +++ b/internal/router/router.go @@ -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 +} diff --git a/layout-reminder.md b/layout-reminder.md new file mode 100644 index 0000000..81fccad --- /dev/null +++ b/layout-reminder.md @@ -0,0 +1,202 @@ + +## Adding a New Page — Step by Step + +### Step 1 — Choose a layout + +Declare the layout in your template's very first line using a Go comment: + +```html +{{/* layout: base_public.html */}} +``` + +If you omit this line, `base.html` (sidebar layout) is used automatically. + +**Built-in layouts:** + +| Layout file | Navigation | Best for | +|---|---|---| +| `base.html` | Left sidebar | Authenticated app pages (dashboard, profile, etc.) | +| `base_public.html` | Top bar | Public/marketing pages, landing pages | +| `base_bare.html` | None | Minimal pages, print view, embeds | + +**Custom layout:** Create `web/templates/layouts/my_layout.html`, then declare `{{/* layout: my_layout.html */}}` in your page. Any layout filename works as long as it lives in the `layouts/` directory and contains `{{define "base"}} ... {{end}}`. + +### Step 2 — Create the template + +`web/templates/pages/mypage.html`: +```html +{{/* layout: base.html */}} +{{template "base" .}} + +{{define "title"}}My Page — GoApp{{end}} + +{{define "content"}} +
+

{{.Title}}

+ + {{range .Items}} +
+

{{.}}

+
+ {{end}} +
+{{end}} + +{{/* Optional: page-specific additions */}} +{{define "head"}}{{end}} + +{{/* Optional: page-specific scripts */}} +{{define "scripts"}}{{end}} +``` + +The `{{/* layout: ... */}}` comment is read only by Go at startup — it never appears in the HTML output. + +### Step 3 — Create the handler + +`internal/handlers/mypage.go`: +```go +package handlers + +import "net/http" + +// MyPageData holds everything the template needs. +// Always embed PageData — it carries User, Nav, flash messages, etc. +type MyPageData struct { + PageData + Items []string // your page-specific data +} + +type MyPageHandler struct { + tmpl *Renderer + // Add other dependencies here: *db.DB, *mailer.Mailer, *logger.Logger +} + +func NewMyPageHandler(r *Renderer) *MyPageHandler { + return &MyPageHandler{tmpl: r} +} + +func (h *MyPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + data := MyPageData{ + PageData: NewPageData(r, "My Page"), + Items: []string{"hello", "world"}, + } + h.tmpl.Render(w, "mypage", data) +} +``` + +### Step 4 — Register the route + +Add one line to `internal/router/router.go`: + +```go +// Public page (no login required): +mux.Handle("/mypage", handlers.NewMyPageHandler(renderer)) + +// Authenticated page (redirects to /login if not signed in): +mux.Handle("/mypage", middleware.RequireAuth(handlers.NewMyPageHandler(renderer))) + +// Admin-only page: +mux.Handle("/mypage", adminMW(handlers.NewMyPageHandler(renderer))) +``` + +### Step 5 — (Optional) Add to sidebar navigation + +In `internal/handlers/renderer.go`, append to `DefaultNav`: +```go +var DefaultNav = []NavItem{ + // existing items ... + {Label: "My Page", Href: "/mypage", Icon: "info"}, // all users + {Label: "Admin Page", Href: "/mypage", Icon: "shield", AdminOnly: true}, // admins only +} +``` + +Available icon names: `home`, `info`, `mail`, `users`, `activity`, `settings`, `logout`, `shield`, `key`, `mfa` + +--- + +## Email — Using the Mailer + +### Configure in `data/config.json` + +```json +"email": { + "enabled": true, + "smtp_host": "smtp.gmail.com", + "smtp_port": 587, + "encryption": "starttls", + "auth": true, + "username": "you@gmail.com", + "password": "your-app-password", + "from_address": "GoApp " +} +``` + +**Gmail tip:** Create an [App Password](https://myaccount.google.com/apppasswords) (not your main password). Requires 2FA enabled on your Google account. + +### Wire the Mailer into a handler + +```go +// In router.go (m is already created from cfg.Email): +mux.Handle("/notify", handlers.NewNotifyHandler(renderer, m, log)) + +// In your handler: +type NotifyHandler struct { + tmpl *Renderer + mailer *mailer.Mailer + log *logger.Logger +} +func NewNotifyHandler(r *Renderer, m *mailer.Mailer, l *logger.Logger) *NotifyHandler { + return &NotifyHandler{tmpl: r, mailer: m, log: l} +} +``` + +### Send plain-text email + +```go +err := h.mailer.Send(mailer.Message{ + To: []string{"user@example.com"}, + Subject: "Welcome to GoApp", + Body: "Hello! Your account is ready.", +}) +if err != nil { + h.log.Warn("send welcome email", "err", err) + // Don't abort — email failure should not break the UX +} +``` + +### Send HTML email + +```go +err := h.mailer.Send(mailer.Message{ + To: []string{"user@example.com"}, + Subject: "Welcome to GoApp", + Body: "

Welcome!

Your account is ready.

", + IsHTML: true, +}) +``` + +### Multiple recipients + CC + +```go +err := h.mailer.Send(mailer.Message{ + To: []string{"alice@example.com", "bob@example.com"}, + CC: []string{"manager@example.com"}, + Subject: "Team notification", + Body: "Something happened that you should know about.", +}) +``` + +### Safe error handling pattern + +When `email.enabled` is `false`, `Send()` returns `nil` immediately — the rest of your handler works normally. Always log email errors rather than aborting the request: + +```go +if err := h.mailer.Send(msg); err != nil { + h.log.Warn("email send failed", "err", err) + // continue — don't return an error page to the user +} +``` \ No newline at end of file diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..fd9de8d --- /dev/null +++ b/web/embed.go @@ -0,0 +1,6 @@ +package web + +import "embed" + +//go:embed static templates +var FS embed.FS diff --git a/web/static/js/dashboard.js b/web/static/js/dashboard.js new file mode 100644 index 0000000..e28544b --- /dev/null +++ b/web/static/js/dashboard.js @@ -0,0 +1,48 @@ +(function () { + 'use strict'; + + var pollInterval = (window._pollInterval || 15) * 1000; + var cliAvailable = !!window._cliAvailable; + + var elDecisions = document.getElementById('stat-decisions'); + var elAlerts = document.getElementById('stat-alerts'); + var elBouncers = document.getElementById('stat-bouncers'); + var elMachines = document.getElementById('stat-machines'); + + function animateTo(el, newVal) { + if (!el) return; + var current = parseInt(el.textContent, 10); + if (isNaN(current) || current === newVal) { + el.textContent = newVal; + return; + } + // Brief flash transition + el.style.opacity = '0.4'; + setTimeout(function () { + el.textContent = newVal; + el.style.opacity = '1'; + }, 150); + } + + function fetchStats() { + fetch('/api/v1/stats') + .then(function (r) { + if (!r.ok) throw new Error('bad response'); + return r.json(); + }) + .then(function (d) { + animateTo(elDecisions, d.decisions); + animateTo(elAlerts, d.alerts); + if (cliAvailable) { + animateTo(elBouncers, d.bouncers); + animateTo(elMachines, d.machines); + } + }) + .catch(function () { + // silently fail — health badge in base.html covers connectivity state + }); + } + + fetchStats(); + setInterval(fetchStats, pollInterval); +})(); diff --git a/web/static/js/tables.js b/web/static/js/tables.js new file mode 100644 index 0000000..41fa507 --- /dev/null +++ b/web/static/js/tables.js @@ -0,0 +1,65 @@ +(function () { + 'use strict'; + + // Client-side live filter for tables with data-filter="true" attribute. + // Filters rows by matching input value against all cell text. + function initLiveFilter(inputId, tableId) { + var input = document.getElementById(inputId); + var table = document.getElementById(tableId); + if (!input || !table) return; + + input.addEventListener('input', function () { + var query = input.value.toLowerCase().trim(); + var rows = table.querySelectorAll('tbody tr'); + rows.forEach(function (row) { + var text = row.textContent.toLowerCase(); + row.style.display = (!query || text.includes(query)) ? '' : 'none'; + }); + }); + } + + // Client-side column sort for a table. + function initSort(tableId) { + var table = document.getElementById(tableId); + if (!table) return; + + var headers = table.querySelectorAll('thead th[data-sort]'); + headers.forEach(function (th, colIdx) { + th.style.cursor = 'pointer'; + th.addEventListener('click', function () { + sortTable(table, colIdx, th); + }); + }); + } + + function sortTable(table, colIdx, th) { + var asc = th.dataset.sortDir !== 'asc'; + th.dataset.sortDir = asc ? 'asc' : 'desc'; + + var tbody = table.querySelector('tbody'); + var rows = Array.from(tbody.querySelectorAll('tr')); + + rows.sort(function (a, b) { + var aText = cellText(a, colIdx); + var bText = cellText(b, colIdx); + var aNum = parseFloat(aText); + var bNum = parseFloat(bText); + if (!isNaN(aNum) && !isNaN(bNum)) { + return asc ? aNum - bNum : bNum - aNum; + } + return asc + ? aText.localeCompare(bText) + : bText.localeCompare(aText); + }); + + rows.forEach(function (r) { tbody.appendChild(r); }); + } + + function cellText(row, idx) { + var cells = row.querySelectorAll('td'); + return cells[idx] ? cells[idx].textContent.trim().toLowerCase() : ''; + } + + // Expose helpers for inline use in page scripts. + window.TableHelpers = { initLiveFilter: initLiveFilter, initSort: initSort }; +})(); diff --git a/web/templates/layouts/base.html b/web/templates/layouts/base.html new file mode 100644 index 0000000..e5752c0 --- /dev/null +++ b/web/templates/layouts/base.html @@ -0,0 +1,109 @@ +{{define "base"}} + + + + + +{{.Title}} — CrowdSec Dashy + + + + + + +
+ + + + + +
+
+ +
+ {{.Title}} +
+
+ checking +
+
+ + {{if .Flash.Message}} +
+ {{.Flash.Message}} + +
+ {{end}} + +
+ {{template "content" .}} +
+
+
+ + +{{block "scripts" .}}{{end}} + + +{{end}} diff --git a/web/templates/layouts/base_bare.html b/web/templates/layouts/base_bare.html new file mode 100644 index 0000000..89a9c87 --- /dev/null +++ b/web/templates/layouts/base_bare.html @@ -0,0 +1,16 @@ +{{define "base"}} + + + + + +{{if .Title}}{{.Title}} — {{end}}CrowdSec Dashy + + + +{{template "content" .}} +{{block "scripts" .}}{{end}} + + + +{{end}} diff --git a/web/templates/pages/alerts.html b/web/templates/pages/alerts.html new file mode 100644 index 0000000..a4a47ec --- /dev/null +++ b/web/templates/pages/alerts.html @@ -0,0 +1,104 @@ +{{template "base" .}} +{{define "content"}} +
+ +
+
Alerts
+
Security events detected by CrowdSec agents
+
+ +
+
+
+ + + + + {{if or .Filter.Scenario .Filter.IP .Filter.Since}}Clear{{end}} +
+
+
+ +
+
+ +
+ Alerts ({{len .Alerts}} shown) +
+ + +
+
+ + {{if .Alerts}} +
+ + + + + + + + + + + + + + + + {{range .Alerts}} + + + + + + + + + + + + {{end}} + +
IDScenarioSourceCountryEventsDecisionsDateAction
{{.ID}}{{truncate .Scenario 36}}{{.Source.Value}}{{.Source.CN}}{{.EventsCount}}{{len .Decisions}}{{truncate .StartAt 16}} + + + + + +
+
+ {{else}} +
+
No alerts found
+
No security events match the current filters
+
+ {{end}} + +
+
+{{end}} + +{{define "scripts"}} + +{{end}} diff --git a/web/templates/pages/bouncers.html b/web/templates/pages/bouncers.html new file mode 100644 index 0000000..5f0693a --- /dev/null +++ b/web/templates/pages/bouncers.html @@ -0,0 +1,131 @@ +{{template "base" .}} +{{define "content"}} +
+ +
+
+
Bouncers
+
Enforcement agents that consume CrowdSec decisions
+
+ {{if .CLIAvailable}} + + {{end}} +
+ + {{if not .CLIAvailable}} +
+
+ cscli unavailable + Bouncer management requires the cscli binary. Mount it at the CSCLI_PATH configured in your environment. +
+
+ {{end}} + + {{if .NewBouncer}} +
+
+ Bouncer "{{.NewBouncer.Name}}" registered. + Copy the API key below — it will not be shown again. +
+
+ {{.NewBouncer.APIKey}} + click to copy +
+
+ {{end}} + +
+
+ Bouncers ({{len .Bouncers}}) +
+ {{if .Bouncers}} +
+ + + + + + + + + + {{if $.CLIAvailable}}{{end}} + + + + {{range .Bouncers}} + + + + + + + + {{if $.CLIAvailable}} + + {{end}} + + {{end}} + +
NameIP AddressLast PullVersionTypeStatusAction
{{.Name}}{{.IPAddress}}{{truncate .LastPull 16}}{{.Version}}{{.Type}} + {{if .Revoked}} + revoked + {{else}} + valid + {{end}} + +
+ + + +
+
+
+ {{else}} +
+
No bouncers registered
+
Add a bouncer to enforce CrowdSec decisions
+
+ {{end}} +
+
+ +{{if .CLIAvailable}} + +{{end}} +{{end}} + +{{define "scripts"}} + +{{end}} diff --git a/web/templates/pages/dashboard.html b/web/templates/pages/dashboard.html new file mode 100644 index 0000000..bdfe84e --- /dev/null +++ b/web/templates/pages/dashboard.html @@ -0,0 +1,108 @@ +{{template "base" .}} +{{define "content"}} +
+ +
+
+
Active Bans
+
+
decisions in effect
+
+
+
Recent Alerts
+
+
up to 500 counted
+
+
+
Bouncers
+
{{if .CLIAvailable}}—{{else}}n/a{{end}}
+
registered agents
+
+
+
Machines
+
{{if .CLIAvailable}}—{{else}}n/a{{end}}
+
registered agents
+
+
+ +
+ +
+
+ Recent Decisions + View all +
+ {{if .RecentDecisions}} + + + + + + + + + + + {{range .RecentDecisions}} + + + + + + + {{end}} + +
ValueTypeOriginExpires
{{.Value}}{{.Type}}{{.Origin}}{{truncate .Until 16}}
+ {{else}} +
+
No active decisions
+
CrowdSec has not issued any bans
+
+ {{end}} +
+ +
+
+ Recent Alerts + View all +
+ {{if .RecentAlerts}} + + + + + + + + + + + {{range .RecentAlerts}} + + + + + + + {{end}} + +
ScenarioSourceEventsDate
{{truncate .Scenario 32}}{{.Source.Value}}{{.EventsCount}}{{truncate .StartAt 16}}
+ {{else}} +
+
No recent alerts
+
No threat activity detected
+
+ {{end}} +
+ +
+
+{{end}} + +{{define "scripts"}} + + +{{end}} diff --git a/web/templates/pages/decisions.html b/web/templates/pages/decisions.html new file mode 100644 index 0000000..bd78dc8 --- /dev/null +++ b/web/templates/pages/decisions.html @@ -0,0 +1,176 @@ +{{template "base" .}} +{{define "content"}} +
+ +
+
+
Decisions
+
Active bans, captchas, and manual decisions
+
+ +
+ + + +
+
+
+ + + + + {{if or .Filter.Type .Filter.Scope .Filter.Value}}Clear{{end}} +
+
+
+ +
+
+ +
+ + Page {{.Page}}{{if .HasNext}} (more available){{end}} + +
+ + +
+
+ + {{if .Decisions}} +
+ + + + + + + + + + + + + + + + {{range .Decisions}} + + + + + + + + + + + + {{end}} + +
ValueTypeScopeOriginScenarioDurationExpiresAction
{{.Value}}{{.Type}}{{.Scope}}{{.Origin}}{{truncate .Scenario 24}}{{.Duration}}{{truncate .Until 16}} + + + + + +
+
+ {{else}} +
+
No decisions found
+
No active decisions match the current filters
+
+ {{end}} + + + {{if or (gt .Page 1) .HasNext}} + + {{end}} +
+
+{{end}} + +{{define "scripts"}} + +{{end}} diff --git a/web/templates/pages/error.html b/web/templates/pages/error.html new file mode 100644 index 0000000..d516cd7 --- /dev/null +++ b/web/templates/pages/error.html @@ -0,0 +1,9 @@ +{{template "base" .}} +{{define "content"}} +
+
{{.Code}}
+
{{.Message}}
+
Something went wrong. Try going back or returning to the dashboard.
+ Dashboard +
+{{end}} diff --git a/web/templates/pages/hub.html b/web/templates/pages/hub.html new file mode 100644 index 0000000..55bb3a5 --- /dev/null +++ b/web/templates/pages/hub.html @@ -0,0 +1,103 @@ +{{template "base" .}} +{{define "content"}} +
+ +
+
+
Hub
+
Collections, parsers, scenarios, and postoverflows
+
+ {{if .CLIAvailable}} +
+ + +
+ {{end}} +
+ + {{if not .CLIAvailable}} +
+
+ cscli unavailable + Hub management requires the cscli binary. Mount it at the CSCLI_PATH configured in your environment. +
+
+ {{end}} + +
+ + + {{if .CLIAvailable}} + {{if .Items}} +
+ + + + + + + + + + + + {{$tab := .Tab}} + {{range .Items}} + + + + + + + + {{end}} + +
NameStatusVersionStateAction
+
{{.Name}}
+ {{if .Description}}
{{truncate .Description 64}}
{{end}} +
{{.Status}}{{.Version}} + {{if .Tainted}} + tainted + {{else if .UpToDate}} + up to date + {{else if .Installed}} + update avail + {{else}} + not installed + {{end}} + + {{if .Installed}} +
+ + + + + +
+ {{else}} +
+ + + + + +
+ {{end}} +
+
+ {{else}} +
+
No {{.Tab}} found
+
Run "Update All" to refresh the hub index
+
+ {{end}} + {{end}} +
+
+{{end}} diff --git a/web/templates/pages/login.html b/web/templates/pages/login.html new file mode 100644 index 0000000..0cbaaa9 --- /dev/null +++ b/web/templates/pages/login.html @@ -0,0 +1,42 @@ +{{template "base" .}} +{{define "content"}} +
+
+ +
+ +
CrowdSec Dashy
+
Sign in to continue
+
+ +
+ + {{if .Error}} +
+ {{.Error}} +
+ {{end}} + +
+
+ + +
+
+ + +
+ +
+ +
+
+
+{{end}} diff --git a/web/templates/pages/machines.html b/web/templates/pages/machines.html new file mode 100644 index 0000000..fe4b5b5 --- /dev/null +++ b/web/templates/pages/machines.html @@ -0,0 +1,81 @@ +{{template "base" .}} +{{define "content"}} +
+ +
+
Machines
+
CrowdSec agent registrations
+
+ + {{if not .CLIAvailable}} +
+
+ cscli unavailable + Machine management requires the cscli binary. Mount it at the CSCLI_PATH configured in your environment. +
+
+ {{end}} + +
+
+ Machines ({{len .Machines}}) +
+ {{if .Machines}} +
+ + + + + + + + + + {{if $.CLIAvailable}}{{end}} + + + + {{range .Machines}} + + + + + + + + {{if $.CLIAvailable}} + + {{end}} + + {{end}} + +
Machine IDIPLast HeartbeatVersionAuthStatusActions
{{truncate .MachineID 32}}{{.IPAddress}}{{truncate .LastHeartbeat 16}}{{.Version}}{{.AuthType}} + {{if .IsValidated}} + validated + {{else}} + pending + {{end}} + + {{if not .IsValidated}} +
+ + + +
+ {{end}} +
+ + + +
+
+
+ {{else}} +
+
No machines registered
+
Register an agent: cscli machines add <name> -a
+
+ {{end}} +
+
+{{end}} diff --git a/web/templates/pages/metrics.html b/web/templates/pages/metrics.html new file mode 100644 index 0000000..8f71e1b --- /dev/null +++ b/web/templates/pages/metrics.html @@ -0,0 +1,62 @@ +{{template "base" .}} +{{define "content"}} +
+ +
+
+
Metrics
+
Real-time CrowdSec statistics from cscli metrics
+
+ {{if .CLIAvailable}} + Refresh + {{end}} +
+ + {{if not .CLIAvailable}} +
+
+ cscli unavailable + Metrics require the cscli binary. Mount it at the CSCLI_PATH configured in your environment. +
+
+ {{end}} + + {{if .Sections}} + {{range .Sections}} +
+
+ {{.Title}} +
+ {{if and .Headers .Rows}} +
+ + + + {{range .Headers}}{{end}} + + + + {{range .Rows}} + + {{range .}}{{end}} + + {{end}} + +
{{.}}
{{.}}
+
+ {{else}} +
+
No data in this section
+
+ {{end}} +
+ {{end}} + {{else if .CLIAvailable}} +
+
No metrics available
+
CrowdSec may not have processed any data yet
+
+ {{end}} + +
+{{end}}