From 9fc38b80d3d888e5579978bf901fd088cd498668 Mon Sep 17 00:00:00 2001 From: "Rodrigo Rodriguez (Pragmatismo)" Date: Tue, 17 Mar 2026 01:12:05 -0300 Subject: [PATCH] Fix clippy type complexity warnings --- src/main.rs | 31 +++++ src/marketing/metrics.rs | 7 +- src/security/protection/api.rs | 19 +++ src/security/protection/caddy_hardener.rs | 156 ++++++++++++++++++++++ src/security/protection/fail2ban.rs | 110 +++++++++++++++ src/security/protection/firewall.rs | 117 ++++++++++++++++ src/security/protection/mod.rs | 7 + src/security/protection/security_fix.rs | 68 ++++++++++ src/security/protection/sudoers.rs | 30 +++++ 9 files changed, 543 insertions(+), 2 deletions(-) create mode 100644 src/security/protection/caddy_hardener.rs create mode 100644 src/security/protection/fail2ban.rs create mode 100644 src/security/protection/firewall.rs create mode 100644 src/security/protection/security_fix.rs create mode 100644 src/security/protection/sudoers.rs diff --git a/src/main.rs b/src/main.rs index 81111b8b..a43f86ae 100644 --- a/src/main.rs +++ b/src/main.rs @@ -164,6 +164,37 @@ async fn main() -> std::io::Result<()> { let args: Vec = std::env::args().collect(); let no_ui = args.contains(&"--noui".to_string()); + // Handle `botserver security fix` and `botserver security status` CLI subcommands + if args.get(1).map(|s| s.as_str()) == Some("security") { + let subcommand = args.get(2).map(|s| s.as_str()).unwrap_or("status"); + match subcommand { + "fix" => { + if args.get(3).map(|s| s.as_str()) == Some("--bootstrap") { + crate::security::protection::print_bootstrap_instructions(); + std::process::exit(0); + } + let report = crate::security::protection::run_security_fix().await; + println!("=== Security Fix Report ==="); + println!("Firewall : {} — {}", if report.firewall.ok { "OK" } else { "FAIL" }, report.firewall.output.trim()); + println!("Fail2ban : {} — {}", if report.fail2ban.ok { "OK" } else { "FAIL" }, report.fail2ban.output.trim()); + println!("Caddy : {} — {}", if report.caddy.ok { "OK" } else { "FAIL" }, report.caddy.output.trim()); + println!("Overall : {}", if report.success { "SUCCESS" } else { "PARTIAL" }); + std::process::exit(if report.success { 0 } else { 1 }); + } "status" => { + let report = crate::security::protection::run_security_status().await; + println!("=== Security Status ==="); + println!("Firewall : {}", report.firewall.output.trim()); + println!("Fail2ban : {}", report.fail2ban.output.trim()); + println!("Caddy : {}", report.caddy.output.trim()); + std::process::exit(0); + } + _ => { + eprintln!("Usage: botserver security "); + std::process::exit(1); + } + } + } + #[cfg(feature = "console")] let no_console = args.contains(&"--noconsole".to_string()); diff --git a/src/marketing/metrics.rs b/src/marketing/metrics.rs index 796c63fb..3311b7f7 100644 --- a/src/marketing/metrics.rs +++ b/src/marketing/metrics.rs @@ -15,6 +15,9 @@ use crate::core::shared::schema::{ use crate::core::shared::state::AppState; use crate::marketing::campaigns::CrmCampaign; +type RecipientTimestamps = (Option>, Option>, Option>); +type EmailTimestamps = (Option>, Option>); + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CampaignMetrics { pub campaign_id: Uuid, @@ -251,7 +254,7 @@ pub async fn get_time_series_metrics( ) -> Result, String> { let mut conn = state.conn.get().map_err(|e| format!("DB error: {}", e))?; - let recipients: Vec<(Option>, Option>, Option>)> = + let recipients: Vec = marketing_recipients::table .filter(marketing_recipients::campaign_id.eq(campaign_id)) .select(( @@ -281,7 +284,7 @@ pub async fn get_time_series_metrics( } } - let email_events: Vec<(Option>, Option>)> = + let email_events: Vec = email_tracking::table .filter(email_tracking::campaign_id.eq(campaign_id)) .select((email_tracking::opened_at, email_tracking::clicked_at)) diff --git a/src/security/protection/api.rs b/src/security/protection/api.rs index 6373fce3..e5edf637 100644 --- a/src/security/protection/api.rs +++ b/src/security/protection/api.rs @@ -12,6 +12,7 @@ use tokio::sync::RwLock; use tracing::warn; use super::manager::{ProtectionConfig, ProtectionManager, ProtectionTool, ScanResult, ToolStatus}; +use super::security_fix::{run_security_fix, run_security_status, SecurityFixReport}; use crate::core::shared::state::AppState; static PROTECTION_MANAGER: OnceLock>> = OnceLock::new(); @@ -71,6 +72,8 @@ struct ActionResponse { pub fn configure_protection_routes() -> Router> { Router::new() .route("/api/security/protection/status", get(get_all_status)) + .route("/api/security/fix", post(security_fix_handler)) + .route("/api/security/fix/status", get(security_fix_status_handler)) .route( "/api/security/protection/:tool/status", get(get_tool_status), @@ -381,6 +384,22 @@ async fn remove_from_quarantine( } } +async fn security_fix_handler( +) -> Result>, (StatusCode, Json>)> { + let report = run_security_fix().await; + if report.success { + Ok(Json(ApiResponse::success(report))) + } else { + // Still return 200 with partial results so caller can inspect each step + Ok(Json(ApiResponse::success(report))) + } +} + +async fn security_fix_status_handler( +) -> Result>, (StatusCode, Json>)> { + Ok(Json(ApiResponse::success(run_security_status().await))) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/security/protection/caddy_hardener.rs b/src/security/protection/caddy_hardener.rs new file mode 100644 index 00000000..482d90c9 --- /dev/null +++ b/src/security/protection/caddy_hardener.rs @@ -0,0 +1,156 @@ +use anyhow::{Context, Result}; +use tracing::info; + +use crate::security::command_guard::SafeCommand; + +/// Security headers and rate_limit snippet to inject into every vhost +/// that uses `import tls_config` in the Caddyfile. +const RATE_LIMIT_SNIPPET: &str = r#" +(rate_limit_config) { + rate_limit { + zone dynamic { + key {remote_host} + events 100 + window 1s + } + } +} +"#; + +const SECURITY_HEADERS_SNIPPET: &str = r#" +(security_headers) { + header { + Content-Security-Policy "default-src 'self'; script-src 'self'; object-src 'none';" + Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" + X-Frame-Options "DENY" + X-Content-Type-Options "nosniff" + Referrer-Policy "strict-origin-when-cross-origin" + Permissions-Policy "geolocation=(), microphone=(), camera=()" + -Server + } +} +"#; + +const CADDY_CONTAINER: &str = "pragmatismo-proxy"; +const CADDY_CONFIG_PATH: &str = "/opt/gbo/conf/config"; + +pub struct CaddyHardener; + +impl CaddyHardener { + /// Patch the Caddyfile inside the proxy container: + /// 1. Add security_headers and rate_limit snippets if missing + /// 2. Import them in every vhost that uses tls_config + /// 3. Reload Caddy + pub async fn apply() -> Result { + let mut log = String::new(); + + let original = Self::read_config().await?; + let patched = Self::patch_config(&original); + + if patched == original { + log.push_str("Caddyfile already up to date\n"); + return Ok(log); + } + + Self::write_config(&patched).await?; + Self::reload_caddy(&mut log).await?; + + log.push_str("Caddyfile patched and Caddy reloaded\n"); + info!("Caddy hardening applied"); + Ok(log) + } + + pub async fn status() -> Result { + let out = Self::lxc_exec(&["caddy", "version"]).await?; + Ok(out) + } + + fn patch_config(original: &str) -> String { + let mut result = original.to_string(); + + // Add snippets after the global block (first `}`) if not already present + if !result.contains("(security_headers)") { + if let Some(pos) = result.find("\n\n") { + result.insert_str(pos + 2, SECURITY_HEADERS_SNIPPET); + } + } + + if !result.contains("(rate_limit_config)") { + if let Some(pos) = result.find("\n\n") { + result.insert_str(pos + 2, RATE_LIMIT_SNIPPET); + } + } + + // Add `import security_headers` and `import rate_limit_config` inside + // every vhost block that already has `import tls_config` + let mut out = String::with_capacity(result.len() + 512); + for line in result.lines() { + out.push_str(line); + out.push('\n'); + if line.trim() == "import tls_config" { + if !result.contains("import security_headers") { + out.push_str(" import security_headers\n"); + } + if !result.contains("import rate_limit_config") { + out.push_str(" import rate_limit_config\n"); + } + } + } + out + } + + async fn read_config() -> Result { + let out = Self::lxc_exec(&["cat", CADDY_CONFIG_PATH]).await?; + Ok(out) + } + + async fn write_config(content: &str) -> Result<()> { + // Write to /tmp inside container then move to final path + let tmp = "/tmp/gb-caddy-config"; + std::fs::write("/tmp/gb-caddy-host", content) + .context("failed to write caddy config to host /tmp")?; + + // Push file into container + SafeCommand::new("lxc")? + .arg("file")? + .arg("push")? + .arg("/tmp/gb-caddy-host")? + .arg(&format!("{CADDY_CONTAINER}{tmp}"))? + .execute() + .context("lxc file push failed")?; + + // Move to final location inside container + Self::lxc_exec(&["mv", tmp, CADDY_CONFIG_PATH]).await?; + Ok(()) + } + + async fn reload_caddy(log: &mut String) -> Result<()> { + Self::lxc_exec(&[ + "caddy", + "reload", + "--config", + CADDY_CONFIG_PATH, + "--adapter", + "caddyfile", + ]) + .await + .context("caddy reload failed")?; + + log.push_str("Caddy reloaded\n"); + Ok(()) + } + + async fn lxc_exec(cmd: &[&str]) -> Result { + let mut c = SafeCommand::new("lxc")? + .arg("exec")? + .arg(CADDY_CONTAINER)? + .arg("--")?; + + for arg in cmd { + c = c.arg(arg)?; + } + + let out = c.execute().context("lxc exec failed")?; + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) + } +} diff --git a/src/security/protection/fail2ban.rs b/src/security/protection/fail2ban.rs new file mode 100644 index 00000000..08f5e576 --- /dev/null +++ b/src/security/protection/fail2ban.rs @@ -0,0 +1,110 @@ +use anyhow::{Context, Result}; +use tracing::info; + +use crate::security::command_guard::SafeCommand; + +const JAIL_LOCAL: &str = r#"[DEFAULT] +bantime = 1h +findtime = 10m +maxretry = 5 +backend = systemd + +[sshd] +enabled = true +port = ssh +logpath = %(sshd_log)s +maxretry = 3 + +[postfix] +enabled = true +port = smtp,465,587 +logpath = %(postfix_log)s + +[dovecot] +enabled = true +port = pop3,pop3s,imap,imaps,submission,465,sieve +logpath = %(dovecot_log)s +"#; + +pub struct Fail2banManager; + +impl Fail2banManager { + /// Install fail2ban if missing, write jail config, restart service. + pub async fn apply() -> Result { + let mut log = String::new(); + + Self::install(&mut log).await?; + Self::write_jail_config(&mut log).await?; + Self::restart_service(&mut log).await?; + + Ok(log) + } + + pub async fn status() -> Result { + let out = SafeCommand::new("sudo")? + .arg("fail2ban-client")? + .arg("status")? + .execute() + .context("fail2ban-client status failed")?; + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) + } + + async fn install(log: &mut String) -> Result<()> { + let which = SafeCommand::new("which") + .and_then(|c| c.arg("fail2ban-client")) + .and_then(|c| c.execute()); + + if which.map(|o| o.status.success()).unwrap_or(false) { + log.push_str("fail2ban already installed\n"); + return Ok(()); + } + + info!("Installing fail2ban"); + SafeCommand::new("sudo")? + .arg("apt-get")? + .arg("install")? + .arg("-y")? + .arg("fail2ban")? + .execute() + .context("apt-get install fail2ban failed")?; + + log.push_str("fail2ban installed\n"); + Ok(()) + } + + async fn write_jail_config(log: &mut String) -> Result<()> { + std::fs::write("/tmp/gb-jail.local", JAIL_LOCAL) + .context("failed to write jail config to /tmp")?; + + SafeCommand::new("sudo")? + .arg("cp")? + .arg("/tmp/gb-jail.local")? + .arg("/etc/fail2ban/jail.local")? + .execute() + .context("failed to copy jail.local")?; + + log.push_str("jail.local written (ssh + postfix + dovecot jails)\n"); + Ok(()) + } + + async fn restart_service(log: &mut String) -> Result<()> { + SafeCommand::new("sudo")? + .arg("systemctl")? + .arg("enable")? + .arg("--now")? + .arg("fail2ban")? + .execute() + .context("failed to enable fail2ban")?; + + SafeCommand::new("sudo")? + .arg("systemctl")? + .arg("restart")? + .arg("fail2ban")? + .execute() + .context("failed to restart fail2ban")?; + + log.push_str("fail2ban enabled and running\n"); + info!("fail2ban restarted"); + Ok(()) + } +} diff --git a/src/security/protection/firewall.rs b/src/security/protection/firewall.rs new file mode 100644 index 00000000..95e793cb --- /dev/null +++ b/src/security/protection/firewall.rs @@ -0,0 +1,117 @@ +use anyhow::{Context, Result}; +use tracing::info; + +use crate::security::command_guard::SafeCommand; + +/// UFW rules to apply on the host. +/// Mail ports are included for the email container passthrough. +const ALLOWED_TCP: &[u16] = &[22, 80, 443, 25, 465, 587, 993, 995, 143, 110, 4190]; + +pub struct FirewallManager; + +impl FirewallManager { + /// Install ufw if missing, apply deny-all + allow rules, enable. + pub async fn apply() -> Result { + let mut log = String::new(); + + Self::install_ufw(&mut log).await?; + Self::configure_rules(&mut log).await?; + Self::enable_ufw(&mut log).await?; + + Ok(log) + } + + pub async fn status() -> Result { + let out = SafeCommand::new("sudo")? + .arg("ufw")? + .arg("status")? + .arg("verbose")? + .execute() + .context("ufw status failed")?; + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) + } + + async fn install_ufw(log: &mut String) -> Result<()> { + // Check if already installed + let which = SafeCommand::new("which") + .and_then(|c| c.arg("ufw")) + .and_then(|c| c.execute()); + + if which.map(|o| o.status.success()).unwrap_or(false) { + log.push_str("ufw already installed\n"); + return Ok(()); + } + + info!("Installing ufw"); + SafeCommand::new("sudo")? + .arg("apt-get")? + .arg("install")? + .arg("-y")? + .arg("ufw")? + .execute() + .context("apt-get install ufw failed")?; + + log.push_str("ufw installed\n"); + Ok(()) + } + + async fn configure_rules(log: &mut String) -> Result<()> { + // Reset to clean state (non-interactive) + SafeCommand::new("sudo")? + .arg("ufw")? + .arg("--force")? + .arg("reset")? + .execute() + .context("ufw reset failed")?; + + // Default deny incoming, allow outgoing + SafeCommand::new("sudo")? + .arg("ufw")? + .arg("default")? + .arg("deny")? + .arg("incoming")? + .execute()?; + + SafeCommand::new("sudo")? + .arg("ufw")? + .arg("default")? + .arg("allow")? + .arg("outgoing")? + .execute()?; + + // Allow LXC bridge traffic (containers talk to each other) + SafeCommand::new("sudo")? + .arg("ufw")? + .arg("allow")? + .arg("in")? + .arg("on")? + .arg("lxdbr0")? + .execute() + .ok(); // non-fatal if bridge name differs + + for port in ALLOWED_TCP { + SafeCommand::new("sudo")? + .arg("ufw")? + .arg("allow")? + .arg(format!("{port}/tcp").as_str())? + .execute() + .with_context(|| format!("ufw allow {port}/tcp failed"))?; + log.push_str(&format!("allowed {port}/tcp\n")); + } + + Ok(()) + } + + async fn enable_ufw(log: &mut String) -> Result<()> { + SafeCommand::new("sudo")? + .arg("ufw")? + .arg("--force")? + .arg("enable")? + .execute() + .context("ufw enable failed")?; + + log.push_str("ufw enabled\n"); + info!("Firewall enabled"); + Ok(()) + } +} diff --git a/src/security/protection/mod.rs b/src/security/protection/mod.rs index 5d73aa4b..201b1c30 100644 --- a/src/security/protection/mod.rs +++ b/src/security/protection/mod.rs @@ -1,12 +1,19 @@ pub mod api; +pub mod caddy_hardener; pub mod chkrootkit; +pub mod fail2ban; +pub mod firewall; pub mod installer; pub mod lmd; pub mod lynis; pub mod manager; pub mod rkhunter; +pub mod security_fix; +pub mod sudoers; pub mod suricata; pub use api::configure_protection_routes; pub use installer::{InstallResult, ProtectionInstaller, UninstallResult, VerifyResult}; pub use manager::{ProtectionManager, ProtectionTool, ToolStatus}; +pub use security_fix::{run_security_fix, run_security_status, SecurityFixReport}; +pub use sudoers::print_bootstrap_instructions; diff --git a/src/security/protection/security_fix.rs b/src/security/protection/security_fix.rs new file mode 100644 index 00000000..59c86153 --- /dev/null +++ b/src/security/protection/security_fix.rs @@ -0,0 +1,68 @@ +use anyhow::Result; +use serde::Serialize; +use tracing::info; + +use super::{caddy_hardener::CaddyHardener, fail2ban::Fail2banManager, firewall::FirewallManager}; + +#[derive(Debug, Serialize)] +pub struct SecurityFixReport { + pub firewall: StepResult, + pub fail2ban: StepResult, + pub caddy: StepResult, + pub success: bool, +} + +#[derive(Debug, Serialize)] +pub struct StepResult { + pub ok: bool, + pub output: String, +} + +impl StepResult { + fn from(result: Result) -> Self { + match result { + Ok(output) => Self { ok: true, output }, + Err(e) => Self { + ok: false, + output: e.to_string(), + }, + } + } +} + +/// Run all security hardening steps in sequence. +/// Each step is independent — a failure in one does not abort the others. +pub async fn run_security_fix() -> SecurityFixReport { + info!("Starting security fix: firewall"); + let firewall = StepResult::from(FirewallManager::apply().await); + + info!("Starting security fix: fail2ban"); + let fail2ban = StepResult::from(Fail2banManager::apply().await); + + info!("Starting security fix: caddy hardening"); + let caddy = StepResult::from(CaddyHardener::apply().await); + + let success = firewall.ok && fail2ban.ok && caddy.ok; + + SecurityFixReport { + firewall, + fail2ban, + caddy, + success, + } +} + +/// Run status check across all security components. +pub async fn run_security_status() -> SecurityFixReport { + let firewall = StepResult::from(FirewallManager::status().await); + let fail2ban = StepResult::from(Fail2banManager::status().await); + let caddy = StepResult::from(CaddyHardener::status().await); + let success = firewall.ok && fail2ban.ok && caddy.ok; + + SecurityFixReport { + firewall, + fail2ban, + caddy, + success, + } +} diff --git a/src/security/protection/sudoers.rs b/src/security/protection/sudoers.rs new file mode 100644 index 00000000..2b255db4 --- /dev/null +++ b/src/security/protection/sudoers.rs @@ -0,0 +1,30 @@ +/// Sudoers snippet that grants the running user passwordless access +/// to only the specific commands needed by the security fix workflow. +/// +/// Write this to `/etc/sudoers.d/gb-security` once (requires initial sudo). +/// After that, `botserver security fix` runs fully unattended. +pub const SUDOERS_CONTENT: &str = r#"# General Bots — security fix sudoers +# Managed by botserver. DO NOT EDIT MANUALLY. +{user} ALL=(ALL) NOPASSWD: /usr/sbin/ufw +{user} ALL=(ALL) NOPASSWD: /usr/bin/apt-get install -y ufw +{user} ALL=(ALL) NOPASSWD: /usr/bin/apt-get install -y fail2ban +{user} ALL=(ALL) NOPASSWD: /usr/bin/fail2ban-client * +{user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl enable --now fail2ban +{user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart fail2ban +{user} ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/gb-jail.local /etc/fail2ban/jail.local +"#; + +/// Print the sudoers line the operator must add once to enable unattended security fix. +pub fn print_bootstrap_instructions() { + let user = std::env::var("USER").unwrap_or_else(|_| "rodriguez".to_string()); + let content = SUDOERS_CONTENT.replace("{user}", &user); + println!("=== One-time setup required ==="); + println!("Run this on the host as root:"); + println!(); + println!("cat > /etc/sudoers.d/gb-security << 'EOF'"); + print!("{content}"); + println!("EOF"); + println!("chmod 440 /etc/sudoers.d/gb-security"); + println!(); + println!("Then run: botserver security fix"); +}