xbp 10.12.2

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};

#[derive(Debug, Clone)]
pub struct NginxSiteInfo {
    pub domain: String,
    pub path: PathBuf,
    pub upstream_ports: Vec<u16>,
    pub listen_ports: Vec<u16>,
    pub content: Option<String>,
}

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 = inspect_nginx_configs(false)?;
    if sites.is_empty() {
        warn!("No NGINX site configs found");
        return Ok(());
    }

    let prefix = get_prefix();
    info!(
        "{}{:<30} {:<14} {:<14} {}",
        prefix, "DOMAIN", "UPSTREAM", "LISTEN", "PATH"
    );
    info!("{}{:-<90}", prefix, "");

    for site in sites {
        if debug {
            debug!(
                "NGINX site: domain={}, upstream_ports={:?}, listen_ports={:?}, path={}",
                site.domain,
                site.upstream_ports,
                site.listen_ports,
                site.path.display()
            );
        }

        info!(
            "{}{:<30} {:<14} {:<14} {}",
            prefix,
            site.domain,
            join_ports(&site.upstream_ports),
            join_ports(&site.listen_ports),
            site.path.display()
        );
    }
    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(())
}

pub fn inspect_nginx_configs(include_content: bool) -> Result<Vec<NginxSiteInfo>> {
    let mut results = Vec::new();
    let mut seen = std::collections::HashSet::new();

    for dir in nginx_config_directories() {
        if !dir.exists() {
            continue;
        }

        for entry in fs::read_dir(&dir)? {
            let entry = entry?;
            let path = entry.path();
            if !path.is_file() && !path.is_symlink() {
                continue;
            }

            let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
            if !seen.insert(canonical) {
                continue;
            }

            let content = fs::read_to_string(&path).unwrap_or_default();
            let site = parse_nginx_site(&path, &content, include_content);
            results.push(site);
        }
    }

    results.sort_by(|a, b| a.domain.cmp(&b.domain));
    Ok(results)
}

pub async fn show_nginx(domain: Option<&str>, debug: bool) -> Result<()> {
    let mut sites = inspect_nginx_configs(true)?;
    if let Some(domain) = domain {
        sites.retain(|site| site.domain == domain);
    }

    if sites.is_empty() {
        return Err(anyhow::anyhow!("No matching NGINX config found"));
    }

    for site in sites {
        println!("Domain: {}", site.domain);
        println!("Path: {}", site.path.display());
        println!("Upstream Ports: {}", join_ports(&site.upstream_ports));
        println!("Listen Ports: {}", join_ports(&site.listen_ports));
        println!("{}", "-".repeat(80));
        if let Some(content) = site.content {
            println!("{}", content);
        }
        if debug {
            println!("{}", "=".repeat(80));
        }
    }

    Ok(())
}

pub async fn edit_nginx(domain: &str, _debug: bool) -> Result<()> {
    let site = inspect_nginx_configs(false)?
        .into_iter()
        .find(|site| site.domain == domain)
        .ok_or_else(|| anyhow::anyhow!("No matching NGINX config found for {}", domain))?;

    println!("Opening {}", site.path.display());
    crate::utils::open_path_with_editor(&site.path)
        .map_err(|e| anyhow::anyhow!("Failed to open editor: {}", e))?;
    Ok(())
}

fn parse_nginx_site(path: &PathBuf, content: &str, include_content: bool) -> NginxSiteInfo {
    let server_name_regex = Regex::new(r"server_name\s+([^;]+);").unwrap();
    let proxy_pass_regex =
        Regex::new(r"proxy_pass\s+http://(?:127\.0\.0\.1|localhost):(\d+)").unwrap();
    let listen_regex = Regex::new(r"listen\s+(\d+)").unwrap();

    let domain = server_name_regex
        .captures(content)
        .and_then(|captures| {
            captures
                .get(1)
                .map(|value| value.as_str().trim().to_string())
        })
        .unwrap_or_else(|| {
            path.file_name()
                .and_then(|name| name.to_str())
                .unwrap_or("unknown")
                .to_string()
        });

    let upstream_ports = proxy_pass_regex
        .captures_iter(content)
        .filter_map(|captures| {
            captures
                .get(1)
                .and_then(|value| value.as_str().parse::<u16>().ok())
        })
        .collect::<Vec<u16>>();
    let listen_ports = listen_regex
        .captures_iter(content)
        .filter_map(|captures| {
            captures
                .get(1)
                .and_then(|value| value.as_str().parse::<u16>().ok())
        })
        .collect::<Vec<u16>>();

    NginxSiteInfo {
        domain,
        path: path.clone(),
        upstream_ports,
        listen_ports,
        content: include_content.then(|| content.to_string()),
    }
}

fn join_ports(ports: &[u16]) -> String {
    if ports.is_empty() {
        "-".to_string()
    } else {
        ports
            .iter()
            .map(|port| port.to_string())
            .collect::<Vec<_>>()
            .join(",")
    }
}

fn nginx_config_directories() -> Vec<PathBuf> {
    vec![
        PathBuf::from("/etc/nginx/sites-enabled"),
        PathBuf::from("/etc/nginx/sites-available"),
    ]
}