function update
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user