geoip and config editor online

This commit is contained in:
2026-05-17 15:38:10 +00:00
parent f4a88035ee
commit 1293bafffa
25 changed files with 1645 additions and 31 deletions
+27
View File
@@ -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)
+73
View File
@@ -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).
+16
View File
@@ -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"`
}
+233
View File
@@ -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])
}
+115
View File
@@ -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)
}
+207
View File
@@ -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
}
+197
View File
@@ -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)
}
+14 -1
View File
@@ -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),
+61
View File
@@ -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")
}
+7 -1
View File
@@ -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.
+25 -7
View File
@@ -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
}
+31 -6
View File
@@ -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)