base dashboard and login
This commit is contained in:
@@ -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 <jwt>`. 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 `<polyline>`.
|
||||
|
||||
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) |
|
||||
+7
-16
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
+28
-12
@@ -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)
|
||||
}
|
||||
|
||||
+23
-25
@@ -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 <crowdsec_api_password from config> -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:
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
module crowdsec-dashy
|
||||
|
||||
go 1.26.2
|
||||
go 1.26.3
|
||||
|
||||
require golang.org/x/crypto v0.51.0
|
||||
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"crowdsec-dashy/internal/crowdsec"
|
||||
)
|
||||
|
||||
// AlertsHandler manages the alerts page and its POST actions.
|
||||
type AlertsHandler struct {
|
||||
deps Deps
|
||||
}
|
||||
|
||||
func NewAlertsHandler(deps Deps) *AlertsHandler {
|
||||
return &AlertsHandler{deps: deps}
|
||||
}
|
||||
|
||||
// AlertsData is passed to the alerts template.
|
||||
type AlertsData struct {
|
||||
PageData
|
||||
Alerts []crowdsec.Alert
|
||||
Filter crowdsec.AlertFilter
|
||||
}
|
||||
|
||||
// List renders the alerts list.
|
||||
func (h *AlertsHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
filter := crowdsec.AlertFilter{
|
||||
Limit: 200,
|
||||
Scenario: r.URL.Query().Get("scenario"),
|
||||
IP: r.URL.Query().Get("ip"),
|
||||
Since: r.URL.Query().Get("since"),
|
||||
}
|
||||
|
||||
alerts, err := h.deps.LAPI.ListAlerts(ctx, filter)
|
||||
if err != nil {
|
||||
h.deps.Renderer.RenderError(w, http.StatusBadGateway, "failed to fetch alerts")
|
||||
return
|
||||
}
|
||||
|
||||
pd := NewPageData(r, "Alerts", h.deps.CLIAvailable, h.deps.PollInterval)
|
||||
if f := readFlash(r); f.Message != "" {
|
||||
pd.Flash = f
|
||||
}
|
||||
|
||||
h.deps.Renderer.Render(w, "alerts", AlertsData{
|
||||
PageData: pd,
|
||||
Alerts: alerts,
|
||||
Filter: filter,
|
||||
})
|
||||
}
|
||||
|
||||
// Delete processes alert deletion (POST; `id` field may repeat for bulk).
|
||||
func (h *AlertsHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 4096)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
flashRedirect(w, r, "/alerts", "error", "invalid form data")
|
||||
return
|
||||
}
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
ids := r.Form["id"]
|
||||
if len(ids) == 0 {
|
||||
flashRedirect(w, r, "/alerts", "error", "no alert selected")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var errs []string
|
||||
deleted := 0
|
||||
for _, s := range ids {
|
||||
id, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
errs = append(errs, fmt.Sprintf("invalid id: %q", s))
|
||||
continue
|
||||
}
|
||||
if err := h.deps.LAPI.DeleteAlert(ctx, id); err != nil {
|
||||
errs = append(errs, fmt.Sprintf("id %d: %v", id, err))
|
||||
continue
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
flashRedirect(w, r, "/alerts", "error", strings.Join(errs, "; "))
|
||||
return
|
||||
}
|
||||
|
||||
flashRedirect(w, r, "/alerts", "success", fmt.Sprintf("%d alert(s) deleted", deleted))
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"crowdsec-dashy/internal/crowdsec"
|
||||
)
|
||||
|
||||
// APIHandler serves the internal JSON API consumed by frontend JS.
|
||||
type APIHandler struct {
|
||||
deps Deps
|
||||
}
|
||||
|
||||
func NewAPIHandler(deps Deps) *APIHandler {
|
||||
return &APIHandler{deps: deps}
|
||||
}
|
||||
|
||||
type statsResponse struct {
|
||||
Decisions int `json:"decisions"`
|
||||
Alerts int `json:"alerts"`
|
||||
Bouncers int `json:"bouncers"`
|
||||
Machines int `json:"machines"`
|
||||
Healthy bool `json:"healthy"`
|
||||
}
|
||||
|
||||
type healthResponse struct {
|
||||
Healthy bool `json:"healthy"`
|
||||
CLIAvailable bool `json:"cli_available"`
|
||||
}
|
||||
|
||||
// Stats returns dashboard summary counts.
|
||||
func (h *APIHandler) Stats(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
decisions, _ := h.deps.LAPI.ListDecisions(ctx, crowdsec.DecisionFilter{Limit: 500})
|
||||
alerts, _ := h.deps.LAPI.ListAlerts(ctx, crowdsec.AlertFilter{Limit: 500})
|
||||
|
||||
resp := statsResponse{
|
||||
Decisions: len(decisions),
|
||||
Alerts: len(alerts),
|
||||
Healthy: h.deps.LAPI.IsHealthy(ctx),
|
||||
}
|
||||
|
||||
if h.deps.CLIAvailable {
|
||||
bouncers, _ := h.deps.CLI.ListBouncers(ctx)
|
||||
machines, _ := h.deps.CLI.ListMachines(ctx)
|
||||
resp.Bouncers = len(bouncers)
|
||||
resp.Machines = len(machines)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// Health returns LAPI health and cscli availability.
|
||||
func (h *APIHandler) Health(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(healthResponse{
|
||||
Healthy: h.deps.LAPI.IsHealthy(ctx),
|
||||
CLIAvailable: h.deps.CLIAvailable,
|
||||
}); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"crowdsec-dashy/internal/middleware"
|
||||
)
|
||||
|
||||
// AuthHandler handles login and logout.
|
||||
type AuthHandler struct {
|
||||
renderer *Renderer
|
||||
secret string
|
||||
uiUsername string
|
||||
verifyPassword func(string) bool
|
||||
}
|
||||
|
||||
// NewAuthHandler constructs an AuthHandler.
|
||||
func NewAuthHandler(renderer *Renderer, secret, uiUsername string, verifyPassword func(string) bool) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
renderer: renderer,
|
||||
secret: secret,
|
||||
uiUsername: uiUsername,
|
||||
verifyPassword: verifyPassword,
|
||||
}
|
||||
}
|
||||
|
||||
// LoginData is passed to the login template.
|
||||
type LoginData struct {
|
||||
Title string
|
||||
Error string
|
||||
}
|
||||
|
||||
// Login handles GET (render form) and POST (verify credentials, set cookie).
|
||||
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
if _, err := r.Cookie("cs_session"); err == nil {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
h.renderer.Render(w, "login", LoginData{Title: "Login"})
|
||||
|
||||
case http.MethodPost:
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 2048)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
h.renderer.Render(w, "login", LoginData{Title: "Login", Error: "Invalid request."})
|
||||
return
|
||||
}
|
||||
username := strings.TrimSpace(r.FormValue("username"))
|
||||
password := r.FormValue("password")
|
||||
|
||||
if username != h.uiUsername || !h.verifyPassword(password) {
|
||||
h.renderer.Render(w, "login", LoginData{Title: "Login", Error: "Invalid credentials."})
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(w, middleware.NewSessionCookie(h.secret, username))
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// Logout clears the session cookie and redirects to /login.
|
||||
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, middleware.ClearSessionCookie())
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"crowdsec-dashy/internal/crowdsec"
|
||||
)
|
||||
|
||||
// BouncersHandler manages the bouncers page and its POST actions.
|
||||
type BouncersHandler struct {
|
||||
deps Deps
|
||||
}
|
||||
|
||||
func NewBouncersHandler(deps Deps) *BouncersHandler {
|
||||
return &BouncersHandler{deps: deps}
|
||||
}
|
||||
|
||||
// BouncersData is passed to the bouncers template.
|
||||
type BouncersData struct {
|
||||
PageData
|
||||
Bouncers []crowdsec.Bouncer
|
||||
NewBouncer *crowdsec.AddBouncerResult // set immediately after add; shown exactly once
|
||||
}
|
||||
|
||||
// List renders the bouncers list.
|
||||
func (h *BouncersHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
pd := NewPageData(r, "Bouncers", h.deps.CLIAvailable, h.deps.PollInterval)
|
||||
if f := readFlash(r); f.Message != "" {
|
||||
pd.Flash = f
|
||||
}
|
||||
|
||||
var bouncers []crowdsec.Bouncer
|
||||
if h.deps.CLIAvailable {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
var err error
|
||||
bouncers, err = h.deps.CLI.ListBouncers(ctx)
|
||||
if err != nil {
|
||||
pd.Flash = FlashMessage{Type: "error", Message: fmt.Sprintf("cscli error: %v", err)}
|
||||
}
|
||||
}
|
||||
|
||||
h.deps.Renderer.Render(w, "bouncers", BouncersData{PageData: pd, Bouncers: bouncers})
|
||||
}
|
||||
|
||||
// Add registers a new bouncer and renders the API key reveal page (no redirect).
|
||||
// The key is shown exactly once in the POST response and never stored by the UI.
|
||||
func (h *BouncersHandler) Add(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 4096)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
flashRedirect(w, r, "/bouncers", "error", "invalid form data")
|
||||
return
|
||||
}
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if !h.deps.CLIAvailable {
|
||||
flashRedirect(w, r, "/bouncers", "error", "cscli not available")
|
||||
return
|
||||
}
|
||||
|
||||
name := r.FormValue("name")
|
||||
if ok, _ := matchName(name); !ok {
|
||||
flashRedirect(w, r, "/bouncers", "error", "invalid name: use 1-64 alphanumeric/dash/underscore characters")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := h.deps.CLI.AddBouncer(ctx, name)
|
||||
if err != nil {
|
||||
flashRedirect(w, r, "/bouncers", "error", fmt.Sprintf("failed to add bouncer: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
bouncers, _ := h.deps.CLI.ListBouncers(ctx)
|
||||
|
||||
pd := NewPageData(r, "Bouncers", h.deps.CLIAvailable, h.deps.PollInterval)
|
||||
h.deps.Renderer.Render(w, "bouncers", BouncersData{
|
||||
PageData: pd,
|
||||
Bouncers: bouncers,
|
||||
NewBouncer: result,
|
||||
})
|
||||
}
|
||||
|
||||
// Delete removes a bouncer by name.
|
||||
func (h *BouncersHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 4096)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
flashRedirect(w, r, "/bouncers", "error", "invalid form data")
|
||||
return
|
||||
}
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if !h.deps.CLIAvailable {
|
||||
flashRedirect(w, r, "/bouncers", "error", "cscli not available")
|
||||
return
|
||||
}
|
||||
|
||||
name := r.FormValue("name")
|
||||
if ok, _ := matchName(name); !ok {
|
||||
flashRedirect(w, r, "/bouncers", "error", "invalid bouncer name")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := h.deps.CLI.DeleteBouncer(ctx, name); err != nil {
|
||||
flashRedirect(w, r, "/bouncers", "error", fmt.Sprintf("failed to delete bouncer: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
flashRedirect(w, r, "/bouncers", "success", fmt.Sprintf("Bouncer %q deleted", name))
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"crowdsec-dashy/internal/crowdsec"
|
||||
)
|
||||
|
||||
// DashboardHandler serves the main dashboard page.
|
||||
type DashboardHandler struct {
|
||||
deps Deps
|
||||
}
|
||||
|
||||
func NewDashboardHandler(deps Deps) *DashboardHandler {
|
||||
return &DashboardHandler{deps: deps}
|
||||
}
|
||||
|
||||
// DashboardData is passed to the dashboard template.
|
||||
type DashboardData struct {
|
||||
PageData
|
||||
RecentDecisions []crowdsec.Decision
|
||||
RecentAlerts []crowdsec.Alert
|
||||
LAPIHealthy bool
|
||||
}
|
||||
|
||||
func (h *DashboardHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
decisions, _ := h.deps.LAPI.ListDecisions(ctx, crowdsec.DecisionFilter{Limit: 10})
|
||||
alerts, _ := h.deps.LAPI.ListAlerts(ctx, crowdsec.AlertFilter{Limit: 10})
|
||||
|
||||
h.deps.Renderer.Render(w, "dashboard", DashboardData{
|
||||
PageData: NewPageData(r, "Dashboard", h.deps.CLIAvailable, h.deps.PollInterval),
|
||||
RecentDecisions: decisions,
|
||||
RecentAlerts: alerts,
|
||||
LAPIHealthy: h.deps.LAPI.IsHealthy(ctx),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"crowdsec-dashy/internal/crowdsec"
|
||||
)
|
||||
|
||||
// DecisionsHandler manages the decisions page and its POST actions.
|
||||
type DecisionsHandler struct {
|
||||
deps Deps
|
||||
}
|
||||
|
||||
func NewDecisionsHandler(deps Deps) *DecisionsHandler {
|
||||
return &DecisionsHandler{deps: deps}
|
||||
}
|
||||
|
||||
// DecisionsData is passed to the decisions template.
|
||||
type DecisionsData struct {
|
||||
PageData
|
||||
Decisions []crowdsec.Decision
|
||||
Filter crowdsec.DecisionFilter
|
||||
Page int
|
||||
HasNext bool
|
||||
}
|
||||
|
||||
const decisionsPerPage = 50
|
||||
|
||||
var durationRE = regexp.MustCompile(`^\d+[smhdw]$`)
|
||||
|
||||
// List renders the paginated decisions list.
|
||||
func (h *DecisionsHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
filter := crowdsec.DecisionFilter{
|
||||
Limit: decisionsPerPage + 1,
|
||||
Offset: (page - 1) * decisionsPerPage,
|
||||
Type: r.URL.Query().Get("type"),
|
||||
Scope: r.URL.Query().Get("scope"),
|
||||
Value: r.URL.Query().Get("value"),
|
||||
Origin: r.URL.Query().Get("origin"),
|
||||
}
|
||||
|
||||
decisions, err := h.deps.LAPI.ListDecisions(ctx, filter)
|
||||
if err != nil {
|
||||
h.deps.Renderer.RenderError(w, http.StatusBadGateway, "failed to fetch decisions")
|
||||
return
|
||||
}
|
||||
|
||||
hasNext := len(decisions) > decisionsPerPage
|
||||
if hasNext {
|
||||
decisions = decisions[:decisionsPerPage]
|
||||
}
|
||||
|
||||
pd := NewPageData(r, "Decisions", h.deps.CLIAvailable, h.deps.PollInterval)
|
||||
if f := readFlash(r); f.Message != "" {
|
||||
pd.Flash = f
|
||||
}
|
||||
|
||||
h.deps.Renderer.Render(w, "decisions", DecisionsData{
|
||||
PageData: pd,
|
||||
Decisions: decisions,
|
||||
Filter: filter,
|
||||
Page: page,
|
||||
HasNext: hasNext,
|
||||
})
|
||||
}
|
||||
|
||||
// Add processes the add-decision form (POST).
|
||||
func (h *DecisionsHandler) Add(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 4096)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
flashRedirect(w, r, "/decisions", "error", "invalid form data")
|
||||
return
|
||||
}
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
value := strings.TrimSpace(r.FormValue("value"))
|
||||
scope := r.FormValue("scope")
|
||||
decType := r.FormValue("type")
|
||||
duration := strings.TrimSpace(r.FormValue("duration"))
|
||||
scenario := strings.TrimSpace(r.FormValue("scenario"))
|
||||
|
||||
if err := validateDecisionInput(value, scope, decType, duration); err != nil {
|
||||
flashRedirect(w, r, "/decisions", "error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if scenario == "" {
|
||||
scenario = "manual"
|
||||
}
|
||||
if matched, _ := regexp.MatchString(`^[a-zA-Z0-9_./\-]{1,128}$`, scenario); !matched {
|
||||
scenario = "manual"
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := h.deps.LAPI.AddDecision(ctx, crowdsec.DecisionInput{
|
||||
Duration: duration,
|
||||
Origin: "cscli",
|
||||
Scenario: scenario,
|
||||
Scope: scope,
|
||||
Type: decType,
|
||||
Value: value,
|
||||
}); err != nil {
|
||||
flashRedirect(w, r, "/decisions", "error", fmt.Sprintf("failed to add decision: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
flashRedirect(w, r, "/decisions", "success",
|
||||
fmt.Sprintf("Decision added: %s %s (%s) for %s", decType, value, scope, duration))
|
||||
}
|
||||
|
||||
// Delete processes decision deletion (POST; `id` field may repeat for bulk).
|
||||
func (h *DecisionsHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 4096)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
flashRedirect(w, r, "/decisions", "error", "invalid form data")
|
||||
return
|
||||
}
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
ids := r.Form["id"]
|
||||
if len(ids) == 0 {
|
||||
flashRedirect(w, r, "/decisions", "error", "no decision selected")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var errs []string
|
||||
deleted := 0
|
||||
for _, s := range ids {
|
||||
id, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
errs = append(errs, fmt.Sprintf("invalid id: %q", s))
|
||||
continue
|
||||
}
|
||||
if err := h.deps.LAPI.DeleteDecision(ctx, id); err != nil {
|
||||
errs = append(errs, fmt.Sprintf("id %d: %v", id, err))
|
||||
continue
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
flashRedirect(w, r, "/decisions", "error", strings.Join(errs, "; "))
|
||||
return
|
||||
}
|
||||
|
||||
flashRedirect(w, r, "/decisions", "success", fmt.Sprintf("%d decision(s) deleted", deleted))
|
||||
}
|
||||
|
||||
func validateDecisionInput(value, scope, decType, duration string) error {
|
||||
switch scope {
|
||||
case "Ip", "Range", "Country":
|
||||
default:
|
||||
return fmt.Errorf("invalid scope: must be Ip, Range, or Country")
|
||||
}
|
||||
|
||||
switch decType {
|
||||
case "ban", "captcha", "throttle":
|
||||
default:
|
||||
return fmt.Errorf("invalid type: must be ban, captcha, or throttle")
|
||||
}
|
||||
|
||||
if value == "" {
|
||||
return fmt.Errorf("value is required")
|
||||
}
|
||||
|
||||
switch scope {
|
||||
case "Ip":
|
||||
if net.ParseIP(value) == nil {
|
||||
return fmt.Errorf("invalid IP address: %q", value)
|
||||
}
|
||||
case "Range":
|
||||
if _, _, err := net.ParseCIDR(value); err != nil {
|
||||
return fmt.Errorf("invalid CIDR range: %q", value)
|
||||
}
|
||||
case "Country":
|
||||
if len(value) != 2 {
|
||||
return fmt.Errorf("invalid country code: must be exactly 2 uppercase letters")
|
||||
}
|
||||
for _, c := range value {
|
||||
if c < 'A' || c > 'Z' {
|
||||
return fmt.Errorf("invalid country code: must be uppercase letters")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !durationRE.MatchString(duration) {
|
||||
return fmt.Errorf("invalid duration %q: use format like 24h, 7d, 30m, 3600s", duration)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
|
||||
"crowdsec-dashy/internal/middleware"
|
||||
)
|
||||
|
||||
var validNameRE = regexp.MustCompile(`^[a-zA-Z0-9_\-]{1,64}$`)
|
||||
|
||||
func matchName(name string) (bool, error) {
|
||||
return validNameRE.MatchString(name), nil
|
||||
}
|
||||
|
||||
// flashRedirect redirects with flash type and message as query params.
|
||||
func flashRedirect(w http.ResponseWriter, r *http.Request, to, flashType, msg string) {
|
||||
v := url.Values{}
|
||||
v.Set("flash", flashType)
|
||||
v.Set("msg", msg)
|
||||
http.Redirect(w, r, to+"?"+v.Encode(), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// readFlash extracts a validated flash message from URL query params.
|
||||
func readFlash(r *http.Request) FlashMessage {
|
||||
flash := r.URL.Query().Get("flash")
|
||||
msg := r.URL.Query().Get("msg")
|
||||
if flash == "" || msg == "" {
|
||||
return FlashMessage{}
|
||||
}
|
||||
switch flash {
|
||||
case "success", "error", "warning", "info":
|
||||
return FlashMessage{Type: flash, Message: msg}
|
||||
}
|
||||
return FlashMessage{}
|
||||
}
|
||||
|
||||
// checkCSRF verifies the _csrf form field against the token in the request context.
|
||||
// Must be called after r.ParseForm().
|
||||
func checkCSRF(r *http.Request) bool {
|
||||
expected := middleware.CSRFFromContext(r)
|
||||
if expected == "" {
|
||||
return false
|
||||
}
|
||||
got := r.FormValue("_csrf")
|
||||
return subtle.ConstantTimeCompare([]byte(got), []byte(expected)) == 1
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"crowdsec-dashy/internal/crowdsec"
|
||||
)
|
||||
|
||||
// HubHandler manages the hub page (collections, parsers, scenarios, postoverflows).
|
||||
type HubHandler struct {
|
||||
deps Deps
|
||||
}
|
||||
|
||||
func NewHubHandler(deps Deps) *HubHandler {
|
||||
return &HubHandler{deps: deps}
|
||||
}
|
||||
|
||||
// HubData is passed to the hub template.
|
||||
type HubData struct {
|
||||
PageData
|
||||
Tab string
|
||||
Items []crowdsec.HubItem // current tab's items
|
||||
}
|
||||
|
||||
// List renders the hub page for the active tab.
|
||||
func (h *HubHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
pd := NewPageData(r, "Hub", h.deps.CLIAvailable, h.deps.PollInterval)
|
||||
if f := readFlash(r); f.Message != "" {
|
||||
pd.Flash = f
|
||||
}
|
||||
|
||||
tab := sanitizeTab(r.URL.Query().Get("tab"))
|
||||
data := HubData{PageData: pd, Tab: tab}
|
||||
|
||||
if h.deps.CLIAvailable {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var err error
|
||||
switch tab {
|
||||
case "collections":
|
||||
data.Items, err = h.deps.CLI.ListCollections(ctx)
|
||||
case "parsers":
|
||||
data.Items, err = h.deps.CLI.ListParsers(ctx)
|
||||
case "scenarios":
|
||||
data.Items, err = h.deps.CLI.ListScenarios(ctx)
|
||||
case "postoverflows":
|
||||
data.Items, err = h.deps.CLI.ListPostoverflows(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
data.PageData.Flash = FlashMessage{Type: "error", Message: fmt.Sprintf("cscli error: %v", err)}
|
||||
}
|
||||
}
|
||||
|
||||
h.deps.Renderer.Render(w, "hub", data)
|
||||
}
|
||||
|
||||
// Install installs a hub item.
|
||||
func (h *HubHandler) Install(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 4096)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
flashRedirect(w, r, "/hub", "error", "invalid form data")
|
||||
return
|
||||
}
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if !h.deps.CLIAvailable {
|
||||
flashRedirect(w, r, "/hub", "error", "cscli not available")
|
||||
return
|
||||
}
|
||||
|
||||
kind := r.FormValue("kind")
|
||||
name := r.FormValue("name")
|
||||
tab := sanitizeTab(r.FormValue("tab"))
|
||||
|
||||
if err := validateHubKind(kind); err != nil {
|
||||
flashRedirect(w, r, hubURL(tab), "error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var err error
|
||||
switch kind {
|
||||
case "collections":
|
||||
err = h.deps.CLI.InstallCollection(ctx, name)
|
||||
case "parsers":
|
||||
err = h.deps.CLI.InstallParser(ctx, name)
|
||||
case "scenarios":
|
||||
err = h.deps.CLI.InstallScenario(ctx, name)
|
||||
default:
|
||||
flashRedirect(w, r, hubURL(tab), "error", "unsupported hub type")
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
flashRedirect(w, r, hubURL(tab), "error", fmt.Sprintf("install failed: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
flashRedirect(w, r, hubURL(tab), "success", fmt.Sprintf("Installed %s", name))
|
||||
}
|
||||
|
||||
// Remove uninstalls a hub item.
|
||||
func (h *HubHandler) Remove(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 4096)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
flashRedirect(w, r, "/hub", "error", "invalid form data")
|
||||
return
|
||||
}
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if !h.deps.CLIAvailable {
|
||||
flashRedirect(w, r, "/hub", "error", "cscli not available")
|
||||
return
|
||||
}
|
||||
|
||||
kind := r.FormValue("kind")
|
||||
name := r.FormValue("name")
|
||||
tab := sanitizeTab(r.FormValue("tab"))
|
||||
|
||||
if err := validateHubKind(kind); err != nil {
|
||||
flashRedirect(w, r, hubURL(tab), "error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var err error
|
||||
switch kind {
|
||||
case "collections":
|
||||
err = h.deps.CLI.RemoveCollection(ctx, name)
|
||||
case "parsers":
|
||||
err = h.deps.CLI.RemoveParser(ctx, name)
|
||||
case "scenarios":
|
||||
err = h.deps.CLI.RemoveScenario(ctx, name)
|
||||
default:
|
||||
flashRedirect(w, r, hubURL(tab), "error", "unsupported hub type")
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
flashRedirect(w, r, hubURL(tab), "error", fmt.Sprintf("remove failed: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
flashRedirect(w, r, hubURL(tab), "success", fmt.Sprintf("Removed %s", name))
|
||||
}
|
||||
|
||||
// Update runs cscli hub update + upgrade.
|
||||
func (h *HubHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1024)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
flashRedirect(w, r, "/hub", "error", "invalid form data")
|
||||
return
|
||||
}
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if !h.deps.CLIAvailable {
|
||||
flashRedirect(w, r, "/hub", "error", "cscli not available")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 180*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := h.deps.CLI.HubUpdate(ctx); err != nil {
|
||||
flashRedirect(w, r, "/hub", "error", fmt.Sprintf("hub update failed: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
flashRedirect(w, r, "/hub", "success", "Hub updated and upgraded successfully")
|
||||
}
|
||||
|
||||
func sanitizeTab(tab string) string {
|
||||
switch tab {
|
||||
case "collections", "parsers", "scenarios", "postoverflows":
|
||||
return tab
|
||||
}
|
||||
return "collections"
|
||||
}
|
||||
|
||||
func hubURL(tab string) string {
|
||||
return "/hub?tab=" + tab
|
||||
}
|
||||
|
||||
func validateHubKind(kind string) error {
|
||||
switch kind {
|
||||
case "collections", "parsers", "scenarios", "postoverflows":
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("invalid hub kind: %q", kind)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"crowdsec-dashy/internal/crowdsec"
|
||||
)
|
||||
|
||||
// MachinesHandler manages the machines page and its POST actions.
|
||||
type MachinesHandler struct {
|
||||
deps Deps
|
||||
}
|
||||
|
||||
func NewMachinesHandler(deps Deps) *MachinesHandler {
|
||||
return &MachinesHandler{deps: deps}
|
||||
}
|
||||
|
||||
// MachinesData is passed to the machines template.
|
||||
type MachinesData struct {
|
||||
PageData
|
||||
Machines []crowdsec.Machine
|
||||
}
|
||||
|
||||
var validMachineID = regexp.MustCompile(`^[a-zA-Z0-9_.\-]{1,128}$`)
|
||||
|
||||
// List renders the machines list.
|
||||
func (h *MachinesHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
pd := NewPageData(r, "Machines", h.deps.CLIAvailable, h.deps.PollInterval)
|
||||
if f := readFlash(r); f.Message != "" {
|
||||
pd.Flash = f
|
||||
}
|
||||
|
||||
var machines []crowdsec.Machine
|
||||
if h.deps.CLIAvailable {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
var err error
|
||||
machines, err = h.deps.CLI.ListMachines(ctx)
|
||||
if err != nil {
|
||||
pd.Flash = FlashMessage{Type: "error", Message: fmt.Sprintf("cscli error: %v", err)}
|
||||
}
|
||||
}
|
||||
|
||||
h.deps.Renderer.Render(w, "machines", MachinesData{PageData: pd, Machines: machines})
|
||||
}
|
||||
|
||||
// Delete removes a machine by ID.
|
||||
func (h *MachinesHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 4096)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
flashRedirect(w, r, "/machines", "error", "invalid form data")
|
||||
return
|
||||
}
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if !h.deps.CLIAvailable {
|
||||
flashRedirect(w, r, "/machines", "error", "cscli not available")
|
||||
return
|
||||
}
|
||||
|
||||
id := r.FormValue("id")
|
||||
if !validMachineID.MatchString(id) {
|
||||
flashRedirect(w, r, "/machines", "error", "invalid machine id")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := h.deps.CLI.DeleteMachine(ctx, id); err != nil {
|
||||
flashRedirect(w, r, "/machines", "error", fmt.Sprintf("failed to delete machine: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
flashRedirect(w, r, "/machines", "success", fmt.Sprintf("Machine %q deleted", id))
|
||||
}
|
||||
|
||||
// Validate approves a pending machine registration.
|
||||
func (h *MachinesHandler) Validate(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 4096)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
flashRedirect(w, r, "/machines", "error", "invalid form data")
|
||||
return
|
||||
}
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if !h.deps.CLIAvailable {
|
||||
flashRedirect(w, r, "/machines", "error", "cscli not available")
|
||||
return
|
||||
}
|
||||
|
||||
id := r.FormValue("id")
|
||||
if !validMachineID.MatchString(id) {
|
||||
flashRedirect(w, r, "/machines", "error", "invalid machine id")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := h.deps.CLI.ValidateMachine(ctx, id); err != nil {
|
||||
flashRedirect(w, r, "/machines", "error", fmt.Sprintf("failed to validate machine: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
flashRedirect(w, r, "/machines", "success", fmt.Sprintf("Machine %q validated", id))
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"crowdsec-dashy/internal/crowdsec"
|
||||
)
|
||||
|
||||
// MetricsHandler serves the parsed cscli metrics page.
|
||||
type MetricsHandler struct {
|
||||
deps Deps
|
||||
}
|
||||
|
||||
func NewMetricsHandler(deps Deps) *MetricsHandler {
|
||||
return &MetricsHandler{deps: deps}
|
||||
}
|
||||
|
||||
// MetricsData is passed to the metrics template.
|
||||
type MetricsData struct {
|
||||
PageData
|
||||
Sections []crowdsec.MetricsSection
|
||||
}
|
||||
|
||||
func (h *MetricsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
pd := NewPageData(r, "Metrics", h.deps.CLIAvailable, h.deps.PollInterval)
|
||||
|
||||
var sections []crowdsec.MetricsSection
|
||||
if h.deps.CLIAvailable {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
var err error
|
||||
sections, err = h.deps.CLI.GetMetrics(ctx)
|
||||
if err != nil {
|
||||
pd.Flash = FlashMessage{Type: "error", Message: fmt.Sprintf("cscli error: %v", err)}
|
||||
}
|
||||
}
|
||||
|
||||
h.deps.Renderer.Render(w, "metrics", MetricsData{PageData: pd, Sections: sections})
|
||||
}
|
||||
@@ -5,12 +5,14 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"crowdsec-dashy/internal/crowdsec"
|
||||
"crowdsec-dashy/internal/middleware"
|
||||
)
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -23,33 +25,46 @@ type Renderer struct {
|
||||
funcMap template.FuncMap
|
||||
}
|
||||
|
||||
// NewRenderer parses all page templates against the base layout.
|
||||
func NewRenderer(templateDir string) (*Renderer, error) {
|
||||
// bareLayoutPages lists page names that use base_bare.html instead of base.html.
|
||||
var bareLayoutPages = map[string]bool{
|
||||
"login": true,
|
||||
}
|
||||
|
||||
// NewRenderer parses all page templates. Most pages use the sidebar layout
|
||||
// (base.html); pages listed in bareLayoutPages use the bare layout (base_bare.html).
|
||||
func NewRenderer(fsys fs.FS) (*Renderer, error) {
|
||||
r := &Renderer{
|
||||
templates: make(map[string]*template.Template),
|
||||
funcMap: buildFuncMap(),
|
||||
}
|
||||
|
||||
basePath := filepath.Join(templateDir, "layouts", "base.html")
|
||||
const (
|
||||
basePath = "templates/layouts/base.html"
|
||||
barePath = "templates/layouts/base_bare.html"
|
||||
)
|
||||
|
||||
pages, err := filepath.Glob(filepath.Join(templateDir, "pages", "*.html"))
|
||||
pages, err := fs.Glob(fsys, "templates/pages/*.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("renderer: glob pages: %w", err)
|
||||
}
|
||||
if len(pages) == 0 {
|
||||
return nil, fmt.Errorf("renderer: no page templates found in %s/pages/", templateDir)
|
||||
return nil, fmt.Errorf("renderer: no page templates found")
|
||||
}
|
||||
|
||||
for _, page := range pages {
|
||||
name := templateName(page)
|
||||
tmpl, err := template.New("base").
|
||||
layout := basePath
|
||||
if bareLayoutPages[name] {
|
||||
layout = barePath
|
||||
}
|
||||
tmpl, err := template.New(path.Base(page)).
|
||||
Funcs(r.funcMap).
|
||||
ParseFiles(basePath, page)
|
||||
ParseFS(fsys, layout, page)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("renderer: parse %s: %w", name, err)
|
||||
}
|
||||
r.templates[name] = tmpl
|
||||
log.Printf("renderer: registered template %q", name)
|
||||
log.Printf("renderer: registered template %q (layout: %s)", name, path.Base(layout))
|
||||
}
|
||||
|
||||
return r, nil
|
||||
@@ -64,7 +79,7 @@ func (r *Renderer) Render(w http.ResponseWriter, name string, data any) {
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.ExecuteTemplate(&buf, "base", data); err != nil {
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
log.Printf("renderer: execute %q: %v", name, err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -84,9 +99,9 @@ func (r *Renderer) RenderError(w http.ResponseWriter, code int, msg string) {
|
||||
})
|
||||
}
|
||||
|
||||
func templateName(path string) string {
|
||||
base := filepath.Base(path)
|
||||
return base[:len(base)-len(filepath.Ext(base))]
|
||||
func templateName(p string) string {
|
||||
base := path.Base(p)
|
||||
return base[:len(base)-len(path.Ext(base))]
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -206,6 +221,7 @@ type PageData struct {
|
||||
Flash FlashMessage
|
||||
CLIAvailable bool
|
||||
PollInterval int
|
||||
CSRFToken string
|
||||
}
|
||||
|
||||
// FlashMessage is a one-shot notification shown on the next page load.
|
||||
@@ -229,6 +245,7 @@ func NewPageData(r *http.Request, title string, cliAvail bool, pollSec int) Page
|
||||
Nav: SidebarNav,
|
||||
CLIAvailable: cliAvail,
|
||||
PollInterval: pollSec,
|
||||
CSRFToken: middleware.CSRFFromContext(r),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"}}
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<h2 class="text-2xl font-semibold text-white mb-6">{{.Title}}</h2>
|
||||
|
||||
{{range .Items}}
|
||||
<div class="card">
|
||||
<p class="text-gray-300">{{.}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* Optional: page-specific <head> additions */}}
|
||||
{{define "head"}}<style>/* page CSS */</style>{{end}}
|
||||
|
||||
{{/* Optional: page-specific scripts */}}
|
||||
{{define "scripts"}}<script>/* page JS */</script>{{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 <you@gmail.com>"
|
||||
}
|
||||
```
|
||||
|
||||
**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: "<h1>Welcome!</h1><p>Your account is <strong>ready</strong>.</p>",
|
||||
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
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,6 @@
|
||||
package web
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed static templates
|
||||
var FS embed.FS
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -0,0 +1,109 @@
|
||||
{{define "base"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Title}} — CrowdSec Dashy</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;600&display=swap">
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<div style="display:flex;height:100vh;overflow:hidden">
|
||||
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar-logo">
|
||||
<div class="logo-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#00d4ff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<span class="logo-name">CrowdSec</span>
|
||||
<span class="logo-sub">Dashy</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
{{range .Nav}}
|
||||
{{if .Divider}}<div class="nav-divider"></div>{{end}}
|
||||
<a href="{{.Path}}" class="nav-item{{if eq $.CurrentPath .Path}} nav-item--active{{end}}">
|
||||
<span class="nav-icon">{{safeHTML .Icon}}</span>
|
||||
{{.Label}}
|
||||
</a>
|
||||
{{end}}
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
{{if .CLIAvailable}}
|
||||
<div class="cli-badge cli-badge--ok">cscli: available</div>
|
||||
{{else}}
|
||||
<div class="cli-badge cli-badge--warn">cscli: unavailable</div>
|
||||
{{end}}
|
||||
<form method="POST" action="/logout" style="margin-top:10px">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<button type="submit" class="btn-ghost-sm" style="width:100%;text-align:center;padding:6px 0">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="sidebar-overlay" id="sidebar-overlay"></div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;flex:1;overflow:hidden">
|
||||
<header class="topbar">
|
||||
<button class="topbar-menu-btn" id="menu-btn" aria-label="Toggle sidebar">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
</button>
|
||||
<div class="topbar-breadcrumb">
|
||||
<span class="topbar-page">{{.Title}}</span>
|
||||
</div>
|
||||
<div id="health-badge" class="status-pill status-pill--loading">
|
||||
<span class="status-dot"></span>checking
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{{if .Flash.Message}}
|
||||
<div class="flash flash--{{.Flash.Type}}" id="flash-msg">
|
||||
<span class="flash-text">{{.Flash.Message}}</span>
|
||||
<button class="flash-close" onclick="document.getElementById('flash-msg').remove()">dismiss</button>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<main style="flex:1;overflow-y:auto;padding:20px">
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
document.body.classList.add('ready');
|
||||
var btn = document.getElementById('menu-btn');
|
||||
var sb = document.getElementById('sidebar');
|
||||
var ov = document.getElementById('sidebar-overlay');
|
||||
if (btn) {
|
||||
btn.addEventListener('click', function() { sb.classList.toggle('open'); ov.classList.toggle('open'); });
|
||||
ov.addEventListener('click', function() { sb.classList.remove('open'); ov.classList.remove('open'); });
|
||||
}
|
||||
function checkHealth() {
|
||||
var badge = document.getElementById('health-badge');
|
||||
if (!badge) return;
|
||||
fetch('/api/v1/health').then(function(r) { return r.json(); }).then(function(d) {
|
||||
if (d.healthy) {
|
||||
badge.className = 'status-pill status-pill--healthy';
|
||||
badge.innerHTML = '<span class="status-dot"></span>healthy';
|
||||
} else {
|
||||
badge.className = 'status-pill status-pill--unhealthy';
|
||||
badge.innerHTML = '<span class="status-dot"></span>unhealthy';
|
||||
}
|
||||
}).catch(function() {
|
||||
badge.className = 'status-pill status-pill--unhealthy';
|
||||
badge.innerHTML = '<span class="status-dot"></span>offline';
|
||||
});
|
||||
}
|
||||
checkHealth();
|
||||
setInterval(checkHealth, 30000);
|
||||
})();
|
||||
</script>
|
||||
{{block "scripts" .}}{{end}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,16 @@
|
||||
{{define "base"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{if .Title}}{{.Title}} — {{end}}CrowdSec Dashy</title>
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
</head>
|
||||
<body style="background:#080b10;min-height:100vh">
|
||||
{{template "content" .}}
|
||||
{{block "scripts" .}}{{end}}
|
||||
<script>document.body.classList.add('ready');</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,104 @@
|
||||
{{template "base" .}}
|
||||
{{define "content"}}
|
||||
<div style="max-width:1400px">
|
||||
|
||||
<div style="margin-bottom:16px">
|
||||
<div class="page-title">Alerts</div>
|
||||
<div class="page-sub">Security events detected by CrowdSec agents</div>
|
||||
</div>
|
||||
|
||||
<div class="panel" style="margin-bottom:16px">
|
||||
<div class="panel-body" style="padding:12px 18px">
|
||||
<form method="GET" action="/alerts" class="filter-bar">
|
||||
<input class="filter-input" type="text" name="scenario" placeholder="Scenario filter..." value="{{.Filter.Scenario}}">
|
||||
<input class="filter-input" type="text" name="ip" placeholder="Source IP..." value="{{.Filter.IP}}">
|
||||
<select class="filter-select" name="since">
|
||||
<option value="">All time</option>
|
||||
<option value="1h" {{if eq .Filter.Since "1h"}}selected{{end}}>Last 1h</option>
|
||||
<option value="24h" {{if eq .Filter.Since "24h"}}selected{{end}}>Last 24h</option>
|
||||
<option value="7d" {{if eq .Filter.Since "7d"}}selected{{end}}>Last 7d</option>
|
||||
</select>
|
||||
<button type="submit" class="btn-secondary">Filter</button>
|
||||
{{if or .Filter.Scenario .Filter.IP .Filter.Since}}<a href="/alerts" class="btn-ghost">Clear</a>{{end}}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<form method="POST" action="/alerts/delete" id="bulk-form">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">Alerts ({{len .Alerts}} shown)</span>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<button type="button" class="btn-ghost-sm" onclick="toggleAll()">Select all</button>
|
||||
<button type="submit" class="btn-danger-sm" onclick="return confirmBulkDelete()">Delete selected</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Alerts}}
|
||||
<div style="overflow-x:auto">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:36px"><input type="checkbox" id="chk-all" onchange="selectAll(this)"></th>
|
||||
<th>ID</th>
|
||||
<th>Scenario</th>
|
||||
<th>Source</th>
|
||||
<th>Country</th>
|
||||
<th>Events</th>
|
||||
<th>Decisions</th>
|
||||
<th>Date</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Alerts}}
|
||||
<tr>
|
||||
<td><input type="checkbox" name="id" value="{{.ID}}" class="row-chk"></td>
|
||||
<td style="font-family:'JetBrains Mono',monospace;font-size:12px">{{.ID}}</td>
|
||||
<td style="font-size:12px" title="{{.Scenario}}">{{truncate .Scenario 36}}</td>
|
||||
<td style="font-family:'JetBrains Mono',monospace;font-size:12px">{{.Source.Value}}</td>
|
||||
<td style="font-size:12px;color:var(--muted)">{{.Source.CN}}</td>
|
||||
<td style="font-family:'JetBrains Mono',monospace">{{.EventsCount}}</td>
|
||||
<td style="font-family:'JetBrains Mono',monospace">{{len .Decisions}}</td>
|
||||
<td style="font-size:12px;color:var(--muted)">{{truncate .StartAt 16}}</td>
|
||||
<td>
|
||||
<form method="POST" action="/alerts/delete" style="display:inline">
|
||||
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<button type="submit" class="btn-danger-sm" onclick="return confirm('Delete alert #{{.ID}}?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<div class="empty-text">No alerts found</div>
|
||||
<div class="empty-sub">No security events match the current filters</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script>
|
||||
function selectAll(cb) {
|
||||
document.querySelectorAll('.row-chk').forEach(function(c) { c.checked = cb.checked; });
|
||||
}
|
||||
function toggleAll() {
|
||||
var cb = document.getElementById('chk-all');
|
||||
cb.checked = !cb.checked;
|
||||
selectAll(cb);
|
||||
}
|
||||
function confirmBulkDelete() {
|
||||
var n = document.querySelectorAll('.row-chk:checked').length;
|
||||
if (n === 0) { alert('Select at least one alert.'); return false; }
|
||||
return confirm('Delete ' + n + ' alert(s)?');
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,131 @@
|
||||
{{template "base" .}}
|
||||
{{define "content"}}
|
||||
<div style="max-width:1200px">
|
||||
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
|
||||
<div>
|
||||
<div class="page-title">Bouncers</div>
|
||||
<div class="page-sub">Enforcement agents that consume CrowdSec decisions</div>
|
||||
</div>
|
||||
{{if .CLIAvailable}}
|
||||
<button class="btn-primary" onclick="document.getElementById('add-modal').classList.remove('hidden')">Add Bouncer</button>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if not .CLIAvailable}}
|
||||
<div class="cli-unavail-banner" style="margin-bottom:16px">
|
||||
<div>
|
||||
<strong style="display:block;margin-bottom:4px">cscli unavailable</strong>
|
||||
Bouncer management requires the cscli binary. Mount it at the CSCLI_PATH configured in your environment.
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .NewBouncer}}
|
||||
<div class="apikey-reveal" style="margin-bottom:16px">
|
||||
<div class="apikey-header">
|
||||
<strong>Bouncer "{{.NewBouncer.Name}}" registered.</strong>
|
||||
Copy the API key below — it will not be shown again.
|
||||
</div>
|
||||
<div class="apikey-box" id="apikey-box" onclick="copyApiKey()" title="Click to copy">
|
||||
<code style="font-family:'JetBrains Mono',monospace;font-size:13px;color:var(--safe);word-break:break-all">{{.NewBouncer.APIKey}}</code>
|
||||
<span class="apikey-copy-hint" id="copy-hint">click to copy</span>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">Bouncers ({{len .Bouncers}})</span>
|
||||
</div>
|
||||
{{if .Bouncers}}
|
||||
<div style="overflow-x:auto">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>IP Address</th>
|
||||
<th>Last Pull</th>
|
||||
<th>Version</th>
|
||||
<th>Type</th>
|
||||
<th>Status</th>
|
||||
{{if $.CLIAvailable}}<th>Action</th>{{end}}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Bouncers}}
|
||||
<tr>
|
||||
<td style="font-family:'JetBrains Mono',monospace;font-size:12px;font-weight:600">{{.Name}}</td>
|
||||
<td style="font-family:'JetBrains Mono',monospace;font-size:12px">{{.IPAddress}}</td>
|
||||
<td style="font-size:12px;color:var(--muted)">{{truncate .LastPull 16}}</td>
|
||||
<td style="font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--muted)">{{.Version}}</td>
|
||||
<td style="font-size:12px;color:var(--muted)">{{.Type}}</td>
|
||||
<td>
|
||||
{{if .Revoked}}
|
||||
<span class="badge badge-red">revoked</span>
|
||||
{{else}}
|
||||
<span class="badge badge-green">valid</span>
|
||||
{{end}}
|
||||
</td>
|
||||
{{if $.CLIAvailable}}
|
||||
<td>
|
||||
<form method="POST" action="/bouncers/delete" style="display:inline">
|
||||
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="name" value="{{.Name}}">
|
||||
<button type="submit" class="btn-danger-sm" onclick="return confirm('Delete bouncer "{{.Name}}"? This cannot be undone.')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
{{end}}
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<div class="empty-text">No bouncers registered</div>
|
||||
<div class="empty-sub">Add a bouncer to enforce CrowdSec decisions</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .CLIAvailable}}
|
||||
<div class="modal-backdrop hidden" id="add-modal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">Add Bouncer</span>
|
||||
<button class="modal-close" onclick="document.getElementById('add-modal').classList.add('hidden')">close</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form method="POST" action="/bouncers/add">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<div style="margin-bottom:16px">
|
||||
<label class="field-label">Bouncer Name</label>
|
||||
<input class="field-input" type="text" name="name" placeholder="my-bouncer" required
|
||||
pattern="[a-zA-Z0-9_\-]{1,64}" autofocus>
|
||||
<div class="field-hint">1-64 characters: letters, digits, dash, underscore</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn-ghost" onclick="document.getElementById('add-modal').classList.add('hidden')">Cancel</button>
|
||||
<button type="submit" class="btn-primary">Register Bouncer</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script>
|
||||
function copyApiKey() {
|
||||
var code = document.querySelector('#apikey-box code');
|
||||
var hint = document.getElementById('copy-hint');
|
||||
if (!code || !navigator.clipboard) return;
|
||||
navigator.clipboard.writeText(code.textContent.trim()).then(function() {
|
||||
if (hint) { hint.textContent = 'copied!'; setTimeout(function() { hint.textContent = 'click to copy'; }, 2500); }
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,108 @@
|
||||
{{template "base" .}}
|
||||
{{define "content"}}
|
||||
<div style="max-width:1400px">
|
||||
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:16px;margin-bottom:24px">
|
||||
<div class="stat-card stat-card--threat">
|
||||
<div class="stat-label">Active Bans</div>
|
||||
<div class="stat-value" id="stat-decisions">—</div>
|
||||
<div class="stat-sub">decisions in effect</div>
|
||||
</div>
|
||||
<div class="stat-card stat-card--warn">
|
||||
<div class="stat-label">Recent Alerts</div>
|
||||
<div class="stat-value" id="stat-alerts">—</div>
|
||||
<div class="stat-sub">up to 500 counted</div>
|
||||
</div>
|
||||
<div class="stat-card stat-card--accent">
|
||||
<div class="stat-label">Bouncers</div>
|
||||
<div class="stat-value" id="stat-bouncers">{{if .CLIAvailable}}—{{else}}n/a{{end}}</div>
|
||||
<div class="stat-sub">registered agents</div>
|
||||
</div>
|
||||
<div class="stat-card stat-card--safe">
|
||||
<div class="stat-label">Machines</div>
|
||||
<div class="stat-value" id="stat-machines">{{if .CLIAvailable}}—{{else}}n/a{{end}}</div>
|
||||
<div class="stat-sub">registered agents</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">Recent Decisions</span>
|
||||
<a href="/decisions" class="panel-link">View all</a>
|
||||
</div>
|
||||
{{if .RecentDecisions}}
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Value</th>
|
||||
<th>Type</th>
|
||||
<th>Origin</th>
|
||||
<th>Expires</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .RecentDecisions}}
|
||||
<tr>
|
||||
<td style="font-family:'JetBrains Mono',monospace;font-size:12px">{{.Value}}</td>
|
||||
<td><span class="badge {{decisionBadgeClass .Type}}">{{.Type}}</span></td>
|
||||
<td><span class="badge {{originBadgeClass .Origin}}">{{.Origin}}</span></td>
|
||||
<td style="font-size:12px;color:var(--muted)">{{truncate .Until 16}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<div class="empty-text">No active decisions</div>
|
||||
<div class="empty-sub">CrowdSec has not issued any bans</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">Recent Alerts</span>
|
||||
<a href="/alerts" class="panel-link">View all</a>
|
||||
</div>
|
||||
{{if .RecentAlerts}}
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Scenario</th>
|
||||
<th>Source</th>
|
||||
<th>Events</th>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .RecentAlerts}}
|
||||
<tr>
|
||||
<td style="font-size:12px">{{truncate .Scenario 32}}</td>
|
||||
<td style="font-family:'JetBrains Mono',monospace;font-size:12px">{{.Source.Value}}</td>
|
||||
<td style="font-family:'JetBrains Mono',monospace">{{.EventsCount}}</td>
|
||||
<td style="font-size:12px;color:var(--muted)">{{truncate .StartAt 16}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<div class="empty-text">No recent alerts</div>
|
||||
<div class="empty-sub">No threat activity detected</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script src="/static/js/dashboard.js"></script>
|
||||
<script>
|
||||
window._pollInterval = {{.PollInterval}};
|
||||
window._cliAvailable = {{.CLIAvailable}};
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,176 @@
|
||||
{{template "base" .}}
|
||||
{{define "content"}}
|
||||
<div style="max-width:1400px">
|
||||
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
|
||||
<div>
|
||||
<div class="page-title">Decisions</div>
|
||||
<div class="page-sub">Active bans, captchas, and manual decisions</div>
|
||||
</div>
|
||||
<button class="btn-primary" onclick="toggleAddForm()">Add Decision</button>
|
||||
</div>
|
||||
|
||||
<div id="add-form" class="panel" style="display:none;margin-bottom:16px">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">New Decision</span>
|
||||
<button class="btn-ghost-sm" onclick="toggleAddForm()">Cancel</button>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<form method="POST" action="/decisions/add">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:16px;margin-bottom:16px">
|
||||
<div>
|
||||
<label class="field-label">Value</label>
|
||||
<input class="field-input" type="text" name="value" placeholder="1.2.3.4 or 1.2.3.0/24 or US" required autofocus>
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label">Scope</label>
|
||||
<select class="field-input" name="scope">
|
||||
<option value="Ip">IP</option>
|
||||
<option value="Range">Range / CIDR</option>
|
||||
<option value="Country">Country (2-letter)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label">Type</label>
|
||||
<select class="field-input" name="type">
|
||||
<option value="ban">ban</option>
|
||||
<option value="captcha">captcha</option>
|
||||
<option value="throttle">throttle</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label">Duration</label>
|
||||
<input class="field-input" type="text" name="duration" placeholder="24h" value="24h" required>
|
||||
<div class="field-hint">Units: s m h d w</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label">Scenario (optional)</label>
|
||||
<input class="field-input" type="text" name="scenario" placeholder="manual">
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:flex-end">
|
||||
<button type="submit" class="btn-primary">Add Decision</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel" style="margin-bottom:16px">
|
||||
<div class="panel-body" style="padding:12px 18px">
|
||||
<form method="GET" action="/decisions" class="filter-bar">
|
||||
<select class="filter-select" name="type">
|
||||
<option value="">All types</option>
|
||||
<option value="ban" {{if eq .Filter.Type "ban"}}selected{{end}}>ban</option>
|
||||
<option value="captcha" {{if eq .Filter.Type "captcha"}}selected{{end}}>captcha</option>
|
||||
<option value="throttle" {{if eq .Filter.Type "throttle"}}selected{{end}}>throttle</option>
|
||||
</select>
|
||||
<select class="filter-select" name="scope">
|
||||
<option value="">All scopes</option>
|
||||
<option value="Ip" {{if eq .Filter.Scope "Ip"}}selected{{end}}>IP</option>
|
||||
<option value="Range" {{if eq .Filter.Scope "Range"}}selected{{end}}>Range</option>
|
||||
<option value="Country" {{if eq .Filter.Scope "Country"}}selected{{end}}>Country</option>
|
||||
</select>
|
||||
<input class="filter-input" type="text" name="value" placeholder="Search value..." value="{{.Filter.Value}}">
|
||||
<button type="submit" class="btn-secondary">Filter</button>
|
||||
{{if or .Filter.Type .Filter.Scope .Filter.Value}}<a href="/decisions" class="btn-ghost">Clear</a>{{end}}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<form method="POST" action="/decisions/delete" id="bulk-form">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">
|
||||
Page {{.Page}}{{if .HasNext}} (more available){{end}}
|
||||
</span>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<button type="button" class="btn-ghost-sm" onclick="toggleAll()">Select all</button>
|
||||
<button type="submit" class="btn-danger-sm" onclick="return confirmBulkDelete()">Delete selected</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Decisions}}
|
||||
<div style="overflow-x:auto">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:36px"><input type="checkbox" id="chk-all" onchange="selectAll(this)"></th>
|
||||
<th>Value</th>
|
||||
<th>Type</th>
|
||||
<th>Scope</th>
|
||||
<th>Origin</th>
|
||||
<th>Scenario</th>
|
||||
<th>Duration</th>
|
||||
<th>Expires</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Decisions}}
|
||||
<tr>
|
||||
<td><input type="checkbox" name="id" value="{{.ID}}" class="row-chk"></td>
|
||||
<td style="font-family:'JetBrains Mono',monospace;font-size:12px">{{.Value}}</td>
|
||||
<td><span class="badge {{decisionBadgeClass .Type}}">{{.Type}}</span></td>
|
||||
<td><span class="badge badge-gray">{{.Scope}}</span></td>
|
||||
<td><span class="badge {{originBadgeClass .Origin}}">{{.Origin}}</span></td>
|
||||
<td style="font-size:12px;color:var(--muted)">{{truncate .Scenario 24}}</td>
|
||||
<td style="font-family:'JetBrains Mono',monospace;font-size:12px">{{.Duration}}</td>
|
||||
<td style="font-size:12px;color:var(--muted)">{{truncate .Until 16}}</td>
|
||||
<td>
|
||||
<form method="POST" action="/decisions/delete" style="display:inline">
|
||||
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<button type="submit" class="btn-danger-sm" onclick="return confirm('Delete this decision?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<div class="empty-text">No decisions found</div>
|
||||
<div class="empty-sub">No active decisions match the current filters</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</form>
|
||||
|
||||
{{if or (gt .Page 1) .HasNext}}
|
||||
<div class="pagination">
|
||||
{{if gt .Page 1}}
|
||||
<a href="/decisions?page={{dec .Page}}&type={{.Filter.Type}}&scope={{.Filter.Scope}}&value={{.Filter.Value}}" class="btn-ghost-sm">Prev</a>
|
||||
{{end}}
|
||||
<span style="font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--muted)">Page {{.Page}}</span>
|
||||
{{if .HasNext}}
|
||||
<a href="/decisions?page={{inc .Page}}&type={{.Filter.Type}}&scope={{.Filter.Scope}}&value={{.Filter.Value}}" class="btn-ghost-sm">Next</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script>
|
||||
function toggleAddForm() {
|
||||
var f = document.getElementById('add-form');
|
||||
f.style.display = f.style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
function selectAll(cb) {
|
||||
document.querySelectorAll('.row-chk').forEach(function(c) { c.checked = cb.checked; });
|
||||
}
|
||||
function toggleAll() {
|
||||
var cb = document.getElementById('chk-all');
|
||||
cb.checked = !cb.checked;
|
||||
selectAll(cb);
|
||||
}
|
||||
function confirmBulkDelete() {
|
||||
var n = document.querySelectorAll('.row-chk:checked').length;
|
||||
if (n === 0) { alert('Select at least one decision.'); return false; }
|
||||
return confirm('Delete ' + n + ' decision(s)?');
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,9 @@
|
||||
{{template "base" .}}
|
||||
{{define "content"}}
|
||||
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:60vh;text-align:center">
|
||||
<div style="font-family:'JetBrains Mono',monospace;font-size:64px;font-weight:600;color:var(--threat);line-height:1;margin-bottom:16px">{{.Code}}</div>
|
||||
<div class="page-title" style="margin-bottom:8px">{{.Message}}</div>
|
||||
<div class="page-sub" style="margin-bottom:24px">Something went wrong. Try going back or returning to the dashboard.</div>
|
||||
<a href="/" class="btn-primary">Dashboard</a>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,103 @@
|
||||
{{template "base" .}}
|
||||
{{define "content"}}
|
||||
<div style="max-width:1400px">
|
||||
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
|
||||
<div>
|
||||
<div class="page-title">Hub</div>
|
||||
<div class="page-sub">Collections, parsers, scenarios, and postoverflows</div>
|
||||
</div>
|
||||
{{if .CLIAvailable}}
|
||||
<form method="POST" action="/hub/update">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<button type="submit" class="btn-secondary"
|
||||
onclick="return confirm('Run hub update + upgrade? This may take a minute.')">Update All</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if not .CLIAvailable}}
|
||||
<div class="cli-unavail-banner" style="margin-bottom:16px">
|
||||
<div>
|
||||
<strong style="display:block;margin-bottom:4px">cscli unavailable</strong>
|
||||
Hub management requires the cscli binary. Mount it at the CSCLI_PATH configured in your environment.
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="panel">
|
||||
<div class="tab-bar">
|
||||
<a href="/hub?tab=collections" class="tab-item{{if eq .Tab "collections"}} tab-item--active{{end}}">Collections</a>
|
||||
<a href="/hub?tab=parsers" class="tab-item{{if eq .Tab "parsers"}} tab-item--active{{end}}">Parsers</a>
|
||||
<a href="/hub?tab=scenarios" class="tab-item{{if eq .Tab "scenarios"}} tab-item--active{{end}}">Scenarios</a>
|
||||
<a href="/hub?tab=postoverflows" class="tab-item{{if eq .Tab "postoverflows"}} tab-item--active{{end}}">Postoverflows</a>
|
||||
</div>
|
||||
|
||||
{{if .CLIAvailable}}
|
||||
{{if .Items}}
|
||||
<div style="overflow-x:auto">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Status</th>
|
||||
<th>Version</th>
|
||||
<th>State</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{$tab := .Tab}}
|
||||
{{range .Items}}
|
||||
<tr>
|
||||
<td>
|
||||
<div style="font-family:'JetBrains Mono',monospace;font-size:12px;font-weight:600">{{.Name}}</div>
|
||||
{{if .Description}}<div style="font-size:11px;color:var(--muted);margin-top:2px">{{truncate .Description 64}}</div>{{end}}
|
||||
</td>
|
||||
<td><span class="badge {{hubStatusClass .Status}}">{{.Status}}</span></td>
|
||||
<td style="font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--muted)">{{.Version}}</td>
|
||||
<td>
|
||||
{{if .Tainted}}
|
||||
<span class="badge badge-amber">tainted</span>
|
||||
{{else if .UpToDate}}
|
||||
<span class="badge badge-green">up to date</span>
|
||||
{{else if .Installed}}
|
||||
<span class="badge badge-amber">update avail</span>
|
||||
{{else}}
|
||||
<span class="badge badge-gray">not installed</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if .Installed}}
|
||||
<form method="POST" action="/hub/remove" style="display:inline">
|
||||
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="kind" value="{{$tab}}">
|
||||
<input type="hidden" name="tab" value="{{$tab}}">
|
||||
<input type="hidden" name="name" value="{{.Name}}">
|
||||
<button type="submit" class="btn-danger-sm" onclick="return confirm('Remove {{.Name}}?')">Remove</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<form method="POST" action="/hub/install" style="display:inline">
|
||||
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="kind" value="{{$tab}}">
|
||||
<input type="hidden" name="tab" value="{{$tab}}">
|
||||
<input type="hidden" name="name" value="{{.Name}}">
|
||||
<button type="submit" class="btn-safe-sm">Install</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<div class="empty-text">No {{.Tab}} found</div>
|
||||
<div class="empty-sub">Run "Update All" to refresh the hub index</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,42 @@
|
||||
{{template "base" .}}
|
||||
{{define "content"}}
|
||||
<div style="display:flex;align-items:center;justify-content:center;min-height:100vh;padding:24px">
|
||||
<div style="width:100%;max-width:380px">
|
||||
|
||||
<div style="text-align:center;margin-bottom:28px">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#00d4ff" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" style="display:block;margin:0 auto 12px"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||
<div style="font-size:22px;font-weight:600;color:#e2e8f0">CrowdSec Dashy</div>
|
||||
<div style="font-size:13px;color:#64748b;margin-top:4px">Sign in to continue</div>
|
||||
</div>
|
||||
|
||||
<div style="background:#0d1117;border:1px solid rgba(255,255,255,0.07);border-radius:10px;padding:28px">
|
||||
|
||||
{{if .Error}}
|
||||
<div style="background:rgba(255,59,59,0.08);color:#ff7b7b;border-left:3px solid #ff3b3b;padding:10px 14px;border-radius:4px;font-size:13px;margin-bottom:20px">
|
||||
{{.Error}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="/login" autocomplete="off" novalidate>
|
||||
<div style="margin-bottom:16px">
|
||||
<label style="display:block;font-size:12px;font-weight:500;color:#8b949e;text-transform:uppercase;letter-spacing:0.06em;margin-bottom:6px" for="username">Username</label>
|
||||
<input style="display:block;width:100%;background:#111823;border:1px solid rgba(255,255,255,0.1);border-radius:6px;padding:9px 12px;font-size:14px;color:#e2e8f0;outline:none;box-sizing:border-box"
|
||||
type="text" id="username" name="username"
|
||||
required autofocus maxlength="128" autocomplete="username" spellcheck="false">
|
||||
</div>
|
||||
<div style="margin-bottom:22px">
|
||||
<label style="display:block;font-size:12px;font-weight:500;color:#8b949e;text-transform:uppercase;letter-spacing:0.06em;margin-bottom:6px" for="password">Password</label>
|
||||
<input style="display:block;width:100%;background:#111823;border:1px solid rgba(255,255,255,0.1);border-radius:6px;padding:9px 12px;font-size:14px;color:#e2e8f0;outline:none;box-sizing:border-box"
|
||||
type="password" id="password" name="password"
|
||||
required maxlength="128" autocomplete="current-password">
|
||||
</div>
|
||||
<button type="submit"
|
||||
style="display:block;width:100%;background:#00d4ff;color:#080b10;font-size:14px;font-weight:600;border:none;border-radius:6px;padding:10px 0;cursor:pointer;letter-spacing:0.3px">
|
||||
Sign in
|
||||
</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,81 @@
|
||||
{{template "base" .}}
|
||||
{{define "content"}}
|
||||
<div style="max-width:1200px">
|
||||
|
||||
<div style="margin-bottom:16px">
|
||||
<div class="page-title">Machines</div>
|
||||
<div class="page-sub">CrowdSec agent registrations</div>
|
||||
</div>
|
||||
|
||||
{{if not .CLIAvailable}}
|
||||
<div class="cli-unavail-banner" style="margin-bottom:16px">
|
||||
<div>
|
||||
<strong style="display:block;margin-bottom:4px">cscli unavailable</strong>
|
||||
Machine management requires the cscli binary. Mount it at the CSCLI_PATH configured in your environment.
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">Machines ({{len .Machines}})</span>
|
||||
</div>
|
||||
{{if .Machines}}
|
||||
<div style="overflow-x:auto">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Machine ID</th>
|
||||
<th>IP</th>
|
||||
<th>Last Heartbeat</th>
|
||||
<th>Version</th>
|
||||
<th>Auth</th>
|
||||
<th>Status</th>
|
||||
{{if $.CLIAvailable}}<th>Actions</th>{{end}}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Machines}}
|
||||
<tr>
|
||||
<td style="font-family:'JetBrains Mono',monospace;font-size:12px;font-weight:600" title="{{.MachineID}}">{{truncate .MachineID 32}}</td>
|
||||
<td style="font-family:'JetBrains Mono',monospace;font-size:12px">{{.IPAddress}}</td>
|
||||
<td style="font-size:12px;color:var(--muted)">{{truncate .LastHeartbeat 16}}</td>
|
||||
<td style="font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--muted)">{{.Version}}</td>
|
||||
<td style="font-size:12px;color:var(--muted)">{{.AuthType}}</td>
|
||||
<td>
|
||||
{{if .IsValidated}}
|
||||
<span class="badge badge-green">validated</span>
|
||||
{{else}}
|
||||
<span class="badge badge-amber">pending</span>
|
||||
{{end}}
|
||||
</td>
|
||||
{{if $.CLIAvailable}}
|
||||
<td style="display:flex;gap:6px;flex-wrap:wrap">
|
||||
{{if not .IsValidated}}
|
||||
<form method="POST" action="/machines/validate" style="display:inline">
|
||||
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="id" value="{{.MachineID}}">
|
||||
<button type="submit" class="btn-safe-sm" onclick="return confirm('Validate machine "{{.MachineID}}"?')">Validate</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<form method="POST" action="/machines/delete" style="display:inline">
|
||||
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="id" value="{{.MachineID}}">
|
||||
<button type="submit" class="btn-danger-sm" onclick="return confirm('Delete machine "{{.MachineID}}"?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
{{end}}
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<div class="empty-text">No machines registered</div>
|
||||
<div class="empty-sub">Register an agent: cscli machines add <name> -a</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,62 @@
|
||||
{{template "base" .}}
|
||||
{{define "content"}}
|
||||
<div style="max-width:1200px">
|
||||
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
|
||||
<div>
|
||||
<div class="page-title">Metrics</div>
|
||||
<div class="page-sub">Real-time CrowdSec statistics from cscli metrics</div>
|
||||
</div>
|
||||
{{if .CLIAvailable}}
|
||||
<a href="/metrics-ui" class="btn-secondary">Refresh</a>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if not .CLIAvailable}}
|
||||
<div class="cli-unavail-banner" style="margin-bottom:16px">
|
||||
<div>
|
||||
<strong style="display:block;margin-bottom:4px">cscli unavailable</strong>
|
||||
Metrics require the cscli binary. Mount it at the CSCLI_PATH configured in your environment.
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Sections}}
|
||||
{{range .Sections}}
|
||||
<div class="panel" style="margin-bottom:16px">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">{{.Title}}</span>
|
||||
</div>
|
||||
{{if and .Headers .Rows}}
|
||||
<div style="overflow-x:auto">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{{range .Headers}}<th>{{.}}</th>{{end}}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Rows}}
|
||||
<tr>
|
||||
{{range .}}<td style="font-family:'JetBrains Mono',monospace;font-size:12px">{{.}}</td>{{end}}
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<div class="empty-text">No data in this section</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{else if .CLIAvailable}}
|
||||
<div class="empty-state" style="padding:48px">
|
||||
<div class="empty-text">No metrics available</div>
|
||||
<div class="empty-sub">CrowdSec may not have processed any data yet</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
</div>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user