geoip and config editor online
This commit is contained in:
+8
-1
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"crowdsec-dashy/internal/config"
|
||||
"crowdsec-dashy/internal/crowdsec"
|
||||
"crowdsec-dashy/internal/geoip"
|
||||
"crowdsec-dashy/internal/router"
|
||||
webfiles "crowdsec-dashy/web"
|
||||
)
|
||||
@@ -69,10 +70,16 @@ func main() {
|
||||
log.Printf("[WARN] cscli not found at %s — bouncer/machine/hub/metrics features disabled", cfg.CscliPath)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// GeoIP auto-updater
|
||||
// ----------------------------------------------------------------
|
||||
geoUpdater := geoip.New(cfg.IPInfoToken, cfg.IPInfoDBFile, cfg.IPInfoDBPath, cfg.IPInfoRefreshDays)
|
||||
go geoUpdater.Start(context.Background())
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Build router
|
||||
// ----------------------------------------------------------------
|
||||
handler, err := router.New(cfg, lapi, webfiles.FS)
|
||||
handler, err := router.New(cfg, lapi, webfiles.FS, geoUpdater)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to initialise router: %v", err)
|
||||
}
|
||||
|
||||
@@ -31,10 +31,16 @@ type Config struct {
|
||||
CrowdSecAPILogin string
|
||||
CrowdSecAPIPassword string // sent as-is to LAPI — never hashed
|
||||
CscliPath string
|
||||
CrowdsecBinPath string // crowdsec daemon binary (for -t config test)
|
||||
CrowdsecConfigDir string // /etc/crowdsec or equivalent
|
||||
UIUsername string
|
||||
UIPassword string // bcrypt hash after first run
|
||||
UISessionSecret string
|
||||
PollIntervalSec int
|
||||
IPInfoToken string // ipinfo.io API token for GeoIP DB download
|
||||
IPInfoDBFile string // e.g. "asn.mmdb" or "country.mmdb"
|
||||
IPInfoDBPath string // absolute path where the MMDB is saved
|
||||
IPInfoRefreshDays int // auto-refresh interval in days
|
||||
}
|
||||
|
||||
// FirstRunError is returned when the config file was just created and needs editing.
|
||||
@@ -96,7 +102,13 @@ func Load() (*Config, error) {
|
||||
CrowdSecAPILogin: vals["crowdsec_api_login"],
|
||||
CrowdSecAPIPassword: vals["crowdsec_api_password"],
|
||||
CscliPath: strVal(vals, "cscli_path", "/usr/local/bin/cscli"),
|
||||
CrowdsecBinPath: strVal(vals, "crowdsec_path", "/usr/sbin/crowdsec"),
|
||||
CrowdsecConfigDir: strVal(vals, "crowdsec_config_dir", "/etc/crowdsec"),
|
||||
UIUsername: strVal(vals, "ui_username", "admin"),
|
||||
IPInfoToken: vals["ipinfo_token"],
|
||||
IPInfoDBFile: strVal(vals, "ipinfo_db_file", "asn.mmdb"),
|
||||
IPInfoDBPath: strVal(vals, "ipinfo_db_path", "/var/lib/crowdsec/data/GeoLite2-ASN.mmdb"),
|
||||
IPInfoRefreshDays: intVal(vals, "ipinfo_refresh_days", 7),
|
||||
UIPassword: strVal(vals, "ui_password", "changeme"),
|
||||
UISessionSecret: vals["ui_session_secret"],
|
||||
PollIntervalSec: intVal(vals, "poll_interval_sec", 15),
|
||||
@@ -197,6 +209,13 @@ crowdsec_api_password =
|
||||
# Leave empty or point to a missing path to disable CLI features gracefully.
|
||||
cscli_path = /usr/local/bin/cscli
|
||||
|
||||
# CrowdSec daemon binary — used to test configs before applying (crowdsec -t)
|
||||
# Leave empty to disable config validation (saves will apply without testing).
|
||||
crowdsec_path = /usr/sbin/crowdsec
|
||||
|
||||
# CrowdSec config directory — files editable via the Config Editor page
|
||||
crowdsec_config_dir = /etc/crowdsec
|
||||
|
||||
# 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"
|
||||
@@ -208,6 +227,14 @@ ui_session_secret = %s
|
||||
|
||||
# Dashboard live-poll interval (seconds)
|
||||
poll_interval_sec = 15
|
||||
|
||||
# IPInfo.io GeoIP database auto-refresh
|
||||
# Get a free token at https://ipinfo.io/signup
|
||||
# Available free DB files: asn.mmdb, country.mmdb, country_asn.mmdb
|
||||
ipinfo_token =
|
||||
ipinfo_db_file = asn.mmdb
|
||||
ipinfo_db_path = /var/lib/crowdsec/data/GeoLite2-ASN.mmdb
|
||||
ipinfo_refresh_days = 7
|
||||
`, secret)
|
||||
|
||||
return os.WriteFile(path, []byte(content), 0600)
|
||||
|
||||
@@ -308,6 +308,74 @@ func (c *CLIClient) GetVersion(ctx context.Context) (string, error) {
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Allowlists
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// ListAllowlists returns all configured allowlists.
|
||||
func (c *CLIClient) ListAllowlists(ctx context.Context) ([]Allowlist, error) {
|
||||
out, err := c.run(ctx, "allowlists", "list", "-o", "json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(out) == 0 || string(out) == "null\n" || string(out) == "null" {
|
||||
return []Allowlist{}, nil
|
||||
}
|
||||
|
||||
var lists []Allowlist
|
||||
if err := json.Unmarshal(out, &lists); err != nil {
|
||||
// try wrapped: {"allowlists": [...]}
|
||||
var wrapper struct {
|
||||
Allowlists []Allowlist `json:"allowlists"`
|
||||
}
|
||||
if err2 := json.Unmarshal(out, &wrapper); err2 != nil {
|
||||
return nil, fmt.Errorf("parse allowlists: %w\noutput: %s", err, string(out))
|
||||
}
|
||||
return wrapper.Allowlists, nil
|
||||
}
|
||||
return lists, nil
|
||||
}
|
||||
|
||||
// InspectAllowlist returns details and items for a named allowlist.
|
||||
func (c *CLIClient) InspectAllowlist(ctx context.Context, name string) (*Allowlist, error) {
|
||||
if !safeArg.MatchString(name) {
|
||||
return nil, fmt.Errorf("invalid allowlist name: %q", name)
|
||||
}
|
||||
out, err := c.run(ctx, "allowlists", "inspect", name, "-o", "json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var al Allowlist
|
||||
if err := json.Unmarshal(out, &al); err != nil {
|
||||
return nil, fmt.Errorf("parse allowlist inspect: %w\noutput: %s", err, string(out))
|
||||
}
|
||||
return &al, nil
|
||||
}
|
||||
|
||||
// AddAllowlistEntry adds a value to a named allowlist.
|
||||
func (c *CLIClient) AddAllowlistEntry(ctx context.Context, listName, value string) error {
|
||||
if !safeArg.MatchString(listName) {
|
||||
return fmt.Errorf("invalid allowlist name: %q", listName)
|
||||
}
|
||||
if !safeArg.MatchString(value) {
|
||||
return fmt.Errorf("invalid allowlist value: %q", value)
|
||||
}
|
||||
_, err := c.run(ctx, "allowlists", "items", "add", listName, value)
|
||||
return err
|
||||
}
|
||||
|
||||
// RemoveAllowlistEntry removes a value from a named allowlist.
|
||||
func (c *CLIClient) RemoveAllowlistEntry(ctx context.Context, listName, value string) error {
|
||||
if !safeArg.MatchString(listName) {
|
||||
return fmt.Errorf("invalid allowlist name: %q", listName)
|
||||
}
|
||||
if !safeArg.MatchString(value) {
|
||||
return fmt.Errorf("invalid allowlist value: %q", value)
|
||||
}
|
||||
_, err := c.run(ctx, "allowlists", "items", "del", listName, value)
|
||||
return err
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -347,6 +415,7 @@ var allowedSubcommands = map[string]bool{
|
||||
"metrics": true,
|
||||
"version": true,
|
||||
"decisions": true,
|
||||
"allowlists": true,
|
||||
}
|
||||
|
||||
// allowedActions for each subcommand.
|
||||
@@ -359,6 +428,10 @@ var allowedActions = map[string]bool{
|
||||
"update": true,
|
||||
"upgrade": true,
|
||||
"validate": true,
|
||||
"create": true,
|
||||
"inspect": true,
|
||||
"items": true,
|
||||
"del": true,
|
||||
}
|
||||
|
||||
// safeArg matches strings that are safe to pass as arguments (no shell metacharacters).
|
||||
|
||||
@@ -135,3 +135,19 @@ type MetricsSection struct {
|
||||
Headers []string
|
||||
Rows [][]string
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Allowlist types (cscli allowlists)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
type AllowlistItem struct {
|
||||
Comment string `json:"comment"`
|
||||
Expiry string `json:"expiry"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type Allowlist struct {
|
||||
Description string `json:"description"`
|
||||
Items []AllowlistItem `json:"items"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
// Package geoip manages automatic download and refresh of the ipinfo.io MMDB file.
|
||||
package geoip
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// baseURL is the ipinfo.io free database endpoint — not user-configurable to prevent SSRF.
|
||||
const baseURL = "https://ipinfo.io/data/free/"
|
||||
|
||||
// Updater downloads and periodically refreshes an ipinfo.io MMDB file.
|
||||
type Updater struct {
|
||||
token string
|
||||
dbFile string // e.g. "asn.mmdb"
|
||||
dbPath string // absolute local destination
|
||||
refreshDays int
|
||||
|
||||
mu sync.RWMutex
|
||||
lastUpdated time.Time
|
||||
lastErr error
|
||||
updating bool
|
||||
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// Status is a point-in-time snapshot returned by Status().
|
||||
type Status struct {
|
||||
DBPath string
|
||||
DBFile string
|
||||
LastUpdated time.Time
|
||||
NextRefresh time.Time
|
||||
LastErrMsg string
|
||||
Updating bool
|
||||
DBExists bool
|
||||
DBSizeBytes int64
|
||||
DBSizeHuman string
|
||||
TokenSet bool
|
||||
RefreshDays int
|
||||
}
|
||||
|
||||
// New creates an Updater. Call Start in a goroutine to enable auto-refresh.
|
||||
func New(token, dbFile, dbPath string, refreshDays int) *Updater {
|
||||
return &Updater{
|
||||
token: token,
|
||||
dbFile: dbFile,
|
||||
dbPath: dbPath,
|
||||
refreshDays: refreshDays,
|
||||
http: &http.Client{
|
||||
Timeout: 10 * time.Minute,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Status returns current state of the updater and DB file.
|
||||
func (u *Updater) Status() Status {
|
||||
u.mu.RLock()
|
||||
defer u.mu.RUnlock()
|
||||
|
||||
s := Status{
|
||||
DBPath: u.dbPath,
|
||||
DBFile: u.dbFile,
|
||||
LastUpdated: u.lastUpdated,
|
||||
Updating: u.updating,
|
||||
TokenSet: u.token != "",
|
||||
RefreshDays: u.refreshDays,
|
||||
}
|
||||
if u.lastErr != nil {
|
||||
s.LastErrMsg = u.lastErr.Error()
|
||||
}
|
||||
|
||||
info, err := os.Stat(u.dbPath)
|
||||
if err == nil {
|
||||
s.DBExists = true
|
||||
s.DBSizeBytes = info.Size()
|
||||
s.DBSizeHuman = formatBytes(info.Size())
|
||||
if u.lastUpdated.IsZero() {
|
||||
s.LastUpdated = info.ModTime()
|
||||
}
|
||||
}
|
||||
|
||||
if !s.LastUpdated.IsZero() {
|
||||
s.NextRefresh = s.LastUpdated.Add(time.Duration(u.refreshDays) * 24 * time.Hour)
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// Refresh downloads the DB file atomically. Safe to call concurrently — second
|
||||
// caller gets "already in progress" error immediately.
|
||||
func (u *Updater) Refresh(ctx context.Context) error {
|
||||
u.mu.Lock()
|
||||
if u.updating {
|
||||
u.mu.Unlock()
|
||||
return fmt.Errorf("update already in progress")
|
||||
}
|
||||
if u.token == "" {
|
||||
u.mu.Unlock()
|
||||
return fmt.Errorf("ipinfo_token not configured in app_config.conf")
|
||||
}
|
||||
u.updating = true
|
||||
u.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
u.mu.Lock()
|
||||
u.updating = false
|
||||
u.mu.Unlock()
|
||||
}()
|
||||
|
||||
err := u.download(ctx)
|
||||
|
||||
u.mu.Lock()
|
||||
if err != nil {
|
||||
u.lastErr = err
|
||||
} else {
|
||||
u.lastUpdated = time.Now()
|
||||
u.lastErr = nil
|
||||
}
|
||||
u.mu.Unlock()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (u *Updater) download(ctx context.Context) error {
|
||||
dlURL := baseURL + u.dbFile + "?token=" + u.token
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, dlURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "crowdsec-dashy/1.0")
|
||||
|
||||
resp, err := u.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("download: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Temp file in same directory → atomic rename (same filesystem).
|
||||
dir := filepath.Dir(u.dbPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("create destination dir: %w", err)
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp(dir, ".ipinfo-*.mmdb.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
|
||||
_, copyErr := io.Copy(tmp, resp.Body)
|
||||
tmp.Close()
|
||||
if copyErr != nil {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("write temp file: %w", copyErr)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmpPath, u.dbPath); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("rename to %s: %w", u.dbPath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start runs the background refresh scheduler until ctx is cancelled.
|
||||
// Call as a goroutine: go updater.Start(ctx).
|
||||
func (u *Updater) Start(ctx context.Context) {
|
||||
if u.token == "" {
|
||||
log.Println("[geoip] ipinfo_token not set — auto-refresh disabled")
|
||||
return
|
||||
}
|
||||
|
||||
s := u.Status()
|
||||
needsRefresh := !s.DBExists ||
|
||||
(!s.LastUpdated.IsZero() && time.Now().After(s.NextRefresh))
|
||||
|
||||
if needsRefresh {
|
||||
log.Println("[geoip] DB missing or stale — refreshing now")
|
||||
if err := u.Refresh(ctx); err != nil {
|
||||
log.Printf("[geoip] initial refresh failed: %v", err)
|
||||
} else {
|
||||
log.Printf("[geoip] DB saved to %s", u.dbPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Check twice daily whether a scheduled refresh is due.
|
||||
ticker := time.NewTicker(12 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
st := u.Status()
|
||||
if !st.TokenSet || time.Now().Before(st.NextRefresh) {
|
||||
continue
|
||||
}
|
||||
log.Println("[geoip] scheduled refresh starting")
|
||||
if err := u.Refresh(ctx); err != nil {
|
||||
log.Printf("[geoip] scheduled refresh failed: %v", err)
|
||||
} else {
|
||||
log.Printf("[geoip] scheduled refresh complete")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func formatBytes(n int64) string {
|
||||
const unit = 1024
|
||||
if n < unit {
|
||||
return fmt.Sprintf("%d B", n)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for x := n / unit; x >= unit; x /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"crowdsec-dashy/internal/crowdsec"
|
||||
)
|
||||
|
||||
// AllowlistHandler manages the allowlist page.
|
||||
type AllowlistHandler struct {
|
||||
deps Deps
|
||||
}
|
||||
|
||||
func NewAllowlistHandler(deps Deps) *AllowlistHandler {
|
||||
return &AllowlistHandler{deps: deps}
|
||||
}
|
||||
|
||||
type AllowlistData struct {
|
||||
PageData
|
||||
Lists []crowdsec.Allowlist
|
||||
FetchErr string
|
||||
}
|
||||
|
||||
func (h *AllowlistHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
pd := NewPageData(r, "Allowlist", h.deps.CLIAvailable, h.deps.PollInterval)
|
||||
if f := readFlash(r); f.Message != "" {
|
||||
pd.Flash = f
|
||||
}
|
||||
|
||||
if !h.deps.CLIAvailable {
|
||||
h.deps.Renderer.Render(w, "allowlist", AllowlistData{
|
||||
PageData: pd,
|
||||
FetchErr: "cscli is not available — allowlist management requires the cscli binary.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
lists, err := h.deps.CLI.ListAllowlists(ctx)
|
||||
fetchErr := ""
|
||||
if err != nil {
|
||||
fetchErr = err.Error()
|
||||
}
|
||||
|
||||
h.deps.Renderer.Render(w, "allowlist", AllowlistData{
|
||||
PageData: pd,
|
||||
Lists: lists,
|
||||
FetchErr: fetchErr,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AllowlistHandler) AddEntry(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 4096)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
listName := strings.TrimSpace(r.FormValue("list"))
|
||||
value := strings.TrimSpace(r.FormValue("value"))
|
||||
|
||||
if listName == "" || value == "" {
|
||||
flashRedirect(w, r, "/allowlist", "error", "list name and value are required")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := h.deps.CLI.AddAllowlistEntry(ctx, listName, value); err != nil {
|
||||
flashRedirect(w, r, "/allowlist", "error", "add failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
flashRedirect(w, r, "/allowlist", "success", value+" added to "+listName)
|
||||
}
|
||||
|
||||
func (h *AllowlistHandler) RemoveEntry(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 4096)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
listName := strings.TrimSpace(r.FormValue("list"))
|
||||
value := strings.TrimSpace(r.FormValue("value"))
|
||||
|
||||
if listName == "" || value == "" {
|
||||
flashRedirect(w, r, "/allowlist", "error", "list name and value are required")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := h.deps.CLI.RemoveAllowlistEntry(ctx, listName, value); err != nil {
|
||||
flashRedirect(w, r, "/allowlist", "error", "remove failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
flashRedirect(w, r, "/allowlist", "success", value+" removed from "+listName)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ansiEscape = regexp.MustCompile(`\x1b\[[0-9;]*[mGKHFJA-Z]`)
|
||||
|
||||
// ConfigEditorHandler allows browsing and editing CrowdSec YAML config files.
|
||||
type ConfigEditorHandler struct {
|
||||
deps Deps
|
||||
}
|
||||
|
||||
func NewConfigEditorHandler(deps Deps) *ConfigEditorHandler {
|
||||
return &ConfigEditorHandler{deps: deps}
|
||||
}
|
||||
|
||||
type ConfigEditorData struct {
|
||||
PageData
|
||||
Files []string
|
||||
File string // relative path within config dir
|
||||
Content string
|
||||
FetchErr string
|
||||
TestOut string // output from crowdsec -t after save
|
||||
}
|
||||
|
||||
func (h *ConfigEditorHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
pd := NewPageData(r, "Config Editor", h.deps.CLIAvailable, h.deps.PollInterval)
|
||||
if f := readFlash(r); f.Message != "" {
|
||||
pd.Flash = f
|
||||
}
|
||||
|
||||
if h.deps.CrowdsecConfigDir == "" {
|
||||
h.deps.Renderer.Render(w, "config-editor", ConfigEditorData{
|
||||
PageData: pd,
|
||||
FetchErr: "crowdsec_config_dir is not set in app_config.conf.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
files, err := listYAMLFiles(h.deps.CrowdsecConfigDir)
|
||||
if err != nil {
|
||||
h.deps.Renderer.Render(w, "config-editor", ConfigEditorData{
|
||||
PageData: pd,
|
||||
FetchErr: "Cannot read config directory: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
file := strings.TrimSpace(r.URL.Query().Get("file"))
|
||||
data := ConfigEditorData{PageData: pd, Files: files}
|
||||
|
||||
if file != "" {
|
||||
absPath, err := safeConfigPath(h.deps.CrowdsecConfigDir, file)
|
||||
if err != nil {
|
||||
data.FetchErr = err.Error()
|
||||
h.deps.Renderer.Render(w, "config-editor", data)
|
||||
return
|
||||
}
|
||||
raw, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
data.FetchErr = "Cannot read file: " + err.Error()
|
||||
h.deps.Renderer.Render(w, "config-editor", data)
|
||||
return
|
||||
}
|
||||
data.File = file
|
||||
data.Content = string(raw)
|
||||
}
|
||||
|
||||
h.deps.Renderer.Render(w, "config-editor", data)
|
||||
}
|
||||
|
||||
func (h *ConfigEditorHandler) Save(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB max
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
file := strings.TrimSpace(r.FormValue("file"))
|
||||
content := r.FormValue("content")
|
||||
|
||||
if file == "" {
|
||||
flashRedirect(w, r, "/config-editor", "error", "no file specified")
|
||||
return
|
||||
}
|
||||
|
||||
absPath, err := safeConfigPath(h.deps.CrowdsecConfigDir, file)
|
||||
if err != nil {
|
||||
flashRedirect(w, r, "/config-editor", "error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Read existing content for rollback.
|
||||
original, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
flashRedirect(w, r, "/config-editor?file="+url.QueryEscape(file), "error", "cannot read current file: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Write new content.
|
||||
if err := os.WriteFile(absPath, []byte(content), 0600); err != nil {
|
||||
flashRedirect(w, r, "/config-editor?file="+url.QueryEscape(file), "error", "write failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Test config if crowdsec binary is available.
|
||||
if h.deps.CrowdsecBinPath != "" {
|
||||
if _, statErr := os.Stat(h.deps.CrowdsecBinPath); statErr == nil {
|
||||
testErr := runCrowdsecTest(h.deps.CrowdsecBinPath)
|
||||
if testErr != nil {
|
||||
// Revert.
|
||||
_ = os.WriteFile(absPath, original, 0600)
|
||||
cleanErr := ansiEscape.ReplaceAllString(testErr.Error(), "")
|
||||
pd := NewPageData(r, "Config Editor", h.deps.CLIAvailable, h.deps.PollInterval)
|
||||
pd = pd.WithFlash("error", "Config test failed — file reverted")
|
||||
files, _ := listYAMLFiles(h.deps.CrowdsecConfigDir)
|
||||
h.deps.Renderer.Render(w, "config-editor", ConfigEditorData{
|
||||
PageData: pd,
|
||||
Files: files,
|
||||
File: file,
|
||||
Content: content,
|
||||
TestOut: cleanErr,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flashRedirect(w, r, "/config-editor?file="+url.QueryEscape(file), "success", file+" saved successfully")
|
||||
}
|
||||
|
||||
// safeConfigPath validates and resolves a relative path within configDir.
|
||||
// Returns error on path traversal or non-YAML extension.
|
||||
func safeConfigPath(configDir, rel string) (string, error) {
|
||||
if strings.ContainsAny(rel, "\x00") {
|
||||
return "", fmt.Errorf("invalid path")
|
||||
}
|
||||
abs := filepath.Clean(filepath.Join(configDir, rel))
|
||||
dir := filepath.Clean(configDir)
|
||||
if !strings.HasPrefix(abs, dir+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("access denied: path outside config directory")
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(abs))
|
||||
if ext != ".yaml" && ext != ".yml" {
|
||||
return "", fmt.Errorf("only .yaml and .yml files are editable")
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
|
||||
// listYAMLFiles returns relative paths of all .yaml/.yml files under dir.
|
||||
func listYAMLFiles(dir string) ([]string, error) {
|
||||
var files []string
|
||||
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil // skip unreadable entries
|
||||
}
|
||||
if d.IsDir() {
|
||||
name := d.Name()
|
||||
// Skip hidden dirs and large data dirs
|
||||
if strings.HasPrefix(name, ".") || name == "hub" || name == "patterns" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if ext == ".yaml" || ext == ".yml" {
|
||||
rel, _ := filepath.Rel(dir, path)
|
||||
files = append(files, rel)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return files, err
|
||||
}
|
||||
|
||||
// runCrowdsecTest runs crowdsec -t to validate config.
|
||||
func runCrowdsecTest(binPath string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, binPath, "-t") //nolint:gosec — path from config, not user input
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
msg := strings.TrimSpace(stderr.String())
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"crowdsec-dashy/internal/crowdsec"
|
||||
)
|
||||
|
||||
// CountriesHandler manages country-scope decisions.
|
||||
type CountriesHandler struct {
|
||||
deps Deps
|
||||
}
|
||||
|
||||
func NewCountriesHandler(deps Deps) *CountriesHandler {
|
||||
return &CountriesHandler{deps: deps}
|
||||
}
|
||||
|
||||
type CountriesData struct {
|
||||
PageData
|
||||
Decisions []crowdsec.Decision
|
||||
Page int
|
||||
HasNext bool
|
||||
PageSize int
|
||||
}
|
||||
|
||||
const countriesPageSize = 50
|
||||
|
||||
func (h *CountriesHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.deps.CLIAvailable {
|
||||
h.deps.Renderer.Render(w, "countries", CountriesData{
|
||||
PageData: NewPageData(r, "Countries", false, h.deps.PollInterval),
|
||||
PageSize: countriesPageSize,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
page := 1
|
||||
if p, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && p > 1 {
|
||||
page = p
|
||||
}
|
||||
offset := (page - 1) * countriesPageSize
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
decisions, err := h.deps.CLI.ListDecisions(ctx, crowdsec.DecisionFilter{
|
||||
Scope: "Country",
|
||||
Limit: countriesPageSize + 1,
|
||||
Offset: offset,
|
||||
})
|
||||
|
||||
pd := NewPageData(r, "Countries", h.deps.CLIAvailable, h.deps.PollInterval)
|
||||
if f := readFlash(r); f.Message != "" {
|
||||
pd.Flash = f
|
||||
}
|
||||
if err != nil {
|
||||
pd = pd.WithFlash("error", "Failed to load country decisions: "+err.Error())
|
||||
}
|
||||
|
||||
hasNext := false
|
||||
if len(decisions) > countriesPageSize {
|
||||
hasNext = true
|
||||
decisions = decisions[:countriesPageSize]
|
||||
}
|
||||
|
||||
h.deps.Renderer.Render(w, "countries", CountriesData{
|
||||
PageData: pd,
|
||||
Decisions: decisions,
|
||||
Page: page,
|
||||
HasNext: hasNext,
|
||||
PageSize: countriesPageSize,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *CountriesHandler) Add(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.deps.CLIAvailable {
|
||||
http.Redirect(w, r, "/countries", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 8192)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse comma/newline/space separated country codes.
|
||||
raw := strings.ToUpper(r.FormValue("countries"))
|
||||
var codes []string
|
||||
for _, part := range strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return r == ',' || r == '\n' || r == '\r' || r == ' '
|
||||
}) {
|
||||
code := strings.TrimSpace(part)
|
||||
if code == "" {
|
||||
continue
|
||||
}
|
||||
if len(code) != 2 {
|
||||
flashRedirect(w, r, "/countries", "error", "invalid country code: "+code)
|
||||
return
|
||||
}
|
||||
for _, c := range code {
|
||||
if c < 'A' || c > 'Z' {
|
||||
flashRedirect(w, r, "/countries", "error", "invalid country code: "+code)
|
||||
return
|
||||
}
|
||||
}
|
||||
codes = append(codes, code)
|
||||
}
|
||||
if len(codes) == 0 {
|
||||
flashRedirect(w, r, "/countries", "error", "at least one country code required")
|
||||
return
|
||||
}
|
||||
|
||||
decType := r.FormValue("type")
|
||||
switch decType {
|
||||
case "ban", "captcha", "throttle":
|
||||
default:
|
||||
decType = "ban"
|
||||
}
|
||||
|
||||
duration := strings.TrimSpace(r.FormValue("duration"))
|
||||
if r.FormValue("permanent") == "1" {
|
||||
duration = "87600h" // 10 years — cscli has no true permanent
|
||||
}
|
||||
if duration == "" {
|
||||
duration = "24h"
|
||||
}
|
||||
if !durationRE.MatchString(duration) {
|
||||
flashRedirect(w, r, "/countries", "error", "invalid duration: "+duration)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var errs []string
|
||||
for _, code := range codes {
|
||||
err := h.deps.CLI.AddDecision(ctx, crowdsec.DecisionInput{
|
||||
Scope: "Country",
|
||||
Value: code,
|
||||
Type: decType,
|
||||
Duration: duration,
|
||||
Origin: "cscli",
|
||||
})
|
||||
if err != nil {
|
||||
errs = append(errs, code+": "+err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
flashRedirect(w, r, "/countries", "error", strings.Join(errs, "; "))
|
||||
return
|
||||
}
|
||||
|
||||
flashRedirect(w, r, "/countries", "success",
|
||||
strings.Join(codes, ", ")+" added as "+decType+" ("+duration+")")
|
||||
}
|
||||
|
||||
func (h *CountriesHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.deps.CLIAvailable {
|
||||
http.Redirect(w, r, "/countries", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 4096)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
for _, idStr := range r.Form["id"] {
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_ = h.deps.CLI.DeleteDecision(ctx, id)
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/countries", http.StatusSeeOther)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"crowdsec-dashy/internal/crowdsec"
|
||||
@@ -33,7 +34,19 @@ func (h *DashboardHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if h.deps.CLIAvailable {
|
||||
decisions, _ = h.deps.CLI.ListDecisions(ctx, crowdsec.DecisionFilter{Limit: 10})
|
||||
}
|
||||
alerts, _ := h.deps.LAPI.ListAlerts(ctx, crowdsec.AlertFilter{Limit: 10})
|
||||
// Fetch extra so filtering "update :" scenarios still yields 10 real threats.
|
||||
rawAlerts, _ := h.deps.LAPI.ListAlerts(ctx, crowdsec.AlertFilter{Limit: 50})
|
||||
var alerts []crowdsec.Alert
|
||||
for _, a := range rawAlerts {
|
||||
s := a.Scenario
|
||||
if strings.HasPrefix(s, "update :") || strings.HasPrefix(s, "update:") {
|
||||
continue
|
||||
}
|
||||
alerts = append(alerts, a)
|
||||
if len(alerts) == 10 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
h.deps.Renderer.Render(w, "dashboard", DashboardData{
|
||||
PageData: NewPageData(r, "Dashboard", h.deps.CLIAvailable, h.deps.PollInterval),
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"crowdsec-dashy/internal/geoip"
|
||||
)
|
||||
|
||||
// GeoIPHandler serves the GeoIP database status and manual refresh page.
|
||||
type GeoIPHandler struct {
|
||||
deps Deps
|
||||
updater *geoip.Updater
|
||||
}
|
||||
|
||||
func NewGeoIPHandler(deps Deps, updater *geoip.Updater) *GeoIPHandler {
|
||||
return &GeoIPHandler{deps: deps, updater: updater}
|
||||
}
|
||||
|
||||
type GeoIPData struct {
|
||||
PageData
|
||||
Status geoip.Status
|
||||
}
|
||||
|
||||
func (h *GeoIPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
pd := NewPageData(r, "GeoIP Database", h.deps.CLIAvailable, h.deps.PollInterval)
|
||||
if f := readFlash(r); f.Message != "" {
|
||||
pd.Flash = f
|
||||
}
|
||||
h.deps.Renderer.Render(w, "geoip", GeoIPData{
|
||||
PageData: pd,
|
||||
Status: h.updater.Status(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *GeoIPHandler) Refresh(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 256)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Kick off in background — download can take 10-30s.
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
if err := h.updater.Refresh(ctx); err != nil {
|
||||
log.Printf("[geoip] manual refresh failed: %v", err)
|
||||
} else {
|
||||
log.Printf("[geoip] manual refresh complete")
|
||||
}
|
||||
}()
|
||||
|
||||
flashRedirect(w, r, "/geoip", "success", "Download started — refresh this page in a moment")
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"crowdsec-dashy/internal/middleware"
|
||||
)
|
||||
@@ -16,11 +17,16 @@ func matchName(name string) (bool, error) {
|
||||
}
|
||||
|
||||
// flashRedirect redirects with flash type and message as query params.
|
||||
// Handles to URLs that already contain a query string.
|
||||
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)
|
||||
sep := "?"
|
||||
if strings.Contains(to, "?") {
|
||||
sep = "&"
|
||||
}
|
||||
http.Redirect(w, r, to+sep+v.Encode(), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// readFlash extracts a validated flash message from URL query params.
|
||||
|
||||
@@ -210,6 +210,18 @@ func buildFuncMap() template.FuncMap {
|
||||
},
|
||||
// join joins a string slice.
|
||||
"join": strings.Join,
|
||||
// countryFlag returns the Unicode flag emoji for a 2-letter ISO code.
|
||||
"countryFlag": func(code string) string {
|
||||
if len(code) != 2 {
|
||||
return ""
|
||||
}
|
||||
a := rune(code[0])
|
||||
b := rune(code[1])
|
||||
if a < 'A' || a > 'Z' || b < 'A' || b > 'Z' {
|
||||
return ""
|
||||
}
|
||||
return string([]rune{0x1F1E6 + a - 'A', 0x1F1E6 + b - 'A'})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,12 +240,16 @@ type NavItem struct {
|
||||
// SidebarNav returns the full navigation definition.
|
||||
var SidebarNav = []NavItem{
|
||||
{Path: "/", Label: "Dashboard", Icon: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>`},
|
||||
{Path: "/decisions", Label: "Decisions", Icon: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>`, Divider: false},
|
||||
{Path: "/decisions", Label: "Decisions", Icon: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>`},
|
||||
{Path: "/alerts", Label: "Alerts", Icon: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`},
|
||||
{Path: "/countries", Label: "Countries", Icon: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>`},
|
||||
{Path: "/bouncers", Label: "Bouncers", Icon: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>`, Divider: true},
|
||||
{Path: "/machines", Label: "Machines", Icon: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>`},
|
||||
{Path: "/hub", Label: "Hub", Icon: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>`, Divider: true},
|
||||
{Path: "/allowlist", Label: "Allowlist", Icon: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`},
|
||||
{Path: "/hub", Label: "Hub", Icon: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>`, Divider: true},
|
||||
{Path: "/metrics-ui", Label: "Metrics", Icon: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>`},
|
||||
{Path: "/config-editor", Label: "Config Editor", Icon: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>`, Divider: true},
|
||||
{Path: "/geoip", Label: "GeoIP DB", Icon: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>`},
|
||||
}
|
||||
|
||||
// PageData contains fields available to every template.
|
||||
@@ -284,9 +300,11 @@ func (pd PageData) WithFlash(flashType, msg string) PageData {
|
||||
|
||||
// Deps holds shared dependencies injected into every handler.
|
||||
type Deps struct {
|
||||
Renderer *Renderer
|
||||
LAPI *crowdsec.LAPIClient
|
||||
CLI *crowdsec.CLIClient
|
||||
CLIAvailable bool
|
||||
PollInterval int
|
||||
Renderer *Renderer
|
||||
LAPI *crowdsec.LAPIClient
|
||||
CLI *crowdsec.CLIClient
|
||||
CLIAvailable bool
|
||||
PollInterval int
|
||||
CrowdsecBinPath string
|
||||
CrowdsecConfigDir string
|
||||
}
|
||||
|
||||
@@ -6,23 +6,26 @@ import (
|
||||
|
||||
"crowdsec-dashy/internal/config"
|
||||
"crowdsec-dashy/internal/crowdsec"
|
||||
"crowdsec-dashy/internal/geoip"
|
||||
"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) {
|
||||
func New(cfg *config.Config, lapi *crowdsec.LAPIClient, webFS fs.FS, geoUpdater *geoip.Updater) (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,
|
||||
Renderer: renderer,
|
||||
LAPI: lapi,
|
||||
CLI: crowdsec.NewCLIClient(cfg.CscliPath),
|
||||
CLIAvailable: cfg.CscliAvailable(),
|
||||
PollInterval: cfg.PollIntervalSec,
|
||||
CrowdsecBinPath: cfg.CrowdsecBinPath,
|
||||
CrowdsecConfigDir: cfg.CrowdsecConfigDir,
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
@@ -78,6 +81,28 @@ func New(cfg *config.Config, lapi *crowdsec.LAPIClient, webFS fs.FS) (http.Handl
|
||||
met := handlers.NewMetricsHandler(deps)
|
||||
mux.HandleFunc("GET /metrics-ui", met.ServeHTTP)
|
||||
|
||||
// Countries
|
||||
ctr := handlers.NewCountriesHandler(deps)
|
||||
mux.HandleFunc("GET /countries", ctr.List)
|
||||
mux.HandleFunc("POST /countries/add", ctr.Add)
|
||||
mux.HandleFunc("POST /countries/delete", ctr.Delete)
|
||||
|
||||
// Allowlist
|
||||
alw := handlers.NewAllowlistHandler(deps)
|
||||
mux.HandleFunc("GET /allowlist", alw.List)
|
||||
mux.HandleFunc("POST /allowlist/add", alw.AddEntry)
|
||||
mux.HandleFunc("POST /allowlist/remove", alw.RemoveEntry)
|
||||
|
||||
// Config Editor
|
||||
ced := handlers.NewConfigEditorHandler(deps)
|
||||
mux.HandleFunc("GET /config-editor", ced.List)
|
||||
mux.HandleFunc("POST /config-editor/save", ced.Save)
|
||||
|
||||
// GeoIP
|
||||
geo := handlers.NewGeoIPHandler(deps, geoUpdater)
|
||||
mux.HandleFunc("GET /geoip", geo.ServeHTTP)
|
||||
mux.HandleFunc("POST /geoip/refresh", geo.Refresh)
|
||||
|
||||
// Internal JSON API
|
||||
api := handlers.NewAPIHandler(deps)
|
||||
mux.HandleFunc("GET /api/v1/stats", api.Stats)
|
||||
|
||||
@@ -433,6 +433,22 @@ a.stat-card:hover { background: var(--s-700); cursor: pointer; }
|
||||
}
|
||||
.btn-ghost:hover { color: #c9d1d9; border-color: rgba(255,255,255,0.15); }
|
||||
|
||||
.btn-danger {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 7px 16px;
|
||||
background: var(--threat);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-danger:hover { background: #e03030; }
|
||||
.btn-danger:active { transform: scale(0.97); }
|
||||
|
||||
.btn-danger-sm {
|
||||
padding: 3px 10px;
|
||||
font-size: 11px;
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var el = {};
|
||||
var _resolve = null;
|
||||
var _busy = false;
|
||||
|
||||
function build() {
|
||||
el.overlay = document.createElement('div');
|
||||
el.overlay.id = 'app-modal';
|
||||
el.overlay.style.cssText = [
|
||||
'display:none', 'position:fixed', 'inset:0',
|
||||
'background:rgba(0,0,0,0.65)', 'z-index:9999',
|
||||
'align-items:center', 'justify-content:center'
|
||||
].join(';');
|
||||
|
||||
el.box = document.createElement('div');
|
||||
el.box.style.cssText = [
|
||||
'background:#0f1520', 'border:1px solid #1e2d45',
|
||||
'border-radius:8px', 'padding:24px 28px',
|
||||
'max-width:440px', 'width:calc(100% - 48px)',
|
||||
'box-shadow:0 16px 48px rgba(0,0,0,0.6)'
|
||||
].join(';');
|
||||
|
||||
el.msg = document.createElement('p');
|
||||
el.msg.style.cssText = 'margin:0 0 20px;color:#c9d1d9;font-size:14px;line-height:1.6;white-space:pre-wrap';
|
||||
|
||||
el.row = document.createElement('div');
|
||||
el.row.style.cssText = 'display:flex;gap:8px;justify-content:flex-end';
|
||||
|
||||
el.cancelBtn = document.createElement('button');
|
||||
el.cancelBtn.textContent = 'Cancel';
|
||||
el.cancelBtn.className = 'btn-ghost';
|
||||
|
||||
el.confirmBtn = document.createElement('button');
|
||||
el.confirmBtn.className = 'btn-danger';
|
||||
|
||||
el.row.appendChild(el.cancelBtn);
|
||||
el.row.appendChild(el.confirmBtn);
|
||||
el.box.appendChild(el.msg);
|
||||
el.box.appendChild(el.row);
|
||||
el.overlay.appendChild(el.box);
|
||||
document.body.appendChild(el.overlay);
|
||||
|
||||
el.cancelBtn.addEventListener('click', function () { close(false); });
|
||||
el.confirmBtn.addEventListener('click', function () { close(true); });
|
||||
el.overlay.addEventListener('click', function (e) {
|
||||
if (e.target === el.overlay) close(false);
|
||||
});
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (el.overlay && el.overlay.style.display !== 'none' && e.key === 'Escape') close(false);
|
||||
});
|
||||
}
|
||||
|
||||
function openModal(msg, confirmLabel, isDanger, showCancel) {
|
||||
if (!el.overlay) build();
|
||||
el.msg.textContent = msg;
|
||||
el.confirmBtn.textContent = confirmLabel || 'OK';
|
||||
el.confirmBtn.className = isDanger !== false ? 'btn-danger' : 'btn-primary';
|
||||
el.cancelBtn.style.display = showCancel === false ? 'none' : '';
|
||||
el.overlay.style.display = 'flex';
|
||||
el.confirmBtn.focus();
|
||||
return new Promise(function (res) { _resolve = res; });
|
||||
}
|
||||
|
||||
function close(result) {
|
||||
if (el.overlay) el.overlay.style.display = 'none';
|
||||
if (_resolve) { _resolve(result); _resolve = null; }
|
||||
}
|
||||
|
||||
window.appModal = {
|
||||
// Confirmation dialog — returns Promise<boolean>
|
||||
confirm: function (msg, label) {
|
||||
return openModal(msg, label || 'Confirm', true, true);
|
||||
},
|
||||
// Info/alert dialog — returns Promise<void>
|
||||
info: function (msg) {
|
||||
return openModal(msg, 'OK', false, false);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-intercept: buttons/links with data-confirm or data-confirm-fn attribute.
|
||||
document.addEventListener('click', function (e) {
|
||||
if (_busy) return;
|
||||
|
||||
var btn = e.target.closest('[data-confirm], [data-confirm-fn]');
|
||||
if (!btn) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
|
||||
var msg;
|
||||
|
||||
if (btn.dataset.confirm) {
|
||||
msg = btn.dataset.confirm;
|
||||
} else {
|
||||
var fn = window[btn.dataset.confirmFn];
|
||||
if (typeof fn !== 'function') return;
|
||||
msg = fn(); // returns message string, or null/'' to abort (fn may show its own info modal)
|
||||
}
|
||||
|
||||
if (!msg) return;
|
||||
|
||||
var label = btn.dataset.confirmLabel || 'Confirm';
|
||||
window.appModal.confirm(msg, label).then(function (ok) {
|
||||
if (!ok) return;
|
||||
_busy = true;
|
||||
// requestSubmit includes button name/value and respects formaction
|
||||
if (btn.form && typeof btn.form.requestSubmit === 'function') {
|
||||
btn.form.requestSubmit(btn);
|
||||
} else if (btn.form) {
|
||||
btn.form.submit();
|
||||
} else {
|
||||
btn.click();
|
||||
}
|
||||
setTimeout(function () { _busy = false; }, 100);
|
||||
});
|
||||
}, true);
|
||||
|
||||
})();
|
||||
@@ -73,6 +73,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/modal.js"></script>
|
||||
<script>
|
||||
(function() {
|
||||
document.body.classList.add('ready');
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<button type="button" class="btn-ghost-sm" id="toggle-updates" onclick="toggleUpdates()">{{if .ShowUpdates}}Hide updates{{else}}Show updates{{end}}</button>
|
||||
<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>
|
||||
<button type="submit" class="btn-danger-sm" data-confirm-fn="confirmBulkDelete">Delete selected</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
<td style="font-family:'JetBrains Mono',monospace;font-size:12px">{{.ID}}</td>
|
||||
<td style="font-size:12px" title="{{.Scenario}}">{{truncate .Scenario 40}}</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-size:12px;color:var(--muted)">{{if .Source.CN}}{{countryFlag .Source.CN}} {{.Source.CN}}{{end}}</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>
|
||||
@@ -68,7 +68,7 @@
|
||||
<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>
|
||||
<button type="submit" class="btn-danger-sm" data-confirm="Delete alert #{{.ID}}?">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -150,8 +150,8 @@ function toggleAll() {
|
||||
}
|
||||
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)?');
|
||||
if (n === 0) { appModal.info('Select at least one alert.'); return null; }
|
||||
return 'Delete ' + n + ' alert(s)? This cannot be undone.';
|
||||
}
|
||||
|
||||
applyFilter();
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
{{template "base" .}}
|
||||
{{define "content"}}
|
||||
<div style="max-width:1400px">
|
||||
|
||||
<div style="margin-bottom:16px">
|
||||
<div class="page-title">Allowlist</div>
|
||||
<div class="page-sub">IPs and ranges exempt from CrowdSec decisions</div>
|
||||
</div>
|
||||
|
||||
{{if .FetchErr}}
|
||||
<div class="panel" style="margin-bottom:16px">
|
||||
<div class="panel-body">
|
||||
<div class="empty-text">Allowlist unavailable</div>
|
||||
<div class="empty-sub">{{.FetchErr}}</div>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
|
||||
<div class="panel" style="margin-bottom:16px">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">Add Entry</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<form method="POST" action="/allowlist/add">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:16px;margin-bottom:16px">
|
||||
<div>
|
||||
<label class="field-label">Allowlist Name</label>
|
||||
<input class="field-input" type="text" name="list" placeholder="default" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label">Value (IP or CIDR)</label>
|
||||
<input class="field-input" type="text" name="value" placeholder="1.2.3.4 or 1.2.3.0/24" required>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:flex-end">
|
||||
<button type="submit" class="btn-primary">Add to Allowlist</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Lists}}
|
||||
{{range .Lists}}
|
||||
<div class="panel" style="margin-bottom:16px">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">{{.Name}}</span>
|
||||
{{if .Description}}<span style="font-size:12px;color:var(--muted)">{{.Description}}</span>{{end}}
|
||||
</div>
|
||||
{{if .Items}}
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Value</th>
|
||||
<th>Comment</th>
|
||||
<th>Expiry</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{$listName := .Name}}
|
||||
{{range .Items}}
|
||||
<tr>
|
||||
<td style="font-family:'JetBrains Mono',monospace;font-size:12px">{{.Value}}</td>
|
||||
<td style="font-size:12px;color:var(--muted)">{{.Comment}}</td>
|
||||
<td style="font-size:12px;color:var(--muted)">{{if .Expiry}}{{.Expiry}}{{else}}permanent{{end}}</td>
|
||||
<td>
|
||||
<form method="POST" action="/allowlist/remove" style="display:inline">
|
||||
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="list" value="{{$listName}}">
|
||||
<input type="hidden" name="value" value="{{.Value}}">
|
||||
<button type="submit" class="btn-danger-sm"
|
||||
data-confirm="Remove {{.Value}} from allowlist?">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<div class="empty-text">No entries in {{.Name}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<div class="panel">
|
||||
<div class="empty-state">
|
||||
<div class="empty-text">No allowlists found</div>
|
||||
<div class="empty-sub">Create an allowlist with cscli allowlists create, then add entries above</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}{{end}}
|
||||
@@ -72,7 +72,7 @@
|
||||
<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>
|
||||
<button type="submit" class="btn-danger-sm" data-confirm="Delete bouncer {{.Name}}? This cannot be undone.">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
{{end}}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
{{template "base" .}}
|
||||
{{define "content"}}
|
||||
<div style="max-width:1400px">
|
||||
|
||||
<div style="margin-bottom:16px">
|
||||
<div class="page-title">Config Editor</div>
|
||||
<div class="page-sub">Edit CrowdSec YAML files — config is tested before applying, reverted on failure</div>
|
||||
</div>
|
||||
|
||||
{{if .FetchErr}}
|
||||
<div class="panel">
|
||||
<div class="panel-body">
|
||||
<div class="empty-text">Config directory unavailable</div>
|
||||
<div class="empty-sub">{{.FetchErr}}</div>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
|
||||
<div style="display:grid;grid-template-columns:220px 1fr;gap:16px">
|
||||
|
||||
<div class="panel" style="align-self:start">
|
||||
<div class="panel-header"><span class="panel-title">Files</span></div>
|
||||
<div style="padding:8px 0">
|
||||
{{if .Files}}
|
||||
{{range .Files}}
|
||||
<a href="/config-editor?file={{.}}"
|
||||
style="display:block;padding:6px 16px;font-family:'JetBrains Mono',monospace;font-size:12px;
|
||||
color:{{if eq . $.File}}var(--accent){{else}}var(--text){{end}};
|
||||
background:{{if eq . $.File}}rgba(0,212,255,0.08){{else}}transparent{{end}};
|
||||
text-decoration:none;word-break:break-all"
|
||||
title="{{.}}">{{.}}</a>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<div style="padding:12px 16px;font-size:12px;color:var(--muted)">No YAML files found</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{{if .File}}
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title" style="font-family:'JetBrains Mono',monospace;font-size:13px">{{.File}}</span>
|
||||
<span style="font-size:12px;color:var(--muted)">Write saves and runs crowdsec -t; reverts on failure</span>
|
||||
</div>
|
||||
{{if .TestOut}}
|
||||
<div style="padding:12px 16px;background:rgba(255,59,59,0.08);border-bottom:1px solid var(--border)">
|
||||
<div style="font-size:12px;color:var(--threat);margin-bottom:4px">Config test failed — file was reverted</div>
|
||||
<pre style="font-size:11px;color:var(--muted);margin:0;white-space:pre-wrap">{{.TestOut}}</pre>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="panel-body">
|
||||
<form method="POST" action="/config-editor/save">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="file" value="{{.File}}">
|
||||
<textarea name="content" id="editor"
|
||||
style="width:100%;min-height:500px;font-family:'JetBrains Mono',monospace;font-size:13px;
|
||||
background:var(--surface);color:var(--text);border:1px solid var(--border);
|
||||
border-radius:4px;padding:12px;resize:vertical;box-sizing:border-box;line-height:1.5"
|
||||
spellcheck="false">{{.Content}}</textarea>
|
||||
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:12px">
|
||||
<a href="/config-editor?file={{.File}}" class="btn-ghost">Reset</a>
|
||||
<button type="submit" class="btn-primary"
|
||||
data-confirm="Save {{.File}}?\n\nThe config will be tested with crowdsec -t before applying. If the test fails, the file will be reverted automatically."
|
||||
data-confirm-label="Save">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="panel">
|
||||
<div class="empty-state">
|
||||
<div class="empty-text">Select a file</div>
|
||||
<div class="empty-sub">Choose a YAML file from the list to view and edit it</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script>
|
||||
// Tab key inserts spaces in textarea instead of focusing next element
|
||||
var ed = document.getElementById('editor');
|
||||
if (ed) {
|
||||
ed.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
var s = ed.selectionStart, en = ed.selectionEnd;
|
||||
ed.value = ed.value.substring(0, s) + ' ' + ed.value.substring(en);
|
||||
ed.selectionStart = ed.selectionEnd = s + 2;
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,158 @@
|
||||
{{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">Countries</div>
|
||||
<div class="page-sub">Country-scope bans managed via cscli decisions</div>
|
||||
</div>
|
||||
{{if .CLIAvailable}}<button class="btn-primary" onclick="toggleAddForm()">Add Country Ban</button>{{end}}
|
||||
</div>
|
||||
|
||||
{{if not .CLIAvailable}}
|
||||
<div class="panel" style="margin-bottom:16px">
|
||||
<div class="panel-body">
|
||||
<div class="empty-text">cscli not available</div>
|
||||
<div class="empty-sub">Country management requires the cscli binary. Set cscli_path in app_config.conf.</div>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
|
||||
<div id="add-form" class="panel" style="display:none;margin-bottom:16px">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">Add Country Ban</span>
|
||||
<button class="btn-ghost-sm" onclick="toggleAddForm()">Cancel</button>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<form method="POST" action="/countries/add">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<div style="display:grid;grid-template-columns:1fr 160px 160px;gap:16px;margin-bottom:16px">
|
||||
<div>
|
||||
<label class="field-label">Country Codes (ISO 3166-1 alpha-2)</label>
|
||||
<textarea class="field-input" name="countries" rows="3" placeholder="CN RU KP" required autofocus
|
||||
style="resize:vertical;font-family:'JetBrains Mono',monospace;text-transform:uppercase"></textarea>
|
||||
<div class="field-hint">One per line or comma-separated. e.g. CN, RU, US</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label">Decision 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" id="dur-input" placeholder="24h" value="24h">
|
||||
<div class="field-hint">Units: s m h d w</div>
|
||||
<label style="display:flex;align-items:center;gap:6px;margin-top:8px;font-size:12px;cursor:pointer">
|
||||
<input type="checkbox" name="permanent" value="1" id="chk-perm" onchange="togglePermanent(this)">
|
||||
Permanent (10 yr)
|
||||
</label>
|
||||
</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">
|
||||
<form method="POST" action="/countries/delete" id="bulk-form">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">Active Country Bans — 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" data-confirm-fn="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>Country</th>
|
||||
<th>Type</th>
|
||||
<th>Origin</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-size:14px">
|
||||
{{countryFlag .Value}} <span style="font-family:'JetBrains Mono',monospace">{{.Value}}</span>
|
||||
</td>
|
||||
<td><span class="badge {{decisionBadgeClass .Type}}">{{.Type}}</span></td>
|
||||
<td><span class="badge {{originBadgeClass .Origin}}">{{.Origin}}</span></td>
|
||||
<td style="font-family:'JetBrains Mono',monospace;font-size:12px">{{.Duration}}</td>
|
||||
<td style="font-size:12px;color:var(--muted)">{{if .Until}}{{truncate .Until 16}}{{else}}{{.Duration}}{{end}}</td>
|
||||
<td>
|
||||
<form method="POST" action="/countries/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" data-confirm="Remove ban for {{.Value}}?">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<div class="empty-text">No country bans active</div>
|
||||
<div class="empty-sub">No country-scope decisions are in effect</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</form>
|
||||
|
||||
{{if or (gt .Page 1) .HasNext}}
|
||||
<div class="pagination">
|
||||
{{if gt .Page 1}}
|
||||
<a href="/countries?page={{dec .Page}}" 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="/countries?page={{inc .Page}}" class="btn-ghost-sm">Next</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script>
|
||||
function togglePermanent(cb) {
|
||||
var dur = document.getElementById('dur-input');
|
||||
if (dur) { dur.disabled = cb.checked; dur.value = cb.checked ? '87600h' : '24h'; }
|
||||
}
|
||||
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) { appModal.info('Select at least one entry.'); return null; }
|
||||
return 'Remove ' + n + ' country ban(s)?';
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -87,7 +87,7 @@
|
||||
</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>
|
||||
<button type="submit" class="btn-danger-sm" data-confirm-fn="confirmBulkDelete">Delete selected</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -111,7 +111,9 @@
|
||||
{{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 style="font-family:'JetBrains Mono',monospace;font-size:12px">
|
||||
{{if eq .Scope "Country"}}{{countryFlag .Value}} {{end}}{{.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>
|
||||
@@ -122,7 +124,7 @@
|
||||
<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>
|
||||
<button type="submit" class="btn-danger-sm" data-confirm="Delete this decision?">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -169,8 +171,8 @@ function toggleAll() {
|
||||
}
|
||||
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)?');
|
||||
if (n === 0) { appModal.info('Select at least one decision.'); return null; }
|
||||
return 'Delete ' + n + ' decision(s)? This cannot be undone.';
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
{{template "base" .}}
|
||||
{{define "content"}}
|
||||
<div style="max-width:900px">
|
||||
|
||||
<div style="margin-bottom:16px">
|
||||
<div class="page-title">GeoIP Database</div>
|
||||
<div class="page-sub">Automatic download and refresh of ipinfo.io MMDB database for CrowdSec enrichment</div>
|
||||
</div>
|
||||
|
||||
<div class="panel" style="margin-bottom:16px">
|
||||
<div class="panel-header"><span class="panel-title">Database Status</span></div>
|
||||
<div class="panel-body">
|
||||
<table style="width:100%;border-collapse:collapse">
|
||||
<tr>
|
||||
<td style="padding:8px 0;color:var(--muted);width:180px;font-size:13px">File</td>
|
||||
<td style="font-family:'JetBrains Mono',monospace;font-size:13px">{{.Status.DBFile}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 0;color:var(--muted);font-size:13px">Destination path</td>
|
||||
<td style="font-family:'JetBrains Mono',monospace;font-size:13px">{{.Status.DBPath}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 0;color:var(--muted);font-size:13px">DB exists</td>
|
||||
<td>
|
||||
{{if .Status.DBExists}}
|
||||
<span class="badge badge-green">Yes</span>
|
||||
<span style="font-size:12px;color:var(--muted);margin-left:8px">{{.Status.DBSizeHuman}}</span>
|
||||
{{else}}
|
||||
<span class="badge badge-red">No</span>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 0;color:var(--muted);font-size:13px">Last updated</td>
|
||||
<td style="font-size:13px">
|
||||
{{if .Status.LastUpdated.IsZero}}
|
||||
<span style="color:var(--muted)">Never</span>
|
||||
{{else}}
|
||||
{{.Status.LastUpdated.Format "2006-01-02 15:04:05 UTC"}}
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 0;color:var(--muted);font-size:13px">Next refresh</td>
|
||||
<td style="font-size:13px">
|
||||
{{if .Status.NextRefresh.IsZero}}
|
||||
<span style="color:var(--muted)">—</span>
|
||||
{{else}}
|
||||
{{.Status.NextRefresh.Format "2006-01-02 15:04:05 UTC"}}
|
||||
<span style="color:var(--muted);font-size:12px;margin-left:8px">(every {{.Status.RefreshDays}} days)</span>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 0;color:var(--muted);font-size:13px">Token configured</td>
|
||||
<td>
|
||||
{{if .Status.TokenSet}}
|
||||
<span class="badge badge-green">Yes</span>
|
||||
{{else}}
|
||||
<span class="badge badge-red">No</span>
|
||||
<span style="font-size:12px;color:var(--muted);margin-left:8px">Set ipinfo_token in app_config.conf</span>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{if .Status.Updating}}
|
||||
<tr>
|
||||
<td style="padding:8px 0;color:var(--muted);font-size:13px">Status</td>
|
||||
<td><span class="badge badge-amber">Downloading...</span></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
{{if .Status.LastErrMsg}}
|
||||
<tr>
|
||||
<td style="padding:8px 0;color:var(--muted);font-size:13px">Last error</td>
|
||||
<td style="font-size:12px;color:var(--threat)">{{.Status.LastErrMsg}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Status.TokenSet}}
|
||||
<div class="panel" style="margin-bottom:16px">
|
||||
<div class="panel-header"><span class="panel-title">Manual Refresh</span></div>
|
||||
<div class="panel-body">
|
||||
<p style="font-size:13px;color:var(--muted);margin:0 0 16px 0">
|
||||
Downloads <strong style="color:var(--text)">{{.Status.DBFile}}</strong> from ipinfo.io
|
||||
and saves it to <code style="font-family:'JetBrains Mono',monospace">{{.Status.DBPath}}</code>.
|
||||
CrowdSec will use the new file automatically — no restart needed.
|
||||
</p>
|
||||
<form method="POST" action="/geoip/refresh">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<button type="submit" class="btn-primary"
|
||||
{{if .Status.Updating}}disabled{{end}}
|
||||
data-confirm="Download {{.Status.DBFile}} from ipinfo.io now?" data-confirm-label="Download">
|
||||
{{if .Status.Updating}}Downloading...{{else}}Refresh Now{{end}}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-header"><span class="panel-title">Setup Notes</span></div>
|
||||
<div class="panel-body" style="font-size:13px;color:var(--muted);line-height:1.7">
|
||||
<p>1. Get a free token at <strong style="color:var(--text)">ipinfo.io/signup</strong></p>
|
||||
<p>2. Add to <code style="font-family:'JetBrains Mono',monospace">app_config.conf</code>:</p>
|
||||
<pre style="background:var(--surface);border:1px solid var(--border);padding:12px;border-radius:4px;font-size:12px;margin:8px 0 16px">ipinfo_token = your-token-here
|
||||
ipinfo_db_file = asn.mmdb
|
||||
ipinfo_db_path = /var/lib/crowdsec/data/GeoLite2-ASN.mmdb
|
||||
ipinfo_refresh_days = 7</pre>
|
||||
<p>3. Restart crowdsec-dashy and click <strong style="color:var(--text)">Refresh Now</strong></p>
|
||||
<p>4. For country enrichment, also download <code style="font-family:'JetBrains Mono',monospace">country.mmdb</code> and configure CrowdSec to use it</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}{{end}}
|
||||
@@ -11,7 +11,7 @@
|
||||
<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>
|
||||
data-confirm="Run hub update + upgrade? This may take a minute." data-confirm-label="Update">Update All</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
@@ -74,7 +74,7 @@
|
||||
<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>
|
||||
<button type="submit" class="btn-danger-sm" data-confirm="Remove {{.Name}}?">Remove</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<form method="POST" action="/hub/install" style="display:inline">
|
||||
|
||||
@@ -55,13 +55,13 @@
|
||||
<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>
|
||||
<button type="submit" class="btn-safe-sm" data-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>
|
||||
<button type="submit" class="btn-danger-sm" data-confirm="Delete machine {{.MachineID}}?">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user