diff --git a/cmd/server/main.go b/cmd/server/main.go index 48e5b52..02f3124 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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) } diff --git a/crowdsec-dashy b/crowdsec-dashy index 8b22d91..1dd68c1 100755 Binary files a/crowdsec-dashy and b/crowdsec-dashy differ diff --git a/internal/config/config.go b/internal/config/config.go index f86ceb6..b2d6eb4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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) diff --git a/internal/crowdsec/cli.go b/internal/crowdsec/cli.go index 95d0405..71937c8 100644 --- a/internal/crowdsec/cli.go +++ b/internal/crowdsec/cli.go @@ -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). diff --git a/internal/crowdsec/types.go b/internal/crowdsec/types.go index a400a1b..f70883b 100644 --- a/internal/crowdsec/types.go +++ b/internal/crowdsec/types.go @@ -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"` +} diff --git a/internal/geoip/updater.go b/internal/geoip/updater.go new file mode 100644 index 0000000..f8e180f --- /dev/null +++ b/internal/geoip/updater.go @@ -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]) +} diff --git a/internal/handlers/allowlist.go b/internal/handlers/allowlist.go new file mode 100644 index 0000000..2b70766 --- /dev/null +++ b/internal/handlers/allowlist.go @@ -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) +} diff --git a/internal/handlers/configeditor.go b/internal/handlers/configeditor.go new file mode 100644 index 0000000..52d9cf8 --- /dev/null +++ b/internal/handlers/configeditor.go @@ -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 +} diff --git a/internal/handlers/countries.go b/internal/handlers/countries.go new file mode 100644 index 0000000..c7a2508 --- /dev/null +++ b/internal/handlers/countries.go @@ -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) +} diff --git a/internal/handlers/dashboard.go b/internal/handlers/dashboard.go index 7ec3979..3ab85a2 100644 --- a/internal/handlers/dashboard.go +++ b/internal/handlers/dashboard.go @@ -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), diff --git a/internal/handlers/geoip.go b/internal/handlers/geoip.go new file mode 100644 index 0000000..3427d17 --- /dev/null +++ b/internal/handlers/geoip.go @@ -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") +} diff --git a/internal/handlers/helpers.go b/internal/handlers/helpers.go index 47d4b31..422bc65 100644 --- a/internal/handlers/helpers.go +++ b/internal/handlers/helpers.go @@ -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. diff --git a/internal/handlers/renderer.go b/internal/handlers/renderer.go index c7eaaaf..40d100e 100644 --- a/internal/handlers/renderer.go +++ b/internal/handlers/renderer.go @@ -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: ``}, - {Path: "/decisions", Label: "Decisions", Icon: ``, Divider: false}, + {Path: "/decisions", Label: "Decisions", Icon: ``}, {Path: "/alerts", Label: "Alerts", Icon: ``}, + {Path: "/countries", Label: "Countries", Icon: ``}, {Path: "/bouncers", Label: "Bouncers", Icon: ``, Divider: true}, {Path: "/machines", Label: "Machines", Icon: ``}, - {Path: "/hub", Label: "Hub", Icon: ``, Divider: true}, + {Path: "/allowlist", Label: "Allowlist", Icon: ``}, + {Path: "/hub", Label: "Hub", Icon: ``, Divider: true}, {Path: "/metrics-ui", Label: "Metrics", Icon: ``}, + {Path: "/config-editor", Label: "Config Editor", Icon: ``, Divider: true}, + {Path: "/geoip", Label: "GeoIP DB", Icon: ``}, } // 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 } diff --git a/internal/router/router.go b/internal/router/router.go index 1063028..2d89b4b 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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) diff --git a/web/static/css/app.css b/web/static/css/app.css index d127bde..322c106 100644 --- a/web/static/css/app.css +++ b/web/static/css/app.css @@ -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; diff --git a/web/static/js/modal.js b/web/static/js/modal.js new file mode 100644 index 0000000..e364d48 --- /dev/null +++ b/web/static/js/modal.js @@ -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 + confirm: function (msg, label) { + return openModal(msg, label || 'Confirm', true, true); + }, + // Info/alert dialog — returns Promise + 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); + +})(); diff --git a/web/templates/layouts/base.html b/web/templates/layouts/base.html index e5752c0..433e5dc 100644 --- a/web/templates/layouts/base.html +++ b/web/templates/layouts/base.html @@ -73,6 +73,7 @@ + +{{end}} diff --git a/web/templates/pages/countries.html b/web/templates/pages/countries.html new file mode 100644 index 0000000..d7a0ca2 --- /dev/null +++ b/web/templates/pages/countries.html @@ -0,0 +1,158 @@ +{{template "base" .}} +{{define "content"}} +
+ +
+
+
Countries
+
Country-scope bans managed via cscli decisions
+
+ {{if .CLIAvailable}}{{end}} +
+ + {{if not .CLIAvailable}} +
+
+
cscli not available
+
Country management requires the cscli binary. Set cscli_path in app_config.conf.
+
+
+ {{else}} + + + +
+
+ +
+ Active Country Bans — Page {{.Page}}{{if .HasNext}} (more available){{end}} +
+ + +
+
+ + {{if .Decisions}} +
+ + + + + + + + + + + + + + {{range .Decisions}} + + + + + + + + + + {{end}} + +
CountryTypeOriginDurationExpiresAction
+ {{countryFlag .Value}} {{.Value}} + {{.Type}}{{.Origin}}{{.Duration}}{{if .Until}}{{truncate .Until 16}}{{else}}{{.Duration}}{{end}} + + + + + +
+
+ {{else}} +
+
No country bans active
+
No country-scope decisions are in effect
+
+ {{end}} + + + {{if or (gt .Page 1) .HasNext}} + + {{end}} +
+ {{end}} +
+{{end}} + +{{define "scripts"}} + +{{end}} diff --git a/web/templates/pages/decisions.html b/web/templates/pages/decisions.html index 24af3b1..801cde4 100644 --- a/web/templates/pages/decisions.html +++ b/web/templates/pages/decisions.html @@ -87,7 +87,7 @@
- +
@@ -111,7 +111,9 @@ {{range .Decisions}} - {{.Value}} + + {{if eq .Scope "Country"}}{{countryFlag .Value}} {{end}}{{.Value}} + {{.Type}} {{.Scope}} {{.Origin}} @@ -122,7 +124,7 @@
- +
@@ -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.'; } {{end}} diff --git a/web/templates/pages/geoip.html b/web/templates/pages/geoip.html new file mode 100644 index 0000000..7616722 --- /dev/null +++ b/web/templates/pages/geoip.html @@ -0,0 +1,119 @@ +{{template "base" .}} +{{define "content"}} +
+ +
+
GeoIP Database
+
Automatic download and refresh of ipinfo.io MMDB database for CrowdSec enrichment
+
+ +
+
Database Status
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + {{if .Status.Updating}} + + + + + {{end}} + {{if .Status.LastErrMsg}} + + + + + {{end}} +
File{{.Status.DBFile}}
Destination path{{.Status.DBPath}}
DB exists + {{if .Status.DBExists}} + Yes + {{.Status.DBSizeHuman}} + {{else}} + No + {{end}} +
Last updated + {{if .Status.LastUpdated.IsZero}} + Never + {{else}} + {{.Status.LastUpdated.Format "2006-01-02 15:04:05 UTC"}} + {{end}} +
Next refresh + {{if .Status.NextRefresh.IsZero}} + + {{else}} + {{.Status.NextRefresh.Format "2006-01-02 15:04:05 UTC"}} + (every {{.Status.RefreshDays}} days) + {{end}} +
Token configured + {{if .Status.TokenSet}} + Yes + {{else}} + No + Set ipinfo_token in app_config.conf + {{end}} +
StatusDownloading...
Last error{{.Status.LastErrMsg}}
+
+
+ + {{if .Status.TokenSet}} +
+
Manual Refresh
+
+

