- Add logging when enrollment is silently skipped due to existing state - Add DELETE /admin/crowdsec/console/enrollment endpoint to clear state - Add re-enrollment UI section with guidance and crowdsec.net link - Add useClearConsoleEnrollment hook for state clearing Fixes silent idempotency bug where backend returned 200 OK without actually executing cscli when status was already enrolled.
522 lines
16 KiB
Go
522 lines
16 KiB
Go
package crowdsec
|
|
|
|
import (
|
|
"context"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/Wikid82/charon/backend/internal/logger"
|
|
"github.com/Wikid82/charon/backend/internal/models"
|
|
)
|
|
|
|
const (
|
|
consoleStatusNotEnrolled = "not_enrolled"
|
|
consoleStatusEnrolling = "enrolling"
|
|
consoleStatusPendingAcceptance = "pending_acceptance"
|
|
consoleStatusEnrolled = "enrolled"
|
|
consoleStatusFailed = "failed"
|
|
|
|
defaultEnrollTimeout = 45 * time.Second
|
|
)
|
|
|
|
var namePattern = regexp.MustCompile(`^[A-Za-z0-9_.\-]{1,64}$`)
|
|
var enrollmentTokenPattern = regexp.MustCompile(`^[A-Za-z0-9]{10,64}$`)
|
|
|
|
// EnvCommandExecutor executes commands with optional environment overrides.
|
|
type EnvCommandExecutor interface {
|
|
ExecuteWithEnv(ctx context.Context, name string, args []string, env map[string]string) ([]byte, error)
|
|
}
|
|
|
|
// SecureCommandExecutor is the production executor that avoids leaking args by passing secrets via env.
|
|
type SecureCommandExecutor struct{}
|
|
|
|
// ExecuteWithEnv runs the command with provided env merged onto the current environment.
|
|
func (r *SecureCommandExecutor) ExecuteWithEnv(ctx context.Context, name string, args []string, env map[string]string) ([]byte, error) {
|
|
cmd := exec.CommandContext(ctx, name, args...)
|
|
cmd.Env = append(os.Environ(), formatEnv(env)...)
|
|
return cmd.CombinedOutput()
|
|
}
|
|
|
|
func formatEnv(env map[string]string) []string {
|
|
if len(env) == 0 {
|
|
return nil
|
|
}
|
|
result := make([]string, 0, len(env))
|
|
for k, v := range env {
|
|
result = append(result, fmt.Sprintf("%s=%s", k, v))
|
|
}
|
|
return result
|
|
}
|
|
|
|
// ConsoleEnrollRequest captures enrollment input.
|
|
type ConsoleEnrollRequest struct {
|
|
EnrollmentKey string
|
|
Tenant string
|
|
AgentName string
|
|
Force bool
|
|
}
|
|
|
|
// ConsoleEnrollmentStatus is the safe, redacted status view.
|
|
type ConsoleEnrollmentStatus struct {
|
|
Status string `json:"status"`
|
|
Tenant string `json:"tenant"`
|
|
AgentName string `json:"agent_name"`
|
|
LastError string `json:"last_error,omitempty"`
|
|
LastAttemptAt *time.Time `json:"last_attempt_at,omitempty"`
|
|
EnrolledAt *time.Time `json:"enrolled_at,omitempty"`
|
|
LastHeartbeatAt *time.Time `json:"last_heartbeat_at,omitempty"`
|
|
KeyPresent bool `json:"key_present"`
|
|
CorrelationID string `json:"correlation_id,omitempty"`
|
|
}
|
|
|
|
// ConsoleEnrollmentService manages console enrollment lifecycle and persistence.
|
|
type ConsoleEnrollmentService struct {
|
|
db *gorm.DB
|
|
exec EnvCommandExecutor
|
|
dataDir string
|
|
key []byte
|
|
nowFn func() time.Time
|
|
mu sync.Mutex
|
|
timeout time.Duration
|
|
}
|
|
|
|
// NewConsoleEnrollmentService constructs a service using the supplied secret material for encryption.
|
|
func NewConsoleEnrollmentService(db *gorm.DB, executor EnvCommandExecutor, dataDir, secret string) *ConsoleEnrollmentService {
|
|
return &ConsoleEnrollmentService{
|
|
db: db,
|
|
exec: executor,
|
|
dataDir: dataDir,
|
|
key: deriveKey(secret),
|
|
nowFn: time.Now,
|
|
timeout: defaultEnrollTimeout,
|
|
}
|
|
}
|
|
|
|
// Status returns the current enrollment state.
|
|
func (s *ConsoleEnrollmentService) Status(ctx context.Context) (ConsoleEnrollmentStatus, error) {
|
|
rec, err := s.load(ctx)
|
|
if err != nil {
|
|
return ConsoleEnrollmentStatus{}, err
|
|
}
|
|
return s.statusFromModel(rec), nil
|
|
}
|
|
|
|
// Enroll performs an enrollment attempt. It is idempotent when already enrolled unless Force is set.
|
|
func (s *ConsoleEnrollmentService) Enroll(ctx context.Context, req ConsoleEnrollRequest) (ConsoleEnrollmentStatus, error) {
|
|
agent := strings.TrimSpace(req.AgentName)
|
|
if agent == "" {
|
|
return ConsoleEnrollmentStatus{}, fmt.Errorf("agent_name required")
|
|
}
|
|
if !namePattern.MatchString(agent) {
|
|
return ConsoleEnrollmentStatus{}, fmt.Errorf("agent_name may only include letters, numbers, dot, dash, underscore")
|
|
}
|
|
tenant := strings.TrimSpace(req.Tenant)
|
|
if tenant != "" && !namePattern.MatchString(tenant) {
|
|
return ConsoleEnrollmentStatus{}, fmt.Errorf("tenant may only include letters, numbers, dot, dash, underscore")
|
|
}
|
|
token, err := normalizeEnrollmentKey(req.EnrollmentKey)
|
|
if err != nil {
|
|
return ConsoleEnrollmentStatus{}, err
|
|
}
|
|
if s.exec == nil {
|
|
return ConsoleEnrollmentStatus{}, fmt.Errorf("executor unavailable")
|
|
}
|
|
|
|
// CRITICAL: Check that LAPI is running before attempting enrollment
|
|
// Console enrollment requires an active LAPI connection to register with crowdsec.net
|
|
if err := s.checkLAPIAvailable(ctx); err != nil {
|
|
return ConsoleEnrollmentStatus{}, err
|
|
}
|
|
|
|
if err := s.ensureCAPIRegistered(ctx); err != nil {
|
|
return ConsoleEnrollmentStatus{}, err
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
rec, err := s.load(ctx)
|
|
if err != nil {
|
|
return ConsoleEnrollmentStatus{}, err
|
|
}
|
|
|
|
if rec.Status == consoleStatusEnrolling {
|
|
return s.statusFromModel(rec), fmt.Errorf("enrollment already in progress")
|
|
}
|
|
// If already enrolled or pending acceptance, skip unless Force is set
|
|
if (rec.Status == consoleStatusEnrolled || rec.Status == consoleStatusPendingAcceptance) && !req.Force {
|
|
logger.Log().WithFields(map[string]interface{}{
|
|
"status": rec.Status,
|
|
"agent_name": rec.AgentName,
|
|
"tenant": rec.Tenant,
|
|
}).Info("console enrollment skipped: already enrolled or pending acceptance - use force=true to re-enroll")
|
|
return s.statusFromModel(rec), nil
|
|
}
|
|
|
|
now := s.nowFn().UTC()
|
|
rec.Status = consoleStatusEnrolling
|
|
rec.AgentName = agent
|
|
rec.Tenant = tenant
|
|
rec.LastAttemptAt = &now
|
|
rec.LastError = ""
|
|
rec.LastCorrelationID = uuid.NewString()
|
|
|
|
encryptedKey, err := s.encrypt(token)
|
|
if err != nil {
|
|
return ConsoleEnrollmentStatus{}, fmt.Errorf("protect secret: %w", err)
|
|
}
|
|
rec.EncryptedEnrollKey = encryptedKey
|
|
|
|
if err := s.db.WithContext(ctx).Save(rec).Error; err != nil {
|
|
return ConsoleEnrollmentStatus{}, err
|
|
}
|
|
|
|
cmdCtx, cancel := context.WithTimeout(ctx, s.timeout)
|
|
defer cancel()
|
|
|
|
args := []string{"console", "enroll", "--name", agent}
|
|
|
|
// Add tenant as a tag if provided
|
|
if tenant != "" {
|
|
args = append(args, "--tags", fmt.Sprintf("tenant:%s", tenant))
|
|
}
|
|
|
|
// Add overwrite flag if force is requested
|
|
if req.Force {
|
|
args = append(args, "--overwrite")
|
|
}
|
|
|
|
// Add config path
|
|
configPath := s.findConfigPath()
|
|
if configPath != "" {
|
|
args = append([]string{"-c", configPath}, args...)
|
|
}
|
|
|
|
// Token is the last positional argument
|
|
args = append(args, token)
|
|
|
|
logger.Log().WithField("tenant", tenant).WithField("agent", agent).WithField("force", req.Force).WithField("correlation_id", rec.LastCorrelationID).WithField("config", configPath).Info("starting crowdsec console enrollment")
|
|
out, cmdErr := s.exec.ExecuteWithEnv(cmdCtx, "cscli", args, nil)
|
|
|
|
// Log command output for debugging (redacting the token)
|
|
redactedOut := redactSecret(string(out), token)
|
|
if cmdErr != nil {
|
|
rec.Status = consoleStatusFailed
|
|
// Redact token from both output and error message
|
|
redactedErr := redactSecret(cmdErr.Error(), token)
|
|
// Extract the meaningful error message from cscli output
|
|
userMessage := extractCscliErrorMessage(redactedOut)
|
|
if userMessage == "" {
|
|
userMessage = redactedOut
|
|
}
|
|
rec.LastError = userMessage
|
|
_ = s.db.WithContext(ctx).Save(rec)
|
|
logger.Log().WithField("error", redactedErr).WithField("correlation_id", rec.LastCorrelationID).WithField("tenant", tenant).WithField("output", redactedOut).Warn("crowdsec console enrollment failed")
|
|
return s.statusFromModel(rec), fmt.Errorf("%s", userMessage)
|
|
}
|
|
|
|
logger.Log().WithField("correlation_id", rec.LastCorrelationID).WithField("output", redactedOut).Debug("cscli console enroll command output")
|
|
|
|
// Enrollment request was sent successfully, but user must still accept it on crowdsec.net.
|
|
// cscli console enroll returns exit code 0 when the request is sent, NOT when enrollment is complete.
|
|
// The CrowdSec help explicitly states: "After running this command your will need to validate the enrollment in the webapp."
|
|
complete := s.nowFn().UTC()
|
|
rec.Status = consoleStatusPendingAcceptance
|
|
rec.LastAttemptAt = &complete
|
|
rec.LastError = ""
|
|
if err := s.db.WithContext(ctx).Save(rec).Error; err != nil {
|
|
return ConsoleEnrollmentStatus{}, err
|
|
}
|
|
|
|
logger.Log().WithField("tenant", tenant).WithField("agent", agent).WithField("correlation_id", rec.LastCorrelationID).Info("crowdsec console enrollment request sent - pending acceptance on crowdsec.net")
|
|
return s.statusFromModel(rec), nil
|
|
}
|
|
|
|
// checkLAPIAvailable verifies that CrowdSec Local API is running and reachable.
|
|
// This is critical for console enrollment as the enrollment process requires LAPI.
|
|
// It retries up to 3 times with 2-second delays to handle LAPI initialization timing.
|
|
func (s *ConsoleEnrollmentService) checkLAPIAvailable(ctx context.Context) error {
|
|
maxRetries := 3
|
|
retryDelay := 2 * time.Second
|
|
|
|
var lastErr error
|
|
for i := 0; i < maxRetries; i++ {
|
|
args := []string{"lapi", "status"}
|
|
configPath := s.findConfigPath()
|
|
if configPath != "" {
|
|
args = append([]string{"-c", configPath}, args...)
|
|
}
|
|
|
|
checkCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
|
out, err := s.exec.ExecuteWithEnv(checkCtx, "cscli", args, nil)
|
|
cancel()
|
|
|
|
if err == nil {
|
|
logger.Log().WithField("config", configPath).Debug("LAPI check succeeded")
|
|
return nil // LAPI is available
|
|
}
|
|
|
|
lastErr = err
|
|
if i < maxRetries-1 {
|
|
logger.Log().WithError(err).WithField("attempt", i+1).WithField("output", string(out)).Debug("LAPI not ready, retrying")
|
|
time.Sleep(retryDelay)
|
|
}
|
|
}
|
|
|
|
return fmt.Errorf("CrowdSec Local API is not running after %d attempts - please wait for LAPI to initialize (typically 5-10 seconds after enabling CrowdSec): %w", maxRetries, lastErr)
|
|
}
|
|
|
|
func (s *ConsoleEnrollmentService) ensureCAPIRegistered(ctx context.Context) error {
|
|
// Check for credentials in config subdirectory first (standard layout),
|
|
// then fall back to dataDir root for backward compatibility
|
|
credsPath := filepath.Join(s.dataDir, "config", "online_api_credentials.yaml")
|
|
if _, err := os.Stat(credsPath); err == nil {
|
|
return nil
|
|
}
|
|
credsPath = filepath.Join(s.dataDir, "online_api_credentials.yaml")
|
|
if _, err := os.Stat(credsPath); err == nil {
|
|
return nil
|
|
}
|
|
|
|
logger.Log().Info("registering with crowdsec capi")
|
|
args := []string{"capi", "register"}
|
|
configPath := s.findConfigPath()
|
|
if configPath != "" {
|
|
args = append([]string{"-c", configPath}, args...)
|
|
}
|
|
|
|
out, err := s.exec.ExecuteWithEnv(ctx, "cscli", args, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("capi register: %s: %w", string(out), err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// findConfigPath returns the path to the CrowdSec config file, checking
|
|
// config subdirectory first (standard layout), then dataDir root.
|
|
// Returns empty string if no config file is found.
|
|
func (s *ConsoleEnrollmentService) findConfigPath() string {
|
|
configPath := filepath.Join(s.dataDir, "config", "config.yaml")
|
|
if _, err := os.Stat(configPath); err == nil {
|
|
return configPath
|
|
}
|
|
configPath = filepath.Join(s.dataDir, "config.yaml")
|
|
if _, err := os.Stat(configPath); err == nil {
|
|
return configPath
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (s *ConsoleEnrollmentService) load(ctx context.Context) (*models.CrowdsecConsoleEnrollment, error) {
|
|
var rec models.CrowdsecConsoleEnrollment
|
|
err := s.db.WithContext(ctx).First(&rec).Error
|
|
if err == nil {
|
|
return &rec, nil
|
|
}
|
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, err
|
|
}
|
|
now := s.nowFn().UTC()
|
|
rec = models.CrowdsecConsoleEnrollment{
|
|
UUID: uuid.NewString(),
|
|
Status: consoleStatusNotEnrolled,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
if err := s.db.WithContext(ctx).Create(&rec).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &rec, nil
|
|
}
|
|
|
|
// ClearEnrollment resets the enrollment state to allow fresh enrollment.
|
|
// This does NOT unenroll from crowdsec.net - that must be done manually on the console.
|
|
func (s *ConsoleEnrollmentService) ClearEnrollment(ctx context.Context) error {
|
|
if s.db == nil {
|
|
return fmt.Errorf("database not initialized")
|
|
}
|
|
|
|
var rec models.CrowdsecConsoleEnrollment
|
|
if err := s.db.WithContext(ctx).First(&rec).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil // Already cleared
|
|
}
|
|
return fmt.Errorf("failed to find enrollment record: %w", err)
|
|
}
|
|
|
|
logger.Log().WithField("previous_status", rec.Status).Info("clearing console enrollment state")
|
|
|
|
// Delete the record
|
|
if err := s.db.WithContext(ctx).Delete(&rec).Error; err != nil {
|
|
return fmt.Errorf("failed to delete enrollment record: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *ConsoleEnrollmentService) statusFromModel(rec *models.CrowdsecConsoleEnrollment) ConsoleEnrollmentStatus {
|
|
if rec == nil {
|
|
return ConsoleEnrollmentStatus{Status: consoleStatusNotEnrolled}
|
|
}
|
|
return ConsoleEnrollmentStatus{
|
|
Status: firstNonEmpty(rec.Status, consoleStatusNotEnrolled),
|
|
Tenant: rec.Tenant,
|
|
AgentName: rec.AgentName,
|
|
LastError: rec.LastError,
|
|
LastAttemptAt: rec.LastAttemptAt,
|
|
EnrolledAt: rec.EnrolledAt,
|
|
LastHeartbeatAt: rec.LastHeartbeatAt,
|
|
KeyPresent: rec.EncryptedEnrollKey != "",
|
|
CorrelationID: rec.LastCorrelationID,
|
|
}
|
|
}
|
|
|
|
func (s *ConsoleEnrollmentService) encrypt(value string) (string, error) {
|
|
if value == "" {
|
|
return "", nil
|
|
}
|
|
block, err := aes.NewCipher(s.key)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
nonce := make([]byte, gcm.NonceSize())
|
|
if _, err := rand.Read(nonce); err != nil {
|
|
return "", err
|
|
}
|
|
sealed := gcm.Seal(nonce, nonce, []byte(value), nil)
|
|
return base64.StdEncoding.EncodeToString(sealed), nil
|
|
}
|
|
|
|
// decrypt is only used in tests to validate encryption roundtrips.
|
|
func (s *ConsoleEnrollmentService) decrypt(value string) (string, error) {
|
|
if value == "" {
|
|
return "", nil
|
|
}
|
|
ciphertext, err := base64.StdEncoding.DecodeString(value)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
block, err := aes.NewCipher(s.key)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
nonceSize := gcm.NonceSize()
|
|
if len(ciphertext) < nonceSize {
|
|
return "", fmt.Errorf("ciphertext too short")
|
|
}
|
|
nonce, ct := ciphertext[:nonceSize], ciphertext[nonceSize:]
|
|
plain, err := gcm.Open(nil, nonce, ct, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(plain), nil
|
|
}
|
|
|
|
func deriveKey(secret string) []byte {
|
|
if secret == "" {
|
|
secret = "charon-console-enroll-default"
|
|
}
|
|
sum := sha256.Sum256([]byte(secret))
|
|
return sum[:]
|
|
}
|
|
|
|
func redactSecret(msg, secret string) string {
|
|
if secret == "" {
|
|
return msg
|
|
}
|
|
return strings.ReplaceAll(msg, secret, "<redacted>")
|
|
}
|
|
|
|
// extractCscliErrorMessage extracts the meaningful error message from cscli output.
|
|
// CrowdSec outputs error messages in formats like:
|
|
// - "level=error msg=\"...\""
|
|
// - "ERRO[...] ..."
|
|
// - Plain error text
|
|
func extractCscliErrorMessage(output string) string {
|
|
output = strings.TrimSpace(output)
|
|
if output == "" {
|
|
return ""
|
|
}
|
|
|
|
// Try to extract from level=error msg="..." format
|
|
msgPattern := regexp.MustCompile(`msg="([^"]+)"`)
|
|
if matches := msgPattern.FindStringSubmatch(output); len(matches) > 1 {
|
|
return matches[1]
|
|
}
|
|
|
|
// Try to extract from ERRO[...] format - get text after the timestamp bracket
|
|
erroPattern := regexp.MustCompile(`ERRO\[[^\]]*\]\s*(.+)`)
|
|
if matches := erroPattern.FindStringSubmatch(output); len(matches) > 1 {
|
|
return strings.TrimSpace(matches[1])
|
|
}
|
|
|
|
// Try to find any line containing "error" or "failed" (case-insensitive)
|
|
lines := strings.Split(output, "\n")
|
|
for _, line := range lines {
|
|
lower := strings.ToLower(line)
|
|
if strings.Contains(lower, "error") || strings.Contains(lower, "failed") || strings.Contains(lower, "invalid") {
|
|
return strings.TrimSpace(line)
|
|
}
|
|
}
|
|
|
|
// If no pattern matched, return the first non-empty line (often the most relevant)
|
|
for _, line := range lines {
|
|
trimmed := strings.TrimSpace(line)
|
|
if trimmed != "" {
|
|
return trimmed
|
|
}
|
|
}
|
|
|
|
return output
|
|
}
|
|
|
|
func normalizeEnrollmentKey(raw string) (string, error) {
|
|
trimmed := strings.TrimSpace(raw)
|
|
if trimmed == "" {
|
|
return "", fmt.Errorf("enrollment_key required")
|
|
}
|
|
if enrollmentTokenPattern.MatchString(trimmed) {
|
|
return trimmed, nil
|
|
}
|
|
|
|
parts := strings.Fields(trimmed)
|
|
if len(parts) == 0 {
|
|
return "", fmt.Errorf("invalid enrollment key")
|
|
}
|
|
if strings.EqualFold(parts[0], "sudo") {
|
|
parts = parts[1:]
|
|
}
|
|
|
|
if len(parts) == 4 && parts[0] == "cscli" && parts[1] == "console" && parts[2] == "enroll" {
|
|
token := parts[3]
|
|
if enrollmentTokenPattern.MatchString(token) {
|
|
return token, nil
|
|
}
|
|
}
|
|
|
|
return "", fmt.Errorf("invalid enrollment key")
|
|
}
|