Skip to main content

xbp_cli/sdk/
nginx.rs

1use crate::logging::{get_prefix, log_error, log_info, log_success, log_warn};
2use crate::sdk::command::{CommandOutcome, CommandRunner};
3use anyhow::{Context, Result};
4use regex::Regex;
5use std::env;
6use std::ffi::OsStr;
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::process::ExitStatus;
10use std::time::{SystemTime, UNIX_EPOCH};
11use tokio::process::Command;
12use tracing::{debug, info, warn};
13
14#[derive(Debug, Clone)]
15pub struct NginxSiteInfo {
16    pub domain: String,
17    pub path: PathBuf,
18    pub upstream_ports: Vec<u16>,
19    pub listen_ports: Vec<u16>,
20    pub content: Option<String>,
21}
22
23const WEBROOT_PATH: &str = "/var/www/certbot";
24const LETSENCRYPT_DIR: &str = "/etc/letsencrypt";
25const LETSENCRYPT_OPTIONS_FILE: &str = "/etc/letsencrypt/options-ssl-nginx.conf";
26const LETSENCRYPT_DHPARAMS_FILE: &str = "/etc/letsencrypt/ssl-dhparams.pem";
27
28const LETSENCRYPT_OPTIONS_CONTENT: &str = r#"ssl_protocols TLSv1.2 TLSv1.3;
29ssl_prefer_server_ciphers off;
30ssl_session_timeout 1d;
31ssl_session_cache shared:SSL:50m;
32ssl_session_tickets off;
33ssl_stapling on;
34ssl_stapling_verify on;
35resolver 1.1.1.1 1.0.0.1 8.8.8.8 8.8.4.4 valid=300s;
36resolver_timeout 5s;
37add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
38add_header X-Frame-Options DENY always;
39add_header X-Content-Type-Options nosniff always;
40"#;
41
42#[derive(Debug, Clone, Copy, Eq, PartialEq)]
43pub enum DnsMode {
44    Manual,
45    Plugin,
46}
47
48impl From<crate::cli::commands::NginxDnsMode> for DnsMode {
49    fn from(value: crate::cli::commands::NginxDnsMode) -> Self {
50        match value {
51            crate::cli::commands::NginxDnsMode::Manual => DnsMode::Manual,
52            crate::cli::commands::NginxDnsMode::Plugin => DnsMode::Plugin,
53        }
54    }
55}
56
57#[derive(Debug, Clone)]
58pub struct NginxSetupOptions {
59    pub domain: String,
60    pub port: u16,
61    pub email: String,
62    pub dns_mode: DnsMode,
63    pub dns_plugin: Option<String>,
64    pub dns_creds: Option<PathBuf>,
65    pub include_base: bool,
66}
67
68impl NginxSetupOptions {
69    fn validate(&self) -> Result<()> {
70        validate_domain(&self.domain)?;
71        if !is_valid_email(&self.email) {
72            anyhow::bail!("Invalid --email value: {}", self.email);
73        }
74        if let Some(plugin) = self.dns_plugin.as_deref() {
75            if !is_valid_dns_plugin_name(plugin) {
76                anyhow::bail!(
77                    "Invalid --dns-plugin value: {} (expected lowercase letters, numbers, or dashes)",
78                    plugin
79                );
80            }
81        }
82        Ok(())
83    }
84}
85
86#[derive(Debug, Clone)]
87struct NginxSetupPaths {
88    nginx_conf: PathBuf,
89    nginx_link: PathBuf,
90    webroot: PathBuf,
91    letsencrypt_dir: PathBuf,
92    letsencrypt_options: PathBuf,
93    letsencrypt_dhparams: PathBuf,
94}
95
96impl NginxSetupPaths {
97    fn from_domain(domain: &str) -> Self {
98        let site_name = site_filename(domain);
99        Self {
100            nginx_conf: PathBuf::from("/etc/nginx/sites-available").join(&site_name),
101            nginx_link: PathBuf::from("/etc/nginx/sites-enabled").join(&site_name),
102            webroot: PathBuf::from(WEBROOT_PATH),
103            letsencrypt_dir: PathBuf::from(LETSENCRYPT_DIR),
104            letsencrypt_options: PathBuf::from(LETSENCRYPT_OPTIONS_FILE),
105            letsencrypt_dhparams: PathBuf::from(LETSENCRYPT_DHPARAMS_FILE),
106        }
107    }
108
109    fn cert_live_dir(&self, domain: &str) -> PathBuf {
110        PathBuf::from(LETSENCRYPT_DIR)
111            .join("live")
112            .join(cert_primary_name(domain))
113    }
114}
115
116#[derive(Clone, Debug)]
117struct PrivilegedCommandRunner {
118    runner: CommandRunner,
119    use_sudo: bool,
120}
121
122impl PrivilegedCommandRunner {
123    async fn detect(debug: bool) -> Result<Self> {
124        let runner = CommandRunner::new(debug);
125        let is_root = match runner.run("id", &["-u"]).await {
126            Ok(outcome) => outcome.stdout.trim() == "0",
127            Err(_) => false,
128        };
129        let sudo_available = match runner
130            .run("sh", &["-c", "command -v sudo >/dev/null 2>&1"])
131            .await
132        {
133            Ok(outcome) => outcome.is_success(),
134            Err(_) => false,
135        };
136
137        Ok(Self {
138            runner,
139            use_sudo: !is_root && sudo_available,
140        })
141    }
142
143    fn base_runner(&self) -> &CommandRunner {
144        &self.runner
145    }
146
147    async fn run(&self, program: &str, args: &[&str]) -> Result<CommandOutcome> {
148        let (program_name, args_owned) = self.with_privilege(program, args);
149        let arg_refs = args_owned.iter().map(String::as_str).collect::<Vec<_>>();
150        self.runner.run(&program_name, &arg_refs).await
151    }
152
153    async fn run_checked(
154        &self,
155        program: &str,
156        args: &[&str],
157        hint: Option<&str>,
158    ) -> Result<CommandOutcome> {
159        let (program_name, args_owned) = self.with_privilege(program, args);
160        let arg_refs = args_owned.iter().map(String::as_str).collect::<Vec<_>>();
161        self.runner
162            .run_checked(&program_name, &arg_refs, hint)
163            .await
164    }
165
166    async fn run_with_stdio(&self, program: &str, args: &[&str]) -> Result<ExitStatus> {
167        let (program_name, args_owned) = self.with_privilege(program, args);
168        let arg_refs = args_owned.iter().map(String::as_str).collect::<Vec<_>>();
169        self.runner.run_with_stdio(&program_name, &arg_refs).await
170    }
171
172    fn with_privilege(&self, program: &str, args: &[&str]) -> (String, Vec<String>) {
173        if self.use_sudo {
174            let mut values = Vec::with_capacity(args.len() + 1);
175            values.push(program.to_string());
176            values.extend(args.iter().map(|value| (*value).to_string()));
177            ("sudo".to_string(), values)
178        } else {
179            (
180                program.to_string(),
181                args.iter().map(|value| (*value).to_string()).collect(),
182            )
183        }
184    }
185}
186
187pub async fn setup_nginx(options: &NginxSetupOptions, debug: bool) -> Result<()> {
188    if !cfg!(target_os = "linux") {
189        anyhow::bail!("`xbp nginx setup` is currently supported on Linux hosts only");
190    }
191
192    options.validate()?;
193
194    let runner = PrivilegedCommandRunner::detect(debug).await?;
195    let paths = NginxSetupPaths::from_domain(&options.domain);
196
197    crate::data::athena::persist_nginx_log(
198        Some(&options.domain),
199        "setup_started",
200        true,
201        "Starting nginx setup",
202        Some(&format!(
203            "target_port={} dns_mode={:?} wildcard={} include_base={}",
204            options.port,
205            options.dns_mode,
206            is_wildcard_domain(&options.domain),
207            options.include_base
208        )),
209        serde_json::json!({
210            "port": options.port,
211            "email": options.email,
212            "dns_mode": format!("{:?}", options.dns_mode).to_lowercase(),
213            "dns_plugin": options.dns_plugin,
214            "dns_creds": options.dns_creds.as_ref().map(|path| path.display().to_string()),
215            "include_base": options.include_base,
216        }),
217    )
218    .await;
219
220    let setup_result = setup_nginx_inner(options, &paths, &runner).await;
221    if let Err(err) = &setup_result {
222        let _ = log_error("nginx", "Nginx setup failed", Some(&err.to_string())).await;
223        crate::data::athena::persist_nginx_log(
224            Some(&options.domain),
225            "setup_failed",
226            false,
227            "Nginx setup failed",
228            Some(&err.to_string()),
229            serde_json::json!({ "port": options.port }),
230        )
231        .await;
232    }
233
234    setup_result
235}
236
237async fn setup_nginx_inner(
238    options: &NginxSetupOptions,
239    paths: &NginxSetupPaths,
240    runner: &PrivilegedCommandRunner,
241) -> Result<()> {
242    let _ = log_info(
243        "nginx",
244        &format!(
245            "Setting up Nginx for {} on port {}",
246            options.domain, options.port
247        ),
248        None,
249    )
250    .await;
251
252    let _ = log_info("nginx", "Checking dependencies", None).await;
253    ensure_command(runner, "nginx").await?;
254    ensure_command(runner, "certbot").await?;
255
256    ensure_letsencrypt_files(runner, paths).await?;
257    cleanup_site(runner, paths).await?;
258
259    if is_wildcard_domain(&options.domain) {
260        let _ = log_warn(
261            "nginx",
262            &format!("Wildcard domain detected: {}", options.domain),
263            Some("HTTP-01 cannot issue wildcard certs; DNS-01 is required."),
264        )
265        .await;
266
267        if cert_exists(paths, &options.domain) {
268            let _ = log_success(
269                "nginx",
270                "Existing certificate detected; skipping issuance",
271                None,
272            )
273            .await;
274        } else {
275            match options.dns_mode {
276                DnsMode::Manual => issue_dns_manual_cert(runner, options).await?,
277                DnsMode::Plugin => issue_dns_plugin_cert(runner, options).await?,
278            }
279        }
280
281        let final_config = render_final_https_config(options, paths);
282        write_site_config(runner, paths, &final_config).await?;
283        persist_setup_snapshot(options, paths, &final_config, "setup_nginx_final").await;
284        validate_and_restart_nginx(runner).await?;
285    } else {
286        install_apt_package(runner, "python3-certbot-nginx", false)
287            .await
288            .ok();
289
290        let http_config = render_http_acme_config(options, paths);
291        write_site_config(runner, paths, &http_config).await?;
292        persist_setup_snapshot(options, paths, &http_config, "setup_nginx_http_acme").await;
293
294        nginx_start_or_reload(runner).await?;
295
296        if cert_exists(paths, &options.domain) {
297            let _ = log_success(
298                "nginx",
299                "Existing certificate detected; skipping issuance",
300                None,
301            )
302            .await;
303        } else if let Err(err) = issue_http_cert(runner, options, paths).await {
304            let _ = log_warn(
305                "nginx",
306                "Webroot certbot flow failed, falling back to nginx installer",
307                Some(&err.to_string()),
308            )
309            .await;
310            issue_http_cert_with_nginx_fallback(runner, options).await?;
311        }
312
313        let final_config = render_final_https_config(options, paths);
314        write_site_config(runner, paths, &final_config).await?;
315        persist_setup_snapshot(options, paths, &final_config, "setup_nginx_final").await;
316        validate_and_restart_nginx(runner).await?;
317    }
318
319    let _ = log_success(
320        "nginx",
321        &format!("HTTPS enabled for {}", options.domain),
322        None,
323    )
324    .await;
325    crate::data::athena::persist_nginx_log(
326        Some(&options.domain),
327        "setup_completed",
328        true,
329        "Completed nginx setup",
330        Some(&format!("target_port={}", options.port)),
331        serde_json::json!({ "port": options.port }),
332    )
333    .await;
334
335    Ok(())
336}
337
338async fn ensure_command(runner: &PrivilegedCommandRunner, command: &str) -> Result<()> {
339    if command_exists(runner.base_runner(), command).await {
340        debug!("{} already installed", command);
341        return Ok(());
342    }
343
344    let _ = log_warn("nginx", &format!("{} missing; installing", command), None).await;
345    install_apt_package(runner, command, true)
346        .await
347        .with_context(|| format!("Failed to install required package `{}`", command))?;
348
349    Ok(())
350}
351
352async fn command_exists(runner: &CommandRunner, command: &str) -> bool {
353    match runner.run("which", &[command]).await {
354        Ok(outcome) => outcome.is_success(),
355        Err(_) => false,
356    }
357}
358
359async fn install_apt_package(
360    runner: &PrivilegedCommandRunner,
361    package: &str,
362    required: bool,
363) -> Result<()> {
364    let update_result = runner
365        .run(
366            "env",
367            &["DEBIAN_FRONTEND=noninteractive", "apt-get", "update", "-y"],
368        )
369        .await;
370
371    if let Err(err) = update_result {
372        if required {
373            return Err(err).context("Failed to run apt-get update");
374        }
375        warn!(
376            "Skipping optional package install; apt-get update failed: {}",
377            err
378        );
379        return Ok(());
380    }
381
382    let install_result = runner
383        .run(
384            "env",
385            &[
386                "DEBIAN_FRONTEND=noninteractive",
387                "apt-get",
388                "install",
389                "-y",
390                package,
391            ],
392        )
393        .await;
394
395    match install_result {
396        Ok(outcome) if outcome.is_success() => {
397            debug!("Installed package {}", package);
398            Ok(())
399        }
400        Ok(outcome) => {
401            let message = format!(
402                "Failed to install apt package {}: {}",
403                package,
404                outcome.stderr.trim()
405            );
406            if required {
407                anyhow::bail!(message);
408            }
409            warn!("{}", message);
410            Ok(())
411        }
412        Err(err) => {
413            if required {
414                Err(err).with_context(|| format!("Failed to install apt package {}", package))
415            } else {
416                warn!("Failed to install optional package {}: {}", package, err);
417                Ok(())
418            }
419        }
420    }
421}
422
423async fn ensure_letsencrypt_files(
424    runner: &PrivilegedCommandRunner,
425    paths: &NginxSetupPaths,
426) -> Result<()> {
427    let le_dir = paths.letsencrypt_dir.to_string_lossy().into_owned();
428    runner
429        .run_checked(
430            "mkdir",
431            &["-p", le_dir.as_str()],
432            Some("create /etc/letsencrypt before writing SSL assets"),
433        )
434        .await
435        .context("Failed to ensure letsencrypt directory")?;
436
437    if !paths.letsencrypt_options.exists() {
438        let _ = log_warn(
439            "nginx",
440            "Creating missing Certbot TLS options file",
441            Some(LETSENCRYPT_OPTIONS_FILE),
442        )
443        .await;
444        write_text_file_privileged(
445            runner,
446            &paths.letsencrypt_options,
447            LETSENCRYPT_OPTIONS_CONTENT,
448        )
449        .await
450        .context("Failed to write options-ssl-nginx.conf")?;
451    }
452
453    if !paths.letsencrypt_dhparams.exists() {
454        let _ = log_warn(
455            "nginx",
456            "Generating DH params (one-time operation)",
457            Some(LETSENCRYPT_DHPARAMS_FILE),
458        )
459        .await;
460
461        let dh_path = paths.letsencrypt_dhparams.to_string_lossy().into_owned();
462        runner
463            .run_checked(
464                "openssl",
465                &["dhparam", "-out", dh_path.as_str(), "2048"],
466                Some("openssl is required to generate /etc/letsencrypt/ssl-dhparams.pem"),
467            )
468            .await
469            .context("Failed to generate DH params")?;
470
471        runner
472            .run_checked(
473                "chmod",
474                &["644", dh_path.as_str()],
475                Some("set permissions on ssl-dhparams.pem"),
476            )
477            .await
478            .context("Failed to set permissions on DH params")?;
479
480        let _ = log_success("nginx", "DH params created", None).await;
481    }
482
483    Ok(())
484}
485
486async fn cleanup_site(runner: &PrivilegedCommandRunner, paths: &NginxSetupPaths) -> Result<()> {
487    let _ = log_warn("nginx", "Removing stale site config before setup", None).await;
488
489    let conf = paths.nginx_conf.to_string_lossy().into_owned();
490    let link = paths.nginx_link.to_string_lossy().into_owned();
491    runner
492        .run_checked(
493            "rm",
494            &["-f", conf.as_str(), link.as_str()],
495            Some("remove broken nginx site files"),
496        )
497        .await
498        .context("Failed to clean previous nginx site files")?;
499
500    Ok(())
501}
502
503async fn write_site_config(
504    runner: &PrivilegedCommandRunner,
505    paths: &NginxSetupPaths,
506    config_content: &str,
507) -> Result<()> {
508    write_text_file_privileged(runner, &paths.nginx_conf, config_content)
509        .await
510        .context("Failed to write NGINX site configuration")?;
511
512    let conf = paths.nginx_conf.to_string_lossy().into_owned();
513    let link = paths.nginx_link.to_string_lossy().into_owned();
514    runner
515        .run_checked(
516            "ln",
517            &["-sf", conf.as_str(), link.as_str()],
518            Some("ensure /etc/nginx/sites-enabled exists"),
519        )
520        .await
521        .context("Failed to link NGINX site into sites-enabled")?;
522
523    Ok(())
524}
525
526async fn write_text_file_privileged(
527    runner: &PrivilegedCommandRunner,
528    target_path: &Path,
529    content: &str,
530) -> Result<()> {
531    let temp_path = temporary_file_path("nginx");
532    fs::write(&temp_path, content)
533        .with_context(|| format!("Failed to write temporary file {}", temp_path.display()))?;
534
535    let result = async {
536        if let Some(parent) = target_path.parent() {
537            let parent_string = parent.to_string_lossy().into_owned();
538            runner
539                .run_checked(
540                    "mkdir",
541                    &["-p", parent_string.as_str()],
542                    Some("create target directory"),
543                )
544                .await?;
545        }
546
547        let temp_string = temp_path.to_string_lossy().into_owned();
548        let target_string = target_path.to_string_lossy().into_owned();
549
550        runner
551            .run_checked(
552                "cp",
553                &[temp_string.as_str(), target_string.as_str()],
554                Some("copy temporary nginx config into final destination"),
555            )
556            .await?;
557
558        runner
559            .run_checked(
560                "chmod",
561                &["644", target_string.as_str()],
562                Some("set file permissions to 644"),
563            )
564            .await?;
565
566        Ok::<(), anyhow::Error>(())
567    }
568    .await;
569
570    let _ = fs::remove_file(&temp_path);
571    result
572}
573
574fn temporary_file_path(label: &str) -> PathBuf {
575    let nonce = SystemTime::now()
576        .duration_since(UNIX_EPOCH)
577        .map(|duration| duration.as_nanos())
578        .unwrap_or(0);
579    env::temp_dir().join(format!(
580        "xbp-{}-{}-{}.tmp",
581        label,
582        std::process::id(),
583        nonce
584    ))
585}
586
587async fn nginx_start_or_reload(runner: &PrivilegedCommandRunner) -> Result<()> {
588    let status = runner
589        .run("systemctl", &["is-active", "--quiet", "nginx"])
590        .await
591        .context("Failed to check nginx service status")?;
592
593    if status.is_success() {
594        debug!("Testing nginx configuration before reload");
595        if let Err(err) = runner
596            .run_checked(
597                "nginx",
598                &["-t"],
599                Some("run `xbp nginx show` to inspect the generated config"),
600            )
601            .await
602        {
603            print_nginx_journal_tail(runner).await;
604            return Err(err).context("nginx config invalid");
605        }
606
607        runner
608            .run_checked(
609                "systemctl",
610                &["reload", "nginx"],
611                Some("reload nginx after config changes"),
612            )
613            .await
614            .context("Failed to reload nginx")?;
615        let _ = log_success("nginx", "nginx reloaded", None).await;
616    } else {
617        let _ = log_warn("nginx", "nginx inactive; starting service", None).await;
618        if let Err(err) = runner
619            .run_checked(
620                "systemctl",
621                &["start", "nginx"],
622                Some("start nginx service"),
623            )
624            .await
625        {
626            print_nginx_journal_tail(runner).await;
627            return Err(err).context("failed to start nginx");
628        }
629        let _ = log_success("nginx", "nginx started", None).await;
630    }
631
632    Ok(())
633}
634
635async fn validate_and_restart_nginx(runner: &PrivilegedCommandRunner) -> Result<()> {
636    let _ = log_info("nginx", "Validating nginx configuration", None).await;
637
638    if let Err(err) = runner
639        .run_checked(
640            "nginx",
641            &["-t"],
642            Some("run `xbp nginx show` to inspect rendered site files"),
643        )
644        .await
645    {
646        print_nginx_journal_tail(runner).await;
647        return Err(err).context("nginx test failed");
648    }
649
650    runner
651        .run_checked(
652            "systemctl",
653            &["restart", "nginx"],
654            Some("restart nginx after final config is written"),
655        )
656        .await
657        .context("Failed to restart nginx")?;
658
659    Ok(())
660}
661
662async fn print_nginx_journal_tail(runner: &PrivilegedCommandRunner) {
663    match runner
664        .run("journalctl", &["-u", "nginx", "-n", "80", "--no-pager"])
665        .await
666    {
667        Ok(outcome) if outcome.is_success() => {
668            if !outcome.stdout.trim().is_empty() {
669                eprintln!("{}", outcome.stdout);
670            }
671        }
672        Ok(outcome) => {
673            debug!(
674                "Unable to read nginx journal tail (status={}): {}",
675                outcome.output.status, outcome.stderr
676            );
677        }
678        Err(err) => {
679            debug!("Unable to read nginx journal tail: {}", err);
680        }
681    }
682}
683
684fn cert_exists(paths: &NginxSetupPaths, domain: &str) -> bool {
685    let live_dir = paths.cert_live_dir(domain);
686    live_dir.join("fullchain.pem").exists() && live_dir.join("privkey.pem").exists()
687}
688
689async fn issue_http_cert(
690    runner: &PrivilegedCommandRunner,
691    options: &NginxSetupOptions,
692    paths: &NginxSetupPaths,
693) -> Result<()> {
694    let _ = log_info(
695        "nginx",
696        &format!("Obtaining certificate for {} via HTTP-01", options.domain),
697        None,
698    )
699    .await;
700
701    let webroot = paths.webroot.to_string_lossy().into_owned();
702    runner
703        .run_checked(
704            "certbot",
705            &[
706                "certonly",
707                "--webroot",
708                "-w",
709                webroot.as_str(),
710                "-d",
711                options.domain.as_str(),
712                "--agree-tos",
713                "--non-interactive",
714                "-m",
715                options.email.as_str(),
716            ],
717            Some("verify DNS A/AAAA records and that port 80 is reachable"),
718        )
719        .await
720        .context("HTTP-01 certificate issuance failed")?;
721
722    Ok(())
723}
724
725async fn issue_http_cert_with_nginx_fallback(
726    runner: &PrivilegedCommandRunner,
727    options: &NginxSetupOptions,
728) -> Result<()> {
729    runner
730        .run_checked(
731            "certbot",
732            &[
733                "--nginx",
734                "-d",
735                options.domain.as_str(),
736                "--agree-tos",
737                "--non-interactive",
738                "-m",
739                options.email.as_str(),
740            ],
741            Some("validate nginx syntax and DNS records before retrying certbot"),
742        )
743        .await
744        .context("Certbot nginx installer fallback failed")?;
745
746    Ok(())
747}
748
749async fn issue_dns_manual_cert(
750    runner: &PrivilegedCommandRunner,
751    options: &NginxSetupOptions,
752) -> Result<()> {
753    let domains = build_certificate_domains(options);
754    let domain_preview = domains
755        .iter()
756        .map(|domain| format!("-d {}", domain))
757        .collect::<Vec<_>>()
758        .join(" ");
759
760    let _ = log_info(
761        "nginx",
762        &format!(
763            "Obtaining certificate for {} via manual DNS-01",
764            domain_preview
765        ),
766        None,
767    )
768    .await;
769    let _ = log_warn(
770        "nginx",
771        "Create the requested TXT records when Certbot prompts",
772        None,
773    )
774    .await;
775
776    let mut args = vec![
777        "certonly".to_string(),
778        "--manual".to_string(),
779        "--preferred-challenges".to_string(),
780        "dns".to_string(),
781        "--manual-public-ip-logging-ok".to_string(),
782        "--agree-tos".to_string(),
783        "-m".to_string(),
784        options.email.clone(),
785    ];
786
787    for domain in domains {
788        args.push("-d".to_string());
789        args.push(domain);
790    }
791
792    let arg_refs = args.iter().map(String::as_str).collect::<Vec<_>>();
793    let status = runner
794        .run_with_stdio("certbot", &arg_refs)
795        .await
796        .context("Failed to run certbot manual DNS-01 flow")?;
797
798    if !status.success() {
799        anyhow::bail!("certbot manual DNS-01 flow failed with status {}", status);
800    }
801
802    Ok(())
803}
804
805async fn issue_dns_plugin_cert(
806    runner: &PrivilegedCommandRunner,
807    options: &NginxSetupOptions,
808) -> Result<()> {
809    let dns_plugin = options
810        .dns_plugin
811        .as_deref()
812        .context("--dns-plugin is required for --dns-mode plugin")?;
813    if !is_valid_dns_plugin_name(dns_plugin) {
814        anyhow::bail!(
815            "Invalid --dns-plugin value: {} (expected lowercase letters, numbers, or dashes)",
816            dns_plugin
817        );
818    }
819
820    let dns_creds = options
821        .dns_creds
822        .as_ref()
823        .context("--dns-creds is required for --dns-mode plugin")?;
824    if !dns_creds.is_file() {
825        anyhow::bail!("DNS creds file not found: {}", dns_creds.display());
826    }
827
828    let package_name = format!("python3-certbot-dns-{}", dns_plugin);
829    install_apt_package(runner, &package_name, true)
830        .await
831        .with_context(|| format!("Failed to install certbot plugin package {}", package_name))?;
832
833    let domains = build_certificate_domains(options);
834    let domain_preview = domains
835        .iter()
836        .map(|domain| format!("-d {}", domain))
837        .collect::<Vec<_>>()
838        .join(" ");
839
840    let _ = log_info(
841        "nginx",
842        &format!(
843            "Obtaining certificate for {} via DNS plugin: {}",
844            domain_preview, dns_plugin
845        ),
846        None,
847    )
848    .await;
849
850    let plugin_flag = format!("--dns-{}", dns_plugin);
851    let creds_flag = format!("--dns-{}-credentials", dns_plugin);
852    let creds_path = dns_creds.to_string_lossy().into_owned();
853
854    let mut args = vec![
855        "certonly".to_string(),
856        plugin_flag,
857        creds_flag,
858        creds_path,
859        "--agree-tos".to_string(),
860        "--non-interactive".to_string(),
861        "-m".to_string(),
862        options.email.clone(),
863    ];
864
865    for domain in domains {
866        args.push("-d".to_string());
867        args.push(domain);
868    }
869
870    let arg_refs = args.iter().map(String::as_str).collect::<Vec<_>>();
871    runner
872        .run_checked(
873            "certbot",
874            &arg_refs,
875            Some("verify DNS plugin credentials file permissions and contents"),
876        )
877        .await
878        .context("DNS plugin certificate issuance failed")?;
879
880    Ok(())
881}
882
883fn build_certificate_domains(options: &NginxSetupOptions) -> Vec<String> {
884    let mut domains = vec![options.domain.clone()];
885    if is_wildcard_domain(&options.domain) && options.include_base {
886        domains.push(base_domain(&options.domain));
887    }
888    domains
889}
890
891fn render_http_acme_config(options: &NginxSetupOptions, paths: &NginxSetupPaths) -> String {
892    let webroot = paths.webroot.to_string_lossy();
893    format!(
894        "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",
895        options.domain, webroot, options.port
896    )
897}
898
899fn render_final_https_config(options: &NginxSetupOptions, paths: &NginxSetupPaths) -> String {
900    let live_dir = paths.cert_live_dir(&options.domain);
901    let live_dir = live_dir.to_string_lossy();
902    format!(
903        "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",
904        options.domain,
905        options.domain,
906        live_dir,
907        live_dir,
908        LETSENCRYPT_OPTIONS_FILE,
909        LETSENCRYPT_DHPARAMS_FILE,
910        options.port
911    )
912}
913
914async fn persist_setup_snapshot(
915    options: &NginxSetupOptions,
916    paths: &NginxSetupPaths,
917    content: &str,
918    action: &str,
919) {
920    crate::data::athena::persist_nginx_config_snapshot(
921        &options.domain,
922        &paths.nginx_conf,
923        content,
924        &[options.port],
925        &[80, 443],
926        action,
927    )
928    .await;
929}
930
931fn validate_domain(domain: &str) -> Result<()> {
932    if domain.trim().is_empty() {
933        anyhow::bail!("Domain cannot be empty");
934    }
935    if domain.contains('/') || domain.contains('\\') || domain.contains("..") {
936        anyhow::bail!("Invalid domain value: {}", domain);
937    }
938    if domain.chars().any(char::is_whitespace) {
939        anyhow::bail!("Domain cannot contain whitespace: {}", domain);
940    }
941    if is_wildcard_domain(domain) && base_domain(domain).trim().is_empty() {
942        anyhow::bail!("Invalid wildcard domain: {}", domain);
943    }
944    Ok(())
945}
946
947fn is_valid_email(email: &str) -> bool {
948    match Regex::new(r"^[^@]+@[^@]+\.[^@]+$") {
949        Ok(regex) => regex.is_match(email),
950        Err(_) => false,
951    }
952}
953
954fn is_valid_dns_plugin_name(plugin: &str) -> bool {
955    match Regex::new(r"^[a-z0-9][a-z0-9-]*$") {
956        Ok(regex) => regex.is_match(plugin),
957        Err(_) => false,
958    }
959}
960
961fn is_wildcard_domain(domain: &str) -> bool {
962    domain.starts_with('*')
963}
964
965fn base_domain(domain: &str) -> String {
966    if !is_wildcard_domain(domain) {
967        return domain.to_string();
968    }
969
970    if let Some((_, rest)) = domain.split_once('.') {
971        rest.to_string()
972    } else {
973        domain
974            .trim_start_matches('*')
975            .trim_start_matches('.')
976            .to_string()
977    }
978}
979
980fn cert_primary_name(domain: &str) -> String {
981    if is_wildcard_domain(domain) {
982        base_domain(domain)
983    } else {
984        domain.to_string()
985    }
986}
987
988fn site_filename(domain: &str) -> String {
989    domain.replace('*', "_wildcard_")
990}
991
992pub async fn list_nginx(debug: bool) -> Result<()> {
993    let sites = inspect_nginx_configs(false)?;
994    if sites.is_empty() {
995        warn!("No NGINX site configs found");
996        return Ok(());
997    }
998
999    let prefix = get_prefix();
1000    info!(
1001        "{}{:<30} {:<14} {:<14} {}",
1002        prefix, "DOMAIN", "UPSTREAM", "LISTEN", "PATH"
1003    );
1004    info!("{}{:-<90}", prefix, "");
1005
1006    for site in sites {
1007        crate::data::athena::persist_nginx_log(
1008            Some(&site.domain),
1009            "list",
1010            true,
1011            "Listed nginx site",
1012            None,
1013            serde_json::json!({
1014                "config_path": site.path.display().to_string(),
1015                "upstream_ports": site.upstream_ports,
1016                "listen_ports": site.listen_ports
1017            }),
1018        )
1019        .await;
1020        if debug {
1021            debug!(
1022                "NGINX site: domain={}, upstream_ports={:?}, listen_ports={:?}, path={}",
1023                site.domain,
1024                site.upstream_ports,
1025                site.listen_ports,
1026                site.path.display()
1027            );
1028        }
1029
1030        info!(
1031            "{}{:<30} {:<14} {:<14} {}",
1032            prefix,
1033            site.domain,
1034            join_ports(&site.upstream_ports),
1035            join_ports(&site.listen_ports),
1036            site.path.display()
1037        );
1038    }
1039    Ok(())
1040}
1041
1042pub async fn update_nginx(domain: &str, port: u16, debug: bool) -> Result<()> {
1043    let runner = CommandRunner::new(debug);
1044    let available_path = format!("/etc/nginx/sites-available/{}", domain);
1045
1046    let content = match fs::read_to_string(&available_path) {
1047        Ok(c) => c,
1048        Err(_) => {
1049            let output = Command::new("sudo")
1050                .arg("cat")
1051                .arg(&available_path)
1052                .output()
1053                .await?;
1054            if !output.status.success() {
1055                return Err(anyhow::anyhow!("Config for {} not found", domain));
1056            }
1057            String::from_utf8_lossy(&output.stdout).to_string()
1058        }
1059    };
1060
1061    if debug {
1062        debug!("Original Nginx Config for {}:\n{}", domain, content);
1063    }
1064
1065    let proxy_pass_regex =
1066        Regex::new(r"(proxy_pass\s+http://(?:127\.0\.0\.1|localhost):)(\d+)(.*)").unwrap();
1067
1068    if !proxy_pass_regex.is_match(&content) {
1069        if debug {
1070            debug!("Regex failed to match proxy_pass in content.");
1071        }
1072        return Err(anyhow::anyhow!(
1073            "Could not find proxy_pass directive in config"
1074        ));
1075    }
1076
1077    let new_content = proxy_pass_regex.replace_all(&content, |caps: &regex::Captures| {
1078        format!("{}{}{}", &caps[1], port, &caps[3])
1079    });
1080
1081    if debug {
1082        debug!("New Nginx Config Preview for {}:\n{}", domain, new_content);
1083    }
1084
1085    let escaped_content = new_content.replace("'", "'\\''");
1086    let write_cmd = format!("echo '{}' | sudo tee {}", escaped_content, available_path);
1087
1088    run_command_checked(
1089        &runner,
1090        "sh",
1091        &["-c", &write_cmd],
1092        Some("run `xbp nginx update --help` and verify write permissions."),
1093    )
1094    .await
1095    .context("Failed to update NGINX config")?;
1096
1097    let actor = env::var("USER").ok();
1098    crate::data::athena::persist_nginx_edit_audit_log(
1099        Some(domain),
1100        Some(Path::new(&available_path)),
1101        actor.as_deref(),
1102        "update",
1103        Some(&content),
1104        Some(new_content.as_ref()),
1105        serde_json::json!({ "new_port": port }),
1106    )
1107    .await;
1108
1109    crate::data::athena::persist_nginx_config_snapshot(
1110        domain,
1111        Path::new(&available_path),
1112        new_content.as_ref(),
1113        &[port],
1114        &[80, 443],
1115        "update_nginx",
1116    )
1117    .await;
1118
1119    let _ = log_success(
1120        "nginx",
1121        &format!("Updated {} to port {}", domain, port),
1122        None,
1123    )
1124    .await;
1125    crate::data::athena::persist_nginx_log(
1126        Some(domain),
1127        "update",
1128        true,
1129        "Updated nginx upstream port",
1130        Some(&format!("new_port={}", port)),
1131        serde_json::json!({ "new_port": port }),
1132    )
1133    .await;
1134
1135    let output = Command::new("sudo")
1136        .arg("systemctl")
1137        .arg("reload")
1138        .arg("nginx")
1139        .output()
1140        .await?;
1141    if !output.status.success() {
1142        warn!(
1143            "Failed to reload nginx: {}",
1144            String::from_utf8_lossy(&output.stderr)
1145        );
1146    } else {
1147        let _ = log_info("nginx", "Nginx reloaded", None).await;
1148    }
1149
1150    Ok(())
1151}
1152
1153pub fn inspect_nginx_configs(include_content: bool) -> Result<Vec<NginxSiteInfo>> {
1154    let mut results = Vec::new();
1155    let mut seen = std::collections::HashSet::new();
1156
1157    for dir in nginx_config_directories() {
1158        if !dir.exists() {
1159            if include_content {
1160                debug!("Skipping missing NGINX directory {}", dir.display());
1161            }
1162            continue;
1163        }
1164
1165        for entry in fs::read_dir(&dir)
1166            .with_context(|| format!("Failed to read NGINX directory {}", dir.display()))?
1167        {
1168            let entry = entry?;
1169            let path = entry.path();
1170            if !path.is_file() && !path.is_symlink() {
1171                continue;
1172            }
1173
1174            let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
1175            if !seen.insert(canonical) {
1176                continue;
1177            }
1178
1179            let content = match fs::read_to_string(&path) {
1180                Ok(content) => content,
1181                Err(err) => {
1182                    warn!("Skipping unreadable NGINX file {}: {}", path.display(), err);
1183                    continue;
1184                }
1185            };
1186            let site = parse_nginx_site(&path, &content, include_content);
1187            results.push(site);
1188        }
1189    }
1190
1191    results.sort_by(|a, b| a.domain.cmp(&b.domain));
1192    Ok(results)
1193}
1194
1195pub async fn show_nginx(domain: Option<&str>, debug: bool) -> Result<()> {
1196    let mut sites = inspect_nginx_configs(true)?;
1197    if let Some(domain) = domain {
1198        sites.retain(|site| site.domain == domain);
1199    }
1200
1201    if sites.is_empty() {
1202        return Err(anyhow::anyhow!(
1203            "No matching NGINX config found.\nhelp: run `xbp nginx list` to inspect available domains."
1204        ));
1205    }
1206
1207    for site in sites {
1208        crate::data::athena::persist_nginx_log(
1209            Some(&site.domain),
1210            "show",
1211            true,
1212            "Displayed nginx site config",
1213            None,
1214            serde_json::json!({
1215                "config_path": site.path.display().to_string(),
1216                "has_content": site.content.is_some()
1217            }),
1218        )
1219        .await;
1220        println!("Domain: {}", site.domain);
1221        println!("Path: {}", site.path.display());
1222        println!("Upstream Ports: {}", join_ports(&site.upstream_ports));
1223        println!("Listen Ports: {}", join_ports(&site.listen_ports));
1224        println!("{}", "-".repeat(80));
1225        if let Some(content) = site.content {
1226            println!("{}", content);
1227        }
1228        if debug {
1229            println!("{}", "=".repeat(80));
1230        }
1231    }
1232
1233    Ok(())
1234}
1235
1236pub async fn edit_nginx(domain: &str, _debug: bool) -> Result<()> {
1237    let site = inspect_nginx_configs(false)?
1238        .into_iter()
1239        .find(|site| site.domain == domain)
1240        .ok_or_else(|| {
1241            anyhow::anyhow!(
1242                "No matching NGINX config found for {}.\nhelp: run `xbp nginx list` to inspect available domains.",
1243                domain
1244            )
1245        })?;
1246
1247    println!("Opening {}", site.path.display());
1248    let actor = env::var("USER").ok();
1249    crate::data::athena::persist_nginx_edit_audit_log(
1250        Some(domain),
1251        Some(&site.path),
1252        actor.as_deref(),
1253        "edit_open",
1254        None,
1255        None,
1256        serde_json::json!({ "config_path": site.path.display().to_string() }),
1257    )
1258    .await;
1259    crate::utils::open_path_with_editor(&site.path)
1260        .map_err(|e| anyhow::anyhow!("Failed to open editor: {}", e))?;
1261    Ok(())
1262}
1263
1264fn parse_nginx_site(path: &Path, content: &str, include_content: bool) -> NginxSiteInfo {
1265    let server_name_regex = Regex::new(r"server_name\s+([^;]+);").unwrap();
1266    let proxy_pass_regex =
1267        Regex::new(r"proxy_pass\s+http://(?:127\.0\.0\.1|localhost):(\d+)").unwrap();
1268    let listen_regex = Regex::new(r"listen\s+(\d+)").unwrap();
1269
1270    let domain = server_name_regex
1271        .captures(content)
1272        .and_then(|captures| {
1273            captures
1274                .get(1)
1275                .map(|value| value.as_str().trim().to_string())
1276        })
1277        .unwrap_or_else(|| {
1278            path.file_name()
1279                .and_then(|name: &OsStr| name.to_str())
1280                .unwrap_or("unknown")
1281                .to_string()
1282        });
1283
1284    let upstream_ports = proxy_pass_regex
1285        .captures_iter(content)
1286        .filter_map(|captures| {
1287            captures
1288                .get(1)
1289                .and_then(|value| value.as_str().parse::<u16>().ok())
1290        })
1291        .collect::<Vec<u16>>();
1292    let listen_ports = listen_regex
1293        .captures_iter(content)
1294        .filter_map(|captures| {
1295            captures
1296                .get(1)
1297                .and_then(|value| value.as_str().parse::<u16>().ok())
1298        })
1299        .collect::<Vec<u16>>();
1300
1301    NginxSiteInfo {
1302        domain,
1303        path: path.to_path_buf(),
1304        upstream_ports,
1305        listen_ports,
1306        content: include_content.then(|| content.to_string()),
1307    }
1308}
1309
1310fn join_ports(ports: &[u16]) -> String {
1311    if ports.is_empty() {
1312        "-".to_string()
1313    } else {
1314        ports
1315            .iter()
1316            .map(|port| port.to_string())
1317            .collect::<Vec<_>>()
1318            .join(",")
1319    }
1320}
1321
1322fn nginx_config_directories() -> Vec<PathBuf> {
1323    vec![
1324        PathBuf::from("/etc/nginx/sites-enabled"),
1325        PathBuf::from("/etc/nginx/sites-available"),
1326    ]
1327}
1328
1329async fn run_command_checked(
1330    runner: &CommandRunner,
1331    command: &str,
1332    args: &[&str],
1333    hint: Option<&str>,
1334) -> Result<std::process::Output> {
1335    let outcome = runner.run_checked(command, args, hint).await?;
1336    Ok(outcome.output)
1337}
1338
1339#[cfg(test)]
1340mod tests {
1341    use super::{
1342        base_domain, cert_primary_name, is_valid_dns_plugin_name, join_ports, parse_nginx_site,
1343        render_http_acme_config, site_filename, DnsMode, NginxSetupOptions, NginxSetupPaths,
1344    };
1345    use std::path::PathBuf;
1346
1347    #[test]
1348    fn parse_nginx_site_extracts_domain_and_ports() {
1349        let content = r#"
1350server {
1351    listen 80;
1352    server_name demo.example.com;
1353    location / {
1354        proxy_pass http://127.0.0.1:3000;
1355    }
1356}
1357"#;
1358        let path = PathBuf::from("/etc/nginx/sites-enabled/demo.example.com");
1359        let site = parse_nginx_site(&path, content, false);
1360
1361        assert_eq!(site.domain, "demo.example.com");
1362        assert_eq!(site.upstream_ports, vec![3000]);
1363        assert_eq!(site.listen_ports, vec![80]);
1364        assert!(site.content.is_none());
1365    }
1366
1367    #[test]
1368    fn join_ports_formats_multiple_ports() {
1369        assert_eq!(join_ports(&[80, 443]), "80,443");
1370        assert_eq!(join_ports(&[]), "-");
1371    }
1372
1373    #[test]
1374    fn wildcard_helpers_match_expected_cert_names() {
1375        assert_eq!(site_filename("*.example.com"), "_wildcard_.example.com");
1376        assert_eq!(base_domain("*.example.com"), "example.com");
1377        assert_eq!(cert_primary_name("*.example.com"), "example.com");
1378        assert_eq!(cert_primary_name("api.example.com"), "api.example.com");
1379    }
1380
1381    #[test]
1382    fn plugin_name_validation_accepts_expected_values() {
1383        assert!(is_valid_dns_plugin_name("cloudflare"));
1384        assert!(is_valid_dns_plugin_name("route53"));
1385        assert!(!is_valid_dns_plugin_name("Cloudflare"));
1386        assert!(!is_valid_dns_plugin_name("bad plugin"));
1387    }
1388
1389    #[test]
1390    fn setup_options_validate_email_shape() {
1391        let options = NginxSetupOptions {
1392            domain: "api.example.com".to_string(),
1393            port: 3000,
1394            email: "invalid-email".to_string(),
1395            dns_mode: DnsMode::Manual,
1396            dns_plugin: None,
1397            dns_creds: None,
1398            include_base: true,
1399        };
1400
1401        assert!(options.validate().is_err());
1402    }
1403
1404    #[test]
1405    fn render_http_config_contains_expected_paths() {
1406        let options = NginxSetupOptions {
1407            domain: "api.example.com".to_string(),
1408            port: 3000,
1409            email: "ops@example.com".to_string(),
1410            dns_mode: DnsMode::Manual,
1411            dns_plugin: None,
1412            dns_creds: None,
1413            include_base: true,
1414        };
1415        let paths = NginxSetupPaths::from_domain(&options.domain);
1416
1417        let content = render_http_acme_config(&options, &paths);
1418        assert!(content.contains("/.well-known/acme-challenge/"));
1419        assert!(content.contains("proxy_pass http://127.0.0.1:3000"));
1420    }
1421}