From e1e05ffef56b7b2d38c01b899ebc07d19b6d1814 Mon Sep 17 00:00:00 2001 From: mahesh bhatiya Date: Sat, 15 Nov 2025 23:12:53 +0530 Subject: [PATCH] Initial commit on dev branch --- .cargo/config.toml | 5 - BUILD-EBPF.md | 46 --- Cargo.toml | 29 -- Makefile | 51 +-- bpf/ssh_accept.bpf.c | 71 ++++ build-ebpf.sh | 46 --- build.sh | 38 -- cmd/secrds/main.go | 75 ++++ config.yaml.example | 209 ----------- config.yaml.minimal | 29 -- go.mod | 12 + go.sum | 16 + internal/logger/logger.go | 98 +++++ internal/monitor/monitor.go | 450 ++++++++++++++++++++++ logrotate.conf | 14 - secrds-agent/Cargo.toml | 28 -- 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/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 --------------- 28 files changed, 732 insertions(+), 2640 deletions(-) delete mode 100644 .cargo/config.toml delete mode 100644 BUILD-EBPF.md delete mode 100644 Cargo.toml create mode 100644 bpf/ssh_accept.bpf.c delete mode 100755 build-ebpf.sh delete mode 100755 build.sh create mode 100644 cmd/secrds/main.go delete mode 100644 config.yaml.example delete mode 100644 config.yaml.minimal create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/logger/logger.go create mode 100644 internal/monitor/monitor.go delete mode 100644 logrotate.conf delete mode 100644 secrds-agent/Cargo.toml delete mode 100644 secrds-agent/src/config.rs delete mode 100644 secrds-agent/src/detector.rs delete mode 100644 secrds-agent/src/main.rs delete mode 100644 secrds-agent/src/processor.rs delete mode 100644 secrds-agent/src/storage.rs delete mode 100644 secrds-agent/src/telegram.rs delete mode 100644 secrds-cli/Cargo.toml delete mode 100644 secrds-cli/src/commands.rs delete mode 100644 secrds-cli/src/main.rs delete mode 100644 secrds-ebpf/Cargo.toml delete mode 100644 secrds-ebpf/README.md delete mode 100644 secrds-ebpf/src/lib.rs diff --git a/.cargo/config.toml b/.cargo/config.toml deleted file mode 100644 index 447bfcb..0000000 --- a/.cargo/config.toml +++ /dev/null @@ -1,5 +0,0 @@ -[unstable] -build-std = ["core"] - -[build] -rustflags = ["-C", "panic=abort"] diff --git a/BUILD-EBPF.md b/BUILD-EBPF.md deleted file mode 100644 index 0e54842..0000000 --- a/BUILD-EBPF.md +++ /dev/null @@ -1,46 +0,0 @@ -# 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 deleted file mode 100644 index 1ea2f04..0000000 --- a/Cargo.toml +++ /dev/null @@ -1,29 +0,0 @@ -[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 c3ccd3a..86062d3 100644 --- a/Makefile +++ b/Makefile @@ -1,48 +1,17 @@ -.PHONY: build install clean test fmt clippy build-bpf help +.PHONY: all bpf go clean -help: - @echo "Available targets:" - @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 " help - Show this help" +all: bpf go -build: build-bpf - @echo "Building secrds Security Monitor..." - @cargo build --release - @echo "Build complete." +bpf: + clang -O2 -g -target bpf -c bpf/ssh_accept.bpf.c -o secrds.bpf.o -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..." - @sudo chmod +x install.sh - @sudo ./install.sh +go: + go mod download + go build -o secrds ./cmd/secrds clean: - @echo "Cleaning build artifacts..." - @cargo clean - @rm -rf target/release/secrds-* - @rm -rf target/bpfel-unknown-none - -test: - @echo "Running Rust tests..." - @cargo test --workspace || true + rm -f secrds secrds.bpf.o -fmt: - @echo "Formatting Rust code..." - @cargo fmt --all || true +run: all + sudo ./secrds -clippy: - @echo "Running clippy linter..." - @cargo clippy --workspace || true diff --git a/bpf/ssh_accept.bpf.c b/bpf/ssh_accept.bpf.c new file mode 100644 index 0000000..2028c2c --- /dev/null +++ b/bpf/ssh_accept.bpf.c @@ -0,0 +1,71 @@ +// bpf/ssh_accept.bpf.c +// Compile target: clang -O2 -g -target bpf -c ssh_accept.bpf.c -o ssh_accept.bpf.o + +#include +#include +#include +#include + +// Tracepoint struct definition for sys_exit +struct trace_event_raw_sys_exit { + unsigned short common_type; + unsigned char common_flags; + unsigned char common_preempt_count; + int common_pid; + long id; + long ret; +}; + +// Event we send to userland +struct accept_event { + __u32 pid; + __u32 tgid; + int fd; // returned fd from accept4 + __u64 ts_ns; + char comm[16]; +}; + +struct { + __uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY); + __uint(key_size, sizeof(__u32)); + __uint(value_size, sizeof(__u32)); + __uint(max_entries, 0); +} events SEC(".maps"); + +// Helper function to handle accept/accept4 exit +static __always_inline int handle_accept_exit(struct trace_event_raw_sys_exit *ctx) +{ + int retval = (int)ctx->ret; + if (retval < 0) { + // accept failed + return 0; + } + + struct accept_event ev = {}; + __u64 pid_tgid = bpf_get_current_pid_tgid(); + ev.pid = (__u32)pid_tgid; // tid + ev.tgid = (__u32)(pid_tgid >> 32); // pid (tgid) + ev.fd = retval; + ev.ts_ns = bpf_ktime_get_ns(); + bpf_get_current_comm(&ev.comm, sizeof(ev.comm)); + + // send to userland + bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, &ev, sizeof(ev)); + return 0; +} + +// tracepoint: sys_exit_accept4 +SEC("tracepoint/syscalls/sys_exit_accept4") +int trace_exit_accept4(struct trace_event_raw_sys_exit *ctx) +{ + return handle_accept_exit(ctx); +} + +// tracepoint: sys_exit_accept (for systems using accept instead of accept4) +SEC("tracepoint/syscalls/sys_exit_accept") +int trace_exit_accept(struct trace_event_raw_sys_exit *ctx) +{ + return handle_accept_exit(ctx); +} + +char _license[] SEC("license") = "GPL"; diff --git a/build-ebpf.sh b/build-ebpf.sh deleted file mode 100755 index 2d7a112..0000000 --- a/build-ebpf.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/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/build.sh b/build.sh deleted file mode 100755 index 43e16ac..0000000 --- a/build.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/bin/bash -set -e - -echo "Building secrds Security Monitor..." - -# Build C kernel programs (required for secrds-agent) -echo "Building C kernel programs..." -cd secrds-programs -make -cd .. - -# Check if Go is installed -if ! command -v go &> /dev/null; then - echo "Error: Go is not installed. Please install Go 1.21 or later." - echo "Visit: https://golang.org/dl/" - exit 1 -fi - -# Build secrds-agent -echo "Building secrds-agent..." -cd secrds-agent -go mod download -go build -o ../target/release/secrds-agent . -cd .. - -# Build secrds-cli -echo "Building secrds-cli..." -cd secrds-cli -go mod download -go build -o ../target/release/secrds-cli . -cd .. - -echo "Build complete!" -echo "" -echo "Binaries:" -echo " - Agent: target/release/secrds-agent" -echo " - CLI: target/release/secrds-cli" - diff --git a/cmd/secrds/main.go b/cmd/secrds/main.go new file mode 100644 index 0000000..ad2a968 --- /dev/null +++ b/cmd/secrds/main.go @@ -0,0 +1,75 @@ +package main + +import ( + "fmt" + "os" + "os/signal" + "syscall" + + "secrds/internal/logger" + "secrds/internal/monitor" +) + +func main() { + // Default log directory - try /var/log/secrds first, fallback to /etc/secrds/logs + logDir := "/var/log/secrds" + if _, err := os.Stat("/var/log"); err != nil { + // Fallback to /etc/secrds/logs if /var/log doesn't exist or isn't writable + logDir = "/etc/secrds/logs" + } + + // Initialize logger + lg, err := logger.NewLogger(logDir) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to initialize logger: %v\n", err) + os.Exit(1) + } + defer lg.Close() + + // Create monitor + mon := monitor.NewMonitor(lg) + + // Load BPF object file + bpfObjFile := "secrds.bpf.o" + if len(os.Args) > 1 { + bpfObjFile = os.Args[1] + } + + if err := mon.LoadBPF(bpfObjFile); err != nil { + lg.LogError("Failed to load BPF: %v", err) + os.Exit(1) + } + + // Attach tracepoints + if err := mon.Attach(); err != nil { + lg.LogError("Failed to attach tracepoints: %v", err) + os.Exit(1) + } + + // Start perf reader + if err := mon.StartPerfReader(); err != nil { + lg.LogError("Failed to start perf reader: %v", err) + os.Exit(1) + } + defer mon.Close() + + // Start monitoring + lg.StartMonitoring() + + // Handle signals + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + // Start event processing in goroutine + go mon.ProcessEvents() + + // Wait for signal + <-sigChan + lg.LogInfo("Shutting down...") + + // Close monitor (this will gracefully stop the goroutine) + mon.Close() + + lg.LogInfo("Exited") +} + diff --git a/config.yaml.example b/config.yaml.example deleted file mode 100644 index 6f44363..0000000 --- a/config.yaml.example +++ /dev/null @@ -1,209 +0,0 @@ -# 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 deleted file mode 100644 index 36ef4a4..0000000 --- a/config.yaml.minimal +++ /dev/null @@ -1,29 +0,0 @@ -# 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/go.mod b/go.mod new file mode 100644 index 0000000..22934f3 --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module secrds + +go 1.21.0 + +toolchain go1.23.4 + +require github.com/cilium/ebpf v0.13.2 + +require ( + golang.org/x/exp v0.0.0-20230224173230-c95f2b4c22f2 // indirect + golang.org/x/sys v0.15.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..616c11d --- /dev/null +++ b/go.sum @@ -0,0 +1,16 @@ +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.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..e1e61e7 --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,98 @@ +package logger + +import ( + "fmt" + "log" + "os" + "path/filepath" + "time" +) + +// Logger handles both console and file logging +type Logger struct { + consoleLog *log.Logger + fileLog *log.Logger + logFile *os.File + logDir string + attempts map[string]int // Track attempts per IP +} + +// NewLogger creates a new logger instance +func NewLogger(logDir string) (*Logger, error) { + // Ensure log directory exists + if err := os.MkdirAll(logDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create log directory: %w", err) + } + + // Create log file with timestamp + logFileName := filepath.Join(logDir, fmt.Sprintf("secrds-%s.log", time.Now().Format("2006-01-02"))) + logFile, err := os.OpenFile(logFileName, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + return nil, fmt.Errorf("failed to open log file: %w", err) + } + + return &Logger{ + consoleLog: log.New(os.Stdout, "", 0), + fileLog: log.New(logFile, "", 0), + logFile: logFile, + logDir: logDir, + attempts: make(map[string]int), + }, nil +} + +// Close closes the log file +func (l *Logger) Close() error { + if l.logFile != nil { + return l.logFile.Close() + } + return nil +} + +// log writes to both console and file +func (l *Logger) log(format string, args ...interface{}) { + message := fmt.Sprintf(format, args...) + timestamp := time.Now().Format("2006-01-02 15:04:05") + logMessage := fmt.Sprintf("[%s] %s", timestamp, message) + + l.consoleLog.Println(logMessage) + l.fileLog.Println(logMessage) +} + +// StartMonitoring logs the start of SSH monitoring +func (l *Logger) StartMonitoring() { + l.log("starting ssh monitoring") +} + +// LogSSHDetected logs SSH detection with IP, attempt count, and time +func (l *Logger) LogSSHDetected(ip string, port int, pid uint32, comm string) { + // Increment attempt counter for this IP + l.attempts[ip]++ + attemptCount := l.attempts[ip] + + // Format: ssh detected : ip, attempt tried time + detectionTime := time.Now().Format("2006-01-02 15:04:05") + message := fmt.Sprintf("ssh detected : %s:%d, attempt %d, time %s (pid=%d, comm=%s)", + ip, port, attemptCount, detectionTime, pid, comm) + + l.log(message) +} + +// LogEvent logs a general accept event +func (l *Logger) LogEvent(ip string, port int, pid uint32, comm string) { + detectionTime := time.Now().Format("2006-01-02 15:04:05") + message := fmt.Sprintf("accept event: %s:%d (pid=%d, comm=%s, time=%s)", + ip, port, pid, comm, detectionTime) + + l.log(message) +} + +// LogError logs an error message +func (l *Logger) LogError(format string, args ...interface{}) { + l.log("ERROR: "+format, args...) +} + +// LogInfo logs an info message +func (l *Logger) LogInfo(format string, args ...interface{}) { + l.log("INFO: "+format, args...) +} + diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go new file mode 100644 index 0000000..671e0b8 --- /dev/null +++ b/internal/monitor/monitor.go @@ -0,0 +1,450 @@ +package monitor + +import ( + "bufio" + "bytes" + "context" + "encoding/binary" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + "unsafe" + + "github.com/cilium/ebpf" + "github.com/cilium/ebpf/link" + "github.com/cilium/ebpf/perf" + "github.com/cilium/ebpf/rlimit" + + "secrds/internal/logger" +) + +// AcceptEvent matches the struct in bpf/ssh_accept.bpf.c +type AcceptEvent struct { + Pid uint32 + Tgid uint32 + Fd int32 + TsNs uint64 + Comm [16]byte +} + +// Monitor handles SSH connection monitoring +type Monitor struct { + logger *logger.Logger + collection *ebpf.Collection + links []link.Link + reader *perf.Reader + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + shuttingDown int32 // atomic flag for shutdown state +} + +// NewMonitor creates a new monitor instance +func NewMonitor(logger *logger.Logger) *Monitor { + ctx, cancel := context.WithCancel(context.Background()) + return &Monitor{ + logger: logger, + links: make([]link.Link, 0), + ctx: ctx, + cancel: cancel, + } +} + +// LoadBPF loads the BPF object file +func (m *Monitor) LoadBPF(bpfObjFile string) error { + // Remove memory limits for eBPF + if err := rlimit.RemoveMemlock(); err != nil { + return fmt.Errorf("failed to remove memlock limit: %w", err) + } + + // Get absolute path + absPath, err := filepath.Abs(bpfObjFile) + if err != nil { + return fmt.Errorf("failed to get absolute path: %w", err) + } + + // Load the BPF object + spec, err := ebpf.LoadCollectionSpec(absPath) + if err != nil { + return fmt.Errorf("failed to load BPF collection spec: %w", err) + } + + coll, err := ebpf.NewCollection(spec) + if err != nil { + return fmt.Errorf("failed to create BPF collection: %w", err) + } + + m.collection = coll + + return nil +} + +// Attach attaches tracepoint programs +func (m *Monitor) Attach() error { + // Attach accept4 tracepoint + progAccept4 := m.collection.Programs["trace_exit_accept4"] + if progAccept4 != nil { + tpAccept4, err := link.Tracepoint("syscalls", "sys_exit_accept4", progAccept4, nil) + if err != nil { + m.logger.LogError("Failed to attach tracepoint accept4: %v", err) + } else { + m.links = append(m.links, tpAccept4) + m.logger.LogInfo("Successfully attached to tracepoint: sys_exit_accept4") + } + } + + // Attach accept tracepoint + progAccept := m.collection.Programs["trace_exit_accept"] + if progAccept != nil { + tpAccept, err := link.Tracepoint("syscalls", "sys_exit_accept", progAccept, nil) + if err != nil { + m.logger.LogError("Failed to attach tracepoint accept: %v", err) + } else { + m.links = append(m.links, tpAccept) + m.logger.LogInfo("Successfully attached to tracepoint: sys_exit_accept") + } + } + + if len(m.links) == 0 { + return fmt.Errorf("failed to attach any tracepoint programs") + } + + return nil +} + +// StartPerfReader starts the perf event reader +func (m *Monitor) StartPerfReader() error { + // Get the events map + eventsMap := m.collection.Maps["events"] + if eventsMap == nil { + return fmt.Errorf("failed to find events map") + } + + // Create perf reader + rd, err := perf.NewReader(eventsMap, 8*os.Getpagesize()) + if err != nil { + return fmt.Errorf("failed to create perf reader: %w", err) + } + + m.reader = rd + return nil +} + +// ProcessEvents processes events from the perf reader +func (m *Monitor) ProcessEvents() { + m.wg.Add(1) + defer m.wg.Done() + + for { + // Check if we're shutting down before blocking read + if atomic.LoadInt32(&m.shuttingDown) != 0 { + return + } + + // Check if context is cancelled before blocking read + select { + case <-m.ctx.Done(): + return + default: + } + + record, err := m.reader.Read() + if err != nil { + // If we're shutting down, exit immediately without logging + if atomic.LoadInt32(&m.shuttingDown) != 0 { + return + } + + // Check if context was cancelled (clean shutdown) + if m.ctx.Err() != nil { + return + } + + // Check for closed reader errors (shutdown) + if err == perf.ErrClosed { + return + } + + // Check if error message indicates file/ringbuffer is closed + errStr := err.Error() + if strings.Contains(errStr, "file already closed") || + strings.Contains(errStr, "perf ringbuffer") || + strings.Contains(errStr, "epoll wait") { + // Reader was closed, exit immediately + return + } + + // Only log unexpected errors if not shutting down + if atomic.LoadInt32(&m.shuttingDown) == 0 { + m.logger.LogError("Error reading perf event: %v", err) + } + continue + } + + // Check shutdown flag after successful read + if atomic.LoadInt32(&m.shuttingDown) != 0 { + return + } + + // Check context again after successful read (in case shutdown happened during read) + select { + case <-m.ctx.Done(): + return + default: + } + + if record.LostSamples > 0 { + m.logger.LogError("Lost %d samples", record.LostSamples) + continue + } + + // Parse the event + if len(record.RawSample) < int(unsafe.Sizeof(AcceptEvent{})) { + continue + } + + var ev AcceptEvent + reader := bytes.NewReader(record.RawSample) + if err := binary.Read(reader, binary.LittleEndian, &ev); err != nil { + // Try direct memory copy as fallback + ev = *(*AcceptEvent)(unsafe.Pointer(&record.RawSample[0])) + } + + m.handleEvent(&ev) + } +} + +// handleEvent processes a single accept event +func (m *Monitor) handleEvent(ev *AcceptEvent) { + // Get comm string (null-terminated) + comm := strings.TrimRight(string(ev.Comm[:]), "\x00") + + // Attempt to resolve socket inode + inode, err := fdToInode(int(ev.Tgid), int(ev.Fd)) + if err != nil { + m.logger.LogInfo("Could not resolve /proc/%d/fd/%d (maybe short-lived or permission)", + ev.Tgid, ev.Fd) + return + } + + // Small delay to let socket appear in /proc/net/tcp and establish connection + time.Sleep(10 * time.Millisecond) + + // Try to get IP and port + ip, remPort, localPort, err := inodeToIPPort(inode) + if err != nil { + // Check if it's a Unix socket + linkPath := fmt.Sprintf("/proc/%d/fd/%d", ev.Tgid, ev.Fd) + linkTarget, err := os.Readlink(linkPath) + if err == nil { + if strings.HasPrefix(linkTarget, "socket:") { + m.logger.LogInfo("inode=%d (socket may be Unix domain or already closed)", inode) + } else { + m.logger.LogInfo("inode=%d found but /proc/net/tcp lookup failed (fd: %s)", inode, linkTarget) + } + } else { + m.logger.LogInfo("inode=%d found but /proc/net/tcp lookup failed", inode) + } + return + } + + // Check if it's SSH - check both local port (22 = listening) and remote port, or sshd process + isSSH := localPort == 22 || remPort == 22 || comm == "sshd" + + if isSSH { + m.logger.LogSSHDetected(ip, remPort, ev.Tgid, comm) + } else { + m.logger.LogEvent(ip, remPort, ev.Tgid, comm) + } +} + +// Stop stops the monitor gracefully +func (m *Monitor) Stop() { + // Set shutdown flag first + atomic.StoreInt32(&m.shuttingDown, 1) + + // Cancel context to signal goroutine to stop + m.cancel() + + // Close reader to unblock any Read() calls + if m.reader != nil { + m.reader.Close() + } + + // Wait for ProcessEvents goroutine to finish + m.wg.Wait() +} + +// Close closes all resources +func (m *Monitor) Close() error { + // Stop the monitor first (graceful shutdown) + m.Stop() + + // Close links + for _, l := range m.links { + l.Close() + } + // Close collection + if m.collection != nil { + m.collection.Close() + } + return nil +} + +// fdToInode resolves pid + fd -> socket inode +func fdToInode(pid, fd int) (uint64, error) { + linkPath := fmt.Sprintf("/proc/%d/fd/%d", pid, fd) + linkTarget, err := os.Readlink(linkPath) + if err != nil { + return 0, err + } + + // linkTarget will be "socket:[12345]" for sockets + start := strings.Index(linkTarget, "[") + end := strings.Index(linkTarget, "]") + if start == -1 || end == -1 || start >= end { + return 0, fmt.Errorf("invalid socket link format") + } + + inodeStr := linkTarget[start+1 : end] + inode, err := strconv.ParseUint(inodeStr, 10, 64) + if err != nil || inode == 0 { + return 0, fmt.Errorf("invalid inode") + } + + return inode, nil +} + +// inodeToIPPort finds IP and port from inode in /proc/net/tcp +// Returns: remoteIP, remotePort, localPort, error +func inodeToIPPort(inode uint64) (string, int, int, error) { + // Try multiple times with increasing delay (socket needs time to establish) + for retry := 0; retry < 10; retry++ { + if retry > 0 { + // Exponential backoff: 5ms, 10ms, 20ms, 40ms, 80ms, 160ms, etc. + delay := time.Duration(5*(1< 200*time.Millisecond { + delay = 200 * time.Millisecond + } + time.Sleep(delay) + } + + // Try IPv4 first + ip, remPort, localPort, err := parseTCPFile("/proc/net/tcp", inode) + if err == nil { + return ip, remPort, localPort, nil + } + + // Try IPv6 if IPv4 fails + ip, remPort, localPort, err = parseTCPFile("/proc/net/tcp6", inode) + if err == nil { + return ip, remPort, localPort, nil + } + } + + return "", 0, 0, fmt.Errorf("not found") +} + +// parseTCPFile parses /proc/net/tcp and finds entry matching socket inode +// Returns: remoteIP, remotePort, localPort, error +func parseTCPFile(filename string, inode uint64) (string, int, int, error) { + file, err := os.Open(filename) + if err != nil { + return "", 0, 0, err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + + // Skip header line + if !scanner.Scan() { + return "", 0, 0, fmt.Errorf("empty file") + } + + for scanner.Scan() { + line := scanner.Text() + fields := strings.Fields(line) + if len(fields) < 12 { + continue + } + + // Parse: sl local_address rem_address st ... + if len(fields[1]) == 0 || fields[1][len(fields[1])-1] != ':' { + continue + } + + // Extract ports + localParts := strings.Split(fields[1], ":") + remParts := strings.Split(fields[2], ":") + if len(localParts) != 2 || len(remParts) != 2 { + continue + } + + // Parse local port + localPortHex := localParts[1] + localPort, err := strconv.ParseUint(localPortHex, 16, 32) + if err != nil { + continue + } + + remPortHex := remParts[1] + remPort, err := strconv.ParseUint(remPortHex, 16, 32) + if err != nil { + continue + } + + // Inode is second-to-last field (before ref count) + entryInodeStr := fields[len(fields)-2] + entryInode, err := strconv.ParseUint(entryInodeStr, 10, 64) + if err != nil { + continue + } + + if entryInode == inode && entryInode != 0 { + // Parse remote IP (hex format, big-endian byte order) + remIPHex := remParts[0] + + // Skip if remote address is 0.0.0.0:0000 (listening socket) + if remIPHex == "00000000" && remPort == 0 { + continue + } + + remIPBytes, err := hex.DecodeString(remIPHex) + if err != nil || len(remIPBytes) != 4 { + continue + } + + // Convert from hex to dotted decimal + ip := fmt.Sprintf("%d.%d.%d.%d", + remIPBytes[3], remIPBytes[2], remIPBytes[1], remIPBytes[0]) + + // Skip if IP is 0.0.0.0 (listening socket) + if ip == "0.0.0.0" { + continue + } + + // Accept all states except LISTEN (0A) + // This includes ESTABLISHED (01), SYN_RECV (03), TIME_WAIT (06), etc. + state := "01" // default + if len(fields) > 3 { + state = fields[3] + } + + // Skip LISTEN state (0A) - these are listening sockets, not accepted connections + if state == "0A" { + continue + } + + return ip, int(remPort), int(localPort), nil + } + } + + return "", 0, 0, fmt.Errorf("inode not found") +} + diff --git a/logrotate.conf b/logrotate.conf deleted file mode 100644 index 711e316..0000000 --- a/logrotate.conf +++ /dev/null @@ -1,14 +0,0 @@ -/var/log/secrds/*.log { - daily - rotate 30 - compress - delaycompress - missingok - notifempty - create 0644 root root - sharedscripts - postrotate - systemctl reload secrds > /dev/null 2>&1 || true - endscript -} - diff --git a/secrds-agent/Cargo.toml b/secrds-agent/Cargo.toml deleted file mode 100644 index a9ddbdd..0000000 --- a/secrds-agent/Cargo.toml +++ /dev/null @@ -1,28 +0,0 @@ -[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/src/config.rs b/secrds-agent/src/config.rs deleted file mode 100644 index f88bdb5..0000000 --- a/secrds-agent/src/config.rs +++ /dev/null @@ -1,190 +0,0 @@ -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 deleted file mode 100644 index 0ddab4c..0000000 --- a/secrds-agent/src/detector.rs +++ /dev/null @@ -1,680 +0,0 @@ -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 deleted file mode 100644 index c4610cd..0000000 --- a/secrds-agent/src/main.rs +++ /dev/null @@ -1,80 +0,0 @@ -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 deleted file mode 100644 index 8b43de0..0000000 --- a/secrds-agent/src/processor.rs +++ /dev/null @@ -1,151 +0,0 @@ -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 deleted file mode 100644 index 94af272..0000000 --- a/secrds-agent/src/storage.rs +++ /dev/null @@ -1,238 +0,0 @@ -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 deleted file mode 100644 index b81a1c9..0000000 --- a/secrds-agent/src/telegram.rs +++ /dev/null @@ -1,130 +0,0 @@ -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 deleted file mode 100644 index 771ced0..0000000 --- a/secrds-cli/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[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/src/commands.rs b/secrds-cli/src/commands.rs deleted file mode 100644 index 172863c..0000000 --- a/secrds-cli/src/commands.rs +++ /dev/null @@ -1,262 +0,0 @@ -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 deleted file mode 100644 index a5e8114..0000000 --- a/secrds-cli/src/main.rs +++ /dev/null @@ -1,58 +0,0 @@ -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 deleted file mode 100644 index 862a423..0000000 --- a/secrds-ebpf/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[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 deleted file mode 100644 index d63a549..0000000 --- a/secrds-ebpf/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# 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 deleted file mode 100644 index ae9ee61..0000000 --- a/secrds-ebpf/src/lib.rs +++ /dev/null @@ -1,304 +0,0 @@ -#![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";