use crate::logging::{get_prefix, log_error, log_info, log_success};
use crate::sdk::command::CommandRunner;
use anyhow::{Context, Result};
use regex::Regex;
use std::env;
use std::fs;
use std::path::{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 runner = CommandRunner::new(debug);
crate::data::athena::persist_nginx_log(
Some(domain),
"setup_started",
true,
"Starting nginx setup",
Some(&format!("target_port={}", port)),
serde_json::json!({ "port": port }),
)
.await;
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);
let escaped_content = config_content.replace("'", "'\\''");
let write_cmd = format!("echo '{}' | sudo tee {}", escaped_content, available_path);
if debug {
debug!("Executing: {}", write_cmd);
}
run_command_checked(
&runner,
"sh",
&["-c", &write_cmd],
Some("run `xbp nginx setup --help` to verify setup arguments."),
)
.await
.context("Failed to write NGINX config")?;
crate::data::athena::persist_nginx_config_snapshot(
domain,
Path::new(&available_path),
&config_content,
&[port],
&[80, 443],
"setup_nginx",
)
.await;
let link_cmd = format!("sudo ln -sf {} {}", available_path, enabled_path);
if debug {
debug!("Executing: {}", link_cmd);
}
run_command_checked(
&runner,
"sh",
&["-c", &link_cmd],
Some("ensure `/etc/nginx/sites-enabled` exists and retry."),
)
.await
.context("Failed to link NGINX config")?;
let test_cmd = "sudo nginx -t";
if debug {
debug!("Executing: {}", test_cmd);
}
if let Err(err) = run_command_checked(
&runner,
"sh",
&["-c", test_cmd],
Some("inspect the generated config with `xbp nginx show --domain <domain>`."),
)
.await
{
let _ = log_error(
"nginx",
"Nginx configuration test failed",
Some(&err.to_string()),
)
.await;
return Err(err);
}
let _ = log_info("nginx", "Running certbot...", None).await;
let reload_cmd = "sudo systemctl reload nginx";
if debug {
debug!("Executing: {}", reload_cmd);
}
run_command_checked(
&runner,
"sh",
&["-c", reload_cmd],
Some("verify `systemctl` availability or reload NGINX manually."),
)
.await
.context("Failed to reload NGINX")?;
let _ = log_success("nginx", &format!("Successfully setup {}", domain), None).await;
crate::data::athena::persist_nginx_log(
Some(domain),
"setup_completed",
true,
"Completed nginx setup",
None,
serde_json::json!({ "port": port }),
)
.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 {
crate::data::athena::persist_nginx_log(
Some(&site.domain),
"list",
true,
"Listed nginx site",
None,
serde_json::json!({
"config_path": site.path.display().to_string(),
"upstream_ports": site.upstream_ports,
"listen_ports": site.listen_ports
}),
)
.await;
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 runner = CommandRunner::new(debug);
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: ®ex::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);
run_command_checked(
&runner,
"sh",
&["-c", &write_cmd],
Some("run `xbp nginx update --help` and verify write permissions."),
)
.await
.context("Failed to update NGINX config")?;
let actor = env::var("USER").ok();
crate::data::athena::persist_nginx_edit_audit_log(
Some(domain),
Some(Path::new(&available_path)),
actor.as_deref(),
"update",
Some(&content),
Some(new_content.as_ref()),
serde_json::json!({ "new_port": port }),
)
.await;
crate::data::athena::persist_nginx_config_snapshot(
domain,
Path::new(&available_path),
new_content.as_ref(),
&[port],
&[80, 443],
"update_nginx",
)
.await;
let _ = log_success(
"nginx",
&format!("Updated {} to port {}", domain, port),
None,
)
.await;
crate::data::athena::persist_nginx_log(
Some(domain),
"update",
true,
"Updated nginx upstream port",
Some(&format!("new_port={}", port)),
serde_json::json!({ "new_port": port }),
)
.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() {
if include_content {
debug!("Skipping missing NGINX directory {}", dir.display());
}
continue;
}
for entry in fs::read_dir(&dir)
.with_context(|| format!("Failed to read NGINX directory {}", dir.display()))?
{
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 = match fs::read_to_string(&path) {
Ok(content) => content,
Err(err) => {
warn!("Skipping unreadable NGINX file {}: {}", path.display(), err);
continue;
}
};
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.\nhelp: run `xbp nginx list` to inspect available domains."
));
}
for site in sites {
crate::data::athena::persist_nginx_log(
Some(&site.domain),
"show",
true,
"Displayed nginx site config",
None,
serde_json::json!({
"config_path": site.path.display().to_string(),
"has_content": site.content.is_some()
}),
)
.await;
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 {}.\nhelp: run `xbp nginx list` to inspect available domains.",
domain
)
})?;
println!("Opening {}", site.path.display());
let actor = env::var("USER").ok();
crate::data::athena::persist_nginx_edit_audit_log(
Some(domain),
Some(&site.path),
actor.as_deref(),
"edit_open",
None,
None,
serde_json::json!({ "config_path": site.path.display().to_string() }),
)
.await;
crate::utils::open_path_with_editor(&site.path)
.map_err(|e| anyhow::anyhow!("Failed to open editor: {}", e))?;
Ok(())
}
fn parse_nginx_site(path: &Path, 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: &std::ffi::OsStr| 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.to_path_buf(),
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"),
]
}
async fn run_command_checked(
runner: &CommandRunner,
command: &str,
args: &[&str],
hint: Option<&str>,
) -> Result<std::process::Output> {
let outcome = runner.run_checked(command, args, hint).await?;
Ok(outcome.output)
}
#[cfg(test)]
mod tests {
use super::{join_ports, parse_nginx_site};
use std::path::PathBuf;
#[test]
fn parse_nginx_site_extracts_domain_and_ports() {
let content = r#"
server {
listen 80;
server_name demo.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
}
}
"#;
let path = PathBuf::from("/etc/nginx/sites-enabled/demo.example.com");
let site = parse_nginx_site(&path, content, false);
assert_eq!(site.domain, "demo.example.com");
assert_eq!(site.upstream_ports, vec![3000]);
assert_eq!(site.listen_ports, vec![80]);
assert!(site.content.is_none());
}
#[test]
fn join_ports_formats_multiple_ports() {
assert_eq!(join_ports(&[80, 443]), "80,443");
assert_eq!(join_ports(&[]), "-");
}
}