function update

This commit is contained in:
2026-05-19 04:30:14 +00:00
parent 1293bafffa
commit 3f82dfd9e9
13 changed files with 8080 additions and 152 deletions
+116 -9
View File
@@ -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"
}
+82 -6
View File
@@ -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)
}
+2
View File
@@ -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
}