xbp 10.5.0

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
use crate::logging::{get_prefix, log_error, log_info, log_success};
use anyhow::{Context, Result};
use regex::Regex;
use std::fs;
use std::path::PathBuf;
use tokio::process::Command;
use tracing::{debug, info, warn};

pub async fn setup_nginx(domain: &str, port: u16, debug: bool) -> Result<()> {
    let _ = log_info(
        "nginx",
        &format!("Setting up Nginx for {} on port {}", domain, port),
        None,
    )
    .await;

    let config_content = format!(
        "server {{
    server_name {};

    location / {{
        proxy_pass http://127.0.0.1:{};
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }}
}}",
        domain, port
    );

    let available_path = format!("/etc/nginx/sites-available/{}", domain);
    let enabled_path = format!("/etc/nginx/sites-enabled/{}", domain);

    // Write config file (requires sudo, so we write to a temp file and move it)
    let escaped_content = config_content.replace("'", "'\\''");

    let write_cmd = format!("echo '{}' | sudo tee {}", escaped_content, available_path);

    if debug {
        debug!("Executing: {}", write_cmd);
    }

    let output = Command::new("sh")
        .arg("-c")
        .arg(&write_cmd)
        .output()
        .await
        .context("Failed to write Nginx config")?;

    if !output.status.success() {
        return Err(anyhow::anyhow!(
            "Failed to write config: {}",
            String::from_utf8_lossy(&output.stderr)
        ));
    }

    // Link to sites-enabled
    let link_cmd = format!("sudo ln -sf {} {}", available_path, enabled_path);
    if debug {
        debug!("Executing: {}", link_cmd);
    }
    let output = Command::new("sh").arg("-c").arg(&link_cmd).output().await?;
    if !output.status.success() {
        return Err(anyhow::anyhow!(
            "Failed to link config: {}",
            String::from_utf8_lossy(&output.stderr)
        ));
    }

    // Test configuration
    let test_cmd = "sudo nginx -t";
    if debug {
        debug!("Executing: {}", test_cmd);
    }
    let output = Command::new("sh").arg("-c").arg(test_cmd).output().await?;
    if !output.status.success() {
        let _ = log_error(
            "nginx",
            "Nginx configuration test failed",
            Some(&String::from_utf8_lossy(&output.stderr)),
        )
        .await;
        return Err(anyhow::anyhow!("Nginx config test failed"));
    }

    // Run certbot
    let _ = log_info("nginx", "Running certbot...", None).await;
    // Reload Nginx
    let reload_cmd = "sudo systemctl reload nginx";
    if debug {
        debug!("Executing: {}", reload_cmd);
    }
    let output = Command::new("sh")
        .arg("-c")
        .arg(reload_cmd)
        .output()
        .await?;
    if !output.status.success() {
        return Err(anyhow::anyhow!(
            "Failed to reload Nginx: {}",
            String::from_utf8_lossy(&output.stderr)
        ));
    }

    let _ = log_success("nginx", &format!("Successfully setup {}", domain), None).await;
    Ok(())
}

pub async fn list_nginx(debug: bool) -> Result<()> {
    let sites_enabled = PathBuf::from("/etc/nginx/sites-enabled");
    if !sites_enabled.exists() {
        warn!("/etc/nginx/sites-enabled does not exist");
        return Ok(());
    }

    let mut entries = fs::read_dir(sites_enabled)?;
    // Relaxed regex to handle whitespace and potential variations
    let server_name_regex = Regex::new(r"server_name\s+([^;]+);").unwrap();
    // Relaxed regex for proxy_pass to handle different localhost representations and spacing
    let proxy_pass_regex =
        Regex::new(r"proxy_pass\s+http://(?:127\.0\.0\.1|localhost):(\d+)(?:;|/)").unwrap();

    let prefix = get_prefix();
    info!("{}{:<30} {:<10}", prefix, "DOMAIN", "PORT");
    info!("{}{:-<40}", prefix, "");

    while let Some(entry) = entries.next() {
        let entry = entry?;
        let path = entry.path();
        if path.is_file() || path.is_symlink() {
            let content = fs::read_to_string(&path).unwrap_or_default();

            if debug {
                debug!("Analyzing config file: {}", path.display());
                debug!("Content preview: {:.100}...", content);
            }

            let domain = server_name_regex
                .captures(&content)
                .map(|c| c.get(1).map_or("", |m| m.as_str().trim()))
                .unwrap_or("unknown");

            let port = proxy_pass_regex
                .captures(&content)
                .map(|c| c.get(1).map_or("", |m| m.as_str()))
                .unwrap_or("-");

            if debug && (domain == "unknown" || port == "-") {
                debug!(
                    "Failed to detect domain or port in {}. Domain: {}, Port: {}",
                    path.display(),
                    domain,
                    port
                );
                debug!("Full content:\n{}", content);
            }

            if !domain.is_empty() && domain != "_" {
                info!("{}{:<30} {:<10}", prefix, domain, port);
            }
        }
    }
    Ok(())
}

pub async fn update_nginx(domain: &str, port: u16, debug: bool) -> Result<()> {
    let available_path = format!("/etc/nginx/sites-available/{}", domain);

    let content = match fs::read_to_string(&available_path) {
        Ok(c) => c,
        Err(_) => {
            let output = Command::new("sudo")
                .arg("cat")
                .arg(&available_path)
                .output()
                .await?;
            if !output.status.success() {
                return Err(anyhow::anyhow!("Config for {} not found", domain));
            }
            String::from_utf8_lossy(&output.stdout).to_string()
        }
    };

    if debug {
        debug!("Original Nginx Config for {}:\n{}", domain, content);
    }

    let proxy_pass_regex =
        Regex::new(r"(proxy_pass\s+http://(?:127\.0\.0\.1|localhost):)(\d+)(.*)").unwrap();

    if !proxy_pass_regex.is_match(&content) {
        if debug {
            debug!("Regex failed to match proxy_pass in content.");
        }
        return Err(anyhow::anyhow!(
            "Could not find proxy_pass directive in config"
        ));
    }

    let new_content = proxy_pass_regex.replace_all(&content, |caps: &regex::Captures| {
        format!("{}{}{}", &caps[1], port, &caps[3])
    });

    if debug {
        debug!("New Nginx Config Preview for {}:\n{}", domain, new_content);
    }

    let escaped_content = new_content.replace("'", "'\\''");
    let write_cmd = format!("echo '{}' | sudo tee {}", escaped_content, available_path);

    let output = Command::new("sh")
        .arg("-c")
        .arg(&write_cmd)
        .output()
        .await?;
    if !output.status.success() {
        return Err(anyhow::anyhow!(
            "Failed to update config: {}",
            String::from_utf8_lossy(&output.stderr)
        ));
    }

    let _ = log_success(
        "nginx",
        &format!("Updated {} to port {}", domain, port),
        None,
    )
    .await;

    let output = Command::new("sudo")
        .arg("systemctl")
        .arg("reload")
        .arg("nginx")
        .output()
        .await?;
    if !output.status.success() {
        warn!(
            "Failed to reload nginx: {}",
            String::from_utf8_lossy(&output.stderr)
        );
    } else {
        let _ = log_info("nginx", "Nginx reloaded", None).await;
    }

    Ok(())
}