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
+7363
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+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
}
+10
View File
@@ -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)
+105
View File
@@ -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
}
+66 -1
View File
@@ -4,6 +4,7 @@
var el = {};
var _resolve = null;
var _busy = false;
var _mode = 'confirm'; // 'confirm' | 'prompt'
function build() {
el.overlay = document.createElement('div');
@@ -25,6 +26,20 @@
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';
// Prompt input (hidden in confirm mode)
el.inputWrap = document.createElement('div');
el.inputWrap.style.cssText = 'margin-bottom:12px;display:none';
el.input = document.createElement('input');
el.input.type = 'text';
el.input.className = 'field-input';
el.input.style.cssText = 'width:100%;box-sizing:border-box;font-family:"JetBrains Mono",monospace;font-size:13px';
el.inputWrap.appendChild(el.input);
el.inputErr = document.createElement('div');
el.inputErr.style.cssText = 'margin-top:6px;font-size:12px;color:#ff3b3b;display:none';
el.inputWrap.appendChild(el.inputErr);
el.row = document.createElement('div');
el.row.style.cssText = 'display:flex;gap:8px;justify-content:flex-end';
@@ -38,12 +53,14 @@
el.row.appendChild(el.cancelBtn);
el.row.appendChild(el.confirmBtn);
el.box.appendChild(el.msg);
el.box.appendChild(el.inputWrap);
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.confirmBtn.addEventListener('click', function () { submitModal(); });
el.input.addEventListener('keydown', function (e) { if (e.key === 'Enter') submitModal(); });
el.overlay.addEventListener('click', function (e) {
if (e.target === el.overlay) close(false);
});
@@ -52,17 +69,61 @@
});
}
var _promptPattern = null;
function openModal(msg, confirmLabel, isDanger, showCancel) {
if (!el.overlay) build();
_mode = 'confirm';
_promptPattern = null;
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.inputWrap.style.display = 'none';
el.overlay.style.display = 'flex';
el.confirmBtn.focus();
return new Promise(function (res) { _resolve = res; });
}
function openPrompt(msg, placeholder, pattern, confirmLabel) {
if (!el.overlay) build();
_mode = 'prompt';
_promptPattern = pattern || null;
el.msg.textContent = msg;
el.confirmBtn.textContent = confirmLabel || 'Install';
el.confirmBtn.className = 'btn-primary';
el.cancelBtn.style.display = '';
el.input.value = '';
el.input.placeholder = placeholder || '';
el.inputErr.style.display = 'none';
el.inputErr.textContent = '';
el.inputWrap.style.display = 'block';
el.overlay.style.display = 'flex';
setTimeout(function () { el.input.focus(); }, 50);
return new Promise(function (res) { _resolve = res; });
}
function submitModal() {
if (_mode === 'prompt') {
var val = el.input.value.trim();
if (!val) {
el.inputErr.textContent = 'Please enter a value.';
el.inputErr.style.display = 'block';
el.input.focus();
return;
}
if (_promptPattern && !_promptPattern.test(val)) {
el.inputErr.textContent = 'Invalid format. Use: author/item-name (e.g. crowdsecurity/ssh-bf)';
el.inputErr.style.display = 'block';
el.input.focus();
return;
}
close(val);
} else {
close(true);
}
}
function close(result) {
if (el.overlay) el.overlay.style.display = 'none';
if (_resolve) { _resolve(result); _resolve = null; }
@@ -76,6 +137,10 @@
// Info/alert dialog — returns Promise<void>
info: function (msg) {
return openModal(msg, 'OK', false, false);
},
// Text input dialog — returns Promise<string|null>
prompt: function (msg, placeholder, pattern, confirmLabel) {
return openPrompt(msg, placeholder, pattern, confirmLabel);
}
};
+59 -20
View File
@@ -16,28 +16,61 @@
</div>
{{else}}
<div class="panel" style="margin-bottom:16px">
<div class="panel-header">
<span class="panel-title">Add Entry</span>
</div>
<div class="panel-body">
<form method="POST" action="/allowlist/add">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:16px;margin-bottom:16px">
<div>
<label class="field-label">Allowlist Name</label>
<input class="field-input" type="text" name="list" placeholder="default" required>
<div style="display:grid;grid-template-columns:2fr 1fr;gap:16px;margin-bottom:16px">
<div class="panel">
<div class="panel-header"><span class="panel-title">Add IPs to Allowlist</span></div>
<div class="panel-body">
<form method="POST" action="/allowlist/add">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<div style="display:grid;gap:12px;margin-bottom:16px">
<div>
<label class="field-label">Allowlist Name</label>
<input class="field-input" type="text" name="list" placeholder="default" required
list="list-names">
{{if .Lists}}
<datalist id="list-names">
{{range .Lists}}<option value="{{.Name}}">{{end}}
</datalist>
{{end}}
<div style="font-size:11px;color:var(--muted);margin-top:4px">
If the list does not exist it will be created automatically.
</div>
</div>
<div>
<label class="field-label">Comment (optional)</label>
<input class="field-input" type="text" name="comment" placeholder="e.g. trusted office IP">
</div>
<div>
<label class="field-label">IP Addresses / CIDR Ranges</label>
<textarea class="field-input" name="value" rows="5"
placeholder="One per line, or comma-separated:&#10;1.2.3.4&#10;192.168.0.0/24&#10;8.8.8.8, 8.8.4.4"
required style="resize:vertical;font-family:'JetBrains Mono',monospace;font-size:12px"></textarea>
</div>
</div>
<div>
<label class="field-label">Value (IP or CIDR)</label>
<input class="field-input" type="text" name="value" placeholder="1.2.3.4 or 1.2.3.0/24" required>
<div style="display:flex;justify-content:flex-end">
<button type="submit" class="btn-primary">Add to Allowlist</button>
</div>
</div>
<div style="display:flex;justify-content:flex-end">
<button type="submit" class="btn-primary">Add to Allowlist</button>
</div>
</form>
</form>
</div>
</div>
<div class="panel">
<div class="panel-header"><span class="panel-title">Create New List</span></div>
<div class="panel-body">
<form method="POST" action="/allowlist/create">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<div style="margin-bottom:16px">
<label class="field-label">List Name</label>
<input class="field-input" type="text" name="name" placeholder="mylist" required>
</div>
<div style="display:flex;justify-content:flex-end">
<button type="submit" class="btn-secondary">Create List</button>
</div>
</form>
</div>
</div>
</div>
{{if .Lists}}
@@ -46,6 +79,12 @@
<div class="panel-header">
<span class="panel-title">{{.Name}}</span>
{{if .Description}}<span style="font-size:12px;color:var(--muted)">{{.Description}}</span>{{end}}
<form method="POST" action="/allowlist/delete-list" style="margin-left:auto">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="name" value="{{.Name}}">
<button type="submit" class="btn-danger-sm"
data-confirm="Delete entire allowlist '{{.Name}}'? This cannot be undone.">Delete List</button>
</form>
</div>
{{if .Items}}
<table class="data-table">
@@ -88,7 +127,7 @@
<div class="panel">
<div class="empty-state">
<div class="empty-text">No allowlists found</div>
<div class="empty-sub">Create an allowlist with cscli allowlists create, then add entries above</div>
<div class="empty-sub">Use "Create New List" above, or type a list name in the Add form — it will be created automatically.</div>
</div>
</div>
{{end}}
+105 -59
View File
@@ -78,67 +78,100 @@
</div>
</div>
<div class="panel">
<form method="POST" action="/decisions/delete" id="bulk-form">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<div class="panel-header">
<span class="panel-title">
Page {{.Page}}{{if .HasNext}} (more available){{end}}
</span>
<div style="display:flex;gap:8px;align-items:center">
<button type="button" class="btn-ghost-sm" onclick="toggleAll()">Select all</button>
<button type="submit" class="btn-danger-sm" data-confirm-fn="confirmBulkDelete">Delete selected</button>
</div>
</div>
{{/* Bulk-delete form lives outside the table so row-level forms are never nested inside it.
Checkboxes reference it via form="bulk-form". */}}
<form id="bulk-form" method="POST" action="/decisions/delete" style="display:none">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
</form>
{{if .Decisions}}
<div style="overflow-x:auto">
<table class="data-table">
<thead>
<tr>
<th style="width:36px"><input type="checkbox" id="chk-all" onchange="selectAll(this)"></th>
<th>Value</th>
<th>Type</th>
<th>Scope</th>
<th>Origin</th>
<th>Scenario</th>
<th>Duration</th>
<th>Expires</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{{range .Decisions}}
<tr>
<td><input type="checkbox" name="id" value="{{.ID}}" class="row-chk"></td>
<td style="font-family:'JetBrains Mono',monospace;font-size:12px">
{{if eq .Scope "Country"}}{{countryFlag .Value}} {{end}}{{.Value}}
</td>
<td><span class="badge {{decisionBadgeClass .Type}}">{{.Type}}</span></td>
<td><span class="badge badge-gray">{{.Scope}}</span></td>
<td><span class="badge {{originBadgeClass .Origin}}">{{.Origin}}</span></td>
<td style="font-size:12px;color:var(--muted)">{{truncate .Scenario 24}}</td>
<td style="font-family:'JetBrains Mono',monospace;font-size:12px">{{.Duration}}</td>
<td style="font-size:12px;color:var(--muted)">{{if .Until}}{{truncate .Until 16}}{{else}}{{.Duration}}{{end}}</td>
<td>
<form method="POST" action="/decisions/delete" style="display:inline">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="btn-danger-sm" data-confirm="Delete this decision?">Delete</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
<div class="panel">
<div class="panel-header">
<span class="panel-title">
Page {{.Page}}{{if .HasNext}} (more available){{end}}
</span>
<div style="display:flex;gap:8px;align-items:center">
<button type="button" class="btn-ghost-sm" onclick="toggleAll()">Select all</button>
<button type="submit" form="bulk-form" class="btn-danger-sm"
data-confirm-fn="confirmBulkDelete">Delete selected</button>
</div>
{{else}}
<div class="empty-state">
<div class="empty-text">No decisions found</div>
<div class="empty-sub">No active decisions match the current filters</div>
</div>
{{end}}
</form>
</div>
{{if .Decisions}}
<div style="overflow-x:auto">
<table class="data-table">
<thead>
<tr>
<th style="width:36px"><input type="checkbox" id="chk-all" onchange="selectAll(this)"></th>
<th>Value</th>
<th>Type</th>
<th>Scope</th>
<th>Origin</th>
<th>Scenario</th>
<th>Duration</th>
<th>Expires</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{range .Decisions}}
<tr>
{{/* form="bulk-form" associates this checkbox with the external bulk form */}}
<td><input type="checkbox" name="id" value="{{.ID}}" class="row-chk" form="bulk-form"></td>
<td style="font-family:'JetBrains Mono',monospace;font-size:12px">
{{if eq .Scope "Country"}}{{countryFlag .Value}} {{end}}{{.Value}}
</td>
<td><span class="badge {{decisionBadgeClass .Type}}">{{.Type}}</span></td>
<td><span class="badge badge-gray">{{.Scope}}</span></td>
<td><span class="badge {{originBadgeClass .Origin}}">{{.Origin}}</span></td>
<td style="font-size:12px;color:var(--muted)">{{truncate .Scenario 24}}</td>
<td style="font-family:'JetBrains Mono',monospace;font-size:12px">{{.Duration}}</td>
<td style="font-size:12px;color:var(--muted)">{{if .Until}}{{truncate .Until 16}}{{else}}{{.Duration}}{{end}}</td>
<td style="display:flex;gap:4px;flex-wrap:wrap;align-items:center">
{{/* Permanent — standalone form, not inside bulk-form */}}
<form method="POST" action="/decisions/add">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="value" value="{{.Value}}">
<input type="hidden" name="scope" value="{{.Scope}}">
<input type="hidden" name="type" value="{{.Type}}">
<input type="hidden" name="duration" value="999999h">
<input type="hidden" name="scenario" value="manual">
<button type="submit" class="btn-ghost-sm"
data-confirm="Make {{.Value}} permanent (999999h)?">Permanent</button>
</form>
{{/* Extend — standalone hidden form, submitted by JS after modal */}}
<form id="ext-{{.ID}}" method="POST" action="/decisions/add">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="value" value="{{.Value}}">
<input type="hidden" name="scope" value="{{.Scope}}">
<input type="hidden" name="type" value="{{.Type}}">
<input type="hidden" name="duration" id="ext-dur-{{.ID}}" value="">
<input type="hidden" name="scenario" value="manual">
</form>
<button type="button" class="btn-ghost-sm"
onclick="extendDecision({{.ID}})">Extend</button>
{{/* Delete — standalone form, not inside bulk-form */}}
<form method="POST" action="/decisions/delete">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="btn-danger-sm"
data-confirm="Delete decision for {{.Value}}?">Delete</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="empty-state">
<div class="empty-text">No decisions found</div>
<div class="empty-sub">No active decisions match the current filters</div>
</div>
{{end}}
{{if or (gt .Page 1) .HasNext}}
<div class="pagination">
@@ -174,5 +207,18 @@ function confirmBulkDelete() {
if (n === 0) { appModal.info('Select at least one decision.'); return null; }
return 'Delete ' + n + ' decision(s)? This cannot be undone.';
}
var _durPattern = /^\d+[smhdw]$/;
function extendDecision(id) {
appModal.prompt(
'Extend decision\n\nEnter new duration:',
'48h',
_durPattern,
'Extend'
).then(function(dur) {
if (!dur) return;
document.getElementById('ext-dur-' + id).value = dur;
document.getElementById('ext-' + id).submit();
});
}
</script>
{{end}}
+77 -32
View File
@@ -8,19 +8,30 @@
<div class="page-sub">Collections, parsers, scenarios, and postoverflows</div>
</div>
{{if .CLIAvailable}}
<form method="POST" action="/hub/update">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<button type="submit" class="btn-secondary"
data-confirm="Run hub update + upgrade? This may take a minute." data-confirm-label="Update">Update All</button>
</form>
<div style="display:flex;gap:8px">
<button type="button" class="btn-secondary" onclick="promptInstallNew()">Install New...</button>
<form method="POST" action="/hub/update">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<button type="submit" class="btn-secondary"
data-confirm="Run hub update + upgrade? This may take a minute." data-confirm-label="Update">Update All</button>
</form>
</div>
{{end}}
</div>
{{/* Hidden form used by Install New modal */}}
<form id="install-new-form" method="POST" action="/hub/install" style="display:none">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<input type="hidden" name="tab" value="{{.Tab}}">
<input type="hidden" name="kind" value="{{.Tab}}">
<input type="hidden" name="name" id="install-new-name" value="">
</form>
{{if not .CLIAvailable}}
<div class="cli-unavail-banner" style="margin-bottom:16px">
<div>
<strong style="display:block;margin-bottom:4px">cscli unavailable</strong>
Hub management requires the cscli binary. Mount it at the CSCLI_PATH configured in your environment.
Hub management requires the cscli binary.
</div>
</div>
{{end}}
@@ -40,50 +51,57 @@
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Version</th>
<th>State</th>
<th>Action</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{$tab := .Tab}}
{{range .Items}}
<tr>
{{$active := or .Installed .Downloaded (ne .Status "")}}
{{$needsUpdate := eq .Status "update-available"}}
<tr{{if .RemovedByUI}} style="opacity:0.7"{{end}}>
<td>
<div style="font-family:'JetBrains Mono',monospace;font-size:12px;font-weight:600">{{.Name}}</div>
{{if .Description}}<div style="font-size:11px;color:var(--muted);margin-top:2px">{{truncate .Description 64}}</div>{{end}}
</td>
<td><span class="badge {{hubStatusClass .Status}}">{{.Status}}</span></td>
<td style="font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--muted)">{{.Version}}</td>
<td style="font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--muted)">
{{if .LocalVersion}}{{.LocalVersion}}{{else if and $active .Version}}{{.Version}}{{else}}—{{end}}
</td>
<td>
{{if .Tainted}}
<span class="badge badge-amber">tainted</span>
{{else if .UpToDate}}
<span class="badge badge-green">up to date</span>
{{else if .Installed}}
<span class="badge badge-amber">update avail</span>
{{else if $needsUpdate}}
<span class="badge badge-amber">update available</span>
{{else if $active}}
<span class="badge badge-green">installed</span>
{{else if .RemovedByUI}}
<span class="badge badge-purple">removed</span>
{{else}}
<span class="badge badge-gray">not installed</span>
{{end}}
</td>
<td>
{{if .Installed}}
<form method="POST" action="/hub/remove" style="display:inline">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="kind" value="{{$tab}}">
<input type="hidden" name="tab" value="{{$tab}}">
<input type="hidden" name="name" value="{{.Name}}">
<button type="submit" class="btn-danger-sm" data-confirm="Remove {{.Name}}?">Remove</button>
</form>
<td style="display:flex;gap:6px;flex-wrap:wrap">
{{if $active}}
{{if .ConfigEditorURL}}
<a href="{{.ConfigEditorURL}}" class="btn-ghost-sm">Edit Config</a>
{{end}}
<form method="POST" action="/hub/remove" style="display:inline">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="kind" value="{{$tab}}">
<input type="hidden" name="tab" value="{{$tab}}">
<input type="hidden" name="name" value="{{.Name}}">
<button type="submit" class="btn-danger-sm" data-confirm="Remove {{.Name}}? Files will be deleted.">Remove</button>
</form>
{{else}}
<form method="POST" action="/hub/install" style="display:inline">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="kind" value="{{$tab}}">
<input type="hidden" name="tab" value="{{$tab}}">
<input type="hidden" name="name" value="{{.Name}}">
<button type="submit" class="btn-safe-sm">Install</button>
</form>
<form method="POST" action="/hub/install" style="display:inline">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="kind" value="{{$tab}}">
<input type="hidden" name="tab" value="{{$tab}}">
<input type="hidden" name="name" value="{{.Name}}">
<button type="submit" class="btn-safe-sm">Install</button>
</form>
{{end}}
</td>
</tr>
@@ -94,10 +112,37 @@
{{else}}
<div class="empty-state">
<div class="empty-text">No {{.Tab}} found</div>
<div class="empty-sub">Run "Update All" to refresh the hub index</div>
<div class="empty-sub">Run "Update All" to refresh the hub index, then use "Install New..." to add items</div>
</div>
{{end}}
{{end}}
</div>
</div>
{{end}}
{{define "scripts"}}
<script>
var _hubTab = '{{.Tab}}';
var _tabSingular = {
collections: 'collection',
parsers: 'parser',
scenarios: 'scenario',
postoverflows: 'postoverflow'
};
var _hubItemPattern = /^[a-zA-Z0-9][a-zA-Z0-9_-]*\/[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
function promptInstallNew() {
var kind = _tabSingular[_hubTab] || _hubTab;
appModal.prompt(
'Install ' + kind + '\n\nEnter the hub item name (author/item-name):',
'e.g. crowdsecurity/ssh-bf',
_hubItemPattern,
'Install'
).then(function (name) {
if (!name) return;
document.getElementById('install-new-name').value = name;
document.getElementById('install-new-form').submit();
});
}
</script>
{{end}}