From 78328eb30c762e7c0629266cf8d552a144ebe452 Mon Sep 17 00:00:00 2001 From: mahesh bhatiya Date: Sun, 9 Nov 2025 12:15:05 +0000 Subject: [PATCH] update trace --- .cargo/config.toml | 5 + BUILD-EBPF.md | 46 ++ Cargo.toml | 29 + Makefile | 45 +- README.md | 144 ----- build-ebpf.sh | 46 ++ config.yaml.example | 209 +++++++ config.yaml.minimal | 29 + diagnose.sh | 158 ------ find-socket-offsets.sh | 48 -- install.sh | 243 -------- secrds-agent/Cargo.toml | 28 + secrds-agent/go.mod | 11 - secrds-agent/go.sum | 19 - secrds-agent/internal/config/config.go | 115 ---- secrds-agent/internal/config/env.go | 56 -- secrds-agent/internal/detector/threat.go | 567 ------------------- secrds-agent/internal/kernel/loader.go | 189 ------- secrds-agent/internal/processor/event.go | 187 ------- secrds-agent/internal/storage/storage.go | 186 ------- secrds-agent/internal/telegram/client.go | 154 ----- secrds-agent/main.go | 124 ----- secrds-agent/src/config.rs | 190 +++++++ secrds-agent/src/detector.rs | 680 +++++++++++++++++++++++ secrds-agent/src/main.rs | 80 +++ secrds-agent/src/processor.rs | 151 +++++ secrds-agent/src/storage.rs | 238 ++++++++ secrds-agent/src/telegram.rs | 130 +++++ secrds-cli/Cargo.toml | 19 + secrds-cli/cmd/alerts.go | 127 ----- secrds-cli/cmd/config.go | 57 -- secrds-cli/cmd/restart.go | 26 - secrds-cli/cmd/root.go | 22 - secrds-cli/cmd/start.go | 26 - secrds-cli/cmd/stats.go | 62 --- secrds-cli/cmd/status.go | 62 --- secrds-cli/cmd/stop.go | 26 - secrds-cli/go.mod | 10 - secrds-cli/go.sum | 10 - secrds-cli/main.go | 10 - secrds-cli/src/commands.rs | 262 +++++++++ secrds-cli/src/main.rs | 58 ++ secrds-ebpf/Cargo.toml | 20 + secrds-ebpf/README.md | 23 + secrds-ebpf/src/lib.rs | 304 ++++++++++ secrds-programs/Makefile | 33 -- secrds-programs/common.h | 131 ----- secrds-programs/ssh_kprobe.c | 170 ------ secrds-programs/tcp_trace.c | 81 --- secrds.service | 44 -- setup-service.sh | 142 ----- 51 files changed, 2570 insertions(+), 3262 deletions(-) create mode 100644 .cargo/config.toml create mode 100644 BUILD-EBPF.md create mode 100644 Cargo.toml delete mode 100644 README.md create mode 100755 build-ebpf.sh create mode 100644 config.yaml.example create mode 100644 config.yaml.minimal delete mode 100755 diagnose.sh delete mode 100755 find-socket-offsets.sh delete mode 100755 install.sh create mode 100644 secrds-agent/Cargo.toml delete mode 100644 secrds-agent/go.mod delete mode 100644 secrds-agent/go.sum delete mode 100644 secrds-agent/internal/config/config.go delete mode 100644 secrds-agent/internal/config/env.go delete mode 100644 secrds-agent/internal/detector/threat.go delete mode 100644 secrds-agent/internal/kernel/loader.go delete mode 100644 secrds-agent/internal/processor/event.go delete mode 100644 secrds-agent/internal/storage/storage.go delete mode 100644 secrds-agent/internal/telegram/client.go delete mode 100644 secrds-agent/main.go create mode 100644 secrds-agent/src/config.rs create mode 100644 secrds-agent/src/detector.rs create mode 100644 secrds-agent/src/main.rs create mode 100644 secrds-agent/src/processor.rs create mode 100644 secrds-agent/src/storage.rs create mode 100644 secrds-agent/src/telegram.rs create mode 100644 secrds-cli/Cargo.toml delete mode 100644 secrds-cli/cmd/alerts.go delete mode 100644 secrds-cli/cmd/config.go delete mode 100644 secrds-cli/cmd/restart.go delete mode 100644 secrds-cli/cmd/root.go delete mode 100644 secrds-cli/cmd/start.go delete mode 100644 secrds-cli/cmd/stats.go delete mode 100644 secrds-cli/cmd/status.go delete mode 100644 secrds-cli/cmd/stop.go delete mode 100644 secrds-cli/go.mod delete mode 100644 secrds-cli/go.sum delete mode 100644 secrds-cli/main.go create mode 100644 secrds-cli/src/commands.rs create mode 100644 secrds-cli/src/main.rs create mode 100644 secrds-ebpf/Cargo.toml create mode 100644 secrds-ebpf/README.md create mode 100644 secrds-ebpf/src/lib.rs delete mode 100644 secrds-programs/Makefile delete mode 100644 secrds-programs/common.h delete mode 100644 secrds-programs/ssh_kprobe.c delete mode 100644 secrds-programs/tcp_trace.c delete mode 100644 secrds.service delete mode 100755 setup-service.sh diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..447bfcb --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,5 @@ +[unstable] +build-std = ["core"] + +[build] +rustflags = ["-C", "panic=abort"] diff --git a/BUILD-EBPF.md b/BUILD-EBPF.md new file mode 100644 index 0000000..0e54842 --- /dev/null +++ b/BUILD-EBPF.md @@ -0,0 +1,46 @@ +# Building Aya eBPF Programs + +Aya eBPF programs require special compilation. Here are the options: + +## Option 1: Use Aya Template (Recommended) + +The easiest way is to use Aya's template system: + +```bash +cargo install aya-toolchain +cd secrds-ebpf +cargo build --release +``` + +## Option 2: Manual Build with rustc + clang + +Since `bpfel-unknown-none` target is not available in stable Rust, you can: + +1. Compile Rust to LLVM IR: +```bash +cd secrds-ebpf +rustc --emit=llvm-ir --target bpfel-unknown-none src/lib.rs +``` + +2. Compile LLVM IR to eBPF with clang: +```bash +clang -target bpf -O2 -g -c output.ll -o secrds-ebpf.bpf.o +``` + +## Option 3: Use Pre-compiled eBPF + +For now, you can use the original C eBPF program (`trace_ssh_guard.c`) +and compile it with clang until the Rust eBPF build is set up: + +```bash +cd secrds-programs # if you still have the C version +clang -O2 -g -target bpf -c trace_ssh_guard.c -o trace_ssh_guard.bpf.o +``` + +## Current Status + +The Rust eBPF code is written but needs proper build setup. The agent +can load pre-compiled eBPF programs from `/usr/local/lib/secrds/`. + +For production, set up the Aya build system or use the C version temporarily. + diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..1ea2f04 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,29 @@ +[workspace] +members = [ + "secrds-ebpf", + "secrds-agent", + "secrds-cli", +] +resolver = "2" + +[workspace.package] +version = "0.1.0" +edition = "2021" +authors = ["secrds"] +license = "Dual BSD/GPL" + +[workspace.dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +serde_yaml = "0.9" +tokio = { version = "1.0", features = ["full"] } +anyhow = "1.0" +thiserror = "1.0" +log = "0.4" +env_logger = "0.11" +clap = { version = "4.5", features = ["derive"] } + +[profile.release] +strip = true +lto = true +panic = "abort" \ No newline at end of file diff --git a/Makefile b/Makefile index 6983685..c3ccd3a 100644 --- a/Makefile +++ b/Makefile @@ -1,21 +1,28 @@ -.PHONY: build install clean test fmt clippy docker-build docker-run help +.PHONY: build install clean test fmt clippy build-bpf help help: @echo "Available targets:" - @echo " build - Build all components" + @echo " build - Build all Rust components" + @echo " build-bpf - Build eBPF programs" @echo " install - Install to system (requires root)" @echo " clean - Clean build artifacts" @echo " test - Run tests" @echo " fmt - Format code" @echo " clippy - Run clippy linter" - @echo " docker-build - Build Docker image" - @echo " docker-run - Run Docker container" @echo " help - Show this help" -build: +build: build-bpf @echo "Building secrds Security Monitor..." - @chmod +x build.sh - @./build.sh + @cargo build --release + @echo "Build complete." + +build-bpf: + @echo "Building eBPF programs..." + @echo "Note: Aya eBPF build requires special setup." + @echo "See BUILD-EBPF.md for instructions." + @chmod +x build-ebpf.sh + @./build-ebpf.sh + @echo "eBPF build complete (may be placeholder)." install: @echo "Installing secrds Security Monitor..." @@ -24,24 +31,18 @@ install: clean: @echo "Cleaning build artifacts..." - @cd secrds-programs && make clean || true + @cargo clean @rm -rf target/release/secrds-* + @rm -rf target/bpfel-unknown-none test: - @echo "Running Go tests..." - @cd secrds-agent && go test ./... || true - @cd secrds-cli && go test ./... || true + @echo "Running Rust tests..." + @cargo test --workspace || true fmt: - @echo "Formatting Go code..." - @cd secrds-agent && go fmt ./... || true - @cd secrds-cli && go fmt ./... || true - -docker-build: - @echo "Building Docker image..." - @docker build -t secrds:latest . - -docker-run: - @echo "Running Docker container..." - @docker-compose up -d + @echo "Formatting Rust code..." + @cargo fmt --all || true +clippy: + @echo "Running clippy linter..." + @cargo clippy --workspace || true diff --git a/README.md b/README.md deleted file mode 100644 index 80f311d..0000000 --- a/README.md +++ /dev/null @@ -1,144 +0,0 @@ -## secrds - Security Monitor - -A kernel-powered host security monitor that detects SSH brute-force and TCP anomalies (port scans / floods), optionally blocks offending IPs via iptables, and sends alerts to Telegram. - -### Components -- **Agent (`secrds-agent`)**: Loads kernel programs, processes events, persists alerts, sends Telegram notifications, and (optionally) blocks IPs. -- **CLI (`secrds`)**: Check status, list recent alerts, view stats, and control the agent. -- **Kernel Programs**: Implemented in C (`secrds-programs`). - -### Requirements -- Linux kernel 5.8+ with kernel program features enabled -- Go 1.21 or later -- `clang` and `llvm` (for building kernel programs) -- `iptables` (for optional auto-blocking) -- `systemd` (to run the agent as a service) -- Internet access for Telegram API - -### Quick Start -```bash -# 1) Build everything (kernel programs + agent + CLI) -./build.sh - -# 2) Install system-wide (requires sudo) -sudo ./install.sh - -# 3) Configure Telegram credentials -sudo nano /etc/secrds/config.yaml -# Set telegram.bot_token and telegram.chat_id under the telegram section - -# 4) (Optional) Tune thresholds in the same config.yaml file - -# 5) Start and enable the service -sudo systemctl start secrds -sudo systemctl enable secrds - -# 6) Check status and logs -systemctl status secrds -journalctl -u secrds -f -``` - -### Installation Details -- `install.sh` will: - - Build binaries (via `build.sh`) - - Install `secrds-agent` and `secrds` to `/usr/local/bin/` - - Install `secrds.service` to `/etc/systemd/system/` - - Create config at `/etc/secrds/config.yaml` (if missing) - - Create env file at `/etc/secrds/env.conf` (Telegram settings) - - Create data dir `/var/lib/secrds` and log dir `/var/log/secrds` - -### Configuration -- Main config file: `/etc/secrds/config.yaml` (YAML format) - - `ssh_threshold` (default 5) - - `ssh_window_seconds` (default 300) - - `tcp_threshold` (default 10) - - `tcp_window_seconds` (default 60) - - `enable_ip_blocking` (default true) - - `storage_path` (default `/var/lib/secrds/events.json`) - - `pid_file` (default `/var/run/secrds.pid`) - - `log_level` (default `info`) - - `log_file` (default `/var/log/secrds/agent.log`) - -- Telegram configuration (in `config.yaml`): - - `telegram.bot_token` = your bot token (from Telegram `@BotFather`) - - `telegram.chat_id` = your chat ID (e.g., via `@userinfobot`) - - Optional: `SECRDS_CONFIG` environment variable to point to a custom config path - - Optional: `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` environment variables (override config file) - -### Service (systemd) -```bash -sudo systemctl start secrds -sudo systemctl enable secrds -systemctl status secrds -journalctl -u secrds -f -``` - -### CLI Usage -```bash -# Show agent/service status -secrds status - -# Show recent alerts (default 10; customize with --limit) -secrds alerts --limit 20 - -# Show stats (e.g., blocked IPs, counts) -secrds stats - -# Print current config (resolved) -secrds config - -# Control the agent -secrds start -secrds stop -secrds restart -``` - -### Paths -- Config: `/etc/secrds/config.yaml` (includes Telegram settings) -- Data: `/var/lib/secrds/events.json` -- PID: `/var/run/secrds.pid` -- Logs: `/var/log/secrds/agent.log` -- Binaries: `/usr/local/bin/secrds-agent`, `/usr/local/bin/secrds` - -### Production Deployment - -1. **Build the project:** - ```bash - make build - ``` - -2. **Install:** - ```bash - sudo ./install.sh - ``` - -3. **Configure Telegram (required for alerts):** - ```bash - sudo nano /etc/secrds/config.yaml - # Set telegram.bot_token and telegram.chat_id - ``` - -4. **Start and enable service:** - ```bash - sudo systemctl start secrds - sudo systemctl enable secrds - ``` - -5. **Verify it's running:** - ```bash - secrds status - ``` - -### Troubleshooting -- Kernel 5.8+ required: `uname -r` -- Build tools: ensure `go`, `clang`, and `llvm` are installed -- Telegram alerts: verify `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` are set and correct -- IP blocking: requires `iptables` and root; see warnings in logs if a rule fails -- View logs: `journalctl -u secrds -f` -- Check alerts: `secrds alerts` - ---- - -Made with Go and kernel programs. Licensed under MIT or Apache-2.0. - - diff --git a/build-ebpf.sh b/build-ebpf.sh new file mode 100755 index 0000000..2d7a112 --- /dev/null +++ b/build-ebpf.sh @@ -0,0 +1,46 @@ +#!/bin/bash +set -e + +# Colors +GREEN="\e[32m" +RED="\e[31m" +YELLOW="\e[33m" +RESET="\e[0m" + +echo -e "${YELLOW}[*] Cleaning old build artifacts...${RESET}" +cargo clean + +echo -e "${YELLOW}[*] Building secrds-ebpf for target bpfel-unknown-none...${RESET}" +cargo +nightly build --release -Z build-std=core -p secrds-ebpf --target bpfel-unknown-none + +if [ $? -ne 0 ]; then + echo -e "${RED}[!] Build failed. Check errors above.${RESET}" + exit 1 +fi + +# Ensure target binary exists +EBPF_BIN="target/bpfel-unknown-none/release/secrds_ebpf" +if [ ! -f "$EBPF_BIN" ]; then + echo -e "${RED}[!] eBPF binary not found at $EBPF_BIN${RESET}" + exit 1 +fi + +echo -e "${YELLOW}[*] Copying built binary to /usr/local/lib/secrds/...${RESET}" +sudo mkdir -p /usr/local/lib/secrds +sudo cp "$EBPF_BIN" /usr/local/lib/secrds/secrds-ebpf.o + +echo -e "${YELLOW}[*] Loading eBPF program into kernel...${RESET}" +sudo bpftool prog load /usr/local/lib/secrds/secrds-ebpf.o \ + /sys/fs/bpf/secrds_prog type tracepoint pinmaps /sys/fs/bpf/secrds_maps 2>&1 | tee /tmp/secrds_load.log || true + +if grep -q "failed" /tmp/secrds_load.log; then + echo -e "${RED}[!] eBPF load failed. See /tmp/secrds_load.log for verifier output.${RESET}" + exit 1 +else + echo -e "${GREEN}[+] eBPF program loaded successfully!${RESET}" +fi + +echo -e "${YELLOW}[*] Checking loaded programs...${RESET}" +sudo bpftool prog show | grep secrds || echo -e "${RED}[!] No secrds program found.${RESET}" + +echo -e "${GREEN}[✓] Done.${RESET}" diff --git a/config.yaml.example b/config.yaml.example new file mode 100644 index 0000000..6f44363 --- /dev/null +++ b/config.yaml.example @@ -0,0 +1,209 @@ +# secrds Enterprise SSH Guard Configuration +# ============================================ +# This is a comprehensive enterprise-grade configuration file +# Copy this to /etc/secrds/config.yaml and customize for your environment + +# SSH Detection Settings +# ---------------------- +# ssh_threshold: Number of failed SSH attempts before triggering an alert +# Recommended: 3-5 for strict environments, 5-10 for normal +# Lower values = more sensitive (may increase false positives) +ssh_threshold: 5 + +# ssh_window_seconds: Time window for counting SSH attempts +# Recommended: 300 (5 minutes) for most environments +# Lower values = shorter detection window (more aggressive) +ssh_window_seconds: 300 + +# ssh_port: SSH port to monitor (default: 22) +# Change this if you use a non-standard SSH port +# Example: 2222, 2200, etc. +ssh_port: 22 + +# TCP Detection Settings +# ---------------------- +# tcp_threshold: Number of TCP connections before triggering port scan alert +# Recommended: 10-20 for most environments +tcp_threshold: 10 + +# tcp_window_seconds: Time window for counting TCP connections +# Recommended: 60 (1 minute) for port scan detection +tcp_window_seconds: 60 + +# IP Blocking Settings +# -------------------- +# enable_ip_blocking: Automatically block malicious IPs using iptables +# true: Enable automatic blocking (recommended for production) +# false: Only alert, do not block (useful for testing/monitoring) +enable_ip_blocking: true + +# block_duration_seconds: How long to block an IP before auto-unblocking +# 0: Permanent block (until manual removal) +# 86400: 24 hours (recommended default) +# 3600: 1 hour (for testing) +# 604800: 7 days (for aggressive blocking) +# Note: Set to 0 for permanent blocks (not recommended for production) +block_duration_seconds: 86400 + +# IP Whitelist +# ------------ +# whitelist_ips: List of IP addresses that should NEVER be blocked +# Add IPs for: +# - Monitoring systems +# - VPN gateways +# - Legitimate automation tools +# - Your office IPs +# - Load balancers +# - Backup systems +# Format: Array of IP addresses (IPv4 or IPv6) +whitelist_ips: + # Example: Office/Admin IPs + - "203.0.113.1" # Main office IP + - "203.0.113.2" # Secondary office IP + + # Example: Monitoring systems + - "198.51.100.10" # Nagios/Zabbix server + - "198.51.100.11" # Prometheus server + + # Example: VPN gateways + - "192.0.2.50" # VPN gateway 1 + - "192.0.2.51" # VPN gateway 2 + + # Example: Load balancers + - "192.0.2.100" # Load balancer 1 + - "192.0.2.101" # Load balancer 2 + + # Example: Backup systems + - "192.0.2.200" # Backup server + + # Example: Automation/CI/CD + - "192.0.2.150" # CI/CD server + + # Example: IPv6 addresses (if needed) + # - "2001:db8::1" + # - "2001:db8::2" + +# Storage Settings +# ---------------- +# storage_path: Path to store alerts and blocked IPs +# Default: /var/lib/secrds/events.json +# Ensure directory exists and has proper permissions +storage_path: /var/lib/secrds/events.json + +# Process Management +# ------------------ +# pid_file: Path to PID file for process management +# Default: /var/run/secrds.pid +# Used by systemd service +pid_file: /var/run/secrds.pid + +# Logging Settings +# ---------------- +# log_level: Logging verbosity level +# Options: "debug", "info", "warn", "error" +# Recommended: "info" for production, "debug" for troubleshooting +log_level: info + +# log_file: Path to log file +# Default: /var/log/secrds/agent.log +# Ensure directory exists and logrotate is configured +log_file: /var/log/secrds/agent.log + +# Telegram Notifications +# ---------------------- +# Configure Telegram bot for real-time alerts +# +# To set up: +# 1. Create a bot with @BotFather on Telegram +# 2. Get your bot token +# 3. Get your chat ID (use @userinfobot or check bot API) +# 4. Add both below +# +# Alternative: Set environment variables: +# export TELEGRAM_BOT_TOKEN="your_bot_token" +# export TELEGRAM_CHAT_ID="your_chat_id" +telegram: + # bot_token: Your Telegram bot token from @BotFather + # Example: "123456789:ABCdefGHIjklMNOpqrsTUVwxyz" + bot_token: "YOUR_TELEGRAM_BOT_TOKEN_HERE" + + # chat_id: Your Telegram chat ID for receiving alerts + # Can be: + # - Your personal chat ID (number or string) + # - Group chat ID (negative number) + # - Channel username (e.g., "@my_channel") + # Example: "123456789" or "-1001234567890" or "@my_channel" + chat_id: "YOUR_TELEGRAM_CHAT_ID_HERE" + +# ============================================ +# Enterprise Configuration Examples +# ============================================ + +# Example 1: Strict Security (High Sensitivity) +# ---------------------------------------------- +# ssh_threshold: 3 +# ssh_window_seconds: 180 +# block_duration_seconds: 604800 # 7 days +# enable_ip_blocking: true + +# Example 2: Balanced (Recommended for Most Environments) +# -------------------------------------------------------- +# ssh_threshold: 5 +# ssh_window_seconds: 300 +# block_duration_seconds: 86400 # 24 hours +# enable_ip_blocking: true + +# Example 3: Monitoring Only (No Blocking) +# ----------------------------------------- +# ssh_threshold: 5 +# ssh_window_seconds: 300 +# enable_ip_blocking: false +# block_duration_seconds: 0 + +# Example 4: High Traffic Environment +# ------------------------------------- +# ssh_threshold: 10 +# ssh_window_seconds: 600 +# tcp_threshold: 20 +# tcp_window_seconds: 120 +# block_duration_seconds: 86400 + +# ============================================ +# Security Best Practices +# ============================================ +# +# 1. Always whitelist your monitoring systems +# 2. Use block_duration_seconds > 0 for auto-recovery +# 3. Monitor logs regularly: tail -f /var/log/secrds/agent.log +# 4. Review blocked IPs: secrds stats +# 5. Keep whitelist updated as infrastructure changes +# 6. Test configuration in staging before production +# 7. Set appropriate file permissions: +# chmod 600 /etc/secrds/config.yaml +# chown root:root /etc/secrds/config.yaml +# +# ============================================ +# Troubleshooting +# ============================================ +# +# If alerts are too frequent: +# - Increase ssh_threshold +# - Increase ssh_window_seconds +# +# If legitimate IPs are blocked: +# - Add to whitelist_ips +# - Check block_duration_seconds (enable auto-unblock) +# +# If no alerts are received: +# - Check Telegram bot_token and chat_id +# - Verify log_file for errors +# - Check systemd status: systemctl status secrds +# +# To view recent alerts: +# secrds alerts --limit 50 +# +# To view statistics: +# secrds stats +# +# ============================================ + diff --git a/config.yaml.minimal b/config.yaml.minimal new file mode 100644 index 0000000..36ef4a4 --- /dev/null +++ b/config.yaml.minimal @@ -0,0 +1,29 @@ +# Minimal secrds Configuration +# Copy to /etc/secrds/config.yaml and customize + +ssh_threshold: 5 +ssh_window_seconds: 300 +ssh_port: 22 + +tcp_threshold: 10 +tcp_window_seconds: 60 + +enable_ip_blocking: true +block_duration_seconds: 86400 # 24 hours + +# Add your trusted IPs here (office, monitoring, etc.) +whitelist_ips: + - "203.0.113.1" # Your office IP + # - "198.51.100.10" # Monitoring server + # - "192.0.2.50" # VPN gateway + +storage_path: /var/lib/secrds/events.json +pid_file: /var/run/secrds.pid + +log_level: info +log_file: /var/log/secrds/agent.log + +telegram: + bot_token: "YOUR_BOT_TOKEN_HERE" + chat_id: "YOUR_CHAT_ID_HERE" + diff --git a/diagnose.sh b/diagnose.sh deleted file mode 100755 index 4e38ffd..0000000 --- a/diagnose.sh +++ /dev/null @@ -1,158 +0,0 @@ -#!/bin/bash -# Diagnostic script to check why detection isn't working - -set -e - -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' - -# Check if running as root -if [ "$EUID" -eq 0 ]; then - SUDO="" -else - SUDO="sudo" -fi - -echo -e "${BLUE}========================================${NC}" -echo -e "${BLUE}secrds Diagnostic Tool${NC}" -echo -e "${BLUE}========================================${NC}" -echo "" - -# 1. Check service status -echo -e "${YELLOW}[1/8] Checking service status...${NC}" -if $SUDO systemctl is-active --quiet secrds 2>/dev/null; then - echo -e "${GREEN}✓ Service is running${NC}" - $SUDO systemctl status secrds --no-pager -l | head -10 -else - echo -e "${RED}✗ Service is NOT running${NC}" - echo "Start it with: sudo systemctl start secrds" -fi -echo "" - -# 2. Check kernel version -echo -e "${YELLOW}[2/8] Checking kernel version...${NC}" -KERNEL_VERSION=$(uname -r | cut -d. -f1,2) -echo "Kernel version: $KERNEL_VERSION" -if [ "$(printf '%s\n' "5.8" "$KERNEL_VERSION" | sort -V | head -n1)" = "5.8" ]; then - echo -e "${GREEN}✓ Kernel version is sufficient (5.8+)${NC}" -else - echo -e "${RED}✗ Kernel version too old (needs 5.8+)${NC}" -fi -echo "" - -# 3. Check eBPF support -echo -e "${YELLOW}[3/8] Checking eBPF support...${NC}" -if [ -d "/sys/fs/bpf" ]; then - echo -e "${GREEN}✓ /sys/fs/bpf exists${NC}" -else - echo -e "${RED}✗ /sys/fs/bpf not found${NC}" -fi - -if [ -d "/sys/kernel/debug/tracing" ]; then - echo -e "${GREEN}✓ Kernel tracing available${NC}" -else - echo -e "${YELLOW}⚠ Kernel tracing not available${NC}" -fi -echo "" - -# 4. Check eBPF program attachment -echo -e "${YELLOW}[4/8] Checking eBPF program attachment...${NC}" -if $SUDO journalctl -u secrds --no-pager 2>/dev/null | grep -q "Attached kprobe"; then - echo -e "${GREEN}✓ eBPF programs attached:${NC}" - $SUDO journalctl -u secrds --no-pager 2>/dev/null | grep "Attached kprobe" | tail -5 -else - echo -e "${RED}✗ No eBPF program attachment found${NC}" - echo "Check recent logs:" - $SUDO journalctl -u secrds --no-pager -n 20 2>/dev/null | tail -10 -fi -echo "" - -# 5. Check if events are being received -echo -e "${YELLOW}[5/8] Checking for SSH events in logs...${NC}" -RECENT_LOGS=$($SUDO journalctl -u secrds --no-pager -n 100 2>/dev/null) -if echo "$RECENT_LOGS" | grep -q "SSH event received\|Invalid SSH event\|Failed to process"; then - echo -e "${GREEN}✓ Events found in logs:${NC}" - echo "$RECENT_LOGS" | grep -E "SSH event|Invalid SSH|Failed to process" | tail -10 -else - echo -e "${RED}✗ No SSH events found in recent logs${NC}" - echo "This means the eBPF program is not detecting connections" -fi -echo "" - -# 6. Check for invalid IPs -echo -e "${YELLOW}[6/8] Checking for IP detection issues...${NC}" -if echo "$RECENT_LOGS" | grep -q "0.0.0.0\|invalid IP"; then - echo -e "${YELLOW}⚠ Invalid IP addresses detected:${NC}" - echo "$RECENT_LOGS" | grep -E "0.0.0.0|invalid IP" | tail -5 - echo "" - echo "This indicates source IP detection is failing" -else - echo -e "${GREEN}✓ No invalid IP issues found${NC}" -fi -echo "" - -# 7. Check binary and kernel program files -echo -e "${YELLOW}[7/8] Checking installed files...${NC}" -if [ -f "/usr/local/bin/secrds-agent" ]; then - echo -e "${GREEN}✓ secrds-agent binary exists${NC}" -else - echo -e "${RED}✗ secrds-agent binary not found${NC}" -fi - -if [ -f "/usr/local/lib/secrds/ssh_kprobe.bpf.o" ]; then - echo -e "${GREEN}✓ SSH kernel program exists${NC}" -else - echo -e "${RED}✗ SSH kernel program not found${NC}" -fi -echo "" - -# 8. Test connection detection -echo -e "${YELLOW}[8/8] Testing connection detection...${NC}" -echo "Making a test SSH connection attempt..." -echo "" - -# Clear recent logs for clean test -$SUDO journalctl --vacuum-time=1s -u secrds > /dev/null 2>&1 || true -sleep 1 - -# Make a test connection -timeout 2 ssh -o StrictHostKeyChecking=no \ - -o UserKnownHostsFile=/dev/null \ - -o ConnectTimeout=2 \ - -o BatchMode=yes \ - root@localhost "exit" 2>/dev/null || true - -sleep 2 - -# Check if event was logged -if $SUDO journalctl -u secrds --no-pager -n 20 2>/dev/null | grep -q "SSH event\|Invalid SSH"; then - echo -e "${GREEN}✓ Connection detected! Event logged${NC}" - $SUDO journalctl -u secrds --no-pager -n 10 2>/dev/null | grep -E "SSH event|Invalid SSH" | tail -3 -else - echo -e "${RED}✗ Connection NOT detected${NC}" - echo "" - echo -e "${YELLOW}Recent logs:${NC}" - $SUDO journalctl -u secrds --no-pager -n 15 2>/dev/null | tail -10 -fi -echo "" - -# Summary -echo -e "${BLUE}========================================${NC}" -echo -e "${BLUE}Summary${NC}" -echo -e "${BLUE}========================================${NC}" -echo "" -echo "If events are not being detected, possible causes:" -echo "1. eBPF program not attaching (check kernel version and symbols)" -echo "2. Source IP detection failing (socket structure offsets wrong)" -echo "3. Kernel doesn't export required symbols (inet_csk_accept)" -echo "4. Service not running or crashed" -echo "" -echo "Next steps:" -echo "1. Check full logs: sudo journalctl -u secrds -f" -echo "2. Try manual run: sudo /usr/local/bin/secrds-agent" -echo "3. Check kernel symbols: sudo cat /proc/kallsyms | grep inet_csk_accept" -echo "4. Verify eBPF program loads: sudo bpftool prog list" - diff --git a/find-socket-offsets.sh b/find-socket-offsets.sh deleted file mode 100755 index ebd487c..0000000 --- a/find-socket-offsets.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/bin/bash -# Helper script to find correct socket structure offsets for your kernel - -echo "Finding socket structure offsets for kernel $(uname -r)..." -echo "" - -# Check if kernel headers are available -if [ -d "/usr/src/linux-headers-$(uname -r)" ]; then - KERNEL_HEADERS="/usr/src/linux-headers-$(uname -r)" - echo "Found kernel headers at: $KERNEL_HEADERS" - echo "" - - # Try to find inet_sock structure definition - if [ -f "$KERNEL_HEADERS/include/net/inet_sock.h" ]; then - echo "inet_sock structure definition:" - grep -A 20 "struct inet_sock" "$KERNEL_HEADERS/include/net/inet_sock.h" | head -30 - echo "" - fi - - # Try to find sock structure - if [ -f "$KERNEL_HEADERS/include/net/sock.h" ]; then - echo "sock_common structure (first part of sock):" - grep -A 30 "struct sock_common" "$KERNEL_HEADERS/include/net/sock.h" | head -40 - echo "" - fi -else - echo "Kernel headers not found. Install with:" - echo " sudo apt-get install linux-headers-$(uname -r)" - echo "" -fi - -# Check available kernel symbols -echo "Checking available kernel symbols for TCP:" -echo "" -echo "inet_csk_accept:" -cat /proc/kallsyms | grep "inet_csk_accept" | head -3 -echo "" -echo "tcp_v4_connect:" -cat /proc/kallsyms | grep "tcp_v4_connect" | head -3 -echo "" -echo "tcp_v4_syn_recv_sock:" -cat /proc/kallsyms | grep "tcp_v4_syn_recv_sock" | head -3 -echo "" - -echo "Note: Socket structure offsets vary by kernel version." -echo "The eBPF program tries multiple offsets automatically." -echo "If detection still fails, the offsets for your kernel may need adjustment." - diff --git a/install.sh b/install.sh deleted file mode 100755 index 963f9d5..0000000 --- a/install.sh +++ /dev/null @@ -1,243 +0,0 @@ -#!/bin/bash -set -e - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' # No Color - -# Configuration -INSTALL_PREFIX="${INSTALL_PREFIX:-/usr/local}" -SYSTEMD_DIR="/etc/systemd/system" -CONFIG_DIR="/etc/secrds" -DATA_DIR="/var/lib/secrds" -RUN_DIR="/var/run" - -echo -e "${GREEN}Installing secrds Security Monitor${NC}" - -# Check if running as root or has sudo -if [ "$EUID" -ne 0 ]; then - if ! command -v sudo &> /dev/null; then - echo -e "${RED}Please run as root or install sudo${NC}" - exit 1 - fi - # Not root, will use sudo - SUDO_CMD="sudo" -else - # Already root, no sudo needed - SUDO_CMD="" -fi - -# Check prerequisites -echo -e "${YELLOW}Checking prerequisites...${NC}" - -# Check kernel version -KERNEL_VERSION=$(uname -r | cut -d. -f1,2) -REQUIRED_VERSION="5.8" -if [ "$(printf '%s\n' "$REQUIRED_VERSION" "$KERNEL_VERSION" | sort -V | head -n1)" != "$REQUIRED_VERSION" ]; then - echo -e "${RED}Kernel version $KERNEL_VERSION is too old. Requires 5.8+${NC}" - exit 1 -fi - -echo -e "${YELLOW}Checking for Go and system tools...${NC}" - -# Check for required tools -for tool in go iptables clang; do - if ! command -v $tool &> /dev/null; then - echo -e "${RED}$tool is not installed${NC}" - exit 1 - fi -done - -# Check Go version (requires 1.21+) -GO_VERSION=$(go version | awk '{print $3}' | sed 's/go//') -REQUIRED_GO_VERSION="1.21" -if [ "$(printf '%s\n' "$REQUIRED_GO_VERSION" "$GO_VERSION" | sort -V | head -n1)" != "$REQUIRED_GO_VERSION" ]; then - echo -e "${YELLOW}Warning: Go version $GO_VERSION may be too old. Recommended: 1.21+${NC}" -fi - -# Build the project -echo -e "${YELLOW}Building project...${NC}" -if [ -f "build.sh" ]; then - chmod +x build.sh - ./build.sh -else - echo -e "${RED}build.sh not found${NC}" - exit 1 -fi - -# Create directories -echo -e "${YELLOW}Creating directories...${NC}" -mkdir -p "$CONFIG_DIR" -mkdir -p "$DATA_DIR" -mkdir -p "$RUN_DIR" -mkdir -p "$(dirname $INSTALL_PREFIX/bin)" - -# Stop service if running (to avoid "Text file busy" error) -SERVICE_WAS_RUNNING=false -if systemctl is-active --quiet secrds 2>/dev/null; then - echo -e "${YELLOW}Stopping secrds service to update binaries...${NC}" - systemctl stop secrds - SERVICE_WAS_RUNNING=true - sleep 1 -fi - -# Install binaries -echo -e "${YELLOW}Installing binaries...${NC}" - -# Install secrds-agent -if [ -f "target/release/secrds-agent" ]; then - # Try to copy, if it fails due to busy file, wait and retry - if ! cp target/release/secrds-agent "$INSTALL_PREFIX/bin/secrds-agent" 2>/dev/null; then - echo -e "${YELLOW}Waiting for file to be released...${NC}" - sleep 2 - cp target/release/secrds-agent "$INSTALL_PREFIX/bin/secrds-agent" - fi - chmod +x "$INSTALL_PREFIX/bin/secrds-agent" - echo -e "${GREEN}Installed secrds-agent${NC}" -else - echo -e "${RED}Error: secrds-agent binary not found${NC}" - exit 1 -fi - -# Install secrds-cli as 'secrds' -if [ -f "target/release/secrds-cli" ]; then - cp target/release/secrds-cli "$INSTALL_PREFIX/bin/secrds" - chmod +x "$INSTALL_PREFIX/bin/secrds" - echo -e "${GREEN}Installed secrds CLI${NC}" -else - echo -e "${RED}Error: secrds-cli binary not found${NC}" - exit 1 -fi - -# Install kernel program object files -echo -e "${YELLOW}Installing kernel program object files...${NC}" -mkdir -p /usr/local/lib/secrds -if [ -f "secrds-programs/ssh_kprobe.bpf.o" ]; then - cp secrds-programs/ssh_kprobe.bpf.o /usr/local/lib/secrds/ - echo -e "${GREEN}Installed SSH kernel program${NC}" -fi -if [ -f "secrds-programs/tcp_trace.bpf.o" ]; then - cp secrds-programs/tcp_trace.bpf.o /usr/local/lib/secrds/ - echo -e "${GREEN}Installed TCP kernel program${NC}" -fi - -# Install systemd service -echo -e "${YELLOW}Installing systemd service...${NC}" -if [ -f "secrds.service" ]; then - cp secrds.service "$SYSTEMD_DIR/secrds.service" - systemctl daemon-reload -else - echo -e "${YELLOW}Warning: secrds.service not found, skipping${NC}" -fi - -# Create default config if it doesn't exist -if [ ! -f "$CONFIG_DIR/config.yaml" ]; then - echo -e "${YELLOW}Creating default configuration...${NC}" - cat > "$CONFIG_DIR/config.yaml" </dev/null; then - echo -e "${GREEN}Service already enabled${NC}" -else - systemctl enable secrds - echo -e "${GREEN}Service enabled for auto-start${NC}" -fi - -# Reload systemd to pick up any service file changes -systemctl daemon-reload - -# Check if config has Telegram credentials -if grep -q "your_bot_token_here\|your_chat_id_here" "$CONFIG_DIR/config.yaml" 2>/dev/null; then - echo -e "${YELLOW}Warning: Telegram credentials not configured yet${NC}" - echo -e "${YELLOW}The service will start but alerts won't be sent to Telegram${NC}" - echo "" - echo -e "${YELLOW}To start the service now, run:${NC}" - echo " systemctl start secrds" - echo "" - echo -e "${YELLOW}To configure Telegram later:${NC}" - echo " 1. Edit $CONFIG_DIR/config.yaml" - echo " 2. Set telegram.bot_token and telegram.chat_id" - echo " 3. Restart: systemctl restart secrds" -else - # Start or restart the service if config is ready - if [ "$SERVICE_WAS_RUNNING" = true ]; then - echo -e "${YELLOW}Restarting service...${NC}" - if systemctl start secrds; then - sleep 2 - if systemctl is-active --quiet secrds; then - echo -e "${GREEN}Service restarted successfully${NC}" - else - echo -e "${YELLOW}Service restarted but may have issues. Check: systemctl status secrds${NC}" - fi - else - echo -e "${YELLOW}Failed to restart service. Check: systemctl status secrds${NC}" - fi - else - # Start the service if config is ready - if systemctl start secrds; then - sleep 2 - if systemctl is-active --quiet secrds; then - echo -e "${GREEN}Service started successfully${NC}" - else - echo -e "${YELLOW}Service started but may have issues. Check: systemctl status secrds${NC}" - fi - else - echo -e "${YELLOW}Failed to start service. Check: systemctl status secrds${NC}" - fi - fi -fi - -echo "" -echo -e "${GREEN}Installation complete!${NC}" -echo "" -echo -e "${YELLOW}Useful commands:${NC}" -echo " Check status: systemctl status secrds" -echo " View logs: journalctl -u secrds -f" -echo " Stop service: systemctl stop secrds" -echo " Start service: systemctl start secrds" -echo " Restart: systemctl restart secrds" -echo " View alerts: secrds alerts" -echo "" -echo -e "${GREEN}Installation successful!${NC}" - diff --git a/secrds-agent/Cargo.toml b/secrds-agent/Cargo.toml new file mode 100644 index 0000000..a9ddbdd --- /dev/null +++ b/secrds-agent/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "secrds-agent" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[[bin]] +name = "secrds-agent" +path = "src/main.rs" + +[dependencies] +aya = { git = "https://github.com/aya-rs/aya", branch = "main" } +aya-log = { git = "https://github.com/aya-rs/aya", branch = "main" } +bytes = "1.5" +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +serde_yaml = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +log = { workspace = true } +env_logger = { workspace = true } +reqwest = { version = "0.11", features = ["json"] } +ipnet = "2.9" +nix = "0.28" +chrono = "0.4" + diff --git a/secrds-agent/go.mod b/secrds-agent/go.mod deleted file mode 100644 index 32b37b1..0000000 --- a/secrds-agent/go.mod +++ /dev/null @@ -1,11 +0,0 @@ -module github.com/secrds/secrds-agent - -go 1.21.0 - -require github.com/cilium/ebpf v0.13.2 - -require ( - golang.org/x/exp v0.0.0-20230224173230-c95f2b4c22f2 // indirect - golang.org/x/sys v0.18.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) diff --git a/secrds-agent/go.sum b/secrds-agent/go.sum deleted file mode 100644 index d657c5f..0000000 --- a/secrds-agent/go.sum +++ /dev/null @@ -1,19 +0,0 @@ -github.com/cilium/ebpf v0.13.2 h1:uhLimLX+jF9BTPPvoCUYh/mBeoONkjgaJ9w9fn0mRj4= -github.com/cilium/ebpf v0.13.2/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso= -github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= -github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= -github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= -golang.org/x/exp v0.0.0-20230224173230-c95f2b4c22f2 h1:Jvc7gsqn21cJHCmAWx0LiimpP18LZmUxkT5Mp7EZ1mI= -golang.org/x/exp v0.0.0-20230224173230-c95f2b4c22f2/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= -golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= -golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/secrds-agent/internal/config/config.go b/secrds-agent/internal/config/config.go deleted file mode 100644 index cfee534..0000000 --- a/secrds-agent/internal/config/config.go +++ /dev/null @@ -1,115 +0,0 @@ -package config - -import ( - "encoding/json" - "fmt" - "os" - "time" - - "gopkg.in/yaml.v3" -) - -type Config struct { - SSHThreshold uint64 `yaml:"ssh_threshold" json:"ssh_threshold"` - SSHWindowSeconds uint64 `yaml:"ssh_window_seconds" json:"ssh_window_seconds"` - TCPThreshold uint64 `yaml:"tcp_threshold" json:"tcp_threshold"` - TCPWindowSeconds uint64 `yaml:"tcp_window_seconds" json:"tcp_window_seconds"` - EnableIPBlocking bool `yaml:"enable_ip_blocking" json:"enable_ip_blocking"` - StoragePath string `yaml:"storage_path" json:"storage_path"` - PIDFile string `yaml:"pid_file" json:"pid_file"` - LogLevel string `yaml:"log_level" json:"log_level"` - LogFile string `yaml:"log_file" json:"log_file"` - Telegram TelegramConfig `yaml:"telegram" json:"telegram"` -} - -type TelegramConfig struct { - BotToken string `yaml:"bot_token" json:"bot_token"` - ChatID string `yaml:"chat_id" json:"chat_id"` -} - -func Default() *Config { - return &Config{ - SSHThreshold: 5, // 5 attempts in 5 minutes triggers alert - SSHWindowSeconds: 300, - TCPThreshold: 10, // 10 connections in 60 seconds triggers alert - TCPWindowSeconds: 60, - EnableIPBlocking: true, - StoragePath: "/var/lib/secrds/events.json", - PIDFile: "/var/run/secrds.pid", - LogLevel: "info", - LogFile: "/var/log/secrds/agent.log", - } -} - -func Load() (*Config, error) { - configPath := os.Getenv("SECRDS_CONFIG") - if configPath == "" { - configPath = "/etc/secrds/config.yaml" - } - - cfg := Default() - - // Try to load config file - if _, err := os.Stat(configPath); err == nil { - data, err := os.ReadFile(configPath) - if err == nil { - // Try YAML first (preferred) - if err := yaml.Unmarshal(data, cfg); err == nil { - // Config loaded successfully - } else if err := json.Unmarshal(data, cfg); err == nil { - // JSON fallback worked - } - } - } - - // Try to load env file as fallback (for backward compatibility) - // But don't fail if we can't read it - envPath := os.Getenv("SECRDS_ENV_FILE") - if envPath == "" { - envPath = "/etc/secrds/env.conf" - } - if err := LoadEnvFile(envPath); err == nil { - // If env file loaded successfully, override config with env vars - if botToken := os.Getenv("TELEGRAM_BOT_TOKEN"); botToken != "" { - cfg.Telegram.BotToken = botToken - } - if chatID := os.Getenv("TELEGRAM_CHAT_ID"); chatID != "" { - cfg.Telegram.ChatID = chatID - } - } - - // Also check environment variables directly (highest priority) - if botToken := os.Getenv("TELEGRAM_BOT_TOKEN"); botToken != "" { - cfg.Telegram.BotToken = botToken - } - if chatID := os.Getenv("TELEGRAM_CHAT_ID"); chatID != "" { - cfg.Telegram.ChatID = chatID - } - - return cfg, nil -} - -func (c *Config) Validate() error { - if c.SSHThreshold == 0 { - return fmt.Errorf("ssh_threshold must be greater than 0") - } - if c.SSHWindowSeconds == 0 { - return fmt.Errorf("ssh_window_seconds must be greater than 0") - } - if c.TCPThreshold == 0 { - return fmt.Errorf("tcp_threshold must be greater than 0") - } - if c.TCPWindowSeconds == 0 { - return fmt.Errorf("tcp_window_seconds must be greater than 0") - } - return nil -} - -func (c *Config) SSHWindow() time.Duration { - return time.Duration(c.SSHWindowSeconds) * time.Second -} - -func (c *Config) TCPWindow() time.Duration { - return time.Duration(c.TCPWindowSeconds) * time.Second -} - diff --git a/secrds-agent/internal/config/env.go b/secrds-agent/internal/config/env.go deleted file mode 100644 index 0c1f486..0000000 --- a/secrds-agent/internal/config/env.go +++ /dev/null @@ -1,56 +0,0 @@ -package config - -import ( - "bufio" - "fmt" - "os" - "strings" -) - -// LoadEnvFile reads environment variables from a file in KEY=VALUE format -// Comments (lines starting with #) and empty lines are ignored -// Returns error only if file exists but can't be read (permission denied, etc.) -// Returns nil if file doesn't exist (not an error) -func LoadEnvFile(path string) error { - file, err := os.Open(path) - if err != nil { - if os.IsNotExist(err) { - return nil // File doesn't exist, not an error - } - return fmt.Errorf("failed to open env file: %w", err) - } - defer file.Close() - - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - - // Skip empty lines and comments - if line == "" || strings.HasPrefix(line, "#") { - continue - } - - // Parse KEY=VALUE - parts := strings.SplitN(line, "=", 2) - if len(parts) != 2 { - continue - } - - key := strings.TrimSpace(parts[0]) - value := strings.TrimSpace(parts[1]) - - // Remove quotes if present - if len(value) >= 2 && ((value[0] == '"' && value[len(value)-1] == '"') || - (value[0] == '\'' && value[len(value)-1] == '\'')) { - value = value[1 : len(value)-1] - } - - // Set environment variable if not already set - if os.Getenv(key) == "" { - os.Setenv(key, value) - } - } - - return scanner.Err() -} - diff --git a/secrds-agent/internal/detector/threat.go b/secrds-agent/internal/detector/threat.go deleted file mode 100644 index 1df32cc..0000000 --- a/secrds-agent/internal/detector/threat.go +++ /dev/null @@ -1,567 +0,0 @@ -package detector - -import ( - "fmt" - "net" - "os/exec" - "sync" - "time" - - "github.com/secrds/secrds-agent/internal/config" - "github.com/secrds/secrds-agent/internal/storage" - "github.com/secrds/secrds-agent/internal/telegram" -) - -// EventType constants matching common.h -const ( - SSH_ATTEMPT = 0 - SSH_FAILURE = 1 - SSH_SUCCESS = 2 - TCP_CONNECT = 3 - TCP_ACCEPT = 4 - TCP_CLOSE = 5 -) - -// ThreatSeverity levels -type ThreatSeverity string - -const ( - SeverityLow ThreatSeverity = "LOW" - SeverityMedium ThreatSeverity = "MEDIUM" - SeverityHigh ThreatSeverity = "HIGH" - SeverityCritical ThreatSeverity = "CRITICAL" -) - -// SSHEventDetail tracks detailed SSH event information -type SSHEventDetail struct { - Timestamp time.Time - EventType uint8 - Port uint16 - PID uint32 -} - -// TCPConnectionDetail tracks TCP connection details -type TCPConnectionDetail struct { - Timestamp time.Time - SrcPort uint16 - DstPort uint16 - EventType uint8 -} - -// IPBehavior tracks behavioral patterns for an IP -type IPBehavior struct { - SSHEvents []SSHEventDetail - TCPConnections []TCPConnectionDetail - FailedSSHCount uint64 - SuccessfulSSHCount uint64 - UniquePorts map[uint16]bool - FirstSeen time.Time - LastSeen time.Time - TotalConnections uint64 -} - -type ThreatDetector struct { - config *config.Config - storage *storage.Storage - telegramClient *telegram.Client - mu sync.RWMutex - ipBehaviors map[string]*IPBehavior - blockedIPs map[string]bool -} - -func New(cfg *config.Config, st *storage.Storage, tg *telegram.Client) *ThreatDetector { - return &ThreatDetector{ - config: cfg, - storage: st, - telegramClient: tg, - ipBehaviors: make(map[string]*IPBehavior), - blockedIPs: make(map[string]bool), - } -} - -func (td *ThreatDetector) ProcessSSHEvent(ip uint32, port uint16, pid uint32, eventType uint8) error { - ipAddr := u32ToIP(ip) - ipStr := ipAddr.String() - - // Skip invalid IPs (0.0.0.0 or invalid) - if ip == 0 || ipStr == "0.0.0.0" { - return nil - } - - // Check if already blocked - if td.storage.IsBlocked(ipStr) { - return nil - } - - td.mu.Lock() - defer td.mu.Unlock() - - now := time.Now() - - // Get or create IP behavior tracking - behavior := td.getOrCreateBehavior(ipStr) - - // Add event - event := SSHEventDetail{ - Timestamp: now, - EventType: eventType, - Port: port, - PID: pid, - } - behavior.SSHEvents = append(behavior.SSHEvents, event) - behavior.LastSeen = now - - // Track failed vs successful attempts - if eventType == SSH_FAILURE { - behavior.FailedSSHCount++ - } else if eventType == SSH_SUCCESS { - behavior.SuccessfulSSHCount++ - } - - // Clean old events (keep last 24 hours) - cutoff := now.Add(-24 * time.Hour) - validEvents := []SSHEventDetail{} - for _, e := range behavior.SSHEvents { - if e.Timestamp.After(cutoff) { - validEvents = append(validEvents, e) - } - } - behavior.SSHEvents = validEvents - - // Advanced threat detection - threats := td.detectSSHThreats(ipStr, behavior, now) - - // Process detected threats - for _, threat := range threats { - if err := td.handleThreat(ipStr, threat); err != nil { - fmt.Printf("Failed to handle threat: %v\n", err) - } - } - - return nil -} - -func (td *ThreatDetector) ProcessTCPEvent(srcIP, dstIP uint32, srcPort, dstPort uint16, eventType uint8) error { - ipAddr := u32ToIP(srcIP) - ipStr := ipAddr.String() - - // Check if already blocked - if td.storage.IsBlocked(ipStr) { - return nil - } - - td.mu.Lock() - defer td.mu.Unlock() - - now := time.Now() - - // Get or create IP behavior tracking - behavior := td.getOrCreateBehavior(ipStr) - - // Add connection - conn := TCPConnectionDetail{ - Timestamp: now, - SrcPort: srcPort, - DstPort: dstPort, - EventType: eventType, - } - behavior.TCPConnections = append(behavior.TCPConnections, conn) - behavior.LastSeen = now - behavior.TotalConnections++ - - // Track unique destination ports for port scan detection - if eventType == TCP_CONNECT { - if behavior.UniquePorts == nil { - behavior.UniquePorts = make(map[uint16]bool) - } - behavior.UniquePorts[dstPort] = true - } - - // Clean old connections (keep last 24 hours) - cutoff := now.Add(-24 * time.Hour) - validConns := []TCPConnectionDetail{} - for _, c := range behavior.TCPConnections { - if c.Timestamp.After(cutoff) { - validConns = append(validConns, c) - } - } - behavior.TCPConnections = validConns - - // Advanced threat detection - threats := td.detectTCPThreats(ipStr, behavior, now) - - // Process detected threats - for _, threat := range threats { - if err := td.handleThreat(ipStr, threat); err != nil { - fmt.Printf("Failed to handle threat: %v\n", err) - } - } - - return nil -} - -// ThreatInfo contains detailed threat information -type ThreatInfo struct { - ThreatType storage.ThreatType - Severity ThreatSeverity - Count uint64 - Details string - Score float64 -} - -func (td *ThreatDetector) detectSSHThreats(ip string, behavior *IPBehavior, now time.Time) []ThreatInfo { - var threats []ThreatInfo - - // Multi-window analysis - shortWindow := 1 * time.Minute - mediumWindow := 5 * time.Minute - longWindow := 15 * time.Minute - - shortTerm := td.countEventsInWindow(behavior.SSHEvents, now, shortWindow) - mediumTerm := td.countEventsInWindow(behavior.SSHEvents, now, mediumWindow) - longTerm := td.countEventsInWindow(behavior.SSHEvents, now, longWindow) - - // Calculate threat score with exponential weighting - score := td.calculateThreatScore(shortTerm, mediumTerm, longTerm) - - // Failed login analysis - failedInShort := td.countFailedInWindow(behavior.SSHEvents, now, shortWindow) - failedInMedium := td.countFailedInWindow(behavior.SSHEvents, now, mediumWindow) - - // Use config threshold for detection - threshold := td.config.SSHThreshold - if threshold == 0 { - threshold = 3 // Default fallback - } - - // Pattern 1: Rapid brute force attack (high frequency in short window) - // Critical: 2x threshold in 1 minute - if shortTerm >= threshold*2 || (shortTerm >= threshold && failedInShort >= threshold) { - threats = append(threats, ThreatInfo{ - ThreatType: storage.ThreatTypeSSHBruteForce, - Severity: SeverityCritical, - Count: shortTerm, - Details: fmt.Sprintf("Rapid brute force: %d attempts in 1 minute", shortTerm), - Score: score, - }) - } else if mediumTerm >= threshold*3 || (mediumTerm >= threshold*2 && failedInMedium >= threshold*2) { - // High: 3x threshold in 5 minutes - threats = append(threats, ThreatInfo{ - ThreatType: storage.ThreatTypeSSHBruteForce, - Severity: SeverityHigh, - Count: mediumTerm, - Details: fmt.Sprintf("Sustained brute force: %d attempts in 5 minutes", mediumTerm), - Score: score, - }) - } else if mediumTerm >= threshold { - // Medium: threshold or more in 5 minutes - threats = append(threats, ThreatInfo{ - ThreatType: storage.ThreatTypeSSHBruteForce, - Severity: SeverityMedium, - Count: mediumTerm, - Details: fmt.Sprintf("Brute force detected: %d attempts in 5 minutes", mediumTerm), - Score: score, - }) - } else if longTerm >= threshold { - // Low: threshold or more in 15 minutes - threats = append(threats, ThreatInfo{ - ThreatType: storage.ThreatTypeSSHBruteForce, - Severity: SeverityLow, - Count: longTerm, - Details: fmt.Sprintf("Suspicious activity: %d attempts in 15 minutes", longTerm), - Score: score, - }) - } - - // Pattern 2: High failure rate (suspicious activity) - totalAttempts := uint64(len(behavior.SSHEvents)) - if totalAttempts > 0 { - failureRate := float64(behavior.FailedSSHCount) / float64(totalAttempts) - if failureRate > 0.8 && totalAttempts >= 5 { - threats = append(threats, ThreatInfo{ - ThreatType: storage.ThreatTypeSSHBruteForce, - Severity: SeverityHigh, - Count: behavior.FailedSSHCount, - Details: fmt.Sprintf("High failure rate: %.1f%% failures (%d/%d)", failureRate*100, behavior.FailedSSHCount, totalAttempts), - Score: score * failureRate, - }) - } - } - - // Pattern 3: Timing pattern analysis (rapid-fire attempts) - if len(behavior.SSHEvents) >= 3 { - rapidFire := td.detectRapidFirePattern(behavior.SSHEvents, now) - if rapidFire { - threats = append(threats, ThreatInfo{ - ThreatType: storage.ThreatTypeSSHBruteForce, - Severity: SeverityHigh, - Count: uint64(len(behavior.SSHEvents)), - Details: "Rapid-fire attack pattern detected", - Score: score * 1.2, - }) - } - } - - return threats -} - -func (td *ThreatDetector) detectTCPThreats(ip string, behavior *IPBehavior, now time.Time) []ThreatInfo { - var threats []ThreatInfo - - // Multi-window analysis - shortWindow := 30 * time.Second - mediumWindow := 2 * time.Minute - longWindow := 10 * time.Minute - - shortTerm := td.countConnectionsInWindow(behavior.TCPConnections, now, shortWindow) - mediumTerm := td.countConnectionsInWindow(behavior.TCPConnections, now, mediumWindow) - longTerm := td.countConnectionsInWindow(behavior.TCPConnections, now, longWindow) - - // Calculate threat score - score := td.calculateThreatScore(shortTerm, mediumTerm, longTerm) - - // Port scanning detection - uniquePorts := len(behavior.UniquePorts) - portScanThreshold := 5 - - // Pattern 1: Port scanning (many unique ports) - if uniquePorts >= portScanThreshold*3 { - threats = append(threats, ThreatInfo{ - ThreatType: storage.ThreatTypeTCPPortScan, - Severity: SeverityCritical, - Count: uint64(uniquePorts), - Details: fmt.Sprintf("Aggressive port scan: %d unique ports scanned", uniquePorts), - Score: score * float64(uniquePorts) / 10, - }) - } else if uniquePorts >= portScanThreshold*2 { - threats = append(threats, ThreatInfo{ - ThreatType: storage.ThreatTypeTCPPortScan, - Severity: SeverityHigh, - Count: uint64(uniquePorts), - Details: fmt.Sprintf("Port scan detected: %d unique ports", uniquePorts), - Score: score * float64(uniquePorts) / 10, - }) - } else if uniquePorts >= portScanThreshold { - threats = append(threats, ThreatInfo{ - ThreatType: storage.ThreatTypeTCPPortScan, - Severity: SeverityMedium, - Count: uint64(uniquePorts), - Details: fmt.Sprintf("Suspicious port activity: %d unique ports", uniquePorts), - Score: score * float64(uniquePorts) / 10, - }) - } - - // Pattern 2: Connection flood - if shortTerm >= 50 { - threats = append(threats, ThreatInfo{ - ThreatType: storage.ThreatTypeTCPFlood, - Severity: SeverityCritical, - Count: shortTerm, - Details: fmt.Sprintf("Connection flood: %d connections in 30 seconds", shortTerm), - Score: score, - }) - } else if mediumTerm >= 100 { - threats = append(threats, ThreatInfo{ - ThreatType: storage.ThreatTypeTCPFlood, - Severity: SeverityHigh, - Count: mediumTerm, - Details: fmt.Sprintf("Sustained flood: %d connections in 2 minutes", mediumTerm), - Score: score, - }) - } else if longTerm >= 200 { - threats = append(threats, ThreatInfo{ - ThreatType: storage.ThreatTypeTCPFlood, - Severity: SeverityMedium, - Count: longTerm, - Details: fmt.Sprintf("High connection volume: %d connections in 10 minutes", longTerm), - Score: score, - }) - } - - // Pattern 3: Sequential port scanning pattern - if uniquePorts >= portScanThreshold { - sequential := td.detectSequentialPortScan(behavior.TCPConnections) - if sequential { - threats = append(threats, ThreatInfo{ - ThreatType: storage.ThreatTypeTCPPortScan, - Severity: SeverityHigh, - Count: uint64(uniquePorts), - Details: "Sequential port scan pattern detected", - Score: score * 1.3, - }) - } - } - - return threats -} - -// Helper functions - -func (td *ThreatDetector) getOrCreateBehavior(ip string) *IPBehavior { - if behavior, exists := td.ipBehaviors[ip]; exists { - return behavior - } - behavior := &IPBehavior{ - SSHEvents: []SSHEventDetail{}, - TCPConnections: []TCPConnectionDetail{}, - UniquePorts: make(map[uint16]bool), - FirstSeen: time.Now(), - LastSeen: time.Now(), - FailedSSHCount: 0, - SuccessfulSSHCount: 0, - } - td.ipBehaviors[ip] = behavior - return behavior -} - -func (td *ThreatDetector) countEventsInWindow(events []SSHEventDetail, now time.Time, window time.Duration) uint64 { - count := uint64(0) - cutoff := now.Add(-window) - for _, e := range events { - if e.Timestamp.After(cutoff) { - count++ - } - } - return count -} - -func (td *ThreatDetector) countConnectionsInWindow(conns []TCPConnectionDetail, now time.Time, window time.Duration) uint64 { - count := uint64(0) - cutoff := now.Add(-window) - for _, c := range conns { - if c.Timestamp.After(cutoff) { - count++ - } - } - return count -} - -func (td *ThreatDetector) countFailedInWindow(events []SSHEventDetail, now time.Time, window time.Duration) uint64 { - count := uint64(0) - cutoff := now.Add(-window) - for _, e := range events { - if e.Timestamp.After(cutoff) && e.EventType == SSH_FAILURE { - count++ - } - } - return count -} - -func (td *ThreatDetector) calculateThreatScore(short, medium, long uint64) float64 { - // Exponential weighting: recent events are more significant - score := float64(short)*3.0 + float64(medium)*1.5 + float64(long)*0.5 - return score -} - -func (td *ThreatDetector) detectRapidFirePattern(events []SSHEventDetail, now time.Time) bool { - if len(events) < 3 { - return false - } - - // Check last 5 events for rapid-fire pattern (multiple attempts within 5 seconds) - recentEvents := events - if len(events) > 5 { - recentEvents = events[len(events)-5:] - } - - for i := 1; i < len(recentEvents); i++ { - timeDiff := recentEvents[i].Timestamp.Sub(recentEvents[i-1].Timestamp) - if timeDiff < 2*time.Second && recentEvents[i].EventType == SSH_FAILURE { - return true - } - } - - return false -} - -func (td *ThreatDetector) detectSequentialPortScan(conns []TCPConnectionDetail) bool { - if len(conns) < 5 { - return false - } - - // Check if ports are being scanned sequentially - recentConns := conns - if len(conns) > 20 { - recentConns = conns[len(conns)-20:] - } - - sequentialCount := 0 - for i := 1; i < len(recentConns); i++ { - portDiff := int(recentConns[i].DstPort) - int(recentConns[i-1].DstPort) - if portDiff > 0 && portDiff <= 10 { - sequentialCount++ - } - } - - // If more than 30% show sequential pattern, it's likely a scan - return float64(sequentialCount)/float64(len(recentConns)-1) > 0.3 -} - -func (td *ThreatDetector) handleThreat(ip string, threat ThreatInfo) error { - // Filter out LOW severity threats with low scores to reduce noise - if threat.Severity == SeverityLow && threat.Score < 5 { - return nil - } - - alert := &storage.Alert{ - IP: ip, - ThreatType: threat.ThreatType, - Count: threat.Count, - Timestamp: time.Now(), - Severity: string(threat.Severity), - Details: threat.Details, - Score: threat.Score, - } - - if err := td.storage.StoreAlert(alert); err != nil { - return fmt.Errorf("failed to store alert: %w", err) - } - - // Send Telegram alert - tgAlert := &telegram.Alert{ - IP: ip, - ThreatType: string(threat.ThreatType), - Count: threat.Count, - Timestamp: time.Now(), - Severity: string(threat.Severity), - Details: threat.Details, - Score: threat.Score, - } - if err := td.telegramClient.SendAlert(tgAlert); err != nil { - fmt.Printf("Failed to send Telegram alert: %v\n", err) - } - - // Auto-block for CRITICAL threats or high-scoring threats - shouldBlock := threat.Severity == SeverityCritical || - (threat.Severity == SeverityHigh && threat.Score > 50) || - (threat.Score > 100) - - if shouldBlock && td.config.EnableIPBlocking { - if err := td.blockIP(ip); err != nil { - fmt.Printf("Failed to block IP %s: %v\n", ip, err) - } else { - td.storage.AddBlockedIP(ip) - fmt.Printf("Auto-blocked IP %s due to %s threat (severity: %s, score: %.1f)\n", - ip, threat.ThreatType, threat.Severity, threat.Score) - } - } - - return nil -} - -func (td *ThreatDetector) blockIP(ip string) error { - cmd := exec.Command("iptables", "-A", "INPUT", "-s", ip, "-j", "DROP") - if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to block IP with iptables: %w", err) - } - return nil -} - -func u32ToIP(ip uint32) net.IP { - return net.IP{ - byte(ip >> 24), - byte(ip >> 16), - byte(ip >> 8), - byte(ip), - } -} diff --git a/secrds-agent/internal/kernel/loader.go b/secrds-agent/internal/kernel/loader.go deleted file mode 100644 index 41ee9e6..0000000 --- a/secrds-agent/internal/kernel/loader.go +++ /dev/null @@ -1,189 +0,0 @@ -package kernel - -import ( - "fmt" - "os" - "path/filepath" - - "github.com/cilium/ebpf" - "github.com/cilium/ebpf/link" - "github.com/cilium/ebpf/perf" -) - -// SSHEvent matches the C struct ssh_event from common.h -// Layout: IP (4) + Port (2) + padding (2) + PID (4) + EventType (1) + padding (3) + Timestamp (8) = 24 bytes -type SSHEvent struct { - IP uint32 - Port uint16 - _ [2]byte // padding to align PID - PID uint32 - EventType uint8 - _ [3]byte // padding to align Timestamp - Timestamp uint64 -} - -type TCPEvent struct { - SrcIP uint32 - DstIP uint32 - SrcPort uint16 - DstPort uint16 - EventType uint8 - _ [3]byte // padding - Timestamp uint64 -} - -type Loader struct { - collection *ebpf.Collection - links []link.Link - sshEvents *perf.Reader - tcpEvents *perf.Reader -} - -func NewLoader() (*Loader, error) { - return &Loader{ - links: []link.Link{}, - }, nil -} - -func (l *Loader) LoadCPrograms() error { - // Try multiple possible locations for the kernel program object file - paths := []string{ - "/usr/local/lib/secrds/ssh_kprobe.bpf.o", - filepath.Join("secrds-programs", "ssh_kprobe.bpf.o"), - filepath.Join("..", "secrds-programs", "ssh_kprobe.bpf.o"), - filepath.Join("../../secrds-programs", "ssh_kprobe.bpf.o"), - } - - var spec *ebpf.CollectionSpec - var err error - - for _, path := range paths { - if _, err := os.Stat(path); os.IsNotExist(err) { - continue - } - - spec, err = ebpf.LoadCollectionSpec(path) - if err == nil { - fmt.Printf("Loaded kernel program from: %s\n", path) - break - } - } - - if spec == nil { - return fmt.Errorf("SSH kernel program object file not found in any expected location") - } - - // Load the collection - coll, err := ebpf.NewCollection(spec) - if err != nil { - return fmt.Errorf("failed to load kernel program collection: %w", err) - } - l.collection = coll - - // Attach kprobe for inet_csk_accept (incoming connections on server) - // This is CRITICAL for detecting incoming SSH connections - acceptAttached := false - if acceptProg := coll.Programs["ssh_kprobe_accept"]; acceptProg != nil { - kpAccept, err := link.Kprobe("inet_csk_accept", acceptProg, nil) - if err != nil { - fmt.Printf("ERROR: failed to attach kprobe inet_csk_accept: %v\n", err) - fmt.Printf("WARNING: Incoming SSH connections may not be detected!\n") - fmt.Printf("This kernel symbol may not be available. Check with: cat /proc/kallsyms | grep inet_csk_accept\n") - - // Try alternative: tcp_v4_syn_recv_sock (called when server receives SYN) - fmt.Printf("Trying alternative: tcp_v4_syn_recv_sock...\n") - if altProg, altErr := link.Kprobe("tcp_v4_syn_recv_sock", acceptProg, nil); altErr == nil { - l.links = append(l.links, altProg) - fmt.Println("Attached kprobe to tcp_v4_syn_recv_sock (alternative for incoming connections)") - acceptAttached = true - } else { - fmt.Printf("Alternative also failed: %v\n", altErr) - fmt.Printf("CRITICAL: No incoming connection detection available!\n") - } - } else { - l.links = append(l.links, kpAccept) - fmt.Println("Attached kprobe to inet_csk_accept (incoming connections)") - acceptAttached = true - } - } - - if !acceptAttached { - fmt.Printf("\n") - fmt.Printf("=================================================================\n") - fmt.Printf("WARNING: Incoming connection detection is NOT working!\n") - fmt.Printf("Only outgoing connections will be detected.\n") - fmt.Printf("To fix this, ensure your kernel exports inet_csk_accept symbol.\n") - fmt.Printf("Check: cat /proc/kallsyms | grep inet_csk_accept\n") - fmt.Printf("=================================================================\n") - fmt.Printf("\n") - } - - // Attach kprobe for tcp_v4_connect (outgoing connections) - // Try different possible program names - progNames := []string{"ssh_kprobe_tcp_connect", "ssh_kprobe_execve", "ssh_tracepoint_write"} - var kprobeProg *ebpf.Program - - for _, name := range progNames { - if prog := coll.Programs[name]; prog != nil { - kprobeProg = prog - fmt.Printf("Found kernel program: %s\n", name) - break - } - } - - if kprobeProg == nil { - // List available programs for debugging - fmt.Println("Available kernel programs:") - for name := range coll.Programs { - fmt.Printf(" - %s\n", name) - } - return fmt.Errorf("no suitable SSH kernel program found") - } - - kp, err := link.Kprobe("tcp_v4_connect", kprobeProg, nil) - if err != nil { - return fmt.Errorf("failed to attach kprobe tcp_v4_connect: %w", err) - } - l.links = append(l.links, kp) - fmt.Println("Attached kprobe to tcp_v4_connect (outgoing connections)") - - // Open perf event array for SSH events - sshMap := coll.Maps["ssh_events"] - if sshMap == nil { - return fmt.Errorf("ssh_events map not found") - } - - sshReader, err := perf.NewReader(sshMap, 4096) - if err != nil { - return fmt.Errorf("failed to create SSH perf reader: %w", err) - } - l.sshEvents = sshReader - - fmt.Println("Kernel programs loaded successfully") - return nil -} - -func (l *Loader) GetSSHEvents() *perf.Reader { - return l.sshEvents -} - -func (l *Loader) GetTCPEvents() *perf.Reader { - return l.tcpEvents -} - -func (l *Loader) Close() error { - for _, lnk := range l.links { - lnk.Close() - } - if l.sshEvents != nil { - l.sshEvents.Close() - } - if l.tcpEvents != nil { - l.tcpEvents.Close() - } - if l.collection != nil { - l.collection.Close() - } - return nil -} - diff --git a/secrds-agent/internal/processor/event.go b/secrds-agent/internal/processor/event.go deleted file mode 100644 index 69d95a5..0000000 --- a/secrds-agent/internal/processor/event.go +++ /dev/null @@ -1,187 +0,0 @@ -package processor - -import ( - "context" - "encoding/binary" - "fmt" - "strings" - "sync" - - "github.com/cilium/ebpf/perf" - "github.com/secrds/secrds-agent/internal/detector" -) - -type EventProcessor struct { - detector *detector.ThreatDetector - loader LoaderInterface - ctx context.Context - cancel context.CancelFunc - wg sync.WaitGroup -} - -type LoaderInterface interface { - GetSSHEvents() *perf.Reader - GetTCPEvents() *perf.Reader - Close() error -} - -func New(det *detector.ThreatDetector, ld LoaderInterface) *EventProcessor { - ctx, cancel := context.WithCancel(context.Background()) - return &EventProcessor{ - detector: det, - loader: ld, - ctx: ctx, - cancel: cancel, - } -} - -func (ep *EventProcessor) Start() error { - sshReader := ep.loader.GetSSHEvents() - if sshReader == nil { - return fmt.Errorf("SSH events reader not available") - } - - // Process SSH events - ep.wg.Add(1) - go func() { - defer ep.wg.Done() - ep.processSSHEvents(sshReader) - }() - - // Process TCP events if available - if tcpReader := ep.loader.GetTCPEvents(); tcpReader != nil { - ep.wg.Add(1) - go func() { - defer ep.wg.Done() - ep.processTCPEvents(tcpReader) - }() - } - - return nil -} - -func (ep *EventProcessor) processSSHEvents(reader *perf.Reader) { - for { - select { - case <-ep.ctx.Done(): - return - default: - } - - record, err := reader.Read() - if err != nil { - // Check if reader is closed - check for various error messages - errStr := err.Error() - if errStr == "EOF" || - errStr == "perf reader closed" || - strings.Contains(errStr, "file already closed") || - strings.Contains(errStr, "perf ringbuffer") { - return - } - // Only log non-closure errors - if !strings.Contains(errStr, "closed") { - fmt.Printf("Error reading SSH event: %v\n", err) - } - continue - } - - // SSHEvent struct: IP (4) + Port (2) + padding (2) + PID (4) + EventType (1) + padding (3) + Timestamp (8) = 24 bytes - if len(record.RawSample) < 24 { - fmt.Printf("Invalid SSH event size: %d bytes (expected 24)\n", len(record.RawSample)) - continue - } - - event := SSHEvent{ - IP: binary.LittleEndian.Uint32(record.RawSample[0:4]), - Port: binary.LittleEndian.Uint16(record.RawSample[4:6]), - PID: binary.LittleEndian.Uint32(record.RawSample[8:12]), - EventType: record.RawSample[12], - Timestamp: binary.LittleEndian.Uint64(record.RawSample[16:24]), - } - - // Convert IP to string for logging - ipStr := fmt.Sprintf("%d.%d.%d.%d", - byte(event.IP>>24), byte(event.IP>>16), byte(event.IP>>8), byte(event.IP)) - - if err := ep.detector.ProcessSSHEvent(event.IP, event.Port, event.PID, event.EventType); err != nil { - fmt.Printf("Failed to process SSH event from %s: %v\n", ipStr, err) - } else { - // Log successful event processing (for debugging) - fmt.Printf("Processed SSH event: IP=%s, Port=%d\n", ipStr, event.Port) - } - } -} - -func (ep *EventProcessor) processTCPEvents(reader *perf.Reader) { - for { - select { - case <-ep.ctx.Done(): - return - default: - } - - record, err := reader.Read() - if err != nil { - // Check if reader is closed - check for various error messages - errStr := err.Error() - if errStr == "EOF" || - errStr == "perf reader closed" || - strings.Contains(errStr, "file already closed") || - strings.Contains(errStr, "perf ringbuffer") { - return - } - // Only log non-closure errors - if !strings.Contains(errStr, "closed") { - fmt.Printf("Error reading TCP event: %v\n", err) - } - continue - } - - if len(record.RawSample) < 24 { // Size of TCPEvent struct - continue - } - - event := TCPEvent{ - SrcIP: binary.LittleEndian.Uint32(record.RawSample[0:4]), - DstIP: binary.LittleEndian.Uint32(record.RawSample[4:8]), - SrcPort: binary.LittleEndian.Uint16(record.RawSample[8:10]), - DstPort: binary.LittleEndian.Uint16(record.RawSample[10:12]), - EventType: record.RawSample[12], - Timestamp: binary.LittleEndian.Uint64(record.RawSample[16:24]), - } - - if err := ep.detector.ProcessTCPEvent(event.SrcIP, event.DstIP, event.SrcPort, event.DstPort, event.EventType); err != nil { - fmt.Printf("Failed to process TCP event: %v\n", err) - } - } -} - -type SSHEvent struct { - IP uint32 - Port uint16 - PID uint32 - EventType uint8 - Timestamp uint64 -} - -type TCPEvent struct { - SrcIP uint32 - DstIP uint32 - SrcPort uint16 - DstPort uint16 - EventType uint8 - Timestamp uint64 -} - -// Cancel cancels the context to signal goroutines to exit -func (ep *EventProcessor) Cancel() { - if ep.cancel != nil { - ep.cancel() - } -} - -// Stop waits for goroutines to finish (assumes Cancel() was called first) -func (ep *EventProcessor) Stop() { - ep.wg.Wait() -} - diff --git a/secrds-agent/internal/storage/storage.go b/secrds-agent/internal/storage/storage.go deleted file mode 100644 index 130622c..0000000 --- a/secrds-agent/internal/storage/storage.go +++ /dev/null @@ -1,186 +0,0 @@ -package storage - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "sync" - "time" -) - -type ThreatType string - -const ( - ThreatTypeSSHBruteForce ThreatType = "SSH_BRUTE_FORCE" - ThreatTypeTCPPortScan ThreatType = "TCP_PORT_SCAN" - ThreatTypeTCPFlood ThreatType = "TCP_FLOOD" -) - -type Alert struct { - IP string `json:"ip"` - ThreatType ThreatType `json:"threat_type"` - Count uint64 `json:"count"` - Timestamp time.Time `json:"timestamp"` - Severity string `json:"severity,omitempty"` - Details string `json:"details,omitempty"` - Score float64 `json:"score,omitempty"` -} - -type Statistics struct { - TotalAlerts uint64 `json:"total_alerts"` - SSHBruteForceCount uint64 `json:"ssh_brute_force_count"` - TCPPortScanCount uint64 `json:"tcp_port_scan_count"` - TCPFloodCount uint64 `json:"tcp_flood_count"` - BlockedIPsCount uint64 `json:"blocked_ips_count"` -} - -type StorageData struct { - Alerts []Alert `json:"alerts"` - BlockedIPs []string `json:"blocked_ips"` - Statistics Statistics `json:"statistics"` -} - -type Storage struct { - path string - mu sync.RWMutex - data *StorageData -} - -func New(path string) (*Storage, error) { - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return nil, fmt.Errorf("failed to create storage directory: %w", err) - } - - s := &Storage{ - path: path, - data: &StorageData{ - Alerts: []Alert{}, - BlockedIPs: []string{}, - Statistics: Statistics{}, - }, - } - - // Load existing data if available - if data, err := os.ReadFile(path); err == nil { - if err := json.Unmarshal(data, s.data); err != nil { - // If unmarshal fails, use default empty data - s.data = &StorageData{ - Alerts: []Alert{}, - BlockedIPs: []string{}, - Statistics: Statistics{}, - } - } - } - - // Start periodic flush - go s.periodicFlush() - - return s, nil -} - -func (s *Storage) periodicFlush() { - ticker := time.NewTicker(60 * time.Second) - defer ticker.Stop() - - for range ticker.C { - if err := s.Flush(); err != nil { - fmt.Printf("Failed to flush storage: %v\n", err) - } - } -} - -func (s *Storage) StoreAlert(alert *Alert) error { - s.mu.Lock() - defer s.mu.Unlock() - - s.data.Alerts = append(s.data.Alerts, *alert) - s.data.Statistics.TotalAlerts++ - - switch alert.ThreatType { - case ThreatTypeSSHBruteForce: - s.data.Statistics.SSHBruteForceCount++ - case ThreatTypeTCPPortScan: - s.data.Statistics.TCPPortScanCount++ - case ThreatTypeTCPFlood: - s.data.Statistics.TCPFloodCount++ - } - - // Keep only last 1000 alerts - if len(s.data.Alerts) > 1000 { - s.data.Alerts = s.data.Alerts[len(s.data.Alerts)-1000:] - } - - return nil -} - -func (s *Storage) AddBlockedIP(ip string) error { - s.mu.Lock() - defer s.mu.Unlock() - - // Check if already blocked - for _, blocked := range s.data.BlockedIPs { - if blocked == ip { - return nil - } - } - - s.data.BlockedIPs = append(s.data.BlockedIPs, ip) - s.data.Statistics.BlockedIPsCount++ - return nil -} - -func (s *Storage) GetAlerts(limit int) []Alert { - s.mu.RLock() - defer s.mu.RUnlock() - - alerts := s.data.Alerts - if len(alerts) > limit { - alerts = alerts[len(alerts)-limit:] - } - - // Reverse to show newest first - result := make([]Alert, len(alerts)) - for i := range alerts { - result[len(alerts)-1-i] = alerts[i] - } - - return result -} - -func (s *Storage) GetStatistics() Statistics { - s.mu.RLock() - defer s.mu.RUnlock() - - return s.data.Statistics -} - -func (s *Storage) IsBlocked(ip string) bool { - s.mu.RLock() - defer s.mu.RUnlock() - - for _, blocked := range s.data.BlockedIPs { - if blocked == ip { - return true - } - } - return false -} - -func (s *Storage) Flush() error { - s.mu.RLock() - data := s.data - s.mu.RUnlock() - - jsonData, err := json.MarshalIndent(data, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal storage data: %w", err) - } - - if err := os.WriteFile(s.path, jsonData, 0644); err != nil { - return fmt.Errorf("failed to write storage file: %w", err) - } - - return nil -} - diff --git a/secrds-agent/internal/telegram/client.go b/secrds-agent/internal/telegram/client.go deleted file mode 100644 index a3d06a1..0000000 --- a/secrds-agent/internal/telegram/client.go +++ /dev/null @@ -1,154 +0,0 @@ -package telegram - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "time" -) - -const TelegramAPIURL = "https://api.telegram.org/bot" - -type Client struct { - botToken string - chatID string - client *http.Client -} - -func New(botToken string, chatID string) (*Client, error) { - if chatID == "" { - return nil, fmt.Errorf("TELEGRAM_CHAT_ID not set. Please set it in /etc/secrds/config.yaml (telegram.chat_id) or as TELEGRAM_CHAT_ID environment variable") - } - - return &Client{ - botToken: botToken, - chatID: chatID, - client: &http.Client{ - Timeout: 10 * time.Second, - }, - }, nil -} - -type SendMessageRequest struct { - ChatID string `json:"chat_id"` - Text string `json:"text"` - ParseMode string `json:"parse_mode"` -} - -type TelegramResponse struct { - OK bool `json:"ok"` - Description string `json:"description,omitempty"` -} - -func (c *Client) SendAlert(alert *Alert) error { - message := c.formatAlert(alert) - url := fmt.Sprintf("%s%s/sendMessage", TelegramAPIURL, c.botToken) - - req := SendMessageRequest{ - ChatID: c.chatID, - Text: message, - ParseMode: "Markdown", - } - - jsonData, err := json.Marshal(req) - if err != nil { - return fmt.Errorf("failed to marshal request: %w", err) - } - - retries := 3 - for retries > 0 { - resp, err := c.client.Post(url, "application/json", bytes.NewBuffer(jsonData)) - if err != nil { - retries-- - if retries > 0 { - time.Sleep(1 * time.Second) - } - continue - } - - if resp.StatusCode == http.StatusOK { - resp.Body.Close() - return nil - } - - body, _ := io.ReadAll(resp.Body) - resp.Body.Close() - - var tgResp TelegramResponse - if err := json.Unmarshal(body, &tgResp); err == nil { - fmt.Printf("Telegram API error: %s\n", tgResp.Description) - } - - retries-- - if retries > 0 { - time.Sleep(1 * time.Second) - } - } - - return fmt.Errorf("failed to send Telegram alert after retries") -} - -func (c *Client) formatAlert(alert *Alert) string { - threatName := alert.ThreatType - switch alert.ThreatType { - case "SSH_BRUTE_FORCE": - threatName = "SSH Brute Force" - case "TCP_PORT_SCAN": - threatName = "TCP Port Scan" - case "TCP_FLOOD": - threatName = "TCP Flood" - } - - // Determine severity emoji - severityEmoji := "⚠️" - if alert.Severity != "" { - switch alert.Severity { - case "CRITICAL": - severityEmoji = "🚨" - case "HIGH": - severityEmoji = "🔴" - case "MEDIUM": - severityEmoji = "🟠" - case "LOW": - severityEmoji = "🟡" - } - } - - message := fmt.Sprintf( - "%s *Security Alert*\n\n"+ - "*Threat Type:* %s\n"+ - "*Severity:* %s\n"+ - "*Source IP:* `%s`\n"+ - "*Attempt Count:* %d\n"+ - "*Timestamp:* %s", - severityEmoji, - threatName, - alert.Severity, - alert.IP, - alert.Count, - alert.Timestamp.Format("2006-01-02 15:04:05 UTC"), - ) - - if alert.Details != "" { - message += fmt.Sprintf("\n*Details:* %s", alert.Details) - } - - if alert.Score > 0 { - message += fmt.Sprintf("\n*Threat Score:* %.1f", alert.Score) - } - - return message -} - -type Alert struct { - IP string - ThreatType string - Count uint64 - Timestamp time.Time - Severity string - Details string - Score float64 -} - diff --git a/secrds-agent/main.go b/secrds-agent/main.go deleted file mode 100644 index 3b58562..0000000 --- a/secrds-agent/main.go +++ /dev/null @@ -1,124 +0,0 @@ -package main - -import ( - "fmt" - "log" - "os" - "os/signal" - "path/filepath" - "syscall" - - "github.com/secrds/secrds-agent/internal/config" - "github.com/secrds/secrds-agent/internal/detector" - "github.com/secrds/secrds-agent/internal/kernel" - "github.com/secrds/secrds-agent/internal/processor" - "github.com/secrds/secrds-agent/internal/storage" - "github.com/secrds/secrds-agent/internal/telegram" -) - - -func main() { - // Load configuration - cfg, err := config.Load() - if err != nil { - log.Fatalf("Failed to load config: %v", err) - } - - if err := cfg.Validate(); err != nil { - log.Fatalf("Invalid config: %v", err) - } - - // Get Telegram credentials from config (may be loaded from YAML or env file) - botToken := cfg.Telegram.BotToken - if botToken == "" { - log.Fatalf("TELEGRAM_BOT_TOKEN not set. Please set it in /etc/secrds/config.yaml (telegram.bot_token) or as TELEGRAM_BOT_TOKEN environment variable") - } - - // Initialize storage - st, err := storage.New(cfg.StoragePath) - if err != nil { - log.Fatalf("Failed to initialize storage: %v", err) - } - defer st.Flush() - - // Initialize Telegram client - chatID := cfg.Telegram.ChatID - tgClient, err := telegram.New(botToken, chatID) - if err != nil { - log.Fatalf("Failed to initialize Telegram client: %v", err) - } - - // Initialize threat detector - threatDetector := detector.New(cfg, st, tgClient) - - // Initialize kernel program loader - kernelLoader, err := kernel.NewLoader() - if err != nil { - log.Fatalf("Failed to create kernel program loader: %v", err) - } - // We'll close the loader explicitly during shutdown to ensure proper order: - // cancel context -> close readers -> wait for goroutines - - // Load kernel programs - if err := kernelLoader.LoadCPrograms(); err != nil { - log.Fatalf("Failed to load kernel programs: %v", err) - } - - // Initialize event processor - eventProcessor := processor.New(threatDetector, kernelLoader) - - // Start event processing - if err := eventProcessor.Start(); err != nil { - log.Fatalf("Failed to start event processor: %v", err) - } - - fmt.Println("secrds Security Monitor started successfully") - fmt.Printf("Monitoring SSH connections on port 22...\n") - fmt.Printf("Note: For incoming connections, ensure inet_csk_accept kprobe is attached.\n") - fmt.Printf("If not, check kernel version and available symbols.\n") - - // Write PID file - if err := writePIDFile(cfg.PIDFile); err != nil { - log.Printf("Warning: failed to write PID file: %v", err) - } - - // Wait for interrupt signal - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) - - <-sigChan - fmt.Println("\nShutting down...") - - // Shutdown sequence: - // 1. Cancel context to signal goroutines to exit - // 2. Close loader (which closes readers, unblocking any Read() calls) - // 3. Wait for goroutines to finish (they'll see the error and return) - eventProcessor.Cancel() - - // Close loader to unblock any Read() calls in goroutines - if err := kernelLoader.Close(); err != nil { - log.Printf("Error closing kernel program loader: %v", err) - } - - // Now wait for goroutines to finish - eventProcessor.Stop() - - // Cleanup - if err := st.Flush(); err != nil { - log.Printf("Error flushing storage: %v", err) - } - - if err := os.Remove(cfg.PIDFile); err != nil { - log.Printf("Error removing PID file: %v", err) - } -} - -func writePIDFile(path string) error { - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return fmt.Errorf("failed to create PID file directory: %w", err) - } - - pid := os.Getpid() - return os.WriteFile(path, []byte(fmt.Sprintf("%d\n", pid)), 0644) -} - diff --git a/secrds-agent/src/config.rs b/secrds-agent/src/config.rs new file mode 100644 index 0000000..f88bdb5 --- /dev/null +++ b/secrds-agent/src/config.rs @@ -0,0 +1,190 @@ +use serde::{Deserialize, Serialize}; +use std::fs; +use std::net::IpAddr; +use std::path::PathBuf; +use std::time::Duration; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + #[serde(default = "default_ssh_threshold")] + pub ssh_threshold: u64, + #[serde(default = "default_ssh_window")] + pub ssh_window_seconds: u64, + #[serde(default = "default_ssh_port")] + pub ssh_port: u16, + #[serde(default = "default_tcp_threshold")] + pub tcp_threshold: u64, + #[serde(default = "default_tcp_window")] + pub tcp_window_seconds: u64, + #[serde(default = "default_enable_blocking")] + pub enable_ip_blocking: bool, + #[serde(default = "default_block_duration")] + pub block_duration_seconds: u64, + #[serde(default)] + pub whitelist_ips: Vec, + #[serde(default)] + pub whitelist_cidrs: Vec, + #[serde(default = "default_storage_path")] + pub storage_path: String, + #[serde(default = "default_pid_file")] + pub pid_file: String, + #[serde(default = "default_log_level")] + pub log_level: String, + #[serde(default = "default_log_file")] + pub log_file: String, + #[serde(default)] + pub telegram: TelegramConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TelegramConfig { + #[serde(default)] + pub bot_token: String, + #[serde(default)] + pub chat_id: String, +} + +fn default_ssh_threshold() -> u64 { + 5 +} + +fn default_ssh_window() -> u64 { + 300 +} + +fn default_ssh_port() -> u16 { + 22 +} + +fn default_tcp_threshold() -> u64 { + 10 +} + +fn default_tcp_window() -> u64 { + 60 +} + +fn default_enable_blocking() -> bool { + true +} + +fn default_block_duration() -> u64 { + 86400 +} + +fn default_storage_path() -> String { + "/var/lib/secrds/events.json".to_string() +} + +fn default_pid_file() -> String { + "/var/run/secrds.pid".to_string() +} + +fn default_log_level() -> String { + "info".to_string() +} + +fn default_log_file() -> String { + "/var/log/secrds/agent.log".to_string() +} + +impl Default for Config { + fn default() -> Self { + Self { + ssh_threshold: default_ssh_threshold(), + ssh_window_seconds: default_ssh_window(), + ssh_port: default_ssh_port(), + tcp_threshold: default_tcp_threshold(), + tcp_window_seconds: default_tcp_window(), + enable_ip_blocking: default_enable_blocking(), + block_duration_seconds: default_block_duration(), + whitelist_ips: Vec::new(), + whitelist_cidrs: Vec::new(), + storage_path: default_storage_path(), + pid_file: default_pid_file(), + log_level: default_log_level(), + log_file: default_log_file(), + telegram: TelegramConfig::default(), + } + } +} + +impl Default for TelegramConfig { + fn default() -> Self { + Self { + bot_token: String::new(), + chat_id: String::new(), + } + } +} + +impl Config { + pub fn load() -> anyhow::Result { + let config_path = std::env::var("SECRDS_CONFIG") + .unwrap_or_else(|_| "/etc/secrds/config.yaml".to_string()); + + let mut config = Self::default(); + + if PathBuf::from(&config_path).exists() { + let content = fs::read_to_string(&config_path)?; + config = serde_yaml::from_str(&content) + .or_else(|_| serde_json::from_str(&content))?; + } + + // Override with environment variables + if let Ok(token) = std::env::var("TELEGRAM_BOT_TOKEN") { + config.telegram.bot_token = token; + } + if let Ok(chat_id) = std::env::var("TELEGRAM_CHAT_ID") { + config.telegram.chat_id = chat_id; + } + + config.validate()?; + Ok(config) + } + + pub fn validate(&self) -> anyhow::Result<()> { + if self.ssh_threshold == 0 { + anyhow::bail!("ssh_threshold must be greater than 0"); + } + if self.ssh_window_seconds == 0 || self.ssh_window_seconds > 86400 { + anyhow::bail!("ssh_window_seconds must be between 1 and 86400"); + } + if self.ssh_port == 0 || self.ssh_port > 65535 { + anyhow::bail!("ssh_port must be between 1 and 65535"); + } + if self.tcp_threshold == 0 { + anyhow::bail!("tcp_threshold must be greater than 0"); + } + if self.tcp_window_seconds == 0 || self.tcp_window_seconds > 86400 { + anyhow::bail!("tcp_window_seconds must be between 1 and 86400"); + } + if self.storage_path.is_empty() { + anyhow::bail!("storage_path cannot be empty"); + } + if self.pid_file.is_empty() { + anyhow::bail!("pid_file cannot be empty"); + } + + for ip in &self.whitelist_ips { + ip.parse::() + .map_err(|e| anyhow::anyhow!("invalid IP in whitelist: {}: {}", ip, e))?; + } + + for cidr in &self.whitelist_cidrs { + cidr.parse::() + .map_err(|e| anyhow::anyhow!("invalid CIDR in whitelist: {}: {}", cidr, e))?; + } + + Ok(()) + } + + pub fn ssh_window(&self) -> Duration { + Duration::from_secs(self.ssh_window_seconds) + } + + pub fn tcp_window(&self) -> Duration { + Duration::from_secs(self.tcp_window_seconds) + } +} + diff --git a/secrds-agent/src/detector.rs b/secrds-agent/src/detector.rs new file mode 100644 index 0000000..0ddab4c --- /dev/null +++ b/secrds-agent/src/detector.rs @@ -0,0 +1,680 @@ +use crate::config::Config; +use crate::storage::{Alert, Storage, ThreatType}; +use ipnet::IpNet; +use std::collections::HashMap; +use std::net::IpAddr; +use std::sync::Arc; +use std::time::{Duration, SystemTime}; +use tokio::sync::RwLock; + +#[derive(Debug, Clone)] +pub enum ThreatSeverity { + Low, + Medium, + High, + Critical, +} + +impl ToString for ThreatSeverity { + fn to_string(&self) -> String { + match self { + ThreatSeverity::Low => "LOW".to_string(), + ThreatSeverity::Medium => "MEDIUM".to_string(), + ThreatSeverity::High => "HIGH".to_string(), + ThreatSeverity::Critical => "CRITICAL".to_string(), + } + } +} + +#[derive(Debug, Clone)] +pub struct SSHEventDetail { + pub timestamp: SystemTime, + pub event_type: u8, + pub port: u16, + pub pid: u32, +} + +#[derive(Debug, Clone)] +pub struct TCPConnectionDetail { + pub timestamp: SystemTime, + pub src_port: u16, + pub dst_port: u16, + pub event_type: u8, +} + +#[derive(Debug)] +struct IPBehavior { + ssh_events: Vec, + tcp_connections: Vec, + failed_ssh_count: u64, + successful_ssh_count: u64, + unique_ports: std::collections::HashSet, + first_seen: SystemTime, + last_seen: SystemTime, + total_connections: u64, +} + +#[derive(Debug)] +pub struct ThreatInfo { + pub threat_type: ThreatType, + pub severity: ThreatSeverity, + pub count: u64, + pub details: String, + pub score: f64, +} + +pub struct ThreatDetector { + config: Arc, + storage: Arc, + telegram_client: Arc>, + ip_behaviors: Arc>>, + blocked_ips: Arc>>, + whitelist_ips: Arc>, + whitelist_cidrs: Arc>, + alert_history: Arc>>, +} + +use crate::telegram::TelegramClient; + +impl ThreatDetector { + pub fn new( + config: Arc, + storage: Arc, + telegram_client: Option, + ) -> Self { + let mut whitelist_map = HashMap::new(); + for ip in &config.whitelist_ips { + whitelist_map.insert(ip.clone(), true); + } + + let mut whitelist_cidrs = Vec::new(); + for cidr_str in &config.whitelist_cidrs { + if let Ok(cidr) = cidr_str.parse::() { + whitelist_cidrs.push(cidr); + } + } + + let detector = Self { + config, + storage, + telegram_client: Arc::new(telegram_client), + ip_behaviors: Arc::new(RwLock::new(HashMap::new())), + blocked_ips: Arc::new(RwLock::new(HashMap::new())), + whitelist_ips: Arc::new(whitelist_map), + whitelist_cidrs: Arc::new(whitelist_cidrs), + alert_history: Arc::new(RwLock::new(HashMap::new())), + }; + + let detector_clone = detector.clone_for_cleanup(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(3600)); + loop { + interval.tick().await; + detector_clone.cleanup_stale_behaviors().await; + if detector_clone.config.block_duration_seconds > 0 { + detector_clone.auto_unblock_expired().await; + } + } + }); + + detector + } + + fn clone_for_cleanup(&self) -> CleanupDetector { + CleanupDetector { + config: Arc::clone(&self.config), + storage: Arc::clone(&self.storage), + ip_behaviors: Arc::clone(&self.ip_behaviors), + blocked_ips: Arc::clone(&self.blocked_ips), + } + } + + pub async fn process_ssh_event( + &self, + ip: u32, + port: u16, + pid: u32, + event_type: u8, + ) -> Result<(), anyhow::Error> { + let ip_addr = u32_to_ip(ip); + let ip_str = ip_addr.to_string(); + + if ip == 0 || ip_str == "0.0.0.0" { + return Ok(()); + } + + if self.is_whitelisted(&ip_str) { + return Ok(()); + } + + if self.storage.is_blocked(&ip_str) { + return Ok(()); + } + + let mut behaviors = self.ip_behaviors.write().await; + let behavior = behaviors + .entry(ip_str.clone()) + .or_insert_with(|| IPBehavior { + ssh_events: Vec::new(), + tcp_connections: Vec::new(), + failed_ssh_count: 0, + successful_ssh_count: 0, + unique_ports: std::collections::HashSet::new(), + first_seen: SystemTime::now(), + last_seen: SystemTime::now(), + total_connections: 0, + }); + + let now = SystemTime::now(); + behavior.ssh_events.push(SSHEventDetail { + timestamp: now, + event_type, + port, + pid, + }); + behavior.last_seen = now; + + if event_type == 1 { + behavior.failed_ssh_count += 1; + } else if event_type == 2 { + behavior.successful_ssh_count += 1; + } + + let cutoff = now - Duration::from_secs(86400); + behavior + .ssh_events + .retain(|e| e.timestamp > cutoff); + + drop(behaviors); + + let behavior_clone = self.ip_behaviors.read().await; + let behavior = behavior_clone.get(&ip_str).unwrap(); + let threats = self.detect_ssh_threats(&ip_str, behavior, now).await; + + for threat in threats { + self.handle_threat(&ip_str, threat).await?; + } + + Ok(()) + } + + async fn detect_ssh_threats( + &self, + _ip: &str, + behavior: &IPBehavior, + now: SystemTime, + ) -> Vec { + let mut threats = Vec::new(); + + let base_window = self.config.ssh_window(); + let short_window = base_window / 5; + let medium_window = base_window; + let long_window = base_window * 3; + + let short_term = count_events_in_window(&behavior.ssh_events, now, short_window); + let medium_term = count_events_in_window(&behavior.ssh_events, now, medium_window); + let long_term = count_events_in_window(&behavior.ssh_events, now, long_window); + + let score = calculate_threat_score(short_term, medium_term, long_term); + + let failed_in_short = count_failed_in_window(&behavior.ssh_events, now, short_window); + let failed_in_medium = count_failed_in_window(&behavior.ssh_events, now, medium_window); + + let threshold = self.config.ssh_threshold; + + if short_term >= threshold * 2 || (short_term >= threshold && failed_in_short >= threshold) { + threats.push(ThreatInfo { + threat_type: ThreatType::SshBruteForce, + severity: ThreatSeverity::Critical, + count: short_term, + details: format!("Rapid brute force: {} attempts in 1 minute", short_term), + score, + }); + } else if medium_term >= threshold * 3 + || (medium_term >= threshold * 2 && failed_in_medium >= threshold * 2) + { + threats.push(ThreatInfo { + threat_type: ThreatType::SshBruteForce, + severity: ThreatSeverity::High, + count: medium_term, + details: format!("Sustained brute force: {} attempts in 5 minutes", medium_term), + score, + }); + } else if medium_term >= threshold { + threats.push(ThreatInfo { + threat_type: ThreatType::SshBruteForce, + severity: ThreatSeverity::Medium, + count: medium_term, + details: format!("Brute force detected: {} attempts in 5 minutes", medium_term), + score, + }); + } else if long_term >= threshold { + threats.push(ThreatInfo { + threat_type: ThreatType::SshBruteForce, + severity: ThreatSeverity::Low, + count: long_term, + details: format!("Suspicious activity: {} attempts in 15 minutes", long_term), + score, + }); + } + + let total_attempts = behavior.ssh_events.len() as u64; + if total_attempts > 0 { + let failure_rate = behavior.failed_ssh_count as f64 / total_attempts as f64; + if failure_rate > 0.8 && total_attempts >= 5 { + threats.push(ThreatInfo { + threat_type: ThreatType::SshBruteForce, + severity: ThreatSeverity::High, + count: behavior.failed_ssh_count, + details: format!( + "High failure rate: {:.1}% failures ({}/{})", + failure_rate * 100.0, + behavior.failed_ssh_count, + total_attempts + ), + score: score * failure_rate, + }); + } + } + + if behavior.ssh_events.len() >= 3 { + if detect_rapid_fire_pattern(&behavior.ssh_events, now) { + threats.push(ThreatInfo { + threat_type: ThreatType::SshBruteForce, + severity: ThreatSeverity::High, + count: behavior.ssh_events.len() as u64, + details: "Rapid-fire attack pattern detected".to_string(), + score: score * 1.2, + }); + } + } + + threats + } + + async fn handle_threat(&self, ip: &str, threat: ThreatInfo) -> Result<(), anyhow::Error> { + if threat.severity.to_string() == "LOW" && threat.score < 5.0 { + return Ok(()); + } + + let alert = Alert { + ip: ip.to_string(), + threat_type: threat.threat_type.clone(), + count: threat.count, + timestamp: SystemTime::now(), + severity: Some(threat.severity.to_string()), + details: Some(threat.details.clone()), + score: Some(threat.score), + }; + + self.storage.store_alert(alert.clone())?; + + let should_send_alert = matches!( + threat.severity, + ThreatSeverity::Critical | ThreatSeverity::High + ); + + if should_send_alert { + let mut alert_history = self.alert_history.write().await; + alert_history.insert(ip.to_string(), SystemTime::now()); + drop(alert_history); + + if let Some(ref client) = *self.telegram_client { + if let Err(e) = client.send_alert(&alert).await { + log::error!("Failed to send Telegram alert: {}", e); + } + } + } else { + let alert_history = self.alert_history.read().await; + if let Some(last_alert) = alert_history.get(ip) { + if last_alert.elapsed().unwrap_or(Duration::from_secs(0)) < Duration::from_secs(300) { + return Ok(()); + } + } + drop(alert_history); + + let mut alert_history = self.alert_history.write().await; + alert_history.insert(ip.to_string(), SystemTime::now()); + drop(alert_history); + + if let Some(ref client) = *self.telegram_client { + if let Err(e) = client.send_alert(&alert).await { + log::error!("Failed to send Telegram alert: {}", e); + } + } + } + + let should_block = matches!(threat.severity, ThreatSeverity::Critical) + || (matches!(threat.severity, ThreatSeverity::High) && threat.score > 50.0) + || threat.score > 100.0; + + if should_block && self.config.enable_ip_blocking && !self.is_internal_ip(ip) { + if self.is_whitelisted(ip) { + log::info!("Skipping block for whitelisted IP {}", ip); + return Ok(()); + } + + if let Err(e) = self.block_ip(ip).await { + log::error!("Failed to block IP {}: {}", ip, e); + } else { + let mut blocked = self.blocked_ips.write().await; + blocked.insert(ip.to_string(), SystemTime::now()); + drop(blocked); + self.storage.add_blocked_ip(ip.to_string())?; + let threat_type_str = format!("{:?}", threat.threat_type); + log::info!( + "Auto-blocked IP {} due to {} threat (severity: {}, score: {:.1})", + ip, + threat_type_str, + threat.severity.to_string(), + threat.score + ); + } + } + + Ok(()) + } + + async fn block_ip(&self, ip: &str) -> Result<(), anyhow::Error> { + if ip.parse::().is_err() { + anyhow::bail!("Invalid IP address format: {}", ip); + } + + let is_ipv6 = ip.parse::().unwrap().is_ipv6(); + + if is_ipv6 { + let check_cmd = tokio::process::Command::new("ip6tables") + .args(["-C", "INPUT", "-s", ip, "-j", "DROP"]) + .output() + .await?; + + if check_cmd.status.success() { + return Ok(()); + } + + let cmd = tokio::process::Command::new("ip6tables") + .args([ + "-I", "INPUT", "1", "-s", ip, "-j", "DROP", "-m", "comment", + "--comment", "secrds-block", + ]) + .output() + .await?; + + if !cmd.status.success() { + anyhow::bail!("Failed to block IP with ip6tables"); + } + } else { + let check_cmd = tokio::process::Command::new("iptables") + .args(["-C", "INPUT", "-s", ip, "-j", "DROP"]) + .output() + .await?; + + if check_cmd.status.success() { + return Ok(()); + } + + let cmd = tokio::process::Command::new("iptables") + .args([ + "-I", "INPUT", "1", "-s", ip, "-j", "DROP", "-m", "comment", + "--comment", "secrds-block", + ]) + .output() + .await?; + + if !cmd.status.success() { + anyhow::bail!("Failed to block IP with iptables"); + } + } + + Ok(()) + } + + fn is_whitelisted(&self, ip: &str) -> bool { + if self.whitelist_ips.contains_key(ip) { + return true; + } + + if let Ok(parsed) = ip.parse::() { + for cidr in self.whitelist_cidrs.iter() { + if cidr.contains(&parsed) { + return true; + } + } + } + + false + } + + fn is_internal_ip(&self, ip: &str) -> bool { + if let Ok(parsed) = ip.parse::() { + if parsed.is_ipv4() { + let octets = parsed.to_string().split('.').map(|s| s.parse::().unwrap()).collect::>(); + if octets[0] == 10 { + return true; + } + if octets[0] == 172 && octets[1] >= 16 && octets[1] <= 31 { + return true; + } + if octets[0] == 192 && octets[1] == 168 { + return true; + } + if octets[0] == 100 && (octets[1] & 0xC0) == 0x40 { + return true; + } + } + } + false + } + + async fn cleanup_stale_behaviors(&self) { + let mut behaviors = self.ip_behaviors.write().await; + let now = SystemTime::now(); + let cutoff = now - Duration::from_secs(86400); + let mut removed = 0; + + behaviors.retain(|ip, behavior| { + if behavior.last_seen < cutoff && !self.storage.is_blocked(ip) { + removed += 1; + false + } else { + true + } + }); + + if removed > 0 { + log::info!("Cleaned up {} stale IP behaviors", removed); + } + } + + async fn auto_unblock_expired(&self) { + let mut blocked = self.blocked_ips.write().await; + let now = SystemTime::now(); + let block_duration = Duration::from_secs(self.config.block_duration_seconds); + let mut unblocked = 0; + + let ips_to_unblock: Vec = blocked + .iter() + .filter_map(|(ip, block_time)| { + if now.duration_since(*block_time).unwrap_or_default() >= block_duration { + Some(ip.clone()) + } else { + None + } + }) + .collect(); + + for ip in ips_to_unblock { + if let Err(e) = self.unblock_ip(&ip).await { + log::error!("Failed to unblock IP {}: {}", ip, e); + continue; + } + blocked.remove(&ip); + unblocked += 1; + log::info!("Auto-unblocked IP {} (block duration expired)", ip); + } + + if unblocked > 0 { + log::info!("Auto-unblocked {} expired IPs", unblocked); + } + } + + async fn unblock_ip(&self, ip: &str) -> Result<(), anyhow::Error> { + if ip.parse::().is_err() { + anyhow::bail!("Invalid IP address format: {}", ip); + } + + let is_ipv6 = ip.parse::().unwrap().is_ipv6(); + + if is_ipv6 { + let cmd = tokio::process::Command::new("ip6tables") + .args([ + "-D", "INPUT", "-s", ip, "-j", "DROP", "-m", "comment", + "--comment", "secrds-block", + ]) + .output() + .await?; + + if !cmd.status.success() { + let cmd2 = tokio::process::Command::new("ip6tables") + .args(["-D", "INPUT", "-s", ip, "-j", "DROP"]) + .output() + .await?; + + if !cmd2.status.success() { + anyhow::bail!("Failed to unblock IP with ip6tables"); + } + } + } else { + let cmd = tokio::process::Command::new("iptables") + .args([ + "-D", "INPUT", "-s", ip, "-j", "DROP", "-m", "comment", + "--comment", "secrds-block", + ]) + .output() + .await?; + + if !cmd.status.success() { + let cmd2 = tokio::process::Command::new("iptables") + .args(["-D", "INPUT", "-s", ip, "-j", "DROP"]) + .output() + .await?; + + if !cmd2.status.success() { + anyhow::bail!("Failed to unblock IP with iptables"); + } + } + } + + Ok(()) + } +} + +struct CleanupDetector { + config: Arc, + storage: Arc, + ip_behaviors: Arc>>, + blocked_ips: Arc>>, +} + +impl CleanupDetector { + async fn cleanup_stale_behaviors(&self) { + let mut behaviors = self.ip_behaviors.write().await; + let now = SystemTime::now(); + let cutoff = now - Duration::from_secs(86400); + let mut removed = 0; + + behaviors.retain(|ip, behavior| { + if behavior.last_seen < cutoff && !self.storage.is_blocked(ip) { + removed += 1; + false + } else { + true + } + }); + + if removed > 0 { + log::info!("Cleaned up {} stale IP behaviors", removed); + } + } + + async fn auto_unblock_expired(&self) { + let mut blocked = self.blocked_ips.write().await; + let now = SystemTime::now(); + let block_duration = Duration::from_secs(self.config.block_duration_seconds); + let mut unblocked = 0; + + let ips_to_unblock: Vec = blocked + .iter() + .filter_map(|(ip, block_time)| { + if now.duration_since(*block_time).unwrap_or_default() >= block_duration { + Some(ip.clone()) + } else { + None + } + }) + .collect(); + + for ip in ips_to_unblock { + blocked.remove(&ip); + unblocked += 1; + } + + if unblocked > 0 { + log::info!("Auto-unblocked {} expired IPs", unblocked); + } + } +} + +fn u32_to_ip(ip: u32) -> std::net::Ipv4Addr { + std::net::Ipv4Addr::new( + ((ip >> 24) & 0xFF) as u8, + ((ip >> 16) & 0xFF) as u8, + ((ip >> 8) & 0xFF) as u8, + (ip & 0xFF) as u8, + ) +} + +fn count_events_in_window(events: &[SSHEventDetail], now: SystemTime, window: Duration) -> u64 { + let cutoff = now - window; + events + .iter() + .filter(|e| e.timestamp > cutoff) + .count() as u64 +} + +fn count_failed_in_window(events: &[SSHEventDetail], now: SystemTime, window: Duration) -> u64 { + let cutoff = now - window; + events + .iter() + .filter(|e| e.timestamp > cutoff && e.event_type == 1) + .count() as u64 +} + +fn calculate_threat_score(short: u64, medium: u64, long: u64) -> f64 { + short as f64 * 3.0 + medium as f64 * 1.5 + long as f64 * 0.5 +} + +fn detect_rapid_fire_pattern(events: &[SSHEventDetail], _now: SystemTime) -> bool { + if events.len() < 3 { + return false; + } + + let recent_events: Vec<_> = if events.len() > 5 { + events.iter().rev().take(5).collect() + } else { + events.iter().rev().collect() + }; + + for i in 1..recent_events.len() { + let time_diff = recent_events[i - 1] + .timestamp + .duration_since(recent_events[i].timestamp) + .unwrap_or_default(); + if time_diff < Duration::from_secs(2) && recent_events[i].event_type == 1 { + return true; + } + } + + false +} + diff --git a/secrds-agent/src/main.rs b/secrds-agent/src/main.rs new file mode 100644 index 0000000..c4610cd --- /dev/null +++ b/secrds-agent/src/main.rs @@ -0,0 +1,80 @@ +mod config; +mod detector; +mod processor; +mod storage; +mod telegram; + +use anyhow::Result; +use config::Config; +use detector::ThreatDetector; +use processor::EventProcessor; +use std::fs; +use std::path::PathBuf; +use std::sync::Arc; +use storage::Storage; +use telegram::TelegramClient; +use tokio::signal; + +#[tokio::main] +async fn main() -> Result<()> { + env_logger::Builder::from_default_env() + .filter_level(log::LevelFilter::Info) + .init(); + + let config = Arc::new(Config::load()?); + + if config.telegram.bot_token.is_empty() { + anyhow::bail!("TELEGRAM_BOT_TOKEN not set. Please set it in /etc/secrds/config.yaml (telegram.bot_token) or as TELEGRAM_BOT_TOKEN environment variable"); + } + + let storage = Arc::new(Storage::new(&config.storage_path)?); + + let telegram_client = if !config.telegram.bot_token.is_empty() { + Some(TelegramClient::new( + config.telegram.bot_token.clone(), + config.telegram.chat_id.clone(), + )?) + } else { + None + }; + + let threat_detector = Arc::new(ThreatDetector::new( + Arc::clone(&config), + Arc::clone(&storage), + telegram_client, + )); + + let event_processor = EventProcessor::new(Arc::clone(&threat_detector), config.ssh_port); + + if let Err(e) = event_processor.start().await { + log::error!("Failed to start event processor: {}", e); + anyhow::bail!("Failed to start event processor: {}", e); + } + + write_pid_file(&config.pid_file)?; + + log::info!("secrds Security Monitor started successfully"); + log::info!("Monitoring SSH connections on port {}...", config.ssh_port); + + signal::ctrl_c().await?; + log::info!("Shutting down..."); + + if let Err(e) = storage.flush() { + log::error!("Error flushing storage: {}", e); + } + + if let Err(e) = fs::remove_file(&config.pid_file) { + log::error!("Error removing PID file: {}", e); + } + + Ok(()) +} + +fn write_pid_file(path: &str) -> Result<()> { + if let Some(parent) = PathBuf::from(path).parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, format!("{}\n", std::process::id()))?; + Ok(()) +} + diff --git a/secrds-agent/src/processor.rs b/secrds-agent/src/processor.rs new file mode 100644 index 0000000..8b43de0 --- /dev/null +++ b/secrds-agent/src/processor.rs @@ -0,0 +1,151 @@ +use crate::detector::ThreatDetector; +use aya::{Ebpf, maps::RingBuf, programs::TracePoint}; +use aya_log::EbpfLogger; +use log::{info, warn}; +use std::{mem, path::Path, sync::Arc, thread, time::Duration}; + +#[repr(C)] +#[derive(Clone, Copy)] +struct EventV4 { + saddr: u32, + daddr: u32, + sport: u16, + dport: u16, +} + +#[repr(C)] +#[derive(Clone, Copy)] +struct NetEvent { + ts_ns: u64, + ifindex: u32, + family: u8, + etype: u8, + tcp_flags: u8, + src_cat: u8, + v4: EventV4, +} + +const AF_INET: u8 = 2; +const EVT_SSH_ATTEMPT: u8 = 2; +const EVT_SSH_BRUTE: u8 = 4; + +pub struct EventProcessor { + detector: Arc, + ssh_port: u16, +} + +impl EventProcessor { + pub fn new(detector: Arc, ssh_port: u16) -> Self { + Self { detector, ssh_port } + } + + pub async fn start(&self) -> anyhow::Result<()> { + // Try multiple known eBPF object paths + let candidates = [ + "/usr/local/lib/secrds/secrds-ebpf.o", + "/usr/local/lib/secrds/secrds-ebpf.bpf.o", + "target/release/bpf/secrds-ebpf.o", + "target/bpfel-unknown-none/release/secrds-ebpf", + "../target/release/bpf/secrds-ebpf.o", + ]; + + // Load eBPF + let mut bpf = { + let mut loaded: Option = None; + for p in candidates { + if Path::new(p).exists() { + match Ebpf::load_file(p) { + Ok(obj) => { + info!("Loaded eBPF program from: {}", p); + loaded = Some(obj); + break; + } + Err(e) => warn!("Found {}, but failed to load eBPF: {}", p, e), + } + } + } + loaded.ok_or_else(|| anyhow::anyhow!("eBPF program not found in known locations"))? + }; + + // Initialize Aya eBPF logger (non-fatal) + if let Err(e) = EbpfLogger::init(&mut bpf) { + warn!("eBPF logger init failed: {}", e); + } + + // Attach to tracepoint from eBPF program + let tp: &mut TracePoint = bpf + .program_mut("inet_sock_set_state") + .ok_or_else(|| anyhow::anyhow!("tracepoint program `inet_sock_set_state` not found"))? + .try_into()?; + + tp.load()?; + tp.attach("sock", "inet_sock_set_state")?; + info!("Attached tracepoint sock/inet_sock_set_state"); + + // Clone for thread + let detector = Arc::clone(&self.detector); + let ssh_port = self.ssh_port; + + // Move bpf into thread + thread::spawn(move || { + let mut bpf_thread = bpf; + + // Create ring buffer inside the thread + let events = match bpf_thread.map_mut("EVENTS_RB") { + Some(map) => map, + None => { + warn!("ring buffer map `EVENTS_RB` not found"); + return; + } + }; + + let mut ring = match RingBuf::try_from(events) { + Ok(r) => r, + Err(e) => { + warn!("Failed to create RingBuf: {}", e); + return; + } + }; + + loop { + // ✅ Aya 0.12: `next()` returns Option + if let Some(data) = ring.next() { + if data.len() < mem::size_of::() { + continue; + } + + let evt = unsafe { *(data.as_ptr() as *const NetEvent) }; + + if evt.family == AF_INET { + let dport = u16::from_be(evt.v4.dport); + if dport == ssh_port { + match evt.etype { + EVT_SSH_ATTEMPT | EVT_SSH_BRUTE => { + let det = Arc::clone(&detector); + let saddr = evt.v4.saddr; + let dport_copy = dport; + tokio::spawn(async move { + let event_type = 1u8; // SSH_FAILURE + if let Err(e) = det + .process_ssh_event(saddr, dport_copy, 0, event_type) + .await + { + warn!("Failed to process SSH event: {}", e); + } + }); + } + _ => {} + } + } + } + } else { + // No events currently available + thread::sleep(Duration::from_millis(100)); + } + } + }); + + info!("Event processing loop started successfully."); + Ok(()) + } +} diff --git a/secrds-agent/src/storage.rs b/secrds-agent/src/storage.rs new file mode 100644 index 0000000..94af272 --- /dev/null +++ b/secrds-agent/src/storage.rs @@ -0,0 +1,238 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; +use std::sync::{Arc, RwLock}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ThreatType { + #[serde(rename = "SSH_BRUTE_FORCE")] + SshBruteForce, + #[serde(rename = "TCP_PORT_SCAN")] + TcpPortScan, + #[serde(rename = "TCP_FLOOD")] + TcpFlood, +} + +#[derive(Debug, Clone)] +pub struct Alert { + pub ip: String, + pub threat_type: ThreatType, + pub count: u64, + pub timestamp: SystemTime, + pub severity: Option, + pub details: Option, + pub score: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Statistics { + pub total_alerts: u64, + pub ssh_brute_force_count: u64, + pub tcp_port_scan_count: u64, + pub tcp_flood_count: u64, + pub blocked_ips_count: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct StorageData { + alerts: Vec, + blocked_ips: Vec, + statistics: Statistics, +} + +pub struct Storage { + path: PathBuf, + data: Arc>, + blocked_map: Arc>>, +} + +impl Storage { + pub fn new(path: impl Into) -> anyhow::Result { + let path = path.into(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let mut data = StorageData { + alerts: Vec::new(), + blocked_ips: Vec::new(), + statistics: Statistics { + total_alerts: 0, + ssh_brute_force_count: 0, + tcp_port_scan_count: 0, + tcp_flood_count: 0, + blocked_ips_count: 0, + }, + }; + + if path.exists() { + if let Ok(content) = fs::read_to_string(&path) { + if let Ok(loaded) = serde_json::from_str::(&content) { + data = loaded; + } + } + } + + let blocked_map: HashMap = data.blocked_ips.iter().map(|ip| (ip.clone(), true)).collect(); + + let storage = Self { + path, + data: Arc::new(RwLock::new(data)), + blocked_map: Arc::new(RwLock::new(blocked_map)), + }; + + let storage_clone = storage.clone_for_background(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(60)); + loop { + interval.tick().await; + if let Err(e) = storage_clone.flush() { + log::error!("Failed to flush storage: {}", e); + } + } + }); + + Ok(storage) + } + + fn clone_for_background(&self) -> BackgroundStorage { + BackgroundStorage { + path: self.path.clone(), + data: Arc::clone(&self.data), + blocked_map: Arc::clone(&self.blocked_map), + } + } + + pub fn store_alert(&self, alert: Alert) -> anyhow::Result<()> { + let mut data = self.data.write().unwrap(); + data.alerts.push(alert.clone()); + data.statistics.total_alerts += 1; + + match alert.threat_type { + ThreatType::SshBruteForce => { + data.statistics.ssh_brute_force_count += 1; + } + ThreatType::TcpPortScan => { + data.statistics.tcp_port_scan_count += 1; + } + ThreatType::TcpFlood => { + data.statistics.tcp_flood_count += 1; + } + } + + if data.alerts.len() > 1000 { + let len = data.alerts.len(); + let keep = data.alerts.split_off(len - 1000); + data.alerts = keep; + } + + Ok(()) + } + + pub fn add_blocked_ip(&self, ip: String) -> anyhow::Result<()> { + let mut blocked_map = self.blocked_map.write().unwrap(); + if blocked_map.contains_key(&ip) { + return Ok(()); + } + + let mut data = self.data.write().unwrap(); + data.blocked_ips.push(ip.clone()); + blocked_map.insert(ip, true); + data.statistics.blocked_ips_count += 1; + + Ok(()) + } + + pub fn get_alerts(&self, limit: usize) -> Vec { + let data = self.data.read().unwrap(); + let mut alerts = data.alerts.clone(); + if alerts.len() > limit { + alerts = alerts.split_off(alerts.len() - limit); + } + alerts.reverse(); + alerts + } + + pub fn get_statistics(&self) -> Statistics { + let data = self.data.read().unwrap(); + data.statistics.clone() + } + + pub fn is_blocked(&self, ip: &str) -> bool { + let blocked_map = self.blocked_map.read().unwrap(); + blocked_map.get(ip).copied().unwrap_or(false) + } + + pub fn flush(&self) -> anyhow::Result<()> { + let data = self.data.read().unwrap(); + let json = serde_json::to_string_pretty(&*data)?; + fs::write(&self.path, json)?; + Ok(()) + } +} + +struct BackgroundStorage { + path: PathBuf, + data: Arc>, + blocked_map: Arc>>, +} + +impl BackgroundStorage { + fn flush(&self) -> anyhow::Result<()> { + let data = self.data.read().unwrap(); + let json = serde_json::to_string_pretty(&*data)?; + fs::write(&self.path, json)?; + Ok(()) + } +} + +impl Serialize for Alert { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut state = serializer.serialize_struct("Alert", 7)?; + state.serialize_field("ip", &self.ip)?; + state.serialize_field("threat_type", &self.threat_type)?; + state.serialize_field("count", &self.count)?; + let timestamp = self.timestamp.duration_since(UNIX_EPOCH).unwrap().as_secs(); + state.serialize_field("timestamp", ×tamp)?; + state.serialize_field("severity", &self.severity)?; + state.serialize_field("details", &self.details)?; + state.serialize_field("score", &self.score)?; + state.end() + } +} + +impl<'de> Deserialize<'de> for Alert { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct AlertHelper { + ip: String, + threat_type: ThreatType, + count: u64, + timestamp: u64, + severity: Option, + details: Option, + score: Option, + } + + let helper = AlertHelper::deserialize(deserializer)?; + Ok(Alert { + ip: helper.ip, + threat_type: helper.threat_type, + count: helper.count, + timestamp: UNIX_EPOCH + Duration::from_secs(helper.timestamp), + severity: helper.severity, + details: helper.details, + score: helper.score, + }) + } +} + diff --git a/secrds-agent/src/telegram.rs b/secrds-agent/src/telegram.rs new file mode 100644 index 0000000..b81a1c9 --- /dev/null +++ b/secrds-agent/src/telegram.rs @@ -0,0 +1,130 @@ +use crate::storage::Alert; +use anyhow::Result; +use reqwest::Client; +use serde::{Deserialize, Serialize}; + +const TELEGRAM_API_URL: &str = "https://api.telegram.org/bot"; + +pub struct TelegramClient { + bot_token: String, + chat_id: String, + client: Client, +} + +#[derive(Serialize)] +struct SendMessageRequest { + chat_id: String, + text: String, + #[serde(rename = "parse_mode")] + parse_mode: String, +} + +#[derive(Deserialize)] +struct TelegramResponse { + ok: bool, + description: Option, +} + +impl TelegramClient { + pub fn new(bot_token: String, chat_id: String) -> Result { + if chat_id.is_empty() { + anyhow::bail!("TELEGRAM_CHAT_ID not set"); + } + + Ok(Self { + bot_token, + chat_id, + client: Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build()?, + }) + } + + pub async fn send_alert(&self, alert: &Alert) -> Result<()> { + let message = self.format_alert(alert); + let url = format!("{}{}/sendMessage", TELEGRAM_API_URL, self.bot_token); + + let request = SendMessageRequest { + chat_id: self.chat_id.clone(), + text: message, + parse_mode: "Markdown".to_string(), + }; + + let mut retries = 3; + while retries > 0 { + let response = self.client.post(&url).json(&request).send().await?; + + if response.status().is_success() { + return Ok(()); + } + + if let Ok(tg_resp) = response.json::().await { + if let Some(desc) = tg_resp.description { + log::error!("Telegram API error: {}", desc); + } + } + + retries -= 1; + if retries > 0 { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } + } + + anyhow::bail!("Failed to send Telegram alert after retries"); + } + + fn format_alert(&self, alert: &Alert) -> String { + let threat_name = match alert.threat_type { + crate::storage::ThreatType::SshBruteForce => "SSH Brute Force", + crate::storage::ThreatType::TcpPortScan => "TCP Port Scan", + crate::storage::ThreatType::TcpFlood => "TCP Flood", + }; + + let severity_emoji = alert + .severity + .as_ref() + .map(|s| match s.as_str() { + "CRITICAL" => "🚨", + "HIGH" => "🔴", + "MEDIUM" => "🟠", + "LOW" => "🟡", + _ => "⚠️", + }) + .unwrap_or("⚠️"); + + let timestamp = alert + .timestamp + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let dt = chrono::DateTime::::from_timestamp(timestamp as i64, 0) + .unwrap_or_default(); + let time_str = dt.format("%Y-%m-%d %H:%M:%S UTC").to_string(); + + let mut message = format!( + "{} *Security Alert*\n\n\ + *Threat Type:* {}\n\ + *Severity:* {}\n\ + *Source IP:* `{}`\n\ + *Attempt Count:* {}\n\ + *Timestamp:* {}", + severity_emoji, + threat_name, + alert.severity.as_deref().unwrap_or("UNKNOWN"), + alert.ip, + alert.count, + time_str + ); + + if let Some(ref details) = alert.details { + message.push_str(&format!("\n*Details:* {}", details)); + } + + if let Some(score) = alert.score { + message.push_str(&format!("\n*Threat Score:* {:.1}", score)); + } + + message + } +} + diff --git a/secrds-cli/Cargo.toml b/secrds-cli/Cargo.toml new file mode 100644 index 0000000..771ced0 --- /dev/null +++ b/secrds-cli/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "secrds-cli" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[[bin]] +name = "secrds" +path = "src/main.rs" + +[dependencies] +clap = { workspace = true, features = ["derive"] } +serde = { workspace = true } +serde_json = { workspace = true } +serde_yaml = { workspace = true } +anyhow = { workspace = true } +tokio = { workspace = true, features = ["full"] } + diff --git a/secrds-cli/cmd/alerts.go b/secrds-cli/cmd/alerts.go deleted file mode 100644 index 9d4729b..0000000 --- a/secrds-cli/cmd/alerts.go +++ /dev/null @@ -1,127 +0,0 @@ -package cmd - -import ( - "encoding/json" - "fmt" - "os" - "time" - - "github.com/spf13/cobra" -) - -var alertsLimit int -var alertsSeverity string - -var alertsCmd = &cobra.Command{ - Use: "alerts", - Short: "Show recent alerts", - Run: func(cmd *cobra.Command, args []string) { - storagePath := "/var/lib/secrds/events.json" - if customPath := os.Getenv("SECRDS_STORAGE"); customPath != "" { - storagePath = customPath - } - - data, err := os.ReadFile(storagePath) - if err != nil { - fmt.Printf("No alerts found (storage file not found: %v)\n", err) - return - } - - var storageData struct { - Alerts []struct { - IP string `json:"ip"` - ThreatType string `json:"threat_type"` - Count uint64 `json:"count"` - Timestamp time.Time `json:"timestamp"` - Severity string `json:"severity,omitempty"` - Details string `json:"details,omitempty"` - Score float64 `json:"score,omitempty"` - } `json:"alerts"` - } - - if err := json.Unmarshal(data, &storageData); err != nil { - fmt.Printf("Failed to parse storage file: %v\n", err) - return - } - - alerts := storageData.Alerts - if len(alerts) == 0 { - fmt.Println("No recent alerts") - return - } - - // Filter by severity if specified - if alertsSeverity != "" { - filtered := []struct { - IP string `json:"ip"` - ThreatType string `json:"threat_type"` - Count uint64 `json:"count"` - Timestamp time.Time `json:"timestamp"` - Severity string `json:"severity,omitempty"` - Details string `json:"details,omitempty"` - Score float64 `json:"score,omitempty"` - }{} - for _, alert := range alerts { - if alert.Severity == alertsSeverity { - filtered = append(filtered, alert) - } - } - alerts = filtered - if len(alerts) == 0 { - fmt.Printf("No alerts found with severity: %s\n", alertsSeverity) - return - } - } - - // Show newest first - start := len(alerts) - alertsLimit - if start < 0 { - start = 0 - } - - fmt.Printf("Recent alerts (showing %d of %d):\n\n", alertsLimit, len(storageData.Alerts)) - for i := len(alerts) - 1; i >= start; i-- { - alert := alerts[i] - - // Severity indicator - severityIcon := "⚠️" - severityText := alert.Severity - if severityText == "" { - severityText = "UNKNOWN" - } else { - switch alert.Severity { - case "CRITICAL": - severityIcon = "🚨" - case "HIGH": - severityIcon = "🔴" - case "MEDIUM": - severityIcon = "🟠" - case "LOW": - severityIcon = "🟡" - } - } - - fmt.Printf("%s [%s] %s\n", severityIcon, severityText, alert.ThreatType) - fmt.Printf(" Time: %s\n", alert.Timestamp.Format("2006-01-02 15:04:05 UTC")) - fmt.Printf(" IP: %s\n", alert.IP) - fmt.Printf(" Count: %d\n", alert.Count) - - if alert.Score > 0 { - fmt.Printf(" Score: %.1f\n", alert.Score) - } - - if alert.Details != "" { - fmt.Printf(" Details: %s\n", alert.Details) - } - - fmt.Println() - } - }, -} - -func init() { - alertsCmd.Flags().IntVarP(&alertsLimit, "limit", "l", 10, "Number of alerts to show") - alertsCmd.Flags().StringVarP(&alertsSeverity, "severity", "s", "", "Filter by severity (LOW, MEDIUM, HIGH, CRITICAL)") - rootCmd.AddCommand(alertsCmd) -} - diff --git a/secrds-cli/cmd/config.go b/secrds-cli/cmd/config.go deleted file mode 100644 index 1a206a3..0000000 --- a/secrds-cli/cmd/config.go +++ /dev/null @@ -1,57 +0,0 @@ -package cmd - -import ( - "encoding/json" - "fmt" - "os" - - "github.com/spf13/cobra" -) - -var configCmd = &cobra.Command{ - Use: "config", - Short: "Show current configuration", - Run: func(cmd *cobra.Command, args []string) { - configPath := os.Getenv("SECRDS_CONFIG") - if configPath == "" { - configPath = "/etc/secrds/config.yaml" - } - - // Try to read config file - if _, err := os.Stat(configPath); os.IsNotExist(err) { - fmt.Println("Using default configuration (config file not found)") - fmt.Println("\nDefault values:") - fmt.Println(" SSH Threshold: 5") - fmt.Println(" SSH Window: 300 seconds") - fmt.Println(" TCP Threshold: 10") - fmt.Println(" TCP Window: 60 seconds") - fmt.Println(" IP Blocking: enabled") - return - } - - data, err := os.ReadFile(configPath) - if err != nil { - fmt.Printf("Failed to read config file: %v\n", err) - return - } - - // Try JSON first - var cfg map[string]interface{} - if err := json.Unmarshal(data, &cfg); err == nil { - fmt.Println("Current configuration:") - for k, v := range cfg { - fmt.Printf(" %s: %v\n", k, v) - } - return - } - - // Otherwise, just print the file content - fmt.Println("Configuration file content:") - fmt.Println(string(data)) - }, -} - -func init() { - rootCmd.AddCommand(configCmd) -} - diff --git a/secrds-cli/cmd/restart.go b/secrds-cli/cmd/restart.go deleted file mode 100644 index 9a59bb9..0000000 --- a/secrds-cli/cmd/restart.go +++ /dev/null @@ -1,26 +0,0 @@ -package cmd - -import ( - "fmt" - "os/exec" - - "github.com/spf13/cobra" -) - -var restartCmd = &cobra.Command{ - Use: "restart", - Short: "Restart the agent service", - Run: func(cmd *cobra.Command, args []string) { - systemctlCmd := exec.Command("systemctl", "restart", "secrds") - if err := systemctlCmd.Run(); err != nil { - fmt.Printf("Failed to restart service: %v\n", err) - return - } - fmt.Println("Service restarted successfully") - }, -} - -func init() { - rootCmd.AddCommand(restartCmd) -} - diff --git a/secrds-cli/cmd/root.go b/secrds-cli/cmd/root.go deleted file mode 100644 index 55299d6..0000000 --- a/secrds-cli/cmd/root.go +++ /dev/null @@ -1,22 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - - "github.com/spf13/cobra" -) - -var rootCmd = &cobra.Command{ - Use: "secrds", - Short: "secrds Security Monitor CLI", - Long: "CLI tool for managing and querying the secrds Security Monitor", -} - -func Execute() { - if err := rootCmd.Execute(); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} - diff --git a/secrds-cli/cmd/start.go b/secrds-cli/cmd/start.go deleted file mode 100644 index 8778d26..0000000 --- a/secrds-cli/cmd/start.go +++ /dev/null @@ -1,26 +0,0 @@ -package cmd - -import ( - "fmt" - "os/exec" - - "github.com/spf13/cobra" -) - -var startCmd = &cobra.Command{ - Use: "start", - Short: "Start the agent service", - Run: func(cmd *cobra.Command, args []string) { - systemctlCmd := exec.Command("systemctl", "start", "secrds") - if err := systemctlCmd.Run(); err != nil { - fmt.Printf("Failed to start service: %v\n", err) - return - } - fmt.Println("Service started successfully") - }, -} - -func init() { - rootCmd.AddCommand(startCmd) -} - diff --git a/secrds-cli/cmd/stats.go b/secrds-cli/cmd/stats.go deleted file mode 100644 index 596a6a0..0000000 --- a/secrds-cli/cmd/stats.go +++ /dev/null @@ -1,62 +0,0 @@ -package cmd - -import ( - "encoding/json" - "fmt" - "os" - - "github.com/spf13/cobra" -) - -var statsCmd = &cobra.Command{ - Use: "stats", - Short: "Show statistics", - Run: func(cmd *cobra.Command, args []string) { - storagePath := "/var/lib/secrds/events.json" - if customPath := os.Getenv("SECRDS_STORAGE"); customPath != "" { - storagePath = customPath - } - - data, err := os.ReadFile(storagePath) - if err != nil { - fmt.Printf("No statistics available (storage file not found: %v)\n", err) - return - } - - var storageData struct { - Statistics struct { - TotalAlerts uint64 `json:"total_alerts"` - SSHBruteForceCount uint64 `json:"ssh_brute_force_count"` - TCPPortScanCount uint64 `json:"tcp_port_scan_count"` - TCPFloodCount uint64 `json:"tcp_flood_count"` - BlockedIPsCount uint64 `json:"blocked_ips_count"` - } `json:"statistics"` - BlockedIPs []string `json:"blocked_ips"` - } - - if err := json.Unmarshal(data, &storageData); err != nil { - fmt.Printf("Failed to parse storage file: %v\n", err) - return - } - - stats := storageData.Statistics - fmt.Println("Statistics:") - fmt.Printf(" Total Alerts: %d\n", stats.TotalAlerts) - fmt.Printf(" SSH Brute Force: %d\n", stats.SSHBruteForceCount) - fmt.Printf(" TCP Port Scans: %d\n", stats.TCPPortScanCount) - fmt.Printf(" TCP Floods: %d\n", stats.TCPFloodCount) - fmt.Printf(" Blocked IPs: %d\n", stats.BlockedIPsCount) - - if len(storageData.BlockedIPs) > 0 { - fmt.Println("\nBlocked IPs:") - for _, ip := range storageData.BlockedIPs { - fmt.Printf(" - %s\n", ip) - } - } - }, -} - -func init() { - rootCmd.AddCommand(statsCmd) -} - diff --git a/secrds-cli/cmd/status.go b/secrds-cli/cmd/status.go deleted file mode 100644 index 062b621..0000000 --- a/secrds-cli/cmd/status.go +++ /dev/null @@ -1,62 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "os/exec" - "strconv" - "strings" - - "github.com/spf13/cobra" -) - -var statusCmd = &cobra.Command{ - Use: "status", - Short: "Show agent/service status", - Run: func(cmd *cobra.Command, args []string) { - pidFile := "/var/run/secrds.pid" - pidData, err := os.ReadFile(pidFile) - if err != nil { - fmt.Println("Status: Not running (PID file not found)") - return - } - - pid, err := strconv.Atoi(strings.TrimSpace(string(pidData))) - if err != nil { - fmt.Printf("Status: Unknown (invalid PID file: %v)\n", err) - return - } - - // Check if process is running - process, err := os.FindProcess(pid) - if err != nil { - fmt.Printf("Status: Not running (PID %d not found)\n", pid) - return - } - - // Try to send signal 0 to check if process exists - err = process.Signal(os.Signal(nil)) - if err != nil { - fmt.Printf("Status: Not running (process %d not responding)\n", pid) - return - } - - // Check systemd service status - systemctlCmd := exec.Command("systemctl", "is-active", "secrds") - output, _ := systemctlCmd.Output() - serviceStatus := strings.TrimSpace(string(output)) - - fmt.Printf("Status: Running\n") - fmt.Printf("PID: %d\n", pid) - if serviceStatus == "active" { - fmt.Println("Service: Active") - } else { - fmt.Printf("Service: %s\n", serviceStatus) - } - }, -} - -func init() { - rootCmd.AddCommand(statusCmd) -} - diff --git a/secrds-cli/cmd/stop.go b/secrds-cli/cmd/stop.go deleted file mode 100644 index c867c5c..0000000 --- a/secrds-cli/cmd/stop.go +++ /dev/null @@ -1,26 +0,0 @@ -package cmd - -import ( - "fmt" - "os/exec" - - "github.com/spf13/cobra" -) - -var stopCmd = &cobra.Command{ - Use: "stop", - Short: "Stop the agent service", - Run: func(cmd *cobra.Command, args []string) { - systemctlCmd := exec.Command("systemctl", "stop", "secrds") - if err := systemctlCmd.Run(); err != nil { - fmt.Printf("Failed to stop service: %v\n", err) - return - } - fmt.Println("Service stopped successfully") - }, -} - -func init() { - rootCmd.AddCommand(stopCmd) -} - diff --git a/secrds-cli/go.mod b/secrds-cli/go.mod deleted file mode 100644 index 4e0dc82..0000000 --- a/secrds-cli/go.mod +++ /dev/null @@ -1,10 +0,0 @@ -module github.com/secrds/secrds-cli - -go 1.21 - -require github.com/spf13/cobra v1.8.0 - -require ( - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect -) diff --git a/secrds-cli/go.sum b/secrds-cli/go.sum deleted file mode 100644 index d0e8c2c..0000000 --- a/secrds-cli/go.sum +++ /dev/null @@ -1,10 +0,0 @@ -github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= -github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/secrds-cli/main.go b/secrds-cli/main.go deleted file mode 100644 index a2185ae..0000000 --- a/secrds-cli/main.go +++ /dev/null @@ -1,10 +0,0 @@ -package main - -import ( - "github.com/secrds/secrds-cli/cmd" -) - -func main() { - cmd.Execute() -} - diff --git a/secrds-cli/src/commands.rs b/secrds-cli/src/commands.rs new file mode 100644 index 0000000..172863c --- /dev/null +++ b/secrds-cli/src/commands.rs @@ -0,0 +1,262 @@ +use anyhow::Result; +use serde_json; +use std::fs; +use std::path::PathBuf; +use std::process::Command; + +pub fn alerts(limit: usize) -> Result<()> { + let storage_path = get_storage_path()?; + if !storage_path.exists() { + println!("No alerts found."); + return Ok(()); + } + + let content = fs::read_to_string(&storage_path)?; + let data: serde_json::Value = serde_json::from_str(&content)?; + + if let Some(alerts) = data.get("alerts").and_then(|a| a.as_array()) { + let alerts: Vec<_> = alerts.iter().rev().take(limit).collect(); + if alerts.is_empty() { + println!("No alerts found."); + return Ok(()); + } + + println!("Recent Alerts (showing {}):\n", alerts.len()); + for alert in alerts { + let ip = alert.get("ip").and_then(|v| v.as_str()).unwrap_or("N/A"); + let threat_type = alert + .get("threat_type") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"); + let count = alert.get("count").and_then(|v| v.as_u64()).unwrap_or(0); + let severity = alert + .get("severity") + .and_then(|v| v.as_str()) + .unwrap_or("UNKNOWN"); + let timestamp = alert + .get("timestamp") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + + println!("IP: {}", ip); + println!("Threat: {}", threat_type); + println!("Severity: {}", severity); + println!("Count: {}", count); + println!("Timestamp: {}", timestamp); + if let Some(details) = alert.get("details").and_then(|v| v.as_str()) { + println!("Details: {}", details); + } + println!(); + } + } else { + println!("No alerts found."); + } + + Ok(()) +} + +pub fn clean(all: bool) -> Result<()> { + let log_dir = "/var/log/secrds"; + let mut cleaned = false; + + println!("Cleaning log files..."); + if let Ok(entries) = fs::read_dir(log_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) == Some("log") { + if let Ok(metadata) = fs::metadata(&path) { + if fs::remove_file(&path).is_ok() { + let size_kb = metadata.len() as f64 / 1024.0; + println!("✓ Removed: {} ({:.2} KB)", path.display(), size_kb); + cleaned = true; + } + } + } + } + } + + if all { + println!("\nCleaning event storage..."); + let storage_path = get_storage_path()?; + if storage_path.exists() { + if let Ok(metadata) = fs::metadata(&storage_path) { + if fs::remove_file(&storage_path).is_ok() { + let size_kb = metadata.len() as f64 / 1024.0; + println!("✓ Removed: {} ({:.2} KB)", storage_path.display(), size_kb); + cleaned = true; + } + } + } + + println!("\nStopping secrds service to unload eBPF programs..."); + if stop_service().is_ok() { + println!("✓ Service stopped (eBPF programs and maps automatically unloaded)"); + cleaned = true; + } else { + println!("Warning: Failed to stop service. You may need to run: sudo systemctl stop secrds"); + } + } + + if !cleaned { + println!("No files to clean."); + } else { + println!("\n✓ Cleanup completed successfully!"); + if !all { + println!("Tip: Use --all flag to also clear event storage data and kernel-level resources"); + } else { + println!("\nNote: Service has been stopped. Restart with: sudo systemctl start secrds"); + } + } + + Ok(()) +} + +pub fn config() -> Result<()> { + let config_path = std::env::var("SECRDS_CONFIG") + .unwrap_or_else(|_| "/etc/secrds/config.yaml".to_string()); + + if PathBuf::from(&config_path).exists() { + let content = fs::read_to_string(&config_path)?; + println!("Configuration file: {}", config_path); + println!("\n{}", content); + } else { + println!("Configuration file not found: {}", config_path); + } + + Ok(()) +} + +pub fn restart() -> Result<()> { + stop_service()?; + std::thread::sleep(std::time::Duration::from_secs(2)); + start_service()?; + println!("Service restarted successfully"); + Ok(()) +} + +pub fn start() -> Result<()> { + start_service()?; + println!("Service started successfully"); + Ok(()) +} + +pub fn stats() -> Result<()> { + let storage_path = get_storage_path()?; + if !storage_path.exists() { + println!("No statistics available."); + return Ok(()); + } + + let content = fs::read_to_string(&storage_path)?; + let data: serde_json::Value = serde_json::from_str(&content)?; + + if let Some(stats) = data.get("statistics") { + println!("Statistics:\n"); + println!( + "Total Alerts: {}", + stats.get("total_alerts").and_then(|v| v.as_u64()).unwrap_or(0) + ); + println!( + "SSH Brute Force: {}", + stats + .get("ssh_brute_force_count") + .and_then(|v| v.as_u64()) + .unwrap_or(0) + ); + println!( + "TCP Port Scan: {}", + stats + .get("tcp_port_scan_count") + .and_then(|v| v.as_u64()) + .unwrap_or(0) + ); + println!( + "TCP Flood: {}", + stats + .get("tcp_flood_count") + .and_then(|v| v.as_u64()) + .unwrap_or(0) + ); + println!( + "Blocked IPs: {}", + stats + .get("blocked_ips_count") + .and_then(|v| v.as_u64()) + .unwrap_or(0) + ); + } else { + println!("No statistics available."); + } + + Ok(()) +} + +pub fn status() -> Result<()> { + let output = Command::new("systemctl") + .args(["is-active", "secrds"]) + .output()?; + + if output.status.success() { + let status = String::from_utf8_lossy(&output.stdout); + println!("Service status: {}", status.trim()); + } else { + println!("Service status: inactive"); + } + + Ok(()) +} + +pub fn stop() -> Result<()> { + stop_service()?; + println!("Service stopped successfully"); + Ok(()) +} + +fn get_storage_path() -> Result { + let config_path = std::env::var("SECRDS_CONFIG") + .unwrap_or_else(|_| "/etc/secrds/config.yaml".to_string()); + + let default_path = "/var/lib/secrds/events.json".to_string(); + + if PathBuf::from(&config_path).exists() { + let content = fs::read_to_string(&config_path)?; + if let Ok(config) = serde_yaml::from_str::(&content) { + if let Some(path) = config.get("storage_path").and_then(|v| v.as_str()) { + return Ok(PathBuf::from(path)); + } + } + } + + Ok(PathBuf::from(default_path)) +} + +fn start_service() -> Result<()> { + let status = if std::env::var("USER").unwrap_or_default() == "root" { + Command::new("systemctl").args(["start", "secrds"]).status()? + } else { + Command::new("sudo") + .args(["systemctl", "start", "secrds"]) + .status()? + }; + + if !status.success() { + anyhow::bail!("Failed to start service"); + } + Ok(()) +} + +fn stop_service() -> Result<()> { + let status = if std::env::var("USER").unwrap_or_default() == "root" { + Command::new("systemctl").args(["stop", "secrds"]).status()? + } else { + Command::new("sudo") + .args(["systemctl", "stop", "secrds"]) + .status()? + }; + + if !status.success() { + anyhow::bail!("Failed to stop service"); + } + Ok(()) +} + diff --git a/secrds-cli/src/main.rs b/secrds-cli/src/main.rs new file mode 100644 index 0000000..a5e8114 --- /dev/null +++ b/secrds-cli/src/main.rs @@ -0,0 +1,58 @@ +use clap::{Parser, Subcommand}; +use serde_json; +use std::fs; +use std::path::PathBuf; + +mod commands; + +#[derive(Parser)] +#[command(name = "secrds")] +#[command(about = "secrds Security Monitor CLI", long_about = None)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Show recent alerts + Alerts { + /// Limit number of alerts to show + #[arg(short, long, default_value_t = 50)] + limit: usize, + }, + /// Clean log files and optionally event storage + Clean { + /// Remove all data including event storage and kernel-level eBPF maps + #[arg(short, long)] + all: bool, + }, + /// Show configuration + Config, + /// Restart the secrds service + Restart, + /// Start the secrds service + Start, + /// Show statistics + Stats, + /// Show service status + Status, + /// Stop the secrds service + Stop, +} + +fn main() -> anyhow::Result<()> { + let cli = Cli::parse(); + + match cli.command { + Commands::Alerts { limit } => commands::alerts(limit), + Commands::Clean { all } => commands::clean(all), + Commands::Config => commands::config(), + Commands::Restart => commands::restart(), + Commands::Start => commands::start(), + Commands::Stats => commands::stats(), + Commands::Status => commands::status(), + Commands::Stop => commands::stop(), + } +} + diff --git a/secrds-ebpf/Cargo.toml b/secrds-ebpf/Cargo.toml new file mode 100644 index 0000000..862a423 --- /dev/null +++ b/secrds-ebpf/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "secrds-ebpf" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[lib] +path = "src/lib.rs" +crate-type = ["bin"] + +[dependencies] +aya-ebpf = { git = "https://github.com/aya-rs/aya", branch = "main" } +aya-log-ebpf = { git = "https://github.com/aya-rs/aya", branch = "main" } + +[build-dependencies] +aya-build = { git = "https://github.com/aya-rs/aya", branch = "main" } + +[profile.release] +panic = "abort" diff --git a/secrds-ebpf/README.md b/secrds-ebpf/README.md new file mode 100644 index 0000000..d63a549 --- /dev/null +++ b/secrds-ebpf/README.md @@ -0,0 +1,23 @@ +# secrds-ebpf - eBPF Program + +## Status + +The eBPF program code is written but requires proper Aya build system setup. + +## Building + +For Aya 0.12, eBPF programs need to be compiled using: +1. Aya's build system (recommended) +2. Manual compilation with clang + +See `BUILD-EBPF.md` for detailed instructions. + +## Note + +The Rust eBPF code structure is correct, but compilation requires: +- Aya build system setup +- Or manual compilation from Rust → LLVM IR → eBPF bytecode + +For production use, you can temporarily use the original C eBPF program +compiled with clang until the Rust build system is fully set up. + diff --git a/secrds-ebpf/src/lib.rs b/secrds-ebpf/src/lib.rs new file mode 100644 index 0000000..ae9ee61 --- /dev/null +++ b/secrds-ebpf/src/lib.rs @@ -0,0 +1,304 @@ +#![no_std] +#![no_main] +#![allow(static_mut_refs)] +#![allow(unused_must_use)] + +use aya_ebpf::{ + helpers::{bpf_ktime_get_ns}, + macros::{kprobe, map, tracepoint}, + maps::{Array, LruHashMap, RingBuf}, + programs::{ProbeContext, TracePointContext}, +}; + +// ======== Struct Definitions ======== + +#[repr(C)] +pub struct TraceEventRawInetSockSetState { + _pad: [u8; 8], + skaddr: u64, + oldstate: i32, + newstate: i32, + sport: u16, + dport: u16, + family: u16, + protocol: u16, + saddr: [u8; 4], + daddr: [u8; 4], +} + +#[repr(C)] +pub struct NetEvent { + ts_ns: u64, + ifindex: u32, + family: u8, + etype: u8, + tcp_flags: u8, + src_cat: u8, + v4: EventV4, +} + +#[repr(C)] +pub struct EventV4 { + saddr: u32, + daddr: u32, + sport: u16, + dport: u16, +} + +#[repr(C)] +pub struct StatWin { + first_ts: u64, + last_ts: u64, + syn_count: u32, + ssh_count: u32, + rst_count: u32, +} + +// ======== Constants ======== + +const AF_INET: u16 = 2; +const IPPROTO_TCP: u16 = 6; +const SSH_PORT: u16 = 22; + +// TCP States +const TCP_ESTABLISHED: i32 = 1; +const TCP_SYN_SENT: i32 = 2; +const TCP_SYN_RECV: i32 = 3; +const TCP_CLOSE: i32 = 7; +const TCP_LISTEN: i32 = 10; +const TCP_NEW_SYN_RECV: i32 = 12; + +// Detection Thresholds +const SCAN_WINDOW_NS: u64 = 5_000_000_000; +const SCAN_THRESH: u32 = 20; +const BRUTE_THRESH: u32 = 40; + +// Event Types +const EVT_SSH_ATTEMPT: u8 = 2; +const EVT_PORT_SCAN: u8 = 3; +const EVT_SSH_BRUTE: u8 = 4; +const EVT_TCP_RST_FLOOD: u8 = 5; + +// Source Categories +const SRC_PUBLIC: u8 = 0; +const SRC_RFC1918: u8 = 1; +const SRC_CGNAT: u8 = 2; +const SRC_LOOP: u8 = 3; + +// ======== eBPF Maps ======== + +// Put debug/format strings into a stable .rodata section to avoid +// relocations against compiler-generated `.rodata.str1.1` which +// libbpf rejects when loading eBPF objects. +#[link_section = ".rodata"] +#[no_mangle] +pub static F_INET_SOCK_SET_STATE: [u8; 54] = *b"inet_sock_set_state: old=%d new=%d sport=%d dport=%d\n\0"; + +#[link_section = ".rodata"] +#[no_mangle] +pub static F_SSH_SYN_DETECTED: [u8; 36] = *b"SSH SYN detected saddr=%x count=%d\n\0"; + +#[link_section = ".rodata"] +#[no_mangle] +pub static F_SSH_SESSION_CLOSED: [u8; 29] = *b"SSH session closed saddr=%x\n\0"; + +#[link_section = ".rodata"] +#[no_mangle] +pub static F_KPROBE_TCP_V4_CONNECT: [u8; 31] = *b"kprobe: tcp_v4_connect called\n\0"; + +#[link_section = ".rodata"] +#[no_mangle] +pub static F_KPROBE_INET_CSK_ACCEPT: [u8; 43] = *b"kprobe: inet_csk_accept called (incoming)\n\0"; + + +#[map] +static mut V4_STATS: LruHashMap = LruHashMap::with_max_entries(65536, 0); + +#[map] +static mut EVENTS_RB: RingBuf = RingBuf::with_byte_size(4096, 0); + +#[map] +static mut DEBUG_COUNT: Array = Array::with_max_entries(1, 0); + +// ======== Debug Print Wrappers ======== + +#[inline(always)] +fn klog0(msg: &[u8]) { + // no-op: plain printk helpers are not available on all kernels + // keep as a thin wrapper so call sites don't need edits +} + +#[inline(always)] +fn klog2(fmt: &[u8], a: i64, b: i64) { + let args = [a, b]; + // no-op +} + +#[inline(always)] +fn klog4(fmt: &[u8], a: i64, b: i64, c: i64, d: i64) { + let args = [a, b, c, d]; + // no-op +} + +// ======== Utility Functions ======== + +#[inline(always)] +fn src_classify_v4(ip: u32) -> u8 { + if (ip & 0xFF00_0000) == 0x7F00_0000 { + SRC_LOOP + } else if (ip & 0xFF00_0000) == 0x0A00_0000 { + SRC_RFC1918 + } else if (ip & 0xFFF0_0000) == 0xAC10_0000 { + SRC_RFC1918 + } else if (ip & 0xFFFF_0000) == 0xC0A8_0000 { + SRC_RFC1918 + } else if (ip & 0xFFC0_0000) == 0x6440_0000 { + SRC_CGNAT + } else { + SRC_PUBLIC + } +} + +#[inline(always)] +fn push_event_v4(saddr: u32, daddr: u32, sport: u16, dport: u16, etype: u8) { + let now = unsafe { bpf_ktime_get_ns() }; + let event = NetEvent { + ts_ns: now, + ifindex: 0, + family: AF_INET as u8, + etype, + tcp_flags: 0x02, + src_cat: src_classify_v4(saddr), + v4: EventV4 { saddr, daddr, sport, dport }, + }; + + unsafe { + if let Some(mut entry) = EVENTS_RB.reserve::(0) { + entry.write(event); + entry.submit(0); + } + } +} + +// ======== Main eBPF Programs ======== + +#[tracepoint] +pub fn inet_sock_set_state(ctx: TracePointContext) -> u32 { + unsafe { + if let Some(count) = DEBUG_COUNT.get_ptr_mut(0) { + *count += 1; + } + } + + let tp = match unsafe { ctx.read_at::(0) } { + Ok(v) => v, + Err(_) => return 0, + }; + + if tp.family != AF_INET || tp.protocol != IPPROTO_TCP { + return 0; + } + + let dport = u16::from_be(tp.dport); + let sport = u16::from_be(tp.sport); + let oldstate = tp.oldstate; + let newstate = tp.newstate; + + let saddr = u32::from_be_bytes(tp.saddr); + let daddr = u32::from_be_bytes(tp.daddr); + + klog4( + &F_INET_SOCK_SET_STATE, + oldstate as i64, + newstate as i64, + sport as i64, + dport as i64, + ); + + let now = unsafe { bpf_ktime_get_ns() }; + + unsafe { + let zero = StatWin { + first_ts: 0, + last_ts: 0, + syn_count: 0, + ssh_count: 0, + rst_count: 0, + }; + + let mut_ptr = V4_STATS.get_ptr_mut(&saddr); + if mut_ptr.is_none() { + let _ = V4_STATS.insert(&saddr, &zero, 0); + } + + let mut_ptr = match V4_STATS.get_ptr_mut(&saddr) { + Some(ptr) => ptr, + None => return 0, + }; + + let st = &mut *mut_ptr; + + if st.first_ts == 0 || (now - st.first_ts) > SCAN_WINDOW_NS { + st.first_ts = now; + st.syn_count = 0; + st.ssh_count = 0; + st.rst_count = 0; + } + st.last_ts = now; + + // SYN-like transitions + if newstate == TCP_SYN_SENT || newstate == TCP_SYN_RECV || newstate == TCP_NEW_SYN_RECV { + st.syn_count += 1; + if dport == SSH_PORT { + st.ssh_count += 1; + klog2(&F_SSH_SYN_DETECTED, saddr as i64, st.ssh_count as i64); + let etype = if st.ssh_count >= BRUTE_THRESH { + EVT_SSH_BRUTE + } else { + EVT_SSH_ATTEMPT + }; + push_event_v4(saddr, daddr, sport, dport, etype); + } else if st.syn_count >= SCAN_THRESH { + push_event_v4(saddr, daddr, sport, dport, EVT_PORT_SCAN); + } + } + + // Detect closes (SSH disconnects) + if newstate == TCP_CLOSE && dport == SSH_PORT { + klog2(&F_SSH_SESSION_CLOSED, saddr as i64, 0); + } + + // Detect RST floods + if newstate == TCP_CLOSE && oldstate != TCP_LISTEN && oldstate != 0 { + st.rst_count += 1; + if st.rst_count >= (SCAN_THRESH * 2) { + push_event_v4(saddr, daddr, sport, dport, EVT_TCP_RST_FLOOD); + } + } + } + + 0 +} + +#[kprobe] +pub fn tcp_v4_connect(_ctx: ProbeContext) -> u32 { + klog0(&F_KPROBE_TCP_V4_CONNECT); + 0 +} + +#[kprobe] +pub fn inet_csk_accept(_ctx: ProbeContext) -> u32 { + klog0(&F_KPROBE_INET_CSK_ACCEPT); + 0 +} + +// ======== Panic & License ======== + +#[panic_handler] +fn panic(_info: &core::panic::PanicInfo) -> ! { + unsafe { core::hint::unreachable_unchecked() } +} + +#[no_mangle] +#[link_section = "license"] +pub static LICENSE: [u8; 4] = *b"GPL\0"; diff --git a/secrds-programs/Makefile b/secrds-programs/Makefile deleted file mode 100644 index 8cf2e5d..0000000 --- a/secrds-programs/Makefile +++ /dev/null @@ -1,33 +0,0 @@ -CLANG ?= clang -LLVM_STRIP ?= llvm-strip -ARCH := $(shell uname -m | sed 's/x86_64/x86/' | sed 's/aarch64/arm64/') - -CFLAGS := -g -O2 -Wall -CFLAGS += -target bpf -CFLAGS += -D__TARGET_ARCH_$(ARCH) -CFLAGS += -I../include - -CFLAGS += -I/usr/include - -# BTF flags for Aya compatibility -CFLAGS += -g -CFLAGS += -mcpu=v2 - -CFLAGS += -D__BPF__ -CFLAGS += -D__BPF_TRACING__ -CFLAGS += -Wno-address-of-packed-member -CFLAGS += -Wno-unknown-warning-option -CFLAGS += -Wno-unused-value -CFLAGS += -Wno-incompatible-pointer-types - -all: ssh_kprobe.bpf.o tcp_trace.bpf.o - -%.bpf.o: %.c - $(CLANG) $(CFLAGS) -c $< -o $@ - # Don't strip - Aya needs the full ELF structure - -clean: - rm -f *.bpf.o - -.PHONY: all clean - diff --git a/secrds-programs/common.h b/secrds-programs/common.h deleted file mode 100644 index a266efe..0000000 --- a/secrds-programs/common.h +++ /dev/null @@ -1,131 +0,0 @@ -#ifndef COMMON_H -#define COMMON_H - -// Basic type definitions for kernel programs (always define) -#ifndef __u64 -typedef unsigned long long __u64; -#endif -#ifndef __u32 -typedef unsigned int __u32; -#endif -#ifndef __u16 -typedef unsigned short __u16; -#endif -#ifndef __u8 -typedef unsigned char __u8; -#endif -#ifndef __s32 -typedef int __s32; -#endif -#ifndef __s64 -typedef long long __s64; -#endif -#ifndef __be16 -typedef __u16 __be16; -#endif -#ifndef __be32 -typedef __u32 __be32; -#endif -#ifndef __wsum -typedef __u32 __wsum; -#endif - -// BPF map type definitions -#ifndef BPF_MAP_TYPE_PERF_EVENT_ARRAY -#define BPF_MAP_TYPE_PERF_EVENT_ARRAY 4 -#endif -#ifndef BPF_MAP_TYPE_HASH -#define BPF_MAP_TYPE_HASH 1 -#endif -#ifndef BPF_ANY -#define BPF_ANY 0 -#endif -#ifndef BPF_F_CURRENT_CPU -#define BPF_F_CURRENT_CPU 0xffffffffULL -#endif - -// TCP state definitions -#ifndef TCP_SYN_SENT -#define TCP_SYN_SENT 2 -#endif -#ifndef TCP_SYN_RECV -#define TCP_SYN_RECV 3 -#endif - -// Minimal pt_regs structure for x86_64 (for kprobe access) -struct pt_regs { - unsigned long r15; - unsigned long r14; - unsigned long r13; - unsigned long r12; - unsigned long rbp; - unsigned long rbx; - unsigned long r11; - unsigned long r10; - unsigned long r9; - unsigned long r8; - unsigned long rax; - unsigned long rcx; - unsigned long rdx; - unsigned long rsi; - unsigned long rdi; // PT_REGS_PARM1 - unsigned long orig_rax; - unsigned long rip; - unsigned long cs; - unsigned long eflags; - unsigned long rsp; - unsigned long ss; -}; - -// Forward declarations only - we can't include full kernel structures in kernel programs -// We'll use offsets to access fields instead -struct sock; -struct sock_common; -struct inet_sock; - -// Common trace entry structure (must be defined first) -struct trace_entry { - unsigned short type; - unsigned char flags; - unsigned char preempt_count; - int pid; -}; - -// Tracepoint context structure for syscalls tracepoints -struct trace_event_raw_sys_enter { - struct trace_entry ent; - long int id; - long unsigned int args[6]; -}; - -#define MAX_IP_ADDRESSES 1024 -#define MAX_EVENTS 1024 - -struct ssh_event { - __u32 ip; - __u16 port; - __u32 pid; - __u8 event_type; - __u64 timestamp; -}; - -struct tcp_event { - __u32 src_ip; - __u32 dst_ip; - __u16 src_port; - __u16 dst_port; - __u8 event_type; - __u64 timestamp; -}; - -enum event_type { - SSH_ATTEMPT = 0, - SSH_FAILURE = 1, - SSH_SUCCESS = 2, - TCP_CONNECT = 3, - TCP_ACCEPT = 4, - TCP_CLOSE = 5, -}; - -#endif // COMMON_H - diff --git a/secrds-programs/ssh_kprobe.c b/secrds-programs/ssh_kprobe.c deleted file mode 100644 index 84a1956..0000000 --- a/secrds-programs/ssh_kprobe.c +++ /dev/null @@ -1,170 +0,0 @@ -#include "common.h" -#include -#include - -struct { - __uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY); - __uint(max_entries, MAX_EVENTS); - __uint(key_size, sizeof(__u32)); - __uint(value_size, sizeof(__u32)); -} ssh_events SEC(".maps"); - -struct { - __uint(type, BPF_MAP_TYPE_HASH); - __uint(max_entries, MAX_IP_ADDRESSES); - __uint(key_size, sizeof(__u32)); - __uint(value_size, sizeof(__u64)); -} ssh_attempts SEC(".maps"); - -// sockaddr_in structure (simplified for IPv4) -struct sockaddr_in { - __u16 sin_family; // AF_INET = 2 - __be16 sin_port; // Port in network byte order - struct in_addr { - __be32 s_addr; // IP address in network byte order - } sin_addr; - __u8 sin_zero[8]; // Padding -}; - -// Hook into inet_csk_accept to detect incoming SSH connections on the server side -// This is called when the server accepts a new connection -// Based on kernel headers: inet_daddr = sk.__sk_common.skc_daddr -// inet_rcv_saddr = sk.__sk_common.skc_rcv_saddr -// inet_dport = sk.__sk_common.skc_dport -// sock_common is at the START of sock structure -SEC("kprobe/inet_csk_accept") -int ssh_kprobe_accept(struct pt_regs *ctx) -{ - struct sock *sk = (struct sock *)PT_REGS_RC(ctx); - - if (!sk) return 0; - - __u64 pid_tgid = bpf_get_current_pid_tgid(); - __u32 pid = (__u32)(pid_tgid >> 32); - - __u32 src_ip = 0; - __u32 dst_ip = 0; - __u16 dst_port = 0; - - // sock_common is at the start of sock structure - // struct sock_common layout: - // offset 0-3: skc_daddr (destination IP - foreign address) - // offset 4-7: skc_rcv_saddr (source IP - bound local address) - // offset 8-9: skc_dport (destination port) - // offset 10-11: skc_num (local port) - - // Read destination port (skc_dport) - offset 8-9 in sock_common (which is at start of sock) - bpf_probe_read_kernel(&dst_port, sizeof(__u16), (char *)sk + 8); - dst_port = __builtin_bswap16(dst_port); - - // Only process SSH connections (port 22) - if (dst_port != 22) { - return 0; - } - - // For incoming connections on the server: - // - skc_daddr (offset 0) = the remote client's IP address (what we want to track!) - // - skc_rcv_saddr (offset 4) = the server's bound local address (usually 0.0.0.0 or server IP) - // - skc_dport (offset 8) = destination port (22 for SSH) - - // Read the remote client's IP directly from skc_daddr (offset 0-3) - // This is the IP of whoever is connecting TO the server - bpf_probe_read_kernel(&src_ip, sizeof(__u32), (char *)sk + 0); - src_ip = __builtin_bswap32(src_ip); - - // If we don't have a valid client IP, skip - if (src_ip == 0) { - return 0; - } - - // Track attempt - __u64 *count = bpf_map_lookup_elem(&ssh_attempts, &src_ip); - __u64 new_count = count ? *count + 1 : 1; - bpf_map_update_elem(&ssh_attempts, &src_ip, &new_count, BPF_F_CURRENT_CPU); - - // Initialize event structure - struct ssh_event event = {}; - event.ip = src_ip; - event.port = dst_port; - event.pid = pid; - event.event_type = SSH_ATTEMPT; - event.timestamp = bpf_ktime_get_ns(); - - bpf_perf_event_output(ctx, &ssh_events, BPF_F_CURRENT_CPU, &event, sizeof(event)); - - return 0; -} - -// Also hook tcp_v4_connect for outgoing connections -// This is more reliable as we have sockaddr_in with destination info -SEC("kprobe/tcp_v4_connect") -int ssh_kprobe_tcp_connect(struct pt_regs *ctx) -{ - struct sock *sk = (struct sock *)PT_REGS_PARM1(ctx); - struct sockaddr *uaddr = (struct sockaddr *)PT_REGS_PARM2(ctx); - - if (!sk || !uaddr) return 0; - - __u64 pid_tgid = bpf_get_current_pid_tgid(); - __u32 pid = (__u32)(pid_tgid >> 32); - - // Read sockaddr_in structure - struct sockaddr_in addr = {}; - bpf_probe_read_kernel(&addr, sizeof(addr), uaddr); - - // Check if it's IPv4 (AF_INET = 2) - if (addr.sin_family != 2) { - return 0; - } - - // Get destination port (convert from network byte order) - __u16 dst_port = __builtin_bswap16(addr.sin_port); - - // Only process SSH connections (port 22) - if (dst_port != 22) { - return 0; - } - - // Get destination IP from sockaddr - __u32 dst_ip = __builtin_bswap32(addr.sin_addr.s_addr); - - // For outgoing connections, we want to track the destination IP - // But for incoming connections (someone connecting TO us), tcp_v4_connect - // is called on the CLIENT side, not server side - - // Try to get source IP from socket (who is connecting) - __u32 src_ip = 0; - - // Read from sock_common (skc_rcv_saddr at offset 4) - bpf_probe_read_kernel(&src_ip, sizeof(__u32), (char *)sk + 4); - src_ip = __builtin_bswap32(src_ip); - - // If source IP not available, use destination IP - if (src_ip == 0) { - src_ip = dst_ip; - } - - // Skip if still invalid - if (src_ip == 0) { - return 0; - } - - // Track attempt - __u64 *count = bpf_map_lookup_elem(&ssh_attempts, &src_ip); - __u64 new_count = count ? *count + 1 : 1; - bpf_map_update_elem(&ssh_attempts, &src_ip, &new_count, BPF_F_CURRENT_CPU); - - // Initialize event structure - struct ssh_event event = {}; - event.ip = src_ip; - event.port = dst_port; - event.pid = pid; - event.event_type = SSH_ATTEMPT; - event.timestamp = bpf_ktime_get_ns(); - - bpf_perf_event_output(ctx, &ssh_events, BPF_F_CURRENT_CPU, &event, sizeof(event)); - - return 0; -} - -char LICENSE[] SEC("license") = "Dual BSD/GPL"; diff --git a/secrds-programs/tcp_trace.c b/secrds-programs/tcp_trace.c deleted file mode 100644 index b3490af..0000000 --- a/secrds-programs/tcp_trace.c +++ /dev/null @@ -1,81 +0,0 @@ -#include "common.h" -#include -#include - -struct { - __uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY); - __uint(max_entries, MAX_EVENTS); - __uint(key_size, sizeof(__u32)); - __uint(value_size, sizeof(__u32)); // PERF_EVENT_ARRAY value is CPU ID (u32) -} tcp_events SEC(".maps"); - -struct { - __uint(type, BPF_MAP_TYPE_HASH); - __uint(max_entries, MAX_IP_ADDRESSES); - __uint(key_size, sizeof(__u32)); - __uint(value_size, sizeof(__u64)); -} tcp_connection_count SEC(".maps"); - -struct { - __uint(type, BPF_MAP_TYPE_HASH); - __uint(max_entries, MAX_IP_ADDRESSES); - __uint(key_size, sizeof(__u32)); - __uint(value_size, sizeof(__u64)); -} tcp_port_scan SEC(".maps"); - -SEC("kprobe/tcp_v4_connect") -int tcp_connect(struct pt_regs *ctx) -{ - - struct sock *sk = (struct sock *)PT_REGS_PARM1(ctx); - - __u32 src_ip = 0; - __u16 dst_port = 0; - - __u64 *count = bpf_map_lookup_elem(&tcp_connection_count, &src_ip); - __u64 new_count = count ? *count + 1 : 1; - bpf_map_update_elem(&tcp_connection_count, &src_ip, &new_count, BPF_ANY); - - struct tcp_event event = { - .src_ip = src_ip, - .dst_ip = 0, - .src_port = 0, - .dst_port = dst_port, - .event_type = TCP_CONNECT, - .timestamp = bpf_ktime_get_ns(), - }; - - bpf_perf_event_output(ctx, &tcp_events, BPF_F_CURRENT_CPU, &event, sizeof(event)); - - return 0; -} - -SEC("tracepoint/sock/inet_sock_set_state") -int tcp_state_change(void *ctx) -{ - __u32 src_ip = 0; - __u16 dst_port = 0; - __u8 newstate = 0; - - if (newstate == TCP_SYN_SENT || newstate == TCP_SYN_RECV) { - __u64 *count = bpf_map_lookup_elem(&tcp_connection_count, &src_ip); - __u64 new_count = count ? *count + 1 : 1; - bpf_map_update_elem(&tcp_connection_count, &src_ip, &new_count, BPF_ANY); - - struct tcp_event event = { - .src_ip = src_ip, - .dst_ip = 0, - .src_port = 0, - .dst_port = dst_port, - .event_type = TCP_CONNECT, - .timestamp = bpf_ktime_get_ns(), - }; - - bpf_perf_event_output(ctx, &tcp_events, BPF_F_CURRENT_CPU, &event, sizeof(event)); - } - - return 0; -} - -char LICENSE[] SEC("license") = "Dual BSD/GPL"; - diff --git a/secrds.service b/secrds.service deleted file mode 100644 index e09aa8e..0000000 --- a/secrds.service +++ /dev/null @@ -1,44 +0,0 @@ -[Unit] -Description=secrds Security Monitor Agent -Documentation=https://github.com/yourorg/secrds -After=network.target -Wants=network-online.target - -[Service] -Type=simple -PIDFile=/var/run/secrds.pid -ExecStart=/usr/local/bin/secrds-agent -ExecReload=/bin/kill -HUP $MAINPID -Restart=on-failure -RestartSec=5s -TimeoutStopSec=30s - -# Ensure service stays running -RemainAfterExit=no - -# Security settings -User=root -Group=root -CapabilityBoundingSet=CAP_SYS_ADMIN CAP_NET_ADMIN CAP_BPF CAP_PERFMON CAP_SYS_RESOURCE -AmbientCapabilities=CAP_SYS_ADMIN CAP_NET_ADMIN CAP_BPF CAP_PERFMON CAP_SYS_RESOURCE -NoNewPrivileges=false -PrivateTmp=yes -ProtectSystem=strict -ProtectHome=yes -ReadWritePaths=/var/lib/secrds /var/run /sys/fs/bpf /sys/kernel/tracing /sys/kernel/debug - -# Resource limits -LimitNOFILE=65536 -LimitNPROC=4096 - -# Environment (optional - config is in /etc/secrds/config.yaml) -# EnvironmentFile=-/etc/secrds/env.conf - -# Logging -StandardOutput=journal -StandardError=journal -SyslogIdentifier=secrds - -[Install] -WantedBy=multi-user.target - diff --git a/setup-service.sh b/setup-service.sh deleted file mode 100755 index f9bdba4..0000000 --- a/setup-service.sh +++ /dev/null @@ -1,142 +0,0 @@ -#!/bin/bash -set -e - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' # No Color - -echo -e "${GREEN}Setting up secrds daemon service...${NC}" - -# Check if running as root -if [ "$EUID" -ne 0 ]; then - echo -e "${RED}Please run as root (use sudo)${NC}" - exit 1 -fi - -# Check if binaries exist -if [ ! -f "target/release/secrds-agent" ]; then - echo -e "${RED}Error: secrds-agent binary not found. Please build first: make build${NC}" - exit 1 -fi - -if [ ! -f "target/release/secrds-cli" ]; then - echo -e "${RED}Error: secrds-cli binary not found. Please build first: make build${NC}" - exit 1 -fi - -# Stop service if running (to avoid "Text file busy" error) -SERVICE_WAS_RUNNING=false -if systemctl is-active --quiet secrds 2>/dev/null; then - echo -e "${YELLOW}Stopping secrds service to update binaries...${NC}" - systemctl stop secrds - SERVICE_WAS_RUNNING=true - sleep 1 -fi - -# Install binaries -echo -e "${YELLOW}Installing binaries...${NC}" -# Try to copy, if it fails due to busy file, wait and retry -if ! cp target/release/secrds-agent /usr/local/bin/secrds-agent 2>/dev/null; then - echo -e "${YELLOW}Waiting for file to be released...${NC}" - sleep 2 - cp target/release/secrds-agent /usr/local/bin/secrds-agent -fi -chmod +x /usr/local/bin/secrds-agent -cp target/release/secrds-cli /usr/local/bin/secrds -chmod +x /usr/local/bin/secrds -echo -e "${GREEN}Binaries installed${NC}" - -# Install kernel programs -echo -e "${YELLOW}Installing kernel programs...${NC}" -mkdir -p /usr/local/lib/secrds -if [ -f "secrds-programs/ssh_kprobe.bpf.o" ]; then - cp secrds-programs/ssh_kprobe.bpf.o /usr/local/lib/secrds/ - echo -e "${GREEN}SSH kernel program installed${NC}" -fi -if [ -f "secrds-programs/tcp_trace.bpf.o" ]; then - cp secrds-programs/tcp_trace.bpf.o /usr/local/lib/secrds/ - echo -e "${GREEN}TCP kernel program installed${NC}" -fi - -# Install systemd service -echo -e "${YELLOW}Installing systemd service...${NC}" -if [ -f "secrds.service" ]; then - cp secrds.service /etc/systemd/system/secrds.service - systemctl daemon-reload - echo -e "${GREEN}Service file installed${NC}" -else - echo -e "${RED}Error: secrds.service not found${NC}" - exit 1 -fi - -# Create directories -mkdir -p /etc/secrds -mkdir -p /var/lib/secrds -mkdir -p /var/run -mkdir -p /var/log/secrds - -# Create default config if it doesn't exist -if [ ! -f "/etc/secrds/config.yaml" ]; then - echo -e "${YELLOW}Creating default configuration...${NC}" - cat > /etc/secrds/config.yaml <