+ Downloads {{.Status.DBFile}} from ipinfo.io + and saves it to {{.Status.DBPath}}. + CrowdSec will use the new file automatically — no restart needed. +

+
+ + +
+
+
+ {{end}} + +
+
Setup Notes
+
+

1. Get a free token at ipinfo.io/signup

+

2. Add to app_config.conf:

+
ipinfo_token        = your-token-here
+ipinfo_db_file      = asn.mmdb
+ipinfo_db_path      = /var/lib/crowdsec/data/GeoLite2-ASN.mmdb
+ipinfo_refresh_days = 7
+

3. Restart crowdsec-dashy and click Refresh Now

+

4. For country enrichment, also download country.mmdb and configure CrowdSec to use it

+
+
+ +
+{{end}} + +{{define "scripts"}}{{end}} diff --git a/web/templates/pages/hub.html b/web/templates/pages/hub.html index 55bb3a5..e75daad 100644 --- a/web/templates/pages/hub.html +++ b/web/templates/pages/hub.html @@ -11,7 +11,7 @@
+ data-confirm="Run hub update + upgrade? This may take a minute." data-confirm-label="Update">Update All
{{end}} @@ -74,7 +74,7 @@ - + {{else}}
diff --git a/web/templates/pages/machines.html b/web/templates/pages/machines.html index fe4b5b5..2b08295 100644 --- a/web/templates/pages/machines.html +++ b/web/templates/pages/machines.html @@ -55,13 +55,13 @@ - +
{{end}}
- +
{{end}}