function update
This commit is contained in:
@@ -41,6 +41,7 @@ type Config struct {
|
||||
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
|
||||
DataFile string // path to crowdsec-dash-data.json
|
||||
}
|
||||
|
||||
// FirstRunError is returned when the config file was just created and needs editing.
|
||||
@@ -109,6 +110,7 @@ func Load() (*Config, error) {
|
||||
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),
|
||||
DataFile: strVal(vals, "data_file", "./crowdsec-dash-data.json"),
|
||||
UIPassword: strVal(vals, "ui_password", "changeme"),
|
||||
UISessionSecret: vals["ui_session_secret"],
|
||||
PollIntervalSec: intVal(vals, "poll_interval_sec", 15),
|
||||
@@ -235,6 +237,9 @@ ipinfo_token =
|
||||
ipinfo_db_file = asn.mmdb
|
||||
ipinfo_db_path = /var/lib/crowdsec/data/GeoLite2-ASN.mmdb
|
||||
ipinfo_refresh_days = 7
|
||||
|
||||
# Dashboard state file (hub removed-items tracking)
|
||||
data_file = ./crowdsec-dash-data.json
|
||||
`, secret)
|
||||
|
||||
return os.WriteFile(path, []byte(content), 0600)
|
||||
|
||||
+77
-13
@@ -268,6 +268,16 @@ func (c *CLIClient) ListPostoverflows(ctx context.Context) ([]HubItem, error) {
|
||||
return c.listHubItems(ctx, "postoverflows")
|
||||
}
|
||||
|
||||
// InstallPostoverflow installs a postoverflow.
|
||||
func (c *CLIClient) InstallPostoverflow(ctx context.Context, name string) error {
|
||||
return c.hubAction(ctx, "postoverflows", "install", name)
|
||||
}
|
||||
|
||||
// RemovePostoverflow removes a postoverflow.
|
||||
func (c *CLIClient) RemovePostoverflow(ctx context.Context, name string) error {
|
||||
return c.hubAction(ctx, "postoverflows", "remove", name)
|
||||
}
|
||||
|
||||
// HubUpdate runs cscli hub update && cscli hub upgrade.
|
||||
func (c *CLIClient) HubUpdate(ctx context.Context) error {
|
||||
if _, err := c.run(ctx, "hub", "update"); err != nil {
|
||||
@@ -352,15 +362,55 @@ func (c *CLIClient) InspectAllowlist(ctx context.Context, name string) (*Allowli
|
||||
return &al, nil
|
||||
}
|
||||
|
||||
// AddAllowlistEntry adds a value to a named allowlist.
|
||||
func (c *CLIClient) AddAllowlistEntry(ctx context.Context, listName, value string) error {
|
||||
// CreateAllowlist creates a new allowlist. Ignores "already exists" errors.
|
||||
// cscli v1.7 requires the -d (description) flag.
|
||||
func (c *CLIClient) CreateAllowlist(ctx context.Context, name string) error {
|
||||
if !safeArg.MatchString(name) {
|
||||
return fmt.Errorf("invalid allowlist name: %q", name)
|
||||
}
|
||||
_, err := c.run(ctx, "allowlists", "create", name, "-d", "crowdsec-dashy")
|
||||
if err != nil {
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, "already exists") || strings.Contains(msg, "already exist") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddAllowlistEntries adds one or more values to a named allowlist in a single call.
|
||||
// comment is optional; if non-empty it is passed as -d to cscli.
|
||||
func (c *CLIClient) AddAllowlistEntries(ctx context.Context, listName, comment string, values []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)
|
||||
if len(values) == 0 {
|
||||
return fmt.Errorf("no values provided")
|
||||
}
|
||||
_, err := c.run(ctx, "allowlists", "items", "add", listName, value)
|
||||
args := []string{"allowlists", "add", listName}
|
||||
for _, v := range values {
|
||||
if !safeArg.MatchString(v) {
|
||||
return fmt.Errorf("invalid allowlist value: %q", v)
|
||||
}
|
||||
args = append(args, v)
|
||||
}
|
||||
if comment != "" {
|
||||
if !safeFlagValue.MatchString(comment) {
|
||||
return fmt.Errorf("invalid comment: only letters, digits, spaces, and common punctuation allowed")
|
||||
}
|
||||
args = append(args, "-d", comment)
|
||||
}
|
||||
_, err := c.run(ctx, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteAllowlist removes an entire allowlist.
|
||||
func (c *CLIClient) DeleteAllowlist(ctx context.Context, name string) error {
|
||||
if !safeArg.MatchString(name) {
|
||||
return fmt.Errorf("invalid allowlist name: %q", name)
|
||||
}
|
||||
_, err := c.run(ctx, "allowlists", "delete", name)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -372,7 +422,7 @@ func (c *CLIClient) RemoveAllowlistEntry(ctx context.Context, listName, value st
|
||||
if !safeArg.MatchString(value) {
|
||||
return fmt.Errorf("invalid allowlist value: %q", value)
|
||||
}
|
||||
_, err := c.run(ctx, "allowlists", "items", "del", listName, value)
|
||||
_, err := c.run(ctx, "allowlists", "remove", listName, value)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -430,13 +480,16 @@ var allowedActions = map[string]bool{
|
||||
"validate": true,
|
||||
"create": true,
|
||||
"inspect": true,
|
||||
"items": true,
|
||||
"del": true,
|
||||
}
|
||||
|
||||
// safeArg matches strings that are safe to pass as arguments (no shell metacharacters).
|
||||
// safeArg matches strings that are safe to pass as positional cscli arguments.
|
||||
var safeArg = regexp.MustCompile(`^[a-zA-Z0-9_./:@\-]+$`)
|
||||
|
||||
// safeFlagValue matches flag values (after --flag). More permissive: allows spaces and
|
||||
// common punctuation, but blocks null bytes, control chars, and shell metacharacters.
|
||||
var safeFlagValue = regexp.MustCompile(`^[a-zA-Z0-9 _./:@\-,!?()']+$`)
|
||||
|
||||
func validateArgs(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("no subcommand provided")
|
||||
@@ -444,19 +497,30 @@ func validateArgs(args []string) error {
|
||||
if !allowedSubcommands[args[0]] {
|
||||
return fmt.Errorf("disallowed cscli subcommand: %q", args[0])
|
||||
}
|
||||
// Skip flag-value pairs like "-o json" and "--machine-id <id>" — validate values
|
||||
// Iterate remaining args. Flag names (start with -) are allowed as-is.
|
||||
// The arg immediately after a flag is treated as its value and validated with
|
||||
// the more permissive safeFlagValue rather than safeArg.
|
||||
prevWasFlag := false
|
||||
for i := 1; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
if strings.HasPrefix(arg, "-") {
|
||||
continue // flags are fine
|
||||
prevWasFlag = true
|
||||
continue
|
||||
}
|
||||
if prevWasFlag {
|
||||
prevWasFlag = false
|
||||
if !safeFlagValue.MatchString(arg) {
|
||||
return fmt.Errorf("unsafe cscli flag value: %q", arg)
|
||||
}
|
||||
continue
|
||||
}
|
||||
prevWasFlag = false
|
||||
if allowedActions[arg] {
|
||||
continue // known action words
|
||||
continue
|
||||
}
|
||||
if arg == "raw" || arg == "json" || arg == "human" {
|
||||
continue // output format values
|
||||
continue
|
||||
}
|
||||
// Everything else (names, IDs) must match safe pattern
|
||||
if !safeArg.MatchString(arg) {
|
||||
return fmt.Errorf("unsafe cscli argument: %q", arg)
|
||||
}
|
||||
|
||||
+13
-12
@@ -116,18 +116,19 @@ type Machine struct {
|
||||
}
|
||||
|
||||
type HubItem struct {
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
Downloaded bool `json:"downloaded"`
|
||||
Installed bool `json:"installed"`
|
||||
Local bool `json:"local"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Stage string `json:"stage"`
|
||||
Status string `json:"status"`
|
||||
Tainted bool `json:"tainted"`
|
||||
UpToDate bool `json:"up_to_date"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
Downloaded bool `json:"downloaded"`
|
||||
Installed bool `json:"installed"`
|
||||
Local bool `json:"local"`
|
||||
LocalVersion string `json:"local_version"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Stage string `json:"stage"`
|
||||
Status string `json:"status"`
|
||||
Tainted bool `json:"tainted"`
|
||||
UpToDate bool `json:"up_to_date"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type MetricsSection struct {
|
||||
|
||||
@@ -2,6 +2,8 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -20,8 +22,8 @@ func NewAllowlistHandler(deps Deps) *AllowlistHandler {
|
||||
|
||||
type AllowlistData struct {
|
||||
PageData
|
||||
Lists []crowdsec.Allowlist
|
||||
FetchErr string
|
||||
Lists []crowdsec.Allowlist
|
||||
FetchErr string
|
||||
}
|
||||
|
||||
func (h *AllowlistHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -54,8 +56,38 @@ func (h *AllowlistHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// CreateList creates a new named allowlist.
|
||||
func (h *AllowlistHandler) CreateList(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1024)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(r.FormValue("name"))
|
||||
if name == "" {
|
||||
flashRedirect(w, r, "/allowlist", "error", "list name is required")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := h.deps.CLI.CreateAllowlist(ctx, name); err != nil {
|
||||
flashRedirect(w, r, "/allowlist", "error", "create failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
flashRedirect(w, r, "/allowlist", "success", "Allowlist "+name+" created")
|
||||
}
|
||||
|
||||
// AddEntry adds one or more IPs/CIDRs to an allowlist. Auto-creates the list if missing.
|
||||
func (h *AllowlistHandler) AddEntry(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 4096)
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 16384)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
@@ -66,22 +98,65 @@ func (h *AllowlistHandler) AddEntry(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
listName := strings.TrimSpace(r.FormValue("list"))
|
||||
value := strings.TrimSpace(r.FormValue("value"))
|
||||
raw := r.FormValue("value")
|
||||
|
||||
if listName == "" || value == "" {
|
||||
flashRedirect(w, r, "/allowlist", "error", "list name and value are required")
|
||||
if listName == "" {
|
||||
flashRedirect(w, r, "/allowlist", "error", "list name is required")
|
||||
return
|
||||
}
|
||||
|
||||
comment := strings.TrimSpace(r.FormValue("comment"))
|
||||
values, err := parseAllowlistValues(raw)
|
||||
if err != nil {
|
||||
flashRedirect(w, r, "/allowlist", "error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Auto-create the list — CreateAllowlist is a no-op if it already exists.
|
||||
if err := h.deps.CLI.CreateAllowlist(ctx, listName); err != nil {
|
||||
flashRedirect(w, r, "/allowlist", "error", "create list failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.deps.CLI.AddAllowlistEntries(ctx, listName, comment, values); err != nil {
|
||||
flashRedirect(w, r, "/allowlist", "error", "add failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("%d entr%s added to %s", len(values), pluralY(len(values)), listName)
|
||||
flashRedirect(w, r, "/allowlist", "success", msg)
|
||||
}
|
||||
|
||||
// DeleteList removes an entire allowlist.
|
||||
func (h *AllowlistHandler) DeleteList(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1024)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(r.FormValue("name"))
|
||||
if name == "" {
|
||||
flashRedirect(w, r, "/allowlist", "error", "list name is 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())
|
||||
if err := h.deps.CLI.DeleteAllowlist(ctx, name); err != nil {
|
||||
flashRedirect(w, r, "/allowlist", "error", "delete failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
flashRedirect(w, r, "/allowlist", "success", value+" added to "+listName)
|
||||
flashRedirect(w, r, "/allowlist", "success", "Allowlist "+name+" deleted")
|
||||
}
|
||||
|
||||
func (h *AllowlistHandler) RemoveEntry(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -113,3 +188,35 @@ func (h *AllowlistHandler) RemoveEntry(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
flashRedirect(w, r, "/allowlist", "success", value+" removed from "+listName)
|
||||
}
|
||||
|
||||
// parseAllowlistValues splits a raw multi-value string (newline/comma/space delimited)
|
||||
// and validates each entry as an IP address or CIDR range.
|
||||
func parseAllowlistValues(raw string) ([]string, error) {
|
||||
fields := strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return r == ',' || r == '\n' || r == '\r' || r == ' ' || r == '\t'
|
||||
})
|
||||
var out []string
|
||||
for _, f := range fields {
|
||||
f = strings.TrimSpace(f)
|
||||
if f == "" {
|
||||
continue
|
||||
}
|
||||
if net.ParseIP(f) == nil {
|
||||
if _, _, err := net.ParseCIDR(f); err != nil {
|
||||
return nil, fmt.Errorf("invalid IP or CIDR: %q", f)
|
||||
}
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("no valid IP addresses or CIDR ranges provided")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func pluralY(n int) string {
|
||||
if n == 1 {
|
||||
return "y"
|
||||
}
|
||||
return "ies"
|
||||
}
|
||||
|
||||
@@ -4,11 +4,17 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"crowdsec-dashy/internal/crowdsec"
|
||||
)
|
||||
|
||||
// hubItemRE validates hub item names of the form "author/item-name".
|
||||
var hubItemRE = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*/[a-zA-Z0-9][a-zA-Z0-9._-]*$`)
|
||||
|
||||
// HubHandler manages the hub page (collections, parsers, scenarios, postoverflows).
|
||||
type HubHandler struct {
|
||||
deps Deps
|
||||
@@ -18,11 +24,18 @@ func NewHubHandler(deps Deps) *HubHandler {
|
||||
return &HubHandler{deps: deps}
|
||||
}
|
||||
|
||||
// HubItemView wraps a cscli HubItem with dashboard-specific state.
|
||||
type HubItemView struct {
|
||||
crowdsec.HubItem
|
||||
RemovedByUI bool // item was explicitly removed via this dashboard
|
||||
ConfigEditorURL string // pre-built URL for /config-editor, or "" if not applicable
|
||||
}
|
||||
|
||||
// HubData is passed to the hub template.
|
||||
type HubData struct {
|
||||
PageData
|
||||
Tab string
|
||||
Items []crowdsec.HubItem // current tab's items
|
||||
Items []HubItemView
|
||||
}
|
||||
|
||||
// List renders the hub page for the active tab.
|
||||
@@ -39,20 +52,49 @@ func (h *HubHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var rawItems []crowdsec.HubItem
|
||||
var err error
|
||||
switch tab {
|
||||
case "collections":
|
||||
data.Items, err = h.deps.CLI.ListCollections(ctx)
|
||||
rawItems, err = h.deps.CLI.ListCollections(ctx)
|
||||
case "parsers":
|
||||
data.Items, err = h.deps.CLI.ListParsers(ctx)
|
||||
rawItems, err = h.deps.CLI.ListParsers(ctx)
|
||||
case "scenarios":
|
||||
data.Items, err = h.deps.CLI.ListScenarios(ctx)
|
||||
rawItems, err = h.deps.CLI.ListScenarios(ctx)
|
||||
case "postoverflows":
|
||||
data.Items, err = h.deps.CLI.ListPostoverflows(ctx)
|
||||
rawItems, err = h.deps.CLI.ListPostoverflows(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
data.PageData.Flash = FlashMessage{Type: "error", Message: fmt.Sprintf("cscli error: %v", err)}
|
||||
}
|
||||
|
||||
removedSet := h.deps.Store.RemovedSet(tab)
|
||||
configDir := h.deps.CrowdsecConfigDir
|
||||
|
||||
// Build name index so we can detect ghost removed entries later.
|
||||
cscliNames := make(map[string]bool, len(rawItems))
|
||||
for _, item := range rawItems {
|
||||
cscliNames[item.Name] = true
|
||||
}
|
||||
|
||||
data.Items = make([]HubItemView, 0, len(rawItems)+len(removedSet))
|
||||
for _, item := range rawItems {
|
||||
data.Items = append(data.Items, HubItemView{
|
||||
HubItem: item,
|
||||
RemovedByUI: removedSet[item.Name],
|
||||
ConfigEditorURL: buildConfigEditorURL(item.Path, configDir),
|
||||
})
|
||||
}
|
||||
|
||||
// Append ghost entries for removed items absent from cscli output.
|
||||
for name := range removedSet {
|
||||
if !cscliNames[name] {
|
||||
data.Items = append(data.Items, HubItemView{
|
||||
HubItem: crowdsec.HubItem{Name: name},
|
||||
RemovedByUI: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h.deps.Renderer.Render(w, "hub", data)
|
||||
@@ -83,6 +125,10 @@ func (h *HubHandler) Install(w http.ResponseWriter, r *http.Request) {
|
||||
flashRedirect(w, r, hubURL(tab), "error", err.Error())
|
||||
return
|
||||
}
|
||||
if !hubItemRE.MatchString(name) {
|
||||
flashRedirect(w, r, hubURL(tab), "error", fmt.Sprintf("invalid hub item name %q: must be author/item-name", name))
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second)
|
||||
defer cancel()
|
||||
@@ -95,6 +141,8 @@ func (h *HubHandler) Install(w http.ResponseWriter, r *http.Request) {
|
||||
err = h.deps.CLI.InstallParser(ctx, name)
|
||||
case "scenarios":
|
||||
err = h.deps.CLI.InstallScenario(ctx, name)
|
||||
case "postoverflows":
|
||||
err = h.deps.CLI.InstallPostoverflow(ctx, name)
|
||||
default:
|
||||
flashRedirect(w, r, hubURL(tab), "error", "unsupported hub type")
|
||||
return
|
||||
@@ -105,10 +153,13 @@ func (h *HubHandler) Install(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Clear the removed-tracking record so the badge disappears after reinstall.
|
||||
_ = h.deps.Store.RemoveTracked(kind, name)
|
||||
|
||||
flashRedirect(w, r, hubURL(tab), "success", fmt.Sprintf("Installed %s", name))
|
||||
}
|
||||
|
||||
// Remove uninstalls a hub item.
|
||||
// Remove uninstalls a hub item and records it as removed.
|
||||
func (h *HubHandler) Remove(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 4096)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
@@ -145,6 +196,8 @@ func (h *HubHandler) Remove(w http.ResponseWriter, r *http.Request) {
|
||||
err = h.deps.CLI.RemoveParser(ctx, name)
|
||||
case "scenarios":
|
||||
err = h.deps.CLI.RemoveScenario(ctx, name)
|
||||
case "postoverflows":
|
||||
err = h.deps.CLI.RemovePostoverflow(ctx, name)
|
||||
default:
|
||||
flashRedirect(w, r, hubURL(tab), "error", "unsupported hub type")
|
||||
return
|
||||
@@ -155,6 +208,9 @@ func (h *HubHandler) Remove(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Track removal so the item reappears with a "removed" badge + Install button.
|
||||
_ = h.deps.Store.AddRemoved(kind, name)
|
||||
|
||||
flashRedirect(w, r, hubURL(tab), "success", fmt.Sprintf("Removed %s", name))
|
||||
}
|
||||
|
||||
@@ -205,3 +261,23 @@ func validateHubKind(kind string) error {
|
||||
}
|
||||
return fmt.Errorf("invalid hub kind: %q", kind)
|
||||
}
|
||||
|
||||
// buildConfigEditorURL returns a /config-editor URL for itemPath if it is a YAML
|
||||
// file inside configDir, otherwise "".
|
||||
func buildConfigEditorURL(itemPath, configDir string) string {
|
||||
if itemPath == "" || configDir == "" {
|
||||
return ""
|
||||
}
|
||||
dir := configDir
|
||||
if !strings.HasSuffix(dir, "/") {
|
||||
dir += "/"
|
||||
}
|
||||
if !strings.HasPrefix(itemPath, dir) {
|
||||
return ""
|
||||
}
|
||||
rel := strings.TrimPrefix(itemPath, dir)
|
||||
if !strings.HasSuffix(rel, ".yaml") && !strings.HasSuffix(rel, ".yml") {
|
||||
return ""
|
||||
}
|
||||
return "/config-editor?file=" + url.QueryEscape(rel)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"crowdsec-dashy/internal/crowdsec"
|
||||
"crowdsec-dashy/internal/middleware"
|
||||
"crowdsec-dashy/internal/store"
|
||||
)
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -307,4 +308,5 @@ type Deps struct {
|
||||
PollInterval int
|
||||
CrowdsecBinPath string
|
||||
CrowdsecConfigDir string
|
||||
Store *store.Store
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"crowdsec-dashy/internal/geoip"
|
||||
"crowdsec-dashy/internal/handlers"
|
||||
"crowdsec-dashy/internal/middleware"
|
||||
"crowdsec-dashy/internal/store"
|
||||
)
|
||||
|
||||
// New constructs the full HTTP handler: renderer, deps, routes, and middleware chain.
|
||||
@@ -18,6 +20,11 @@ func New(cfg *config.Config, lapi *crowdsec.LAPIClient, webFS fs.FS, geoUpdater
|
||||
return nil, err
|
||||
}
|
||||
|
||||
st, err := store.New(cfg.DataFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("data store: %w", err)
|
||||
}
|
||||
|
||||
deps := handlers.Deps{
|
||||
Renderer: renderer,
|
||||
LAPI: lapi,
|
||||
@@ -26,6 +33,7 @@ func New(cfg *config.Config, lapi *crowdsec.LAPIClient, webFS fs.FS, geoUpdater
|
||||
PollInterval: cfg.PollIntervalSec,
|
||||
CrowdsecBinPath: cfg.CrowdsecBinPath,
|
||||
CrowdsecConfigDir: cfg.CrowdsecConfigDir,
|
||||
Store: st,
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
@@ -90,6 +98,8 @@ func New(cfg *config.Config, lapi *crowdsec.LAPIClient, webFS fs.FS, geoUpdater
|
||||
// Allowlist
|
||||
alw := handlers.NewAllowlistHandler(deps)
|
||||
mux.HandleFunc("GET /allowlist", alw.List)
|
||||
mux.HandleFunc("POST /allowlist/create", alw.CreateList)
|
||||
mux.HandleFunc("POST /allowlist/delete-list", alw.DeleteList)
|
||||
mux.HandleFunc("POST /allowlist/add", alw.AddEntry)
|
||||
mux.HandleFunc("POST /allowlist/remove", alw.RemoveEntry)
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RemovedItem records a hub item explicitly removed via the dashboard.
|
||||
type RemovedItem struct {
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
RemovedAt time.Time `json:"removed_at"`
|
||||
}
|
||||
|
||||
type storeData struct {
|
||||
RemovedItems []RemovedItem `json:"removed_items"`
|
||||
}
|
||||
|
||||
// Store is a file-backed JSON store for dashboard-specific state.
|
||||
type Store struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
data storeData
|
||||
}
|
||||
|
||||
// New opens or creates the store at path. Returns error only for malformed JSON.
|
||||
func New(path string) (*Store, error) {
|
||||
s := &Store{path: path}
|
||||
if err := s.load(); err != nil && !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("store: load %s: %w", path, err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) load() error {
|
||||
b, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(b, &s.data)
|
||||
}
|
||||
|
||||
func (s *Store) save() error {
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := json.MarshalIndent(s.data, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := s.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, b, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, s.path)
|
||||
}
|
||||
|
||||
// AddRemoved records that kind/name was removed via the dashboard.
|
||||
// Idempotent — duplicate calls are no-ops.
|
||||
func (s *Store) AddRemoved(kind, name string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, item := range s.data.RemovedItems {
|
||||
if item.Kind == kind && item.Name == name {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
s.data.RemovedItems = append(s.data.RemovedItems, RemovedItem{
|
||||
Kind: kind,
|
||||
Name: name,
|
||||
RemovedAt: time.Now().UTC(),
|
||||
})
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// RemoveTracked clears the tracking record for kind/name (item re-installed).
|
||||
func (s *Store) RemoveTracked(kind, name string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var filtered []RemovedItem
|
||||
for _, item := range s.data.RemovedItems {
|
||||
if !(item.Kind == kind && item.Name == name) {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
s.data.RemovedItems = filtered
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// RemovedSet returns a set of item names removed for the given kind.
|
||||
func (s *Store) RemovedSet(kind string) map[string]bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
set := make(map[string]bool)
|
||||
for _, item := range s.data.RemovedItems {
|
||||
if item.Kind == kind {
|
||||
set[item.Name] = true
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
Reference in New Issue
Block a user