Fix clippy type complexity warnings
This commit is contained in:
parent
ab1f2df476
commit
9fc38b80d3
9 changed files with 543 additions and 2 deletions
31
src/main.rs
31
src/main.rs
|
|
@ -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());
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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::*;
|
||||||
|
|
|
||||||
156
src/security/protection/caddy_hardener.rs
Normal file
156
src/security/protection/caddy_hardener.rs
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
110
src/security/protection/fail2ban.rs
Normal file
110
src/security/protection/fail2ban.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
117
src/security/protection/firewall.rs
Normal file
117
src/security/protection/firewall.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
68
src/security/protection/security_fix.rs
Normal file
68
src/security/protection/security_fix.rs
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/security/protection/sudoers.rs
Normal file
30
src/security/protection/sudoers.rs
Normal 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");
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue