Files
Charon/backend/internal/api/handlers/crowdsec_exec.go

155 lines
4.4 KiB
Go

package handlers
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"github.com/Wikid82/charon/backend/internal/logger"
)
// DefaultCrowdsecExecutor implements CrowdsecExecutor using OS processes.
type DefaultCrowdsecExecutor struct {
// procPath allows overriding /proc for testing
procPath string
}
func NewDefaultCrowdsecExecutor() *DefaultCrowdsecExecutor {
return &DefaultCrowdsecExecutor{
procPath: "/proc",
}
}
// isCrowdSecProcess checks if the given PID is actually a CrowdSec process
// by reading /proc/{pid}/cmdline and verifying it contains "crowdsec".
// This prevents false positives when PIDs are recycled by the OS.
func (e *DefaultCrowdsecExecutor) isCrowdSecProcess(pid int) bool {
cmdlinePath := filepath.Join(e.procPath, strconv.Itoa(pid), "cmdline")
data, err := os.ReadFile(cmdlinePath)
if err != nil {
// Process doesn't exist or can't read - not CrowdSec
return false
}
// cmdline is null-separated, but strings.Contains works on the raw bytes
return strings.Contains(string(data), "crowdsec")
}
func (e *DefaultCrowdsecExecutor) pidFile(configDir string) string {
return filepath.Join(configDir, "crowdsec.pid")
}
func (e *DefaultCrowdsecExecutor) Start(ctx context.Context, binPath, configDir string) (int, error) {
configFile := filepath.Join(configDir, "config", "config.yaml")
// Use exec.Command (not CommandContext) to avoid context cancellation killing the process
// CrowdSec should run independently of the startup goroutine's lifecycle
cmd := exec.Command(binPath, "-c", configFile)
// Detach the process so it doesn't get killed when the parent exits
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true, // Create new process group
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
return 0, err
}
pid := cmd.Process.Pid
// write pid file
if err := os.WriteFile(e.pidFile(configDir), []byte(strconv.Itoa(pid)), 0o644); err != nil {
return pid, fmt.Errorf("failed to write pid file: %w", err)
}
// wait in background
go func() {
_ = cmd.Wait()
_ = os.Remove(e.pidFile(configDir))
}()
return pid, nil
}
// Stop stops the CrowdSec process. It is idempotent - stopping an already-stopped
// service or one that was never started will succeed without error.
func (e *DefaultCrowdsecExecutor) Stop(ctx context.Context, configDir string) error {
pidFilePath := e.pidFile(configDir)
b, err := os.ReadFile(pidFilePath)
if err != nil {
// If PID file doesn't exist, service is already stopped - return success
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("pid file read: %w", err)
}
pid, err := strconv.Atoi(string(b))
if err != nil {
// Malformed PID file - clean it up and return success
_ = os.Remove(pidFilePath)
return nil
}
proc, err := os.FindProcess(pid)
if err != nil {
// Process lookup failed - clean up PID file and return success
_ = os.Remove(pidFilePath)
return nil
}
if err := proc.Signal(syscall.SIGTERM); err != nil {
// Check if process is already dead (ESRCH = no such process)
if errors.Is(err, syscall.ESRCH) || errors.Is(err, os.ErrProcessDone) {
_ = os.Remove(pidFilePath)
return nil
}
return err
}
// Successfully sent signal - remove PID file
_ = os.Remove(pidFilePath)
return nil
}
func (e *DefaultCrowdsecExecutor) Status(ctx context.Context, configDir string) (running bool, pid int, err error) {
b, err := os.ReadFile(e.pidFile(configDir))
if err != nil {
// Missing pid file is treated as not running
return false, 0, nil
}
pid, err = strconv.Atoi(string(b))
if err != nil {
// Malformed pid file is treated as not running
return false, 0, nil
}
proc, err := os.FindProcess(pid)
if err != nil {
// Process lookup failures are treated as not running
return false, pid, nil
}
// Sending signal 0 is not portable on Windows, but OK for Linux containers
if err = proc.Signal(syscall.Signal(0)); err != nil {
if errors.Is(err, os.ErrProcessDone) {
return false, pid, nil
}
// ESRCH or other errors mean process isn't running
return false, pid, nil
}
// After successful Signal(0) check, verify it's actually CrowdSec
// This prevents false positives when PIDs are recycled by the OS
if !e.isCrowdSecProcess(pid) {
logger.Log().WithField("pid", pid).Warn("PID exists but is not CrowdSec (PID recycled)")
return false, pid, nil
}
return true, pid, nil
}