use std::collections::{BTreeMap, BTreeSet};
use std::io::IsTerminal;
use anyhow::{Result, bail};
use dialoguer::{Confirm, Input};
use ryra_core::caddy::AcmeMode;
use ryra_core::config::ConfigPaths;
use ryra_core::config::schema::Config;
use ryra_core::registry::resolve::ServiceRef;
use ryra_core::registry::service_def::{AuthKind, HttpsRequirement, ServiceKind};
use ryra_core::{
Capability, REGISTRY_BUNDLED, Warning, WellKnownService, find_installed_provider,
service_provides,
};
use super::apply;
use super::prompts;
const DEFAULT_CADDY_HTTPS_PORT: u16 = 8443;
const DEFAULT_AUTHELIA_PORT: u16 = 9091;
const INBUCKET_SMTP_PORT: u16 = 2500;
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
pub enum SmtpProvider {
Inbucket,
}
#[allow(clippy::too_many_arguments)]
pub async fn run(
services: &[String],
url: Option<&str>,
auth: bool,
smtp: Option<SmtpProvider>,
enable: &[String],
tailscale: bool,
acme: Option<&AcmeMode>,
dry_run: bool,
yes: bool,
) -> Result<()> {
if url.is_some() && services.len() > 1 {
bail!("--url can only be used when adding a single service");
}
if !enable.is_empty() && services.len() > 1 {
bail!("--enable can only be used when adding a single service");
}
if acme.is_some()
&& services
.iter()
.any(|s| service_provides(s, Capability::ReverseProxy))
{
super::sysctl_low_ports::offer_enable().await?;
}
let preflight_paths = ryra_core::config::ConfigPaths::resolve()?;
let preflight_config = ryra_core::config::load_or_default(&preflight_paths.config_file)?;
let issues = ryra_core::system::doctor::check_all(&preflight_config);
let blockers: Vec<&ryra_core::system::doctor::Issue> = issues
.iter()
.filter(|i| i.severity() == ryra_core::system::doctor::Severity::Blocker)
.collect();
if !blockers.is_empty() {
let rendered = blockers
.iter()
.map(|i| i.to_string())
.collect::<Vec<_>>()
.join("\n\n");
bail!("ryra doctor reports blockers:\n\n{rendered}");
}
if issues.iter().any(|i| {
matches!(
i.severity(),
ryra_core::system::doctor::Severity::Warning
| ryra_core::system::doctor::Severity::Info
)
}) {
eprintln!("Note: `ryra doctor` reports issues — run it to see them.");
}
if tailscale && let Err(e) = ryra_core::system::doctor::check_tailscale_runtime() {
bail!("--tailscale flag passed but {e}");
}
let interactive = super::is_interactive();
if tailscale && !dry_run {
ensure_tailscale_admin_token(interactive).await?;
}
let first_run = !ryra_core::config::ConfigPaths::resolve()?
.config_file
.exists();
if !dry_run {
ensure_dependencies(auth, tailscale, interactive).await?;
}
if let Some(provider) = smtp
&& !dry_run
{
ensure_smtp_for_add(provider).await?;
}
let paths = ryra_core::config::ConfigPaths::resolve()?;
let _auth_lock = if auth && !dry_run {
paths.ensure_dirs()?;
let lock_path = paths.config_dir.join(".authelia-oidc.lock");
let file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(&lock_path)?;
file.lock()?;
Some(file)
} else {
None
};
for service_input in services {
let service_ref = ServiceRef::parse(service_input)?;
let repo_dir = ryra_core::resolve_registry_dir(&service_ref).await?;
let service = service_ref.service_name();
if !dry_run && ryra_core::is_service_installed(service) {
bail!("service {service} is already installed");
}
let mut config = ryra_core::config::load_or_default(&paths.config_file)?;
if !dry_run
&& !ryra_core::is_service_installed(service)
&& let Some(orphan) = ryra_core::data::enumerate_service(service)?
&& orphan.status == ryra_core::data::ServiceStatus::Orphan
&& (!orphan.volumes.is_empty() || !orphan.data_paths.is_empty())
{
println!("\n '{service}' has data from a previous install (orphaned on disk):");
for v in &orphan.volumes {
println!(" volume: {}", v.name);
}
for p in &orphan.data_paths {
println!(" data: {}", p.display());
}
println!("\n Proceeding will reuse this data (podman reuses named volumes by name).");
if interactive && !yes {
let proceed = Confirm::new()
.with_prompt(format!("Continue adding {service} with existing data?"))
.default(true)
.interact()?;
if !proceed {
println!("\nCancelled. To purge and start clean:");
println!(" ryra remove {service} --purge");
println!(" ryra add {service}");
return Ok(());
}
} else {
println!(
" (use --yes to auto-accept, or run `ryra remove {service} --purge` first to start clean)"
);
}
}
let reg_service = ryra_core::registry::find_service(&repo_dir, service)?;
if let Some(msg) = reg_service.def.check_architecture() {
bail!("{msg}");
}
if service_ref.registry_name() != REGISTRY_BUNDLED && !yes && !dry_run {
warn_untrusted_service(®_service.service_dir, service, interactive)?;
}
let service_supports_smtp =
reg_service.def.integrations.smtp && !reg_service.def.mappings.smtp.is_empty();
let enable_smtp: bool = if !service_supports_smtp || dry_run {
false
} else if interactive {
if config.smtp.is_some() {
Confirm::new()
.with_prompt("Use SMTP for this service?")
.default(true)
.interact()?
} else {
match prompts::prompt_smtp()? {
prompts::SmtpSetupChoice::Custom(smtp) => {
let had_secrets_before = config.has_secrets();
config.smtp = Some(smtp);
paths.ensure_dirs()?;
ryra_core::config::save_config(&paths.config_file, &config)?;
println!(
" SMTP configured. Saved to {}\n",
paths.config_file.display()
);
warn_if_first_secret_save(&paths, had_secrets_before, &config);
true
}
prompts::SmtpSetupChoice::Inbucket => {
if !ryra_core::is_service_installed("inbucket") {
println!("\nInstalling inbucket...\n");
Box::pin(run(
&[WellKnownService::Inbucket.to_string()],
None,
false,
None,
&[],
false,
None,
false,
true,
))
.await?;
config = ryra_core::config::load_or_default(&paths.config_file)?;
}
let had_secrets_before = config.has_secrets();
config.smtp = Some(ryra_core::config::schema::SmtpCredentials {
host: "inbucket".to_string(),
port: INBUCKET_SMTP_PORT, username: String::new(),
password: String::new(),
from: "noreply@example.com".to_string(),
security: ryra_core::config::schema::SmtpSecurity::Off,
});
paths.ensure_dirs()?;
ryra_core::config::save_config(&paths.config_file, &config)?;
println!(
" SMTP configured (inbucket). Saved to {}\n",
paths.config_file.display()
);
warn_if_first_secret_save(&paths, had_secrets_before, &config);
true
}
prompts::SmtpSetupChoice::Skip => false,
}
}
} else {
config.smtp.is_some()
};
let auth_kind: Option<AuthKind> = resolve_auth_kind(
auth,
interactive,
®_service.def.integrations.auth,
config.auth.is_some(),
)?;
let needs_https = needs_https(
reg_service.def.service.https.clone(),
auth_kind.is_some(),
url,
);
let will_install_authelia = auth_kind.is_some() && config.auth.is_none();
let is_user_facing_app = matches!(reg_service.def.service.kind, ServiceKind::Application);
let exposure: ryra_core::Exposure = if let Some(u) = url {
ryra_core::Exposure::from_url(u)
} else if tailscale {
let ts_url = derive_tailscale_url(service)?;
println!("→ Using {ts_url} (Tailscale)");
ryra_core::Exposure::Tailscale { url: ts_url }
} else if needs_https {
if ryra_core::is_service_installed("caddy") {
let installed_all = ryra_core::list_installed().unwrap_or_default();
let caddy_https_port =
find_installed_provider(&installed_all, Capability::ReverseProxy)
.and_then(|s| s.ports.get("https").copied())
.unwrap_or(DEFAULT_CADDY_HTTPS_PORT);
let default_url = format!(
"https://{service}.{}:{caddy_https_port}",
ryra_core::config::schema::CADDY_LOCAL_DOMAIN
);
let chosen = if interactive && !dry_run {
Input::new()
.with_prompt(format!("URL for '{service}'"))
.default(default_url)
.interact_text()?
} else {
default_url
};
ryra_core::Exposure::from_url(&chosen)
} else if interactive && !dry_run {
let chosen = prompt_exposure_for(service, will_install_authelia, false).await?;
config = ryra_core::config::load_or_default(&paths.config_file)?;
chosen
} else {
bail!(
"service '{service}' requires HTTPS but no exposure was selected.\n\
Pass --tailscale, --url <X>, or `ryra add caddy` first to enable \
local HTTPS."
);
}
} else if is_user_facing_app && interactive && !dry_run {
let chosen = prompt_exposure_for(service, false, true).await?;
config = ryra_core::config::load_or_default(&paths.config_file)?;
chosen
} else {
ryra_core::Exposure::Loopback
};
let url: Option<&str> = exposure.url();
let tailscale_enabled: bool = exposure.is_tailscale();
let caddy_already_installed = ryra_core::is_service_installed("caddy");
let need_caddy_for_public_url = url.is_some_and(ryra_core::is_public_url)
&& !caddy_already_installed
&& !service_provides(service, Capability::ReverseProxy)
&& !tailscale_enabled
&& !dry_run;
if need_caddy_for_public_url {
let chosen = match acme {
Some(mode) => Some(TlsHandling::LetsEncrypt(mode.clone())),
None if interactive => Some(prompt_tls_for_public_url(url.unwrap_or("")).await?),
None => None,
};
match chosen {
Some(TlsHandling::LetsEncrypt(mode)) => {
if let Some(u) = url {
dns_preflight_for_acme(u, interactive).await?;
}
println!("\nInstalling caddy (Let's Encrypt mode)...\n");
Box::pin(run(
&[WellKnownService::Caddy.to_string()],
None,
false,
None,
&[],
false,
Some(&mode),
false,
true,
))
.await?;
config = ryra_core::config::load_or_default(&paths.config_file)?;
}
Some(TlsHandling::SelfSigned) => {
println!("\nInstalling caddy (self-signed LAN mode)...\n");
Box::pin(run(
&[WellKnownService::Caddy.to_string()],
None,
false,
None,
&[],
false,
None,
false,
true,
))
.await?;
config = ryra_core::config::load_or_default(&paths.config_file)?;
}
Some(TlsHandling::External) | None => {
}
}
} else if acme.is_some()
&& caddy_already_installed
&& !service_provides(service, Capability::ReverseProxy)
{
eprintln!(
"\nNote: --acme is ignored — caddy is already installed.\n \
Edit ~/.local/share/services/caddy/config/tls.caddy to switch TLS mode.\n"
);
}
if auth_kind.is_some() && config.auth.is_some() {
check_auth_exposure_compat(&config, service, url)?;
}
if will_install_authelia {
if !ensure_auth_for_add(&mut config, &paths, dry_run, tailscale_enabled).await? {
return Ok(());
}
config = ryra_core::config::load_or_default(&paths.config_file)?;
}
use ryra_core::registry::service_def::EnvKind;
let mut env_overrides = BTreeMap::new();
let mut prompt_ctx: Option<BTreeMap<String, String>> = None;
let known_group_names: BTreeSet<&str> = reg_service
.def
.env_groups
.iter()
.map(|g| g.name.as_str())
.collect();
for g in enable {
if !known_group_names.contains(g.as_str()) {
let hint = if known_group_names.is_empty() {
format!("service '{service}' defines no env_groups")
} else {
let known: Vec<&str> = known_group_names.iter().copied().collect();
format!(
"service '{service}' has no env_group '{g}' (known: {})",
known.join(", ")
)
};
bail!("{hint}");
}
}
let mut enabled_groups: BTreeSet<String> = enable.iter().cloned().collect();
let has_promptable_top = reg_service
.def
.env
.iter()
.any(|e| matches!(e.kind, EnvKind::Prompted | EnvKind::Required));
let has_groups = !reg_service.def.env_groups.is_empty();
if (has_promptable_top || has_groups) && interactive {
let default_ctx = ryra_core::generate::context::build_context(
&config,
®_service.def,
None,
auth_kind.as_ref(),
url,
enable_smtp,
)?;
prompt_ctx = Some(default_ctx.clone());
println!("\nConfigure {service}:");
for group in ®_service.def.env_groups {
if !enabled_groups.contains(&group.name) {
let enabled = Confirm::new()
.with_prompt(format!(" {}", group.prompt))
.default(false)
.interact()?;
if !enabled {
continue;
}
enabled_groups.insert(group.name.clone());
}
for env in &group.env {
if !matches!(env.kind, EnvKind::Prompted | EnvKind::Required) {
continue;
}
prompt_env(env, &default_ctx, &mut env_overrides)?;
}
}
for env in ®_service.def.env {
if !matches!(env.kind, EnvKind::Prompted | EnvKind::Required) {
continue;
}
prompt_env(env, &default_ctx, &mut env_overrides)?;
}
println!();
} else if !interactive {
let mut missing_required = Vec::new();
for env in ®_service.def.env {
if !matches!(env.kind, EnvKind::Prompted | EnvKind::Required) {
continue;
}
collect_non_interactive(env, &mut env_overrides, &mut missing_required);
}
for group in ®_service.def.env_groups {
if !enabled_groups.contains(&group.name) {
continue;
}
for env in &group.env {
if !matches!(env.kind, EnvKind::Prompted | EnvKind::Required) {
continue;
}
collect_non_interactive(env, &mut env_overrides, &mut missing_required);
}
}
if !missing_required.is_empty() {
bail!(
"required env vars not provided (run interactively or set via env): {}",
missing_required.join(", ")
);
}
}
let acme_for_service = if service_provides(service, Capability::ReverseProxy) {
acme
} else {
None
};
let result = match ryra_core::add_service(
service,
&exposure,
auth_kind.clone(),
auth || auth_kind.is_some(),
enable_smtp,
&env_overrides,
&enabled_groups,
service_ref.registry_name(),
&repo_dir,
prompt_ctx.clone(),
&super::is_port_in_use,
acme_for_service,
ryra_core::PlanMode::Add,
&BTreeMap::new(),
) {
Err(ryra_core::error::Error::ServiceIncomplete(_)) => {
if interactive && !yes {
println!("\n '{service}' has preserved data from a previous install.");
println!(" Reinstalling will delete the named volume(s) and service dir.");
println!(" Inspect with: ryra data ls\n");
let proceed = Confirm::new()
.with_prompt(format!("Purge existing data and reinstall {service}?"))
.default(false)
.interact()?;
if !proceed {
println!("\nCancelled. To purge and reinstall later:");
println!(" ryra remove {service} --purge");
println!(" ryra add {service}");
return Ok(());
}
}
println!("{service} has leftover state — cleaning up before retry...");
if ryra_core::is_service_installed(service) {
let remove_result =
ryra_core::remove_service(service, ryra_core::RemoveMode::Purge)?;
apply::execute_all(&remove_result.steps).await?;
ryra_core::finalize_remove(service)?;
} else {
let svc_data =
ryra_core::data::enumerate_service(service)?.ok_or_else(|| {
anyhow::anyhow!(
"internal: ServiceIncomplete for '{service}' but no state found"
)
})?;
let steps = ryra_core::orphan_purge_steps(&svc_data);
apply::execute_all(&steps).await?;
}
ryra_core::add_service(
service,
&exposure,
auth_kind.clone(),
auth || auth_kind.is_some(),
enable_smtp,
&env_overrides,
&enabled_groups,
service_ref.registry_name(),
&repo_dir,
prompt_ctx.clone(),
&super::is_port_in_use,
acme_for_service,
ryra_core::PlanMode::Add,
&BTreeMap::new(),
)?
}
other => other?,
};
for warning in &result.warnings {
match warning {
Warning::PortReassigned {
port_name,
original_port,
assigned_port,
reason,
..
} => {
if *original_port < 1024 {
println!(" {port_name} port {original_port} → {assigned_port} ({reason})");
} else {
println!(
" {} {port_name} port {original_port} → {assigned_port} ({reason})",
super::style::warning()
);
}
}
Warning::UrlWithoutReverseProxy {
service_name,
url,
host_port,
} => {
println!(
" {} --url was set for {service_name} but no bundled reverse proxy \
(Caddy) is installed. Ryra will template {url} into the service but \
won't configure routing — point your own reverse proxy (nginx, \
Cloudflare Tunnel, Tailscale Funnel, etc.) at 127.0.0.1:{host_port}, \
or run `ryra add caddy` to let ryra handle it.",
super::style::note()
);
}
Warning::RamBelowMinimum { .. } | Warning::RamBelowRecommended { .. } => {}
}
}
let needs_confirm: Vec<_> = result
.warnings
.iter()
.filter(|w| match w {
Warning::RamBelowMinimum { .. } | Warning::RamBelowRecommended { .. } => true,
Warning::PortReassigned { original_port, .. } => *original_port >= 1024,
Warning::UrlWithoutReverseProxy { .. } => false,
})
.collect();
if !needs_confirm.is_empty() {
for warning in &needs_confirm {
match warning {
Warning::RamBelowMinimum {
service_name,
min_mb,
available_mb,
} => {
println!(
" {} {service_name} requires at least {min_mb} MB RAM, \
but this system has {available_mb} MB — service may fail to start",
super::style::warning()
);
}
Warning::RamBelowRecommended {
service_name,
recommended_mb,
available_mb,
} => {
println!(
" {} {service_name} recommends {recommended_mb} MB RAM, \
but this system has {available_mb} MB — performance may be degraded",
super::style::note()
);
}
Warning::PortReassigned { .. } | Warning::UrlWithoutReverseProxy { .. } => {}
}
}
println!();
if interactive && !dry_run {
let confirmed = Confirm::new()
.with_prompt("Continue?")
.default(true)
.interact()?;
if !confirmed {
println!("Cancelled.");
return Ok(());
}
}
}
if dry_run {
super::print_dry_run(&result.steps);
println!("{service} will be started.");
} else {
ryra_core::record_pending(ryra_core::RecordPendingParams {
service_name: service,
auth_kind,
registry_name: service_ref.registry_name(),
allocated_ports: &result.allocated_ports,
repo_dir: &repo_dir,
exposure: &exposure,
})?;
let url_display = result.url.clone().or_else(|| {
result
.allocated_ports
.iter()
.find(|(name, _)| name.eq_ignore_ascii_case("http"))
.or_else(|| result.allocated_ports.first())
.map(|(_, p)| format!("http://127.0.0.1:{p}"))
});
super::print_plan_header(&result.steps, service, url_display.as_deref());
if let Err(e) = apply::execute_all(&result.steps).await {
eprintln!(
"\n{} {e}",
super::style::error_prefix("Error during setup:")
);
eprintln!("Cleaning up partial installation...");
match ryra_core::remove_service(service, ryra_core::RemoveMode::Purge) {
Ok(remove_result) => {
if let Err(cleanup_err) = apply::execute_all(&remove_result.steps).await {
eprintln!("Cleanup also failed: {cleanup_err}");
eprintln!("Run manually: ryra remove {service}");
} else {
if let Err(e) = ryra_core::finalize_remove(service) {
eprintln!("Warning: finalize_remove failed: {e}");
}
let _ = std::process::Command::new("systemctl")
.args(["--user", "reset-failed"])
.status();
eprintln!("Cleaned up. Retry with: ryra add {service}");
}
}
Err(_) => {
eprintln!("Could not clean up automatically.");
eprintln!("Run manually: ryra remove {service}");
}
}
return Err(e);
}
if let Some(service_url) = result.url.as_deref()
&& service_url_is_caddy_local(service_url)
{
setup_host_access(service, &[service_url]);
}
let home_dir = ryra_core::service_home(service)?;
if let Some(ref url) = result.url {
println!("\n{service} is running at {url}");
if ryra_core::is_tailscale_url(url) {
println!(" Note: tailnet URLs don't loop back to this host. From other");
println!(" tailnet devices, the URL above works (HTTPS via Tailscale).");
if let Some((_, p)) = result.allocated_ports.first() {
println!(" Locally on this host: http://127.0.0.1:{p} (HTTP only —");
println!(" services that require HTTPS won't accept it; reinstall");
println!(
" without --tailscale for Caddy-local HTTPS at *.internal)."
);
}
}
} else {
println!("\n{service} is running.");
}
println!(" May take a moment to start. Check: systemctl --user status {service}");
if result.url.is_none() && !result.allocated_ports.is_empty() {
for (_, host_port) in &result.allocated_ports {
println!(" URL: http://127.0.0.1:{host_port}");
}
}
if !result.generated_secrets.is_empty() {
let env_path = home_dir.join(".env");
let env_content = match std::fs::read_to_string(&env_path) {
Ok(content) => content,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => {
eprintln!(" Warning: could not read {}: {e}", env_path.display());
String::new()
}
};
println!(" Secrets (auto-generated):");
for secret_name in &result.generated_secrets {
let matching_env = env_content.lines().find(|l| {
l.split_once('=')
.map(|(k, _)| k.to_lowercase().contains(secret_name))
.unwrap_or(false)
});
if let Some(line) = matching_env
&& let Some((key, val)) = line.split_once('=')
{
println!(" {key}={val}");
continue;
}
println!(" {secret_name} (see .env)");
}
}
println!(" Config: {}", home_dir.display());
let env_path = home_dir.join(".env");
println!();
println!("Commands:");
println!(" cat {} # view config", env_path.display());
println!(" systemctl --user restart {service} # restart (picks up .env changes)");
println!(" journalctl --user-unit {service}.service -f # follow logs");
if WellKnownService::Caddy.matches(service) {
let snippet_pathbuf = ryra_core::caddy::tls_snippet_path().ok();
let snippet_path = snippet_pathbuf
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| {
"~/.local/share/services/caddy/config/tls.caddy".to_string()
});
let detected_mode: Option<AcmeMode> = snippet_pathbuf
.as_ref()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|s| AcmeMode::detect_from_snippet(&s));
println!();
let displayed_mode: AcmeMode = detected_mode
.clone()
.or_else(|| acme_for_service.cloned())
.unwrap_or(AcmeMode::Internal);
match &displayed_mode {
AcmeMode::WithEmail(email) => {
println!("TLS: Let's Encrypt ({email})");
}
AcmeMode::Anonymous => {
println!("TLS: Let's Encrypt (anonymous — no renewal notices)");
}
AcmeMode::Internal => {
println!(
"TLS: self-signed (LAN — browsers warn unless ryra's CA is trusted)"
);
}
}
if detected_mode.is_none() && acme_for_service.is_none() {
println!(" (note: tls.caddy looks user-customized — leaving it untouched)");
}
if matches!(displayed_mode, AcmeMode::WithEmail(_) | AcmeMode::Anonymous) {
let (http_port, https_port) = result.allocated_ports.iter().fold(
(8080u16, 8443u16),
|(h, hs), (n, p)| match n.as_str() {
"http" => (*p, hs),
"https" => (h, *p),
_ => (h, hs),
},
);
println!(" For LE to issue certs Caddy must be reachable from the internet:");
println!(" - DNS A/AAAA for each --url host must point at this machine");
if http_port == 80 && https_port == 443 {
println!(
" - Caddy listens on host 80/443; forward router 80→80 and 443→443"
);
println!(" - Firewall must allow 80/443 (ufw / firewalld / nft varies)");
} else {
println!(
" - Caddy listens on host {http_port}/{https_port} (rootless); \
forward router 80→{http_port} and 443→{https_port}"
);
println!(
" - Firewall must allow {http_port}/{https_port} \
(ufw / firewalld / nft varies)"
);
}
println!(" Cert issuance is async — watch progress with:");
println!(" journalctl --user -u caddy -f");
} else {
println!(
" For Let's Encrypt: ryra remove caddy && ryra add caddy --acme you@example.com"
);
}
println!(" For Cloudflare DNS-01, wildcards, or BYO certs: edit {snippet_path}");
}
}
}
if !dry_run {
if first_run {
super::linger::offer_enable().await?;
} else {
super::linger::warn_if_disabled().await?;
}
}
Ok(())
}
fn prompt_env(
env: &ryra_core::registry::service_def::EnvVar,
default_ctx: &BTreeMap<String, String>,
env_overrides: &mut BTreeMap<String, String>,
) -> Result<()> {
use ryra_core::registry::service_def::EnvKind;
let prompt_text = env.prompt.as_deref().unwrap_or(&env.name);
if env.kind == EnvKind::Required {
let value: String = Input::new()
.with_prompt(format!(" {prompt_text} (required)"))
.interact_text()?;
env_overrides.insert(env.name.clone(), value);
} else {
let resolved_default = ryra_core::generate::template::render(&env.value, default_ctx)
.unwrap_or_else(|_| env.value.clone());
let value: String = Input::new()
.with_prompt(format!(" {prompt_text}"))
.default(resolved_default.clone())
.interact_text()?;
if value != resolved_default {
env_overrides.insert(env.name.clone(), value);
} else if env.value.contains("{{secret.") {
env_overrides.insert(env.name.clone(), resolved_default);
}
}
Ok(())
}
fn collect_non_interactive<'a>(
env: &'a ryra_core::registry::service_def::EnvVar,
env_overrides: &mut BTreeMap<String, String>,
missing: &mut Vec<&'a str>,
) {
use ryra_core::registry::service_def::EnvKind;
if let Ok(val) = std::env::var(&env.name) {
env_overrides.insert(env.name.clone(), val);
} else if env.kind == EnvKind::Required {
missing.push(env.name.as_str());
}
}
fn setup_host_access(service: &str, domains: &[&str]) {
use std::process::Command;
let ca_source = ryra_core::service_home("caddy")
.map(|h| h.parent().map(|p| p.join("caddy-root-ca.crt")))
.unwrap_or_else(|e| {
eprintln!(" Warning: could not resolve caddy service home: {e}");
None
})
.filter(|p| p.exists());
let have_certutil = Command::new("certutil").arg("-V").output().is_ok();
if !have_certutil {
println!();
println!(" Note: `certutil` is not installed, so ryra can't register the Caddy CA");
println!(" with Chromium or Firefox automatically. To enable rootless trust:");
println!(" Fedora/RHEL: sudo dnf install nss-tools");
println!(" Debian/Ubuntu: sudo apt install libnss3-tools");
println!(" Arch: sudo pacman -S nss");
println!(" Then re-run `ryra add caddy` (or click through the browser warning).");
}
if have_certutil && let (Some(nssdb_path), Some(ca)) = (super::nssdb_dir(), ca_source.as_ref())
{
if !nssdb_path.exists() {
if let Err(e) = std::fs::create_dir_all(&nssdb_path) {
eprintln!(" Warning: could not create {}: {e}", nssdb_path.display());
} else {
let init = Command::new("certutil")
.args([
"-N",
"-d",
&format!("sql:{}", nssdb_path.display()),
"--empty-password",
])
.status();
match init {
Ok(s) if s.success() => {}
_ => eprintln!(
" Warning: could not initialize NSS DB at {}",
nssdb_path.display()
),
}
}
}
if nssdb_path.exists() {
add_ca_to_nssdb(
&format!("sql:{}", nssdb_path.display()),
ca,
"Chromium family",
);
}
}
if have_certutil && let Some(ca) = ca_source.as_ref() {
for profile in super::firefox_profile_dirs() {
add_ca_to_nssdb(
&format!("sql:{}", profile.display()),
ca,
&format!("Firefox profile {}", profile.display()),
);
}
}
let hostnames: Vec<String> = domains
.iter()
.filter_map(|d| {
url::Url::parse(d)
.ok()
.and_then(|u| u.host_str().map(String::from))
})
.filter(|h| !h.to_ascii_lowercase().ends_with(".ts.net"))
.collect();
let hosts_content = std::fs::read_to_string("/etc/hosts").unwrap_or_default();
let mut missing_hosts: Vec<&str> = hostnames
.iter()
.filter(|h| {
!hosts_content.lines().any(|l| {
let l = l.trim();
!l.starts_with('#') && l.split_whitespace().any(|w| w == h.as_str())
})
})
.map(String::as_str)
.collect();
if !missing_hosts.is_empty() {
let line = format!(
"127.0.0.1 {} # Service-Source: registry/{service}",
missing_hosts.join(" ")
);
let cmd = format!("echo '{line}' >> /etc/hosts");
let sudo_n = Command::new("sudo")
.args(["-n", "sh", "-c", &cmd])
.status()
.map(|s| s.success())
.unwrap_or(false);
let wrote = if sudo_n {
true
} else if std::io::stderr().is_terminal() {
eprintln!(
" Adding {} to /etc/hosts (sudo required):",
missing_hosts.join(", ")
);
Command::new("sudo")
.args(["sh", "-c", &cmd])
.status()
.map(|s| s.success())
.unwrap_or(false)
} else {
false
};
if wrote {
println!(
" Added {} to /etc/hosts (via sudo).",
missing_hosts.join(", ")
);
missing_hosts.clear();
} else {
eprintln!();
eprintln!(
" WARN: {} not in /etc/hosts — the service URL won't resolve.",
missing_hosts.join(", ")
);
eprintln!(" Run: echo '{line}' | sudo tee -a /etc/hosts");
eprintln!();
}
}
let ca_target = super::CA_TARGETS.iter().find(|t| {
let dir = std::path::Path::new(t.cert_path)
.parent()
.unwrap_or(std::path::Path::new("/"));
dir.is_dir()
});
let need_system_ca = ca_source.is_some()
&& ca_target.is_some()
&& !super::CA_TARGETS
.iter()
.any(|t| std::path::Path::new(t.cert_path).exists());
if missing_hosts.is_empty() && !need_system_ca {
return;
}
println!();
println!(" Optional (requires sudo) — run yourself if you need these:");
if !missing_hosts.is_empty() {
println!(
" echo '127.0.0.1 {}' | sudo tee -a /etc/hosts",
missing_hosts.join(" ")
);
}
if let (true, Some(ca), Some(target)) = (need_system_ca, ca_source.as_ref(), ca_target) {
println!(
" sudo cp {} {} && sudo {}",
ca.display(),
target.cert_path,
target.update_cmd,
);
println!(" (lets curl/wget and Firefox with p11-kit trust the Caddy CA too)");
}
println!();
}
fn add_ca_to_nssdb(nss_arg: &str, ca: &std::path::Path, context: &str) {
use std::process::Command;
let present = Command::new("certutil")
.args(["-d", nss_arg, "-L", "-n", super::CADDY_CA_NICKNAME])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if present {
return;
}
let status = Command::new("certutil")
.args([
"-d",
nss_arg,
"-A",
"-t",
"C,,",
"-n",
super::CADDY_CA_NICKNAME,
"-i",
&ca.display().to_string(),
])
.status();
match status {
Ok(s) if s.success() => println!(" Caddy CA added to {context}."),
Ok(s) => eprintln!(" Warning: certutil exited with {s} for {context}"),
Err(e) => eprintln!(" Warning: could not run certutil for {context}: {e}"),
}
}
async fn ensure_tailscale_admin_token(interactive: bool) -> Result<()> {
let paths = ryra_core::config::ConfigPaths::resolve()?;
let mut config = ryra_core::config::load_or_default(&paths.config_file)?;
if config.tailscale.is_some() {
return Ok(()); }
let admin_api_key = if interactive {
prompt_tailscale_admin_token()?
} else {
std::env::var("TAILSCALE_API_KEY").map_err(|_| {
anyhow::anyhow!(
"--tailscale needs a Tailscale admin API token. Set TAILSCALE_API_KEY \
(tskey-api-…) or run interactively to be prompted.\n\
Generate one at https://login.tailscale.com/admin/settings/keys \
(use the \"API access token\" type, not an auth key)"
)
})?
};
let had_secrets_before = config.has_secrets();
config.tailscale = Some(ryra_core::config::schema::TailscaleConfig {
admin_api_key,
tailnet: None,
});
paths.ensure_dirs()?;
ryra_core::config::save_config(&paths.config_file, &config)?;
println!(
" ✓ Tailscale admin token saved to {}",
paths.config_file.display()
);
warn_if_first_secret_save(&paths, had_secrets_before, &config);
Ok(())
}
fn prompt_tailscale_admin_token() -> Result<String> {
println!();
println!("First-time Tailscale setup — paste an admin API token.");
println!(" Generate at: https://login.tailscale.com/admin/settings/keys");
println!(" Type: \"API access token\" (NOT an auth key)");
println!();
println!(" ryra uses this to define Tailscale Services in your tailnet,");
println!(" set up the ACL with auto-approval, and apply tag:ryra-host");
println!(" to this machine — all so `ryra add … --tailscale` is one step.");
println!();
let raw: String = Input::new()
.with_prompt("Tailnet admin API token")
.validate_with(|input: &String| -> std::result::Result<(), &str> {
let s = input.trim();
if s.starts_with("tskey-api-") {
Ok(())
} else {
Err(
"Admin API tokens start with `tskey-api-`. The other tskey-* \
prefixes (auth-, client-) are for joining devices, not for \
admin operations. Generate one at \
https://login.tailscale.com/admin/settings/keys with type \
\"API access token\".",
)
}
})
.interact_text()?;
Ok(raw.trim().to_string())
}
fn derive_tailscale_url(service: &str) -> Result<String> {
let node = ryra_core::system::tailscale::self_dns_name().ok_or_else(|| {
anyhow::anyhow!("--tailscale: no logged-in tailnet (preflight should have caught this)")
})?;
let host = ryra_core::system::tailscale::self_short_hostname().ok_or_else(|| {
anyhow::anyhow!(
"--tailscale: couldn't extract host label from MagicDNS name '{node}' \
(expected three-label `<host>.<tailnet>.ts.net`)"
)
})?;
let tailnet = ryra_core::system::tailscale::tailnet_suffix(&node).ok_or_else(|| {
anyhow::anyhow!(
"--tailscale: couldn't extract tailnet from MagicDNS name '{node}' \
(expected three-label `<host>.<tailnet>.ts.net`)"
)
})?;
Ok(format!("https://{service}-{host}.{tailnet}"))
}
fn service_url_is_caddy_local(url: &str) -> bool {
url::Url::parse(url)
.ok()
.and_then(|u| u.host_str().map(|h| h.to_ascii_lowercase()))
.is_some_and(|h| {
h.ends_with(&format!(
".{}",
ryra_core::config::schema::CADDY_LOCAL_DOMAIN
))
})
}
fn check_auth_exposure_compat(
config: &Config,
service: &str,
service_url: Option<&str>,
) -> Result<()> {
let Some(auth) = &config.auth else {
return Ok(());
};
let auth_url = auth.url();
let auth_is_local = service_url_is_caddy_local(auth_url);
if !auth_is_local {
return Ok(());
}
let Some(svc_url) = service_url else {
return Ok(());
};
if service_url_is_caddy_local(svc_url) {
return Ok(());
}
bail!(
"authelia is local-only at {auth_url}, but {service} will be reachable at \
{svc_url}. Off-host clients (e.g., other devices on your tailnet) can't \
resolve `*.internal` hostnames, so the OIDC redirect from {service} back \
to authelia would fail.\n\n\
Fix: re-install authelia at the same exposure as {service}:\n \
ryra remove authelia --purge\n \
ryra add authelia --tailscale (or --url <public-https-url>)"
);
}
async fn dns_resolves(host: &str) -> bool {
tokio::net::lookup_host((host, 0u16))
.await
.map(|mut it| it.next().is_some())
.unwrap_or(false)
}
async fn dns_preflight_for_acme(url: &str, interactive: bool) -> Result<()> {
let Some(host) = url::Url::parse(url)
.ok()
.and_then(|u| u.host_str().map(|s| s.to_string()))
else {
return Ok(());
};
if dns_resolves(&host).await {
return Ok(());
}
eprintln!("\n Warning: DNS for '{host}' doesn't resolve.");
eprintln!(" Caddy will request a Let's Encrypt cert and fail repeatedly until DNS is fixed.");
eprintln!(
" Each failure counts against LE rate limits (~5 failed validations/hour per domain)."
);
if !interactive {
eprintln!(" (Continuing — you're running non-interactively.)");
return Ok(());
}
let proceed = Confirm::new()
.with_prompt(format!("Continue installing Caddy with LE for '{host}'?"))
.default(false)
.interact()?;
if !proceed {
bail!("aborted: fix DNS for '{host}' and re-run, or pick a different TLS option");
}
Ok(())
}
enum TlsHandling {
LetsEncrypt(AcmeMode),
SelfSigned,
External,
}
fn acme_mode_from_email(email: String) -> AcmeMode {
if email.is_empty() {
AcmeMode::Anonymous
} else {
AcmeMode::WithEmail(email)
}
}
async fn prompt_tls_for_public_url(url: &str) -> Result<TlsHandling> {
let host = url::Url::parse(url)
.ok()
.and_then(|u| u.host_str().map(|s| s.to_string()))
.unwrap_or_else(|| url.to_string());
println!();
println!("'{host}' is a public URL but Caddy (reverse proxy) isn't installed.");
let items = &[
"Let's Encrypt — Caddy auto-issues real certs (DNS + Caddy reachable from the internet)",
"Self-signed (LAN) — Caddy local CA, browsers warn unless trusted",
"External — I'll handle TLS myself (Cloudflare Tunnel, nginx, etc.)",
];
let selection = dialoguer::Select::new()
.with_prompt("How should TLS be handled?")
.items(items)
.default(0)
.interact()?;
match selection {
0 => {
let email: String = Input::new()
.with_prompt(
"Email for Let's Encrypt (optional — for renewal notices, press Enter to skip)",
)
.allow_empty(true)
.interact_text()?;
Ok(TlsHandling::LetsEncrypt(acme_mode_from_email(email)))
}
1 => Ok(TlsHandling::SelfSigned),
_ => Ok(TlsHandling::External),
}
}
async fn prompt_exposure_for(
service: &str,
auth_will_inherit: bool,
allow_loopback: bool,
) -> Result<ryra_core::Exposure> {
let mut items: Vec<&str> = Vec::with_capacity(5);
if allow_loopback {
items.push("Local only — http://127.0.0.1 on this machine (no proxy)");
}
items.extend_from_slice(&[
"Tailscale (recommended): access from anywhere in your own global network",
"Self-signed (LAN) — Caddy local CA at *.internal (browsers warn unless trusted)",
"Public + Let's Encrypt — Caddy issues real certs (DNS + Caddy reachable from the internet)",
"External — I have my own reverse proxy (Cloudflare Tunnel, nginx, etc.)",
]);
if auth_will_inherit {
println!(
"(authelia will inherit this choice — install it separately first if you need a different exposure)"
);
}
let selection = dialoguer::Select::new()
.with_prompt(format!("How will '{service}' be reachable?"))
.items(&items)
.default(0)
.interact()?;
let loopback_offset: usize = if allow_loopback { 1 } else { 0 };
if allow_loopback && selection == 0 {
return Ok(ryra_core::Exposure::Loopback);
}
match selection - loopback_offset {
0 => {
if let Err(e) = ryra_core::system::doctor::check_tailscale_runtime() {
bail!("Tailscale not ready:\n\n{e}");
}
ensure_tailscale_admin_token(true).await?;
Ok(ryra_core::Exposure::Tailscale {
url: derive_tailscale_url(service)?,
})
}
1 => {
if !ryra_core::is_service_installed("caddy") {
println!("\nInstalling caddy (self-signed LAN mode)...\n");
Box::pin(run(
&[WellKnownService::Caddy.to_string()],
None,
false,
None,
&[],
false,
None,
false,
true,
))
.await?;
}
let installed_all = ryra_core::list_installed().unwrap_or_default();
let caddy_https_port =
find_installed_provider(&installed_all, Capability::ReverseProxy)
.and_then(|s| s.ports.get("https").copied())
.unwrap_or(DEFAULT_CADDY_HTTPS_PORT);
Ok(ryra_core::Exposure::Internal {
url: format!(
"https://{service}.{}:{caddy_https_port}",
ryra_core::config::schema::CADDY_LOCAL_DOMAIN
),
})
}
2 => {
let url: String = Input::new()
.with_prompt(format!("Public URL for '{service}'"))
.interact_text()?;
if !ryra_core::is_service_installed("caddy") {
let email: String = Input::new()
.with_prompt("Email for Let's Encrypt (optional — for renewal notices, press Enter to skip)")
.allow_empty(true)
.interact_text()?;
dns_preflight_for_acme(&url, true).await?;
let mode = acme_mode_from_email(email);
println!("\nInstalling caddy (Let's Encrypt mode)...\n");
Box::pin(run(
&[WellKnownService::Caddy.to_string()],
None,
false,
None,
&[],
false,
Some(&mode),
false,
true,
))
.await?;
} else {
eprintln!(
" Note: caddy is already installed — using its existing TLS mode.\n \
Edit ~/.local/share/services/caddy/config/tls.caddy to switch to Let's Encrypt."
);
}
Ok(ryra_core::Exposure::Public { url })
}
_ => {
let url: String = Input::new()
.with_prompt(format!("Public URL for '{service}'"))
.interact_text()?;
Ok(ryra_core::Exposure::Public { url })
}
}
}
fn warn_if_first_secret_save(
paths: &ryra_core::config::ConfigPaths,
had_secrets_before: bool,
config: &ryra_core::config::schema::Config,
) {
if !had_secrets_before && config.has_secrets() {
println!(
" Note: credentials saved to {} (mode 0600 / do not commit or share).",
paths.config_file.display()
);
}
}
async fn ensure_smtp_for_add(provider: SmtpProvider) -> Result<()> {
let paths = ConfigPaths::resolve()?;
let mut config = ryra_core::config::load_or_default(&paths.config_file)?;
if config.smtp.is_some() {
return Ok(());
}
match provider {
SmtpProvider::Inbucket => {
if !ryra_core::is_service_installed("inbucket") {
println!("\nInstalling inbucket...\n");
Box::pin(run(
&[WellKnownService::Inbucket.to_string()],
None,
false,
None,
&[],
false,
None,
false,
true,
))
.await?;
config = ryra_core::config::load_or_default(&paths.config_file)?;
}
let had_secrets_before = config.has_secrets();
config.smtp = Some(ryra_core::config::schema::SmtpCredentials {
host: "inbucket".to_string(),
port: INBUCKET_SMTP_PORT,
username: String::new(),
password: String::new(),
from: "noreply@example.com".to_string(),
security: ryra_core::config::schema::SmtpSecurity::Off,
});
paths.ensure_dirs()?;
ryra_core::config::save_config(&paths.config_file, &config)?;
println!(
" SMTP configured (inbucket). Saved to {}\n",
paths.config_file.display()
);
warn_if_first_secret_save(&paths, had_secrets_before, &config);
}
}
Ok(())
}
async fn ensure_dependencies(auth: bool, tailscale: bool, interactive: bool) -> Result<()> {
let config = ryra_core::config::load_or_default(
&ryra_core::config::ConfigPaths::resolve()?.config_file,
)?;
let needs_authelia =
auth && !ryra_core::is_service_installed("authelia") && config.auth.is_none();
if !needs_authelia {
return Ok(());
}
if interactive {
let confirm = Confirm::new()
.with_prompt("Authelia (SSO provider) is not installed. Install it?")
.default(true)
.interact()?;
if !confirm {
bail!("authelia is required for --auth");
}
}
println!("\nInstalling authelia...\n");
Box::pin(run(
&[WellKnownService::Authelia.to_string()],
None,
false,
None,
&[],
tailscale,
None,
false,
true,
))
.await?;
Ok(())
}
async fn ensure_auth_for_add(
config: &mut Config,
paths: &ConfigPaths,
dry_run: bool,
parent_tailscale: bool,
) -> Result<bool> {
match prompts::ensure_auth_configured(config, paths).await? {
prompts::AuthSetupChoice::External(_) => Ok(true),
prompts::AuthSetupChoice::InstallAuthelia => {
if ryra_core::is_service_installed("authelia") {
println!();
println!("Authelia is already installed — configuring auth...");
if try_configure_auth_from_installed(config, paths)? {
return Ok(true);
}
println!("Could not auto-configure auth from installed authelia.");
return Ok(false);
}
println!("\nInstalling authelia...\n");
Box::pin(run(
&[WellKnownService::Authelia.to_string()],
None,
false,
None,
&[],
parent_tailscale,
None,
dry_run,
true,
))
.await?;
*config = ryra_core::config::load_or_default(&paths.config_file)?;
if config.auth.is_some() {
println!();
Ok(true)
} else {
println!("Auth was not configured after installing authelia.");
Ok(false)
}
}
prompts::AuthSetupChoice::Skip => {
println!("Skipped auth setup.");
Ok(false)
}
}
}
fn resolve_auth_kind(
auth_flag: bool,
interactive: bool,
supported: &[AuthKind],
auth_configured: bool,
) -> Result<Option<AuthKind>> {
if auth_flag {
return Ok(supported.first().cloned());
}
if supported.is_empty() || !interactive {
return Ok(None);
}
if supported.len() == 1 {
let kind = &supported[0];
let enable = Confirm::new()
.with_prompt(format!("Enable {kind} auth?"))
.default(auth_configured)
.interact()?;
return Ok(if enable { Some(kind.clone()) } else { None });
}
let items: Vec<String> = std::iter::once("None".to_string())
.chain(supported.iter().map(|k| k.to_string()))
.collect();
let selection = dialoguer::Select::new()
.with_prompt("Auth mode")
.items(&items)
.default(if auth_configured { 1 } else { 0 })
.interact()?;
Ok(if selection == 0 {
None
} else {
Some(supported[selection - 1].clone())
})
}
fn warn_untrusted_service(
service_dir: &std::path::Path,
service: &str,
interactive: bool,
) -> Result<()> {
let quadlet_dir = service_dir.join("quadlets");
let mut scripts: Vec<String> = Vec::new();
let mut volumes: Vec<String> = Vec::new();
if let Ok(entries) = std::fs::read_dir(&quadlet_dir) {
for entry in entries.flatten() {
if let Ok(content) = std::fs::read_to_string(entry.path()) {
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("ExecStartPre=") || trimmed.starts_with("ExecStartPost=")
{
scripts.push(trimmed.to_string());
}
if trimmed.starts_with("Volume=") {
let vol = trimmed.strip_prefix("Volume=").unwrap_or(trimmed);
if vol.contains("%h") || vol.starts_with('/') {
volumes.push(vol.to_string());
}
}
}
}
}
}
let scripts_dir = service_dir.join("configs").join("scripts");
let mut config_scripts: Vec<String> = Vec::new();
if let Ok(entries) = std::fs::read_dir(&scripts_dir) {
for entry in entries.flatten() {
if let Some(name) = entry.file_name().to_str() {
config_scripts.push(name.to_string());
}
}
}
println!();
println!(
" {} {service} is from an external registry.",
super::style::warning()
);
println!(" External services can run arbitrary code on your host.");
if !scripts.is_empty() {
println!();
println!(" Quadlet hooks (run as your user):");
for s in &scripts {
println!(" {s}");
}
}
if !config_scripts.is_empty() {
println!();
println!(" Scripts (copied to service data dir):");
for s in &config_scripts {
println!(" {s}");
}
}
if !volumes.is_empty() {
println!();
println!(" Host bind mounts:");
for v in &volumes {
println!(" {v}");
}
}
println!();
if !interactive {
bail!("{service} is from an external registry — use --yes to accept or run interactively");
}
let proceed = Confirm::new()
.with_prompt(" Install this service?")
.default(false)
.interact()?;
if !proceed {
bail!("cancelled");
}
Ok(())
}
fn try_configure_auth_from_installed(config: &mut Config, paths: &ConfigPaths) -> Result<bool> {
let env_path = ryra_core::service_home(WellKnownService::Authelia.as_str())?.join(".env");
let env_content = match std::fs::read_to_string(&env_path) {
Ok(content) => content,
Err(_) => return Ok(false),
};
let installed_all = ryra_core::list_installed().unwrap_or_default();
let port = find_installed_provider(&installed_all, Capability::OidcProvider)
.and_then(|s| s.ports.values().next().copied())
.unwrap_or(DEFAULT_AUTHELIA_PORT);
if env_content.is_empty() {
return Ok(false);
}
let url = format!("http://localhost:{port}");
config.auth = Some(ryra_core::config::schema::AuthCredentials::Authelia { url, port });
paths.ensure_dirs()?;
ryra_core::config::save_config(&paths.config_file, config)?;
println!(
" Auth configured. Saved to {}",
paths.config_file.display()
);
Ok(true)
}
fn needs_https(
https_requirement: HttpsRequirement,
auth_requested: bool,
url: Option<&str>,
) -> bool {
matches!(https_requirement, HttpsRequirement::Always)
|| (matches!(https_requirement, HttpsRequirement::Auth) && auth_requested)
|| url.is_some_and(|u| u.starts_with("https://"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn never_service_stays_http() {
assert!(!needs_https(HttpsRequirement::Never, false, None));
assert!(!needs_https(HttpsRequirement::Never, true, None));
assert!(!needs_https(
HttpsRequirement::Never,
true,
Some("http://foo.example.com"),
));
}
#[test]
fn always_service_always_promotes() {
assert!(needs_https(HttpsRequirement::Always, false, None));
assert!(needs_https(
HttpsRequirement::Always,
false,
Some("http://foo.example.com"),
));
}
#[test]
fn auth_service_promotes_only_with_auth() {
assert!(needs_https(HttpsRequirement::Auth, true, None));
assert!(!needs_https(HttpsRequirement::Auth, false, None));
}
#[test]
fn explicit_https_url_promotes() {
assert!(needs_https(
HttpsRequirement::Never,
false,
Some("https://foo.example.com"),
));
}
}