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: ®ex::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}