geoip and config editor online
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user