This commit is contained in:
2026-05-17 04:54:34 +00:00
commit 64f4f3c5d4
12 changed files with 2624 additions and 0 deletions
+405
View File
@@ -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
}
+337
View File
@@ -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
}