use crate::logging::{get_prefix, log_error, log_info, log_success, log_warn};
use crate::sdk::command::{CommandOutcome, CommandRunner};
use anyhow::{Context, Result};
use regex::Regex;
use std::env;
use std::ffi::OsStr;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::ExitStatus;
use std::time::{SystemTime, UNIX_EPOCH};
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>,
}
const WEBROOT_PATH: &str = "/var/www/certbot";
const LETSENCRYPT_DIR: &str = "/etc/letsencrypt";
const LETSENCRYPT_OPTIONS_FILE: &str = "/etc/letsencrypt/options-ssl-nginx.conf";
const LETSENCRYPT_DHPARAMS_FILE: &str = "/etc/letsencrypt/ssl-dhparams.pem";
const LETSENCRYPT_OPTIONS_CONTENT: &str = r#"ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 1.0.0.1 8.8.8.8 8.8.4.4 valid=300s;
resolver_timeout 5s;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Frame-Options DENY always;
add_header X-Content-Type-Options nosniff always;
"#;
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum DnsMode {
Manual,
Plugin,
}
impl From<crate::cli::commands::NginxDnsMode> for DnsMode {
fn from(value: crate::cli::commands::NginxDnsMode) -> Self {
match value {
crate::cli::commands::NginxDnsMode::Manual => DnsMode::Manual,
crate::cli::commands::NginxDnsMode::Plugin => DnsMode::Plugin,
}
}
}
#[derive(Debug, Clone)]
pub struct NginxSetupOptions {
pub domain: String,
pub port: u16,
pub email: String,
pub dns_mode: DnsMode,
pub dns_plugin: Option<String>,
pub dns_creds: Option<PathBuf>,
pub include_base: bool,
}
impl NginxSetupOptions {
fn validate(&self) -> Result<()> {
validate_domain(&self.domain)?;
if !is_valid_email(&self.email) {
anyhow::bail!("Invalid --email value: {}", self.email);
}
if let Some(plugin) = self.dns_plugin.as_deref() {
if !is_valid_dns_plugin_name(plugin) {
anyhow::bail!(
"Invalid --dns-plugin value: {} (expected lowercase letters, numbers, or dashes)",
plugin
);
}
}
Ok(())
}
}
#[derive(Debug, Clone)]
struct NginxSetupPaths {
nginx_conf: PathBuf,
nginx_link: PathBuf,
webroot: PathBuf,
letsencrypt_dir: PathBuf,
letsencrypt_options: PathBuf,
letsencrypt_dhparams: PathBuf,
}
impl NginxSetupPaths {
fn from_domain(domain: &str) -> Self {
let site_name = site_filename(domain);
Self {
nginx_conf: PathBuf::from("/etc/nginx/sites-available").join(&site_name),
nginx_link: PathBuf::from("/etc/nginx/sites-enabled").join(&site_name),
webroot: PathBuf::from(WEBROOT_PATH),
letsencrypt_dir: PathBuf::from(LETSENCRYPT_DIR),
letsencrypt_options: PathBuf::from(LETSENCRYPT_OPTIONS_FILE),
letsencrypt_dhparams: PathBuf::from(LETSENCRYPT_DHPARAMS_FILE),
}
}
fn cert_live_dir(&self, domain: &str) -> PathBuf {
PathBuf::from(LETSENCRYPT_DIR)
.join("live")
.join(cert_primary_name(domain))
}
}
#[derive(Clone, Debug)]
struct PrivilegedCommandRunner {
runner: CommandRunner,
use_sudo: bool,
}
impl PrivilegedCommandRunner {
async fn detect(debug: bool) -> Result<Self> {
let runner = CommandRunner::new(debug);
let is_root = match runner.run("id", &["-u"]).await {
Ok(outcome) => outcome.stdout.trim() == "0",
Err(_) => false,
};
let sudo_available = match runner
.run("sh", &["-c", "command -v sudo >/dev/null 2>&1"])
.await
{
Ok(outcome) => outcome.is_success(),
Err(_) => false,
};
Ok(Self {
runner,
use_sudo: !is_root && sudo_available,
})
}
fn base_runner(&self) -> &CommandRunner {
&self.runner
}
async fn run(&self, program: &str, args: &[&str]) -> Result<CommandOutcome> {
let (program_name, args_owned) = self.with_privilege(program, args);
let arg_refs = args_owned.iter().map(String::as_str).collect::<Vec<_>>();
self.runner.run(&program_name, &arg_refs).await
}
async fn run_checked(
&self,
program: &str,
args: &[&str],
hint: Option<&str>,
) -> Result<CommandOutcome> {
let (program_name, args_owned) = self.with_privilege(program, args);
let arg_refs = args_owned.iter().map(String::as_str).collect::<Vec<_>>();
self.runner
.run_checked(&program_name, &arg_refs, hint)
.await
}
async fn run_with_stdio(&self, program: &str, args: &[&str]) -> Result<ExitStatus> {
let (program_name, args_owned) = self.with_privilege(program, args);
let arg_refs = args_owned.iter().map(String::as_str).collect::<Vec<_>>();
self.runner.run_with_stdio(&program_name, &arg_refs).await
}
fn with_privilege(&self, program: &str, args: &[&str]) -> (String, Vec<String>) {
if self.use_sudo {
let mut values = Vec::with_capacity(args.len() + 1);
values.push(program.to_string());
values.extend(args.iter().map(|value| (*value).to_string()));
("sudo".to_string(), values)
} else {
(
program.to_string(),
args.iter().map(|value| (*value).to_string()).collect(),
)
}
}
}
pub async fn setup_nginx(options: &NginxSetupOptions, debug: bool) -> Result<()> {
if !cfg!(target_os = "linux") {
anyhow::bail!("`xbp nginx setup` is currently supported on Linux hosts only");
}
options.validate()?;
let runner = PrivilegedCommandRunner::detect(debug).await?;
let paths = NginxSetupPaths::from_domain(&options.domain);
crate::data::athena::persist_nginx_log(
Some(&options.domain),
"setup_started",
true,
"Starting nginx setup",
Some(&format!(
"target_port={} dns_mode={:?} wildcard={} include_base={}",
options.port,
options.dns_mode,
is_wildcard_domain(&options.domain),
options.include_base
)),
serde_json::json!({
"port": options.port,
"email": options.email,
"dns_mode": format!("{:?}", options.dns_mode).to_lowercase(),
"dns_plugin": options.dns_plugin,
"dns_creds": options.dns_creds.as_ref().map(|path| path.display().to_string()),
"include_base": options.include_base,
}),
)
.await;
let setup_result = setup_nginx_inner(options, &paths, &runner).await;
if let Err(err) = &setup_result {
let _ = log_error("nginx", "Nginx setup failed", Some(&err.to_string())).await;
crate::data::athena::persist_nginx_log(
Some(&options.domain),
"setup_failed",
false,
"Nginx setup failed",
Some(&err.to_string()),
serde_json::json!({ "port": options.port }),
)
.await;
}
setup_result
}
async fn setup_nginx_inner(
options: &NginxSetupOptions,
paths: &NginxSetupPaths,
runner: &PrivilegedCommandRunner,
) -> Result<()> {
let _ = log_info(
"nginx",
&format!(
"Setting up Nginx for {} on port {}",
options.domain, options.port
),
None,
)
.await;
let _ = log_info("nginx", "Checking dependencies", None).await;
ensure_command(runner, "nginx").await?;
ensure_command(runner, "certbot").await?;
ensure_letsencrypt_files(runner, paths).await?;
cleanup_site(runner, paths).await?;
if is_wildcard_domain(&options.domain) {
let _ = log_warn(
"nginx",
&format!("Wildcard domain detected: {}", options.domain),
Some("HTTP-01 cannot issue wildcard certs; DNS-01 is required."),
)
.await;
if cert_exists(paths, &options.domain) {
let _ = log_success(
"nginx",
"Existing certificate detected; skipping issuance",
None,
)
.await;
} else {
match options.dns_mode {
DnsMode::Manual => issue_dns_manual_cert(runner, options).await?,
DnsMode::Plugin => issue_dns_plugin_cert(runner, options).await?,
}
}
let final_config = render_final_https_config(options, paths);
write_site_config(runner, paths, &final_config).await?;
persist_setup_snapshot(options, paths, &final_config, "setup_nginx_final").await;
validate_and_restart_nginx(runner).await?;
} else {
install_apt_package(runner, "python3-certbot-nginx", false)
.await
.ok();
let http_config = render_http_acme_config(options, paths);
write_site_config(runner, paths, &http_config).await?;
persist_setup_snapshot(options, paths, &http_config, "setup_nginx_http_acme").await;
nginx_start_or_reload(runner).await?;
if cert_exists(paths, &options.domain) {
let _ = log_success(
"nginx",
"Existing certificate detected; skipping issuance",
None,
)
.await;
} else if let Err(err) = issue_http_cert(runner, options, paths).await {
let _ = log_warn(
"nginx",
"Webroot certbot flow failed, falling back to nginx installer",
Some(&err.to_string()),
)
.await;
issue_http_cert_with_nginx_fallback(runner, options).await?;
}
let final_config = render_final_https_config(options, paths);
write_site_config(runner, paths, &final_config).await?;
persist_setup_snapshot(options, paths, &final_config, "setup_nginx_final").await;
validate_and_restart_nginx(runner).await?;
}
let _ = log_success(
"nginx",
&format!("HTTPS enabled for {}", options.domain),
None,
)
.await;
crate::data::athena::persist_nginx_log(
Some(&options.domain),
"setup_completed",
true,
"Completed nginx setup",
Some(&format!("target_port={}", options.port)),
serde_json::json!({ "port": options.port }),
)
.await;
Ok(())
}
async fn ensure_command(runner: &PrivilegedCommandRunner, command: &str) -> Result<()> {
if command_exists(runner.base_runner(), command).await {
debug!("{} already installed", command);
return Ok(());
}
let _ = log_warn("nginx", &format!("{} missing; installing", command), None).await;
install_apt_package(runner, command, true)
.await
.with_context(|| format!("Failed to install required package `{}`", command))?;
Ok(())
}
async fn command_exists(runner: &CommandRunner, command: &str) -> bool {
match runner.run("which", &[command]).await {
Ok(outcome) => outcome.is_success(),
Err(_) => false,
}
}
async fn install_apt_package(
runner: &PrivilegedCommandRunner,
package: &str,
required: bool,
) -> Result<()> {
let update_result = runner
.run(
"env",
&["DEBIAN_FRONTEND=noninteractive", "apt-get", "update", "-y"],
)
.await;
if let Err(err) = update_result {
if required {
return Err(err).context("Failed to run apt-get update");
}
warn!(
"Skipping optional package install; apt-get update failed: {}",
err
);
return Ok(());
}
let install_result = runner
.run(
"env",
&[
"DEBIAN_FRONTEND=noninteractive",
"apt-get",
"install",
"-y",
package,
],
)
.await;
match install_result {
Ok(outcome) if outcome.is_success() => {
debug!("Installed package {}", package);
Ok(())
}
Ok(outcome) => {
let message = format!(
"Failed to install apt package {}: {}",
package,
outcome.stderr.trim()
);
if required {
anyhow::bail!(message);
}
warn!("{}", message);
Ok(())
}
Err(err) => {
if required {
Err(err).with_context(|| format!("Failed to install apt package {}", package))
} else {
warn!("Failed to install optional package {}: {}", package, err);
Ok(())
}
}
}
}
async fn ensure_letsencrypt_files(
runner: &PrivilegedCommandRunner,
paths: &NginxSetupPaths,
) -> Result<()> {
let le_dir = paths.letsencrypt_dir.to_string_lossy().into_owned();
runner
.run_checked(
"mkdir",
&["-p", le_dir.as_str()],
Some("create /etc/letsencrypt before writing SSL assets"),
)
.await
.context("Failed to ensure letsencrypt directory")?;
if !paths.letsencrypt_options.exists() {
let _ = log_warn(
"nginx",
"Creating missing Certbot TLS options file",
Some(LETSENCRYPT_OPTIONS_FILE),
)
.await;
write_text_file_privileged(
runner,
&paths.letsencrypt_options,
LETSENCRYPT_OPTIONS_CONTENT,
)
.await
.context("Failed to write options-ssl-nginx.conf")?;
}
if !paths.letsencrypt_dhparams.exists() {
let _ = log_warn(
"nginx",
"Generating DH params (one-time operation)",
Some(LETSENCRYPT_DHPARAMS_FILE),
)
.await;
let dh_path = paths.letsencrypt_dhparams.to_string_lossy().into_owned();
runner
.run_checked(
"openssl",
&["dhparam", "-out", dh_path.as_str(), "2048"],
Some("openssl is required to generate /etc/letsencrypt/ssl-dhparams.pem"),
)
.await
.context("Failed to generate DH params")?;
runner
.run_checked(
"chmod",
&["644", dh_path.as_str()],
Some("set permissions on ssl-dhparams.pem"),
)
.await
.context("Failed to set permissions on DH params")?;
let _ = log_success("nginx", "DH params created", None).await;
}
Ok(())
}
async fn cleanup_site(runner: &PrivilegedCommandRunner, paths: &NginxSetupPaths) -> Result<()> {
let _ = log_warn("nginx", "Removing stale site config before setup", None).await;
let conf = paths.nginx_conf.to_string_lossy().into_owned();
let link = paths.nginx_link.to_string_lossy().into_owned();
runner
.run_checked(
"rm",
&["-f", conf.as_str(), link.as_str()],
Some("remove broken nginx site files"),
)
.await
.context("Failed to clean previous nginx site files")?;
Ok(())
}
async fn write_site_config(
runner: &PrivilegedCommandRunner,
paths: &NginxSetupPaths,
config_content: &str,
) -> Result<()> {
write_text_file_privileged(runner, &paths.nginx_conf, config_content)
.await
.context("Failed to write NGINX site configuration")?;
let conf = paths.nginx_conf.to_string_lossy().into_owned();
let link = paths.nginx_link.to_string_lossy().into_owned();
runner
.run_checked(
"ln",
&["-sf", conf.as_str(), link.as_str()],
Some("ensure /etc/nginx/sites-enabled exists"),
)
.await
.context("Failed to link NGINX site into sites-enabled")?;
Ok(())
}
async fn write_text_file_privileged(
runner: &PrivilegedCommandRunner,
target_path: &Path,
content: &str,
) -> Result<()> {
let temp_path = temporary_file_path("nginx");
fs::write(&temp_path, content)
.with_context(|| format!("Failed to write temporary file {}", temp_path.display()))?;
let result = async {
if let Some(parent) = target_path.parent() {
let parent_string = parent.to_string_lossy().into_owned();
runner
.run_checked(
"mkdir",
&["-p", parent_string.as_str()],
Some("create target directory"),
)
.await?;
}
let temp_string = temp_path.to_string_lossy().into_owned();
let target_string = target_path.to_string_lossy().into_owned();
runner
.run_checked(
"cp",
&[temp_string.as_str(), target_string.as_str()],
Some("copy temporary nginx config into final destination"),
)
.await?;
runner
.run_checked(
"chmod",
&["644", target_string.as_str()],
Some("set file permissions to 644"),
)
.await?;
Ok::<(), anyhow::Error>(())
}
.await;
let _ = fs::remove_file(&temp_path);
result
}
fn temporary_file_path(label: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0);
env::temp_dir().join(format!(
"xbp-{}-{}-{}.tmp",
label,
std::process::id(),
nonce
))
}
async fn nginx_start_or_reload(runner: &PrivilegedCommandRunner) -> Result<()> {
let status = runner
.run("systemctl", &["is-active", "--quiet", "nginx"])
.await
.context("Failed to check nginx service status")?;
if status.is_success() {
debug!("Testing nginx configuration before reload");
if let Err(err) = runner
.run_checked(
"nginx",
&["-t"],
Some("run `xbp nginx show` to inspect the generated config"),
)
.await
{
print_nginx_journal_tail(runner).await;
return Err(err).context("nginx config invalid");
}
runner
.run_checked(
"systemctl",
&["reload", "nginx"],
Some("reload nginx after config changes"),
)
.await
.context("Failed to reload nginx")?;
let _ = log_success("nginx", "nginx reloaded", None).await;
} else {
let _ = log_warn("nginx", "nginx inactive; starting service", None).await;
if let Err(err) = runner
.run_checked(
"systemctl",
&["start", "nginx"],
Some("start nginx service"),
)
.await
{
print_nginx_journal_tail(runner).await;
return Err(err).context("failed to start nginx");
}
let _ = log_success("nginx", "nginx started", None).await;
}
Ok(())
}
async fn validate_and_restart_nginx(runner: &PrivilegedCommandRunner) -> Result<()> {
let _ = log_info("nginx", "Validating nginx configuration", None).await;
if let Err(err) = runner
.run_checked(
"nginx",
&["-t"],
Some("run `xbp nginx show` to inspect rendered site files"),
)
.await
{
print_nginx_journal_tail(runner).await;
return Err(err).context("nginx test failed");
}
runner
.run_checked(
"systemctl",
&["restart", "nginx"],
Some("restart nginx after final config is written"),
)
.await
.context("Failed to restart nginx")?;
Ok(())
}
async fn print_nginx_journal_tail(runner: &PrivilegedCommandRunner) {
match runner
.run("journalctl", &["-u", "nginx", "-n", "80", "--no-pager"])
.await
{
Ok(outcome) if outcome.is_success() => {
if !outcome.stdout.trim().is_empty() {
eprintln!("{}", outcome.stdout);
}
}
Ok(outcome) => {
debug!(
"Unable to read nginx journal tail (status={}): {}",
outcome.output.status, outcome.stderr
);
}
Err(err) => {
debug!("Unable to read nginx journal tail: {}", err);
}
}
}
fn cert_exists(paths: &NginxSetupPaths, domain: &str) -> bool {
let live_dir = paths.cert_live_dir(domain);
live_dir.join("fullchain.pem").exists() && live_dir.join("privkey.pem").exists()
}
async fn issue_http_cert(
runner: &PrivilegedCommandRunner,
options: &NginxSetupOptions,
paths: &NginxSetupPaths,
) -> Result<()> {
let _ = log_info(
"nginx",
&format!("Obtaining certificate for {} via HTTP-01", options.domain),
None,
)
.await;
let webroot = paths.webroot.to_string_lossy().into_owned();
runner
.run_checked(
"certbot",
&[
"certonly",
"--webroot",
"-w",
webroot.as_str(),
"-d",
options.domain.as_str(),
"--agree-tos",
"--non-interactive",
"-m",
options.email.as_str(),
],
Some("verify DNS A/AAAA records and that port 80 is reachable"),
)
.await
.context("HTTP-01 certificate issuance failed")?;
Ok(())
}
async fn issue_http_cert_with_nginx_fallback(
runner: &PrivilegedCommandRunner,
options: &NginxSetupOptions,
) -> Result<()> {
runner
.run_checked(
"certbot",
&[
"--nginx",
"-d",
options.domain.as_str(),
"--agree-tos",
"--non-interactive",
"-m",
options.email.as_str(),
],
Some("validate nginx syntax and DNS records before retrying certbot"),
)
.await
.context("Certbot nginx installer fallback failed")?;
Ok(())
}
async fn issue_dns_manual_cert(
runner: &PrivilegedCommandRunner,
options: &NginxSetupOptions,
) -> Result<()> {
let domains = build_certificate_domains(options);
let domain_preview = domains
.iter()
.map(|domain| format!("-d {}", domain))
.collect::<Vec<_>>()
.join(" ");
let _ = log_info(
"nginx",
&format!(
"Obtaining certificate for {} via manual DNS-01",
domain_preview
),
None,
)
.await;
let _ = log_warn(
"nginx",
"Create the requested TXT records when Certbot prompts",
None,
)
.await;
let mut args = vec![
"certonly".to_string(),
"--manual".to_string(),
"--preferred-challenges".to_string(),
"dns".to_string(),
"--manual-public-ip-logging-ok".to_string(),
"--agree-tos".to_string(),
"-m".to_string(),
options.email.clone(),
];
for domain in domains {
args.push("-d".to_string());
args.push(domain);
}
let arg_refs = args.iter().map(String::as_str).collect::<Vec<_>>();
let status = runner
.run_with_stdio("certbot", &arg_refs)
.await
.context("Failed to run certbot manual DNS-01 flow")?;
if !status.success() {
anyhow::bail!("certbot manual DNS-01 flow failed with status {}", status);
}
Ok(())
}
async fn issue_dns_plugin_cert(
runner: &PrivilegedCommandRunner,
options: &NginxSetupOptions,
) -> Result<()> {
let dns_plugin = options
.dns_plugin
.as_deref()
.context("--dns-plugin is required for --dns-mode plugin")?;
if !is_valid_dns_plugin_name(dns_plugin) {
anyhow::bail!(
"Invalid --dns-plugin value: {} (expected lowercase letters, numbers, or dashes)",
dns_plugin
);
}
let dns_creds = options
.dns_creds
.as_ref()
.context("--dns-creds is required for --dns-mode plugin")?;
if !dns_creds.is_file() {
anyhow::bail!("DNS creds file not found: {}", dns_creds.display());
}
let package_name = format!("python3-certbot-dns-{}", dns_plugin);
install_apt_package(runner, &package_name, true)
.await
.with_context(|| format!("Failed to install certbot plugin package {}", package_name))?;
let domains = build_certificate_domains(options);
let domain_preview = domains
.iter()
.map(|domain| format!("-d {}", domain))
.collect::<Vec<_>>()
.join(" ");
let _ = log_info(
"nginx",
&format!(
"Obtaining certificate for {} via DNS plugin: {}",
domain_preview, dns_plugin
),
None,
)
.await;
let plugin_flag = format!("--dns-{}", dns_plugin);
let creds_flag = format!("--dns-{}-credentials", dns_plugin);
let creds_path = dns_creds.to_string_lossy().into_owned();
let mut args = vec![
"certonly".to_string(),
plugin_flag,
creds_flag,
creds_path,
"--agree-tos".to_string(),
"--non-interactive".to_string(),
"-m".to_string(),
options.email.clone(),
];
for domain in domains {
args.push("-d".to_string());
args.push(domain);
}
let arg_refs = args.iter().map(String::as_str).collect::<Vec<_>>();
runner
.run_checked(
"certbot",
&arg_refs,
Some("verify DNS plugin credentials file permissions and contents"),
)
.await
.context("DNS plugin certificate issuance failed")?;
Ok(())
}
fn build_certificate_domains(options: &NginxSetupOptions) -> Vec<String> {
let mut domains = vec![options.domain.clone()];
if is_wildcard_domain(&options.domain) && options.include_base {
domains.push(base_domain(&options.domain));
}
domains
}
fn render_http_acme_config(options: &NginxSetupOptions, paths: &NginxSetupPaths) -> String {
let webroot = paths.webroot.to_string_lossy();
format!(
"server {{\n listen 80;\n server_name {};\n\n location /.well-known/acme-challenge/ {{\n root {};\n }}\n\n location / {{\n proxy_pass http://127.0.0.1:{};\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n }}\n}}\n",
options.domain, webroot, options.port
)
}
fn render_final_https_config(options: &NginxSetupOptions, paths: &NginxSetupPaths) -> String {
let live_dir = paths.cert_live_dir(&options.domain);
let live_dir = live_dir.to_string_lossy();
format!(
"server {{\n listen 80;\n server_name {};\n return 301 https://$host$request_uri;\n}}\n\nserver {{\n listen 443 ssl http2;\n server_name {};\n\n ssl_certificate {}/fullchain.pem;\n ssl_certificate_key {}/privkey.pem;\n include {};\n ssl_dhparam {};\n\n location / {{\n proxy_pass http://127.0.0.1:{};\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n proxy_buffers 8 32k;\n proxy_buffer_size 64k;\n client_max_body_size 500M;\n }}\n}}\n",
options.domain,
options.domain,
live_dir,
live_dir,
LETSENCRYPT_OPTIONS_FILE,
LETSENCRYPT_DHPARAMS_FILE,
options.port
)
}
async fn persist_setup_snapshot(
options: &NginxSetupOptions,
paths: &NginxSetupPaths,
content: &str,
action: &str,
) {
crate::data::athena::persist_nginx_config_snapshot(
&options.domain,
&paths.nginx_conf,
content,
&[options.port],
&[80, 443],
action,
)
.await;
}
fn validate_domain(domain: &str) -> Result<()> {
if domain.trim().is_empty() {
anyhow::bail!("Domain cannot be empty");
}
if domain.contains('/') || domain.contains('\\') || domain.contains("..") {
anyhow::bail!("Invalid domain value: {}", domain);
}
if domain.chars().any(char::is_whitespace) {
anyhow::bail!("Domain cannot contain whitespace: {}", domain);
}
if is_wildcard_domain(domain) && base_domain(domain).trim().is_empty() {
anyhow::bail!("Invalid wildcard domain: {}", domain);
}
Ok(())
}
fn is_valid_email(email: &str) -> bool {
match Regex::new(r"^[^@]+@[^@]+\.[^@]+$") {
Ok(regex) => regex.is_match(email),
Err(_) => false,
}
}
fn is_valid_dns_plugin_name(plugin: &str) -> bool {
match Regex::new(r"^[a-z0-9][a-z0-9-]*$") {
Ok(regex) => regex.is_match(plugin),
Err(_) => false,
}
}
fn is_wildcard_domain(domain: &str) -> bool {
domain.starts_with('*')
}
fn base_domain(domain: &str) -> String {
if !is_wildcard_domain(domain) {
return domain.to_string();
}
if let Some((_, rest)) = domain.split_once('.') {
rest.to_string()
} else {
domain
.trim_start_matches('*')
.trim_start_matches('.')
.to_string()
}
}
fn cert_primary_name(domain: &str) -> String {
if is_wildcard_domain(domain) {
base_domain(domain)
} else {
domain.to_string()
}
}
fn site_filename(domain: &str) -> String {
domain.replace('*', "_wildcard_")
}
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: &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::{
base_domain, cert_primary_name, is_valid_dns_plugin_name, join_ports, parse_nginx_site,
render_http_acme_config, site_filename, DnsMode, NginxSetupOptions, NginxSetupPaths,
};
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(&[]), "-");
}
#[test]
fn wildcard_helpers_match_expected_cert_names() {
assert_eq!(site_filename("*.example.com"), "_wildcard_.example.com");
assert_eq!(base_domain("*.example.com"), "example.com");
assert_eq!(cert_primary_name("*.example.com"), "example.com");
assert_eq!(cert_primary_name("api.example.com"), "api.example.com");
}
#[test]
fn plugin_name_validation_accepts_expected_values() {
assert!(is_valid_dns_plugin_name("cloudflare"));
assert!(is_valid_dns_plugin_name("route53"));
assert!(!is_valid_dns_plugin_name("Cloudflare"));
assert!(!is_valid_dns_plugin_name("bad plugin"));
}
#[test]
fn setup_options_validate_email_shape() {
let options = NginxSetupOptions {
domain: "api.example.com".to_string(),
port: 3000,
email: "invalid-email".to_string(),
dns_mode: DnsMode::Manual,
dns_plugin: None,
dns_creds: None,
include_base: true,
};
assert!(options.validate().is_err());
}
#[test]
fn render_http_config_contains_expected_paths() {
let options = NginxSetupOptions {
domain: "api.example.com".to_string(),
port: 3000,
email: "ops@example.com".to_string(),
dns_mode: DnsMode::Manual,
dns_plugin: None,
dns_creds: None,
include_base: true,
};
let paths = NginxSetupPaths::from_domain(&options.domain);
let content = render_http_acme_config(&options, &paths);
assert!(content.contains("/.well-known/acme-challenge/"));
assert!(content.contains("proxy_pass http://127.0.0.1:3000"));
}
}