Fix clippy type complexity warnings

This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2026-03-17 01:12:05 -03:00
parent ab1f2df476
commit 9fc38b80d3
9 changed files with 543 additions and 2 deletions

View file

@ -164,6 +164,37 @@ async fn main() -> std::io::Result<()> {
let args: Vec<String> = std::env::args().collect(); let args: Vec<String> = std::env::args().collect();
let no_ui = args.contains(&"--noui".to_string()); 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 <fix|status>");
std::process::exit(1);
}
}
}
#[cfg(feature = "console")] #[cfg(feature = "console")]
let no_console = args.contains(&"--noconsole".to_string()); let no_console = args.contains(&"--noconsole".to_string());

View file

@ -15,6 +15,9 @@ use crate::core::shared::schema::{
use crate::core::shared::state::AppState; use crate::core::shared::state::AppState;
use crate::marketing::campaigns::CrmCampaign; use crate::marketing::campaigns::CrmCampaign;
type RecipientTimestamps = (Option<DateTime<Utc>>, Option<DateTime<Utc>>, Option<DateTime<Utc>>);
type EmailTimestamps = (Option<DateTime<Utc>>, Option<DateTime<Utc>>);
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CampaignMetrics { pub struct CampaignMetrics {
pub campaign_id: Uuid, pub campaign_id: Uuid,
@ -251,7 +254,7 @@ pub async fn get_time_series_metrics(
) -> Result<Vec<TimeSeriesMetric>, String> { ) -> Result<Vec<TimeSeriesMetric>, String> {
let mut conn = state.conn.get().map_err(|e| format!("DB error: {}", e))?; let mut conn = state.conn.get().map_err(|e| format!("DB error: {}", e))?;
let recipients: Vec<(Option<DateTime<Utc>>, Option<DateTime<Utc>>, Option<DateTime<Utc>>)> = let recipients: Vec<RecipientTimestamps> =
marketing_recipients::table marketing_recipients::table
.filter(marketing_recipients::campaign_id.eq(campaign_id)) .filter(marketing_recipients::campaign_id.eq(campaign_id))
.select(( .select((
@ -281,7 +284,7 @@ pub async fn get_time_series_metrics(
} }
} }
let email_events: Vec<(Option<DateTime<Utc>>, Option<DateTime<Utc>>)> = let email_events: Vec<EmailTimestamps> =
email_tracking::table email_tracking::table
.filter(email_tracking::campaign_id.eq(campaign_id)) .filter(email_tracking::campaign_id.eq(campaign_id))
.select((email_tracking::opened_at, email_tracking::clicked_at)) .select((email_tracking::opened_at, email_tracking::clicked_at))

View file

@ -12,6 +12,7 @@ use tokio::sync::RwLock;
use tracing::warn; use tracing::warn;
use super::manager::{ProtectionConfig, ProtectionManager, ProtectionTool, ScanResult, ToolStatus}; 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; use crate::core::shared::state::AppState;
static PROTECTION_MANAGER: OnceLock<Arc<RwLock<ProtectionManager>>> = OnceLock::new(); static PROTECTION_MANAGER: OnceLock<Arc<RwLock<ProtectionManager>>> = OnceLock::new();
@ -71,6 +72,8 @@ struct ActionResponse {
pub fn configure_protection_routes() -> Router<Arc<AppState>> { pub fn configure_protection_routes() -> Router<Arc<AppState>> {
Router::new() Router::new()
.route("/api/security/protection/status", get(get_all_status)) .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( .route(
"/api/security/protection/:tool/status", "/api/security/protection/:tool/status",
get(get_tool_status), get(get_tool_status),
@ -381,6 +384,22 @@ async fn remove_from_quarantine(
} }
} }
async fn security_fix_handler(
) -> Result<Json<ApiResponse<SecurityFixReport>>, (StatusCode, Json<ApiResponse<()>>)> {
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<Json<ApiResponse<SecurityFixReport>>, (StatusCode, Json<ApiResponse<()>>)> {
Ok(Json(ApiResponse::success(run_security_status().await)))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View file

@ -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<String> {
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<String> {
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<String> {
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<String> {
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())
}
}

View file

@ -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<String> {
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<String> {
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(())
}
}

View file

@ -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<String> {
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<String> {
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(())
}
}

View file

@ -1,12 +1,19 @@
pub mod api; pub mod api;
pub mod caddy_hardener;
pub mod chkrootkit; pub mod chkrootkit;
pub mod fail2ban;
pub mod firewall;
pub mod installer; pub mod installer;
pub mod lmd; pub mod lmd;
pub mod lynis; pub mod lynis;
pub mod manager; pub mod manager;
pub mod rkhunter; pub mod rkhunter;
pub mod security_fix;
pub mod sudoers;
pub mod suricata; pub mod suricata;
pub use api::configure_protection_routes; pub use api::configure_protection_routes;
pub use installer::{InstallResult, ProtectionInstaller, UninstallResult, VerifyResult}; pub use installer::{InstallResult, ProtectionInstaller, UninstallResult, VerifyResult};
pub use manager::{ProtectionManager, ProtectionTool, ToolStatus}; pub use manager::{ProtectionManager, ProtectionTool, ToolStatus};
pub use security_fix::{run_security_fix, run_security_status, SecurityFixReport};
pub use sudoers::print_bootstrap_instructions;

View file

@ -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<String>) -> 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,
}
}

View file

@ -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");
}