initial
This commit is contained in:
@@ -0,0 +1,405 @@
|
||||
package crowdsec
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CLIClient wraps the cscli binary.
|
||||
type CLIClient struct {
|
||||
cscliPath string
|
||||
}
|
||||
|
||||
// NewCLIClient creates a new CLI client.
|
||||
// cscliPath should be the absolute path to the cscli binary.
|
||||
func NewCLIClient(cscliPath string) *CLIClient {
|
||||
return &CLIClient{cscliPath: cscliPath}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Bouncers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// ListBouncers returns all registered bouncers.
|
||||
func (c *CLIClient) ListBouncers(ctx context.Context) ([]Bouncer, error) {
|
||||
out, err := c.run(ctx, "bouncers", "list", "-o", "json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var bouncers []Bouncer
|
||||
if err := json.Unmarshal(out, &bouncers); err != nil {
|
||||
return nil, fmt.Errorf("parse bouncers: %w\noutput: %s", err, string(out))
|
||||
}
|
||||
return bouncers, nil
|
||||
}
|
||||
|
||||
// AddBouncer registers a new bouncer and returns the generated API key.
|
||||
func (c *CLIClient) AddBouncer(ctx context.Context, name string) (*AddBouncerResult, error) {
|
||||
if err := validateName(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// cscli bouncers add <name> -o raw → prints only the API key
|
||||
out, err := c.run(ctx, "bouncers", "add", name, "-o", "raw")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
apiKey := strings.TrimSpace(string(out))
|
||||
if apiKey == "" {
|
||||
return nil, fmt.Errorf("no API key returned for bouncer %q", name)
|
||||
}
|
||||
|
||||
return &AddBouncerResult{Name: name, APIKey: apiKey}, nil
|
||||
}
|
||||
|
||||
// DeleteBouncer removes a bouncer by name.
|
||||
func (c *CLIClient) DeleteBouncer(ctx context.Context, name string) error {
|
||||
if err := validateName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := c.run(ctx, "bouncers", "delete", name)
|
||||
return err
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Machines
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// ListMachines returns all registered machines.
|
||||
func (c *CLIClient) ListMachines(ctx context.Context) ([]Machine, error) {
|
||||
out, err := c.run(ctx, "machines", "list", "-o", "json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var machines []Machine
|
||||
if err := json.Unmarshal(out, &machines); err != nil {
|
||||
return nil, fmt.Errorf("parse machines: %w\noutput: %s", err, string(out))
|
||||
}
|
||||
return machines, nil
|
||||
}
|
||||
|
||||
// DeleteMachine removes a machine by ID.
|
||||
func (c *CLIClient) DeleteMachine(ctx context.Context, machineID string) error {
|
||||
if err := validateName(machineID); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := c.run(ctx, "machines", "delete", "--machine-id", machineID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ValidateMachine validates a pending machine registration.
|
||||
func (c *CLIClient) ValidateMachine(ctx context.Context, machineID string) error {
|
||||
if err := validateName(machineID); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := c.run(ctx, "machines", "validate", machineID)
|
||||
return err
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Hub — Collections
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// ListCollections returns all known collections.
|
||||
func (c *CLIClient) ListCollections(ctx context.Context) ([]HubItem, error) {
|
||||
return c.listHubItems(ctx, "collections")
|
||||
}
|
||||
|
||||
// InstallCollection installs a hub collection.
|
||||
func (c *CLIClient) InstallCollection(ctx context.Context, name string) error {
|
||||
return c.hubAction(ctx, "collections", "install", name)
|
||||
}
|
||||
|
||||
// RemoveCollection removes a hub collection.
|
||||
func (c *CLIClient) RemoveCollection(ctx context.Context, name string) error {
|
||||
return c.hubAction(ctx, "collections", "remove", name)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Hub — Parsers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// ListParsers returns all known parsers.
|
||||
func (c *CLIClient) ListParsers(ctx context.Context) ([]HubItem, error) {
|
||||
return c.listHubItems(ctx, "parsers")
|
||||
}
|
||||
|
||||
// InstallParser installs a parser.
|
||||
func (c *CLIClient) InstallParser(ctx context.Context, name string) error {
|
||||
return c.hubAction(ctx, "parsers", "install", name)
|
||||
}
|
||||
|
||||
// RemoveParser removes a parser.
|
||||
func (c *CLIClient) RemoveParser(ctx context.Context, name string) error {
|
||||
return c.hubAction(ctx, "parsers", "remove", name)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Hub — Scenarios
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// ListScenarios returns all known scenarios.
|
||||
func (c *CLIClient) ListScenarios(ctx context.Context) ([]HubItem, error) {
|
||||
return c.listHubItems(ctx, "scenarios")
|
||||
}
|
||||
|
||||
// InstallScenario installs a scenario.
|
||||
func (c *CLIClient) InstallScenario(ctx context.Context, name string) error {
|
||||
return c.hubAction(ctx, "scenarios", "install", name)
|
||||
}
|
||||
|
||||
// RemoveScenario removes a scenario.
|
||||
func (c *CLIClient) RemoveScenario(ctx context.Context, name string) error {
|
||||
return c.hubAction(ctx, "scenarios", "remove", name)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Hub — Postoverflows
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// ListPostoverflows returns all known postoverflows.
|
||||
func (c *CLIClient) ListPostoverflows(ctx context.Context) ([]HubItem, error) {
|
||||
return c.listHubItems(ctx, "postoverflows")
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return fmt.Errorf("hub update: %w", err)
|
||||
}
|
||||
if _, err := c.run(ctx, "hub", "upgrade"); err != nil {
|
||||
return fmt.Errorf("hub upgrade: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Metrics
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// GetMetrics runs cscli metrics and returns parsed sections.
|
||||
func (c *CLIClient) GetMetrics(ctx context.Context) ([]MetricsSection, error) {
|
||||
// cscli metrics does not support JSON output reliably — parse table output
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, err := c.run(ctx, "metrics")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return parseMetricsOutput(string(out)), nil
|
||||
}
|
||||
|
||||
// GetVersion returns cscli version information.
|
||||
func (c *CLIClient) GetVersion(ctx context.Context) (string, error) {
|
||||
out, err := c.run(ctx, "version")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// version output is plain text like "v1.6.4-..."
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// run executes cscli with the given arguments.
|
||||
// Arguments are passed as a slice — never through a shell — preventing injection.
|
||||
// Only specific subcommands are allowed.
|
||||
func (c *CLIClient) run(ctx context.Context, args ...string) ([]byte, error) {
|
||||
if err := validateArgs(args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, c.cscliPath, args...) //nolint:gosec — path validated at startup
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("cscli %s: %w — %s", strings.Join(args, " "), err, stderr.String())
|
||||
}
|
||||
|
||||
return stdout.Bytes(), nil
|
||||
}
|
||||
|
||||
// allowedSubcommands is the strict allow-list of cscli first arguments.
|
||||
var allowedSubcommands = map[string]bool{
|
||||
"bouncers": true,
|
||||
"machines": true,
|
||||
"collections": true,
|
||||
"parsers": true,
|
||||
"scenarios": true,
|
||||
"postoverflows": true,
|
||||
"hub": true,
|
||||
"metrics": true,
|
||||
"version": true,
|
||||
}
|
||||
|
||||
// allowedActions for each subcommand.
|
||||
var allowedActions = map[string]bool{
|
||||
"list": true,
|
||||
"add": true,
|
||||
"delete": true,
|
||||
"install": true,
|
||||
"remove": true,
|
||||
"update": true,
|
||||
"upgrade": true,
|
||||
"validate": true,
|
||||
}
|
||||
|
||||
// safeArg matches strings that are safe to pass as arguments (no shell metacharacters).
|
||||
var safeArg = regexp.MustCompile(`^[a-zA-Z0-9_./:@\-]+$`)
|
||||
|
||||
func validateArgs(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("no subcommand provided")
|
||||
}
|
||||
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
|
||||
for i := 1; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
if strings.HasPrefix(arg, "-") {
|
||||
continue // flags are fine
|
||||
}
|
||||
if allowedActions[arg] {
|
||||
continue // known action words
|
||||
}
|
||||
if arg == "raw" || arg == "json" || arg == "human" {
|
||||
continue // output format values
|
||||
}
|
||||
// Everything else (names, IDs) must match safe pattern
|
||||
if !safeArg.MatchString(arg) {
|
||||
return fmt.Errorf("unsafe cscli argument: %q", arg)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateName checks that a name passed by the user is safe to use as a cscli argument.
|
||||
var validName = regexp.MustCompile(`^[a-zA-Z0-9_\-]{1,64}$`)
|
||||
|
||||
func validateName(name string) error {
|
||||
if !validName.MatchString(name) {
|
||||
return fmt.Errorf("invalid name %q: must be 1-64 alphanumeric/dash/underscore characters", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *CLIClient) listHubItems(ctx context.Context, kind string) ([]HubItem, error) {
|
||||
out, err := c.run(ctx, kind, "list", "-o", "json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// cscli returns either {"<kind>": [...]} or just [...]
|
||||
// Try array first
|
||||
var items []HubItem
|
||||
if err := json.Unmarshal(out, &items); err == nil {
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// Try object wrapper
|
||||
var wrapper map[string][]HubItem
|
||||
if err := json.Unmarshal(out, &wrapper); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w\noutput: %s", kind, err, string(out))
|
||||
}
|
||||
for _, v := range wrapper {
|
||||
return v, nil
|
||||
}
|
||||
return []HubItem{}, nil
|
||||
}
|
||||
|
||||
func (c *CLIClient) hubAction(ctx context.Context, kind, action, name string) error {
|
||||
if err := validateName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := c.run(ctx, kind, action, name)
|
||||
return err
|
||||
}
|
||||
|
||||
// parseMetricsOutput parses the tabular output of `cscli metrics`.
|
||||
// It looks for lines that look like table headers (─── separators).
|
||||
func parseMetricsOutput(output string) []MetricsSection {
|
||||
var sections []MetricsSection
|
||||
var current *MetricsSection
|
||||
var headerFound bool
|
||||
|
||||
scanner := bufio.NewScanner(strings.NewReader(output))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
// Section title lines — plain text before the table
|
||||
if !strings.Contains(line, "│") && !strings.Contains(line, "─") &&
|
||||
strings.TrimSpace(line) != "" && !strings.HasPrefix(strings.TrimSpace(line), "time=") {
|
||||
if current != nil && len(current.Rows) > 0 {
|
||||
sections = append(sections, *current)
|
||||
}
|
||||
current = &MetricsSection{Title: strings.TrimSpace(line)}
|
||||
headerFound = false
|
||||
continue
|
||||
}
|
||||
|
||||
if current == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Header row (contains │ and no numbers)
|
||||
if strings.Contains(line, "│") && !headerFound {
|
||||
parts := splitTableRow(line)
|
||||
if len(parts) > 0 {
|
||||
current.Headers = parts
|
||||
headerFound = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Separator lines
|
||||
if strings.Contains(line, "─") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Data rows
|
||||
if strings.Contains(line, "│") && headerFound {
|
||||
parts := splitTableRow(line)
|
||||
if len(parts) > 0 {
|
||||
current.Rows = append(current.Rows, parts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if current != nil && len(current.Rows) > 0 {
|
||||
sections = append(sections, *current)
|
||||
}
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
func splitTableRow(line string) []string {
|
||||
parts := strings.Split(line, "│")
|
||||
var cells []string
|
||||
for _, p := range parts {
|
||||
trimmed := strings.TrimSpace(p)
|
||||
if trimmed != "" {
|
||||
cells = append(cells, trimmed)
|
||||
}
|
||||
}
|
||||
return cells
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
package crowdsec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LAPIClient is a thread-safe HTTP client for the CrowdSec Local API.
|
||||
type LAPIClient struct {
|
||||
baseURL string
|
||||
login string
|
||||
password string
|
||||
|
||||
mu sync.RWMutex
|
||||
token string // JWT, refreshed on 401
|
||||
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// NewLAPIClient creates a new client. Call Login() before other methods.
|
||||
func NewLAPIClient(baseURL, login, password string) *LAPIClient {
|
||||
return &LAPIClient{
|
||||
baseURL: baseURL,
|
||||
login: login,
|
||||
password: password,
|
||||
http: &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Login authenticates with the LAPI and stores the JWT token.
|
||||
func (c *LAPIClient) Login(ctx context.Context) error {
|
||||
payload := LoginRequest{
|
||||
MachineID: c.login,
|
||||
Password: c.password,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lapi login marshal: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
c.baseURL+"/v1/watchers/login", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("lapi login request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lapi login: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("lapi login: status %d: %s", resp.StatusCode, string(data))
|
||||
}
|
||||
|
||||
var lr LoginResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&lr); err != nil {
|
||||
return fmt.Errorf("lapi login decode: %w", err)
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.token = lr.Token
|
||||
c.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsHealthy returns true if the LAPI responds to a ping.
|
||||
func (c *LAPIClient) IsHealthy(ctx context.Context) bool {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/v1/decisions", nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
c.setAuth(req)
|
||||
req.URL.RawQuery = "limit=1"
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode < 500
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Decisions
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// DecisionFilter contains optional filters for listing decisions.
|
||||
type DecisionFilter struct {
|
||||
Limit int
|
||||
Offset int
|
||||
Type string // ban, captcha, etc.
|
||||
Scope string // Ip, Range, Country
|
||||
Value string // specific IP/value
|
||||
Origin string
|
||||
}
|
||||
|
||||
// ListDecisions returns decisions matching the filter.
|
||||
func (c *LAPIClient) ListDecisions(ctx context.Context, f DecisionFilter) ([]Decision, error) {
|
||||
q := url.Values{}
|
||||
q.Set("limit", strconv.Itoa(max(f.Limit, 100)))
|
||||
if f.Offset > 0 {
|
||||
q.Set("offset", strconv.Itoa(f.Offset))
|
||||
}
|
||||
if f.Type != "" {
|
||||
q.Set("type", f.Type)
|
||||
}
|
||||
if f.Scope != "" {
|
||||
q.Set("scope", f.Scope)
|
||||
}
|
||||
if f.Value != "" {
|
||||
q.Set("value", f.Value)
|
||||
}
|
||||
if f.Origin != "" {
|
||||
q.Set("origin", f.Origin)
|
||||
}
|
||||
|
||||
var decisions []Decision
|
||||
err := c.doJSON(ctx, http.MethodGet, "/v1/decisions?"+q.Encode(), nil, &decisions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if decisions == nil {
|
||||
decisions = []Decision{}
|
||||
}
|
||||
return decisions, nil
|
||||
}
|
||||
|
||||
// DeleteDecision deletes a decision by ID.
|
||||
func (c *LAPIClient) DeleteDecision(ctx context.Context, id int64) error {
|
||||
return c.doJSON(ctx, http.MethodDelete,
|
||||
fmt.Sprintf("/v1/decisions/%d", id), nil, nil)
|
||||
}
|
||||
|
||||
// DeleteAllDecisions deletes all active decisions.
|
||||
func (c *LAPIClient) DeleteAllDecisions(ctx context.Context) error {
|
||||
return c.doJSON(ctx, http.MethodDelete, "/v1/decisions", nil, nil)
|
||||
}
|
||||
|
||||
// AddDecision posts one manual decision to the LAPI.
|
||||
func (c *LAPIClient) AddDecision(ctx context.Context, d DecisionInput) error {
|
||||
payload := struct {
|
||||
Decisions []DecisionInput `json:"decisions"`
|
||||
}{
|
||||
Decisions: []DecisionInput{d},
|
||||
}
|
||||
// The LAPI wraps decisions in an alert object
|
||||
type alertPayload struct {
|
||||
Capacity int32 `json:"capacity"`
|
||||
Decisions []DecisionInput `json:"decisions"`
|
||||
Events []interface{} `json:"events"`
|
||||
EventsCount int32 `json:"events_count"`
|
||||
Leakspeed string `json:"leakspeed"`
|
||||
Message string `json:"message"`
|
||||
ScenarioHash string `json:"scenario_hash"`
|
||||
ScenarioVersion string `json:"scenario_version"`
|
||||
Simulated bool `json:"simulated"`
|
||||
Source AlertSource `json:"source"`
|
||||
StartAt string `json:"start_at"`
|
||||
StopAt string `json:"stop_at"`
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
ap := alertPayload{
|
||||
Capacity: 0,
|
||||
Decisions: payload.Decisions,
|
||||
Events: []interface{}{},
|
||||
EventsCount: 1,
|
||||
Leakspeed: "0",
|
||||
Message: fmt.Sprintf("manual decision for %s", d.Value),
|
||||
Simulated: false,
|
||||
Source: AlertSource{
|
||||
Scope: d.Scope,
|
||||
Value: d.Value,
|
||||
IP: d.Value,
|
||||
},
|
||||
StartAt: now,
|
||||
StopAt: now,
|
||||
}
|
||||
|
||||
return c.doJSON(ctx, http.MethodPost, "/v1/alerts", []alertPayload{ap}, nil)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Alerts
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// AlertFilter contains optional filters for listing alerts.
|
||||
type AlertFilter struct {
|
||||
Limit int
|
||||
Offset int
|
||||
Scenario string
|
||||
IP string
|
||||
Since string // duration string e.g. "24h"
|
||||
}
|
||||
|
||||
// ListAlerts returns alerts matching the filter.
|
||||
func (c *LAPIClient) ListAlerts(ctx context.Context, f AlertFilter) ([]Alert, error) {
|
||||
q := url.Values{}
|
||||
q.Set("limit", strconv.Itoa(max(f.Limit, 100)))
|
||||
if f.Offset > 0 {
|
||||
q.Set("offset", strconv.Itoa(f.Offset))
|
||||
}
|
||||
if f.Scenario != "" {
|
||||
q.Set("scenario", f.Scenario)
|
||||
}
|
||||
if f.IP != "" {
|
||||
q.Set("ip", f.IP)
|
||||
}
|
||||
if f.Since != "" {
|
||||
q.Set("since", f.Since)
|
||||
}
|
||||
|
||||
var alerts []Alert
|
||||
err := c.doJSON(ctx, http.MethodGet, "/v1/alerts?"+q.Encode(), nil, &alerts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if alerts == nil {
|
||||
alerts = []Alert{}
|
||||
}
|
||||
return alerts, nil
|
||||
}
|
||||
|
||||
// DeleteAlert deletes an alert by ID.
|
||||
func (c *LAPIClient) DeleteAlert(ctx context.Context, id int64) error {
|
||||
return c.doJSON(ctx, http.MethodDelete,
|
||||
fmt.Sprintf("/v1/alerts/%d", id), nil, nil)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// doJSON performs an authenticated JSON request, automatically re-logging on 401.
|
||||
func (c *LAPIClient) doJSON(ctx context.Context, method, path string, reqBody, respBody any) error {
|
||||
err := c.doJSONOnce(ctx, method, path, reqBody, respBody)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
// If 401, try refreshing token once
|
||||
if isUnauthorized(err) {
|
||||
if loginErr := c.Login(ctx); loginErr != nil {
|
||||
return fmt.Errorf("lapi re-login failed: %w", loginErr)
|
||||
}
|
||||
return c.doJSONOnce(ctx, method, path, reqBody, respBody)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
type lapiError struct {
|
||||
status int
|
||||
body string
|
||||
}
|
||||
|
||||
func (e *lapiError) Error() string {
|
||||
return fmt.Sprintf("lapi status %d: %s", e.status, e.body)
|
||||
}
|
||||
|
||||
func isUnauthorized(err error) bool {
|
||||
if e, ok := err.(*lapiError); ok {
|
||||
return e.status == http.StatusUnauthorized
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *LAPIClient) doJSONOnce(ctx context.Context, method, path string, reqBody, respBody any) error {
|
||||
var bodyReader io.Reader
|
||||
if reqBody != nil {
|
||||
b, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
bodyReader = bytes.NewReader(b)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bodyReader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
c.setAuth(req)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lapi %s %s: %w", method, path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lapi read body: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return &lapiError{status: resp.StatusCode, body: string(data)}
|
||||
}
|
||||
|
||||
if respBody != nil && len(data) > 0 && string(data) != "null" {
|
||||
if err := json.Unmarshal(data, respBody); err != nil {
|
||||
return fmt.Errorf("lapi decode response: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *LAPIClient) setAuth(r *http.Request) {
|
||||
c.mu.RLock()
|
||||
token := c.token
|
||||
c.mu.RUnlock()
|
||||
if token != "" {
|
||||
r.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
// Package handlers provides the template renderer and all HTTP handlers.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"crowdsec-dashy/internal/crowdsec"
|
||||
)
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Renderer
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// Renderer holds pre-parsed templates, one per page.
|
||||
type Renderer struct {
|
||||
templates map[string]*template.Template
|
||||
funcMap template.FuncMap
|
||||
}
|
||||
|
||||
// NewRenderer parses all page templates against the base layout.
|
||||
func NewRenderer(templateDir string) (*Renderer, error) {
|
||||
r := &Renderer{
|
||||
templates: make(map[string]*template.Template),
|
||||
funcMap: buildFuncMap(),
|
||||
}
|
||||
|
||||
basePath := filepath.Join(templateDir, "layouts", "base.html")
|
||||
|
||||
pages, err := filepath.Glob(filepath.Join(templateDir, "pages", "*.html"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("renderer: glob pages: %w", err)
|
||||
}
|
||||
if len(pages) == 0 {
|
||||
return nil, fmt.Errorf("renderer: no page templates found in %s/pages/", templateDir)
|
||||
}
|
||||
|
||||
for _, page := range pages {
|
||||
name := templateName(page)
|
||||
tmpl, err := template.New("base").
|
||||
Funcs(r.funcMap).
|
||||
ParseFiles(basePath, page)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("renderer: parse %s: %w", name, err)
|
||||
}
|
||||
r.templates[name] = tmpl
|
||||
log.Printf("renderer: registered template %q", name)
|
||||
}
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Render executes the named template into a buffer then writes to w.
|
||||
func (r *Renderer) Render(w http.ResponseWriter, name string, data any) {
|
||||
tmpl, ok := r.templates[name]
|
||||
if !ok {
|
||||
http.Error(w, fmt.Sprintf("template %q not found", name), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.ExecuteTemplate(&buf, "base", data); err != nil {
|
||||
log.Printf("renderer: execute %q: %v", name, err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = buf.WriteTo(w)
|
||||
}
|
||||
|
||||
// RenderError renders the error page.
|
||||
func (r *Renderer) RenderError(w http.ResponseWriter, code int, msg string) {
|
||||
w.WriteHeader(code)
|
||||
r.Render(w, "error", ErrorData{
|
||||
PageData: PageData{CurrentPath: "/error", Title: fmt.Sprintf("Error %d", code)},
|
||||
Code: code,
|
||||
Message: msg,
|
||||
})
|
||||
}
|
||||
|
||||
func templateName(path string) string {
|
||||
base := filepath.Base(path)
|
||||
return base[:len(base)-len(filepath.Ext(base))]
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// FuncMap
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
func buildFuncMap() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"inc": func(i int) int { return i + 1 },
|
||||
"dec": func(i int) int { return i - 1 },
|
||||
// dict builds a map for passing multiple values to a sub-template.
|
||||
// Usage: {{template "foo" dict "Key1" val1 "Key2" val2}}
|
||||
"dict": func(pairs ...any) (map[string]any, error) {
|
||||
if len(pairs)%2 != 0 {
|
||||
return nil, fmt.Errorf("dict: odd number of arguments")
|
||||
}
|
||||
m := make(map[string]any, len(pairs)/2)
|
||||
for i := 0; i < len(pairs); i += 2 {
|
||||
k, ok := pairs[i].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("dict: key %v is not a string", pairs[i])
|
||||
}
|
||||
m[k] = pairs[i+1]
|
||||
}
|
||||
return m, nil
|
||||
},
|
||||
// decisionTypeBadge returns a CSS class suffix for a decision type.
|
||||
"decisionBadgeClass": func(t string) string {
|
||||
switch strings.ToLower(t) {
|
||||
case "ban":
|
||||
return "badge-red"
|
||||
case "captcha":
|
||||
return "badge-amber"
|
||||
case "throttle":
|
||||
return "badge-blue"
|
||||
default:
|
||||
return "badge-gray"
|
||||
}
|
||||
},
|
||||
// originBadgeClass returns a CSS class for a decision origin.
|
||||
"originBadgeClass": func(o string) string {
|
||||
switch strings.ToLower(o) {
|
||||
case "crowdsec":
|
||||
return "badge-cyan"
|
||||
case "cscli":
|
||||
return "badge-purple"
|
||||
case "lists":
|
||||
return "badge-green"
|
||||
default:
|
||||
return "badge-gray"
|
||||
}
|
||||
},
|
||||
// hubStatusClass returns a CSS class for a hub item status.
|
||||
"hubStatusClass": func(s string) string {
|
||||
switch strings.ToLower(s) {
|
||||
case "enabled":
|
||||
return "badge-green"
|
||||
case "disabled":
|
||||
return "badge-gray"
|
||||
case "update-available":
|
||||
return "badge-amber"
|
||||
default:
|
||||
return "badge-gray"
|
||||
}
|
||||
},
|
||||
// safeHTML allows raw HTML in templates (use carefully).
|
||||
"safeHTML": func(s string) template.HTML {
|
||||
return template.HTML(s) //nolint:gosec
|
||||
},
|
||||
// boolIcon returns a UTF-8 icon for boolean display.
|
||||
"boolIcon": func(b bool) string {
|
||||
if b {
|
||||
return "✓"
|
||||
}
|
||||
return "✗"
|
||||
},
|
||||
// truncate shortens a string.
|
||||
"truncate": func(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
},
|
||||
// join joins a string slice.
|
||||
"join": strings.Join,
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Shared page data
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// NavItem describes one sidebar navigation entry.
|
||||
type NavItem struct {
|
||||
Path string
|
||||
Label string
|
||||
Icon string
|
||||
Divider bool // show divider before this item
|
||||
}
|
||||
|
||||
// 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: "/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: "/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: "/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>`},
|
||||
}
|
||||
|
||||
// PageData contains fields available to every template.
|
||||
type PageData struct {
|
||||
CurrentPath string
|
||||
Title string
|
||||
Nav []NavItem
|
||||
Flash FlashMessage
|
||||
CLIAvailable bool
|
||||
PollInterval int
|
||||
}
|
||||
|
||||
// FlashMessage is a one-shot notification shown on the next page load.
|
||||
type FlashMessage struct {
|
||||
Type string // success, error, warning, info
|
||||
Message string
|
||||
}
|
||||
|
||||
// ErrorData is passed to the error page template.
|
||||
type ErrorData struct {
|
||||
PageData
|
||||
Code int
|
||||
Message string
|
||||
}
|
||||
|
||||
// NewPageData creates PageData from the current request.
|
||||
func NewPageData(r *http.Request, title string, cliAvail bool, pollSec int) PageData {
|
||||
return PageData{
|
||||
CurrentPath: r.URL.Path,
|
||||
Title: title,
|
||||
Nav: SidebarNav,
|
||||
CLIAvailable: cliAvail,
|
||||
PollInterval: pollSec,
|
||||
}
|
||||
}
|
||||
|
||||
// WithFlash attaches a flash message to PageData.
|
||||
func (pd PageData) WithFlash(flashType, msg string) PageData {
|
||||
pd.Flash = FlashMessage{Type: flashType, Message: msg}
|
||||
return pd
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Shared handler dependencies
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// Deps holds shared dependencies injected into every handler.
|
||||
type Deps struct {
|
||||
Renderer *Renderer
|
||||
LAPI *crowdsec.LAPIClient
|
||||
CLI *crowdsec.CLIClient
|
||||
CLIAvailable bool
|
||||
PollInterval int
|
||||
}
|
||||
Reference in New Issue
Block a user