From 9c4c55dec803ec6e7687995f56f721a94f1802b5 Mon Sep 17 00:00:00 2001 From: Ben Herila Date: Fri, 19 Jun 2026 10:07:25 -0700 Subject: [PATCH 1/9] feat(settings): add flat public hostname strategy (cherry picked from commit 11482bd7885c8918a62e5c4116bff8d2583ff4e1) --- CHANGELOG.md | 7 + crates/temps-config/src/handler.rs | 24 +- crates/temps-config/src/service.rs | 25 +- crates/temps-core/src/app_settings.rs | 4 + crates/temps-core/src/lib.rs | 2 + crates/temps-core/src/public_hostname.rs | 377 ++++++++++++++++++ .../src/handlers/deployments.rs | 110 ++--- .../temps-deployments/src/handlers/nodes.rs | 18 +- .../src/services/services.rs | 21 +- .../src/services/environment_service.rs | 17 +- crates/temps-routes/src/route_table.rs | 46 +-- .../src/services/preview_urls.rs | 6 +- docs/advanced/networking/page.mdx | 41 ++ web/src/api/client/types.gen.ts | 22 + web/src/api/platformSettings.ts | 11 + web/src/pages/Settings.tsx | 161 +++++++- 16 files changed, 737 insertions(+), 155 deletions(-) create mode 100644 crates/temps-core/src/public_hostname.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 85dbfda82..91c8f67f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.1.0-beta.41] - 2026-07-02 ### CI +### Added +- **Configurable public hostname strategy**: platform settings now support + `public_hostnames` templates for generated environment, deployment, and + public compose-service routes. Operators can switch to the `flat` strategy to + keep generated hostnames one label beneath `preview_domain` for proxied + wildcard TLS providers such as Cloudflare Universal SSL; labels are sanitized + and truncated with stable short-hash suffixes when they exceed DNS limits. - **changelog:** Skip preview comment on fork PRs diff --git a/crates/temps-config/src/handler.rs b/crates/temps-config/src/handler.rs index 8ca937f31..f793cccdb 100644 --- a/crates/temps-config/src/handler.rs +++ b/crates/temps-config/src/handler.rs @@ -17,7 +17,8 @@ use temps_core::{ problemdetails::Problem, AiConfigSettings, AppSettings, AuditContext, AuditLogger, AuditOperation, BuildLimitsSettings, ClusterDnsSettings, ContainerLogSettings, DiskSpaceAlertSettings, LetsEncryptSettings, MetricsStoreKind, RateLimitSettings, - RequestMetadata, ScreenshotSettings, SecurityHeadersSettings, + PublicHostnameSettings, PublicHostnameStrategy, RequestMetadata, ScreenshotSettings, + SecurityHeadersSettings, }; use tracing::{error, info}; use utoipa::{OpenApi, ToSchema}; @@ -82,6 +83,7 @@ pub struct AppSettingsResponse { pub external_url: Option, pub internal_url: Option, pub preview_domain: String, + pub public_hostnames: PublicHostnameSettings, // Screenshot settings pub screenshots: ScreenshotSettings, @@ -265,6 +267,7 @@ impl From for AppSettingsResponse { external_url: settings.external_url, internal_url: settings.internal_url, preview_domain: settings.preview_domain, + public_hostnames: settings.public_hostnames, screenshots: settings.screenshots, letsencrypt: settings.letsencrypt, dns_provider: DnsProviderSettingsMasked { @@ -393,6 +396,8 @@ impl AppSettingsResponse { crate::disk_status::DiskSpaceCheckResult, ContainerLogSettings, ClusterDnsSettings, + PublicHostnameSettings, + PublicHostnameStrategy, DnsProviderSettingsMasked, DockerRegistrySettingsMasked, AgentSandboxSettingsMasked, @@ -748,6 +753,21 @@ fn preserve_self_recorded_fields(incoming: &mut AppSettings, current: &AppSettin incoming.console_version = current.console_version.clone(); } +fn normalize_public_hostname_templates(settings: &mut AppSettings) { + fn normalize_template(template: &mut Option) { + if let Some(value) = template.take() { + let trimmed = value.trim().to_string(); + if !trimmed.is_empty() { + *template = Some(trimmed); + } + } + } + + normalize_template(&mut settings.public_hostnames.environment_template); + normalize_template(&mut settings.public_hostnames.service_template); + normalize_template(&mut settings.public_hostnames.deployment_template); +} + /// Update application settings #[utoipa::path( tag = "Settings", @@ -972,6 +992,8 @@ async fn update_settings( } } + normalize_public_hostname_templates(&mut settings); + match app_state.config_service.update_settings(settings).await { Ok(_) => { let audit = SettingsUpdatedAudit { diff --git a/crates/temps-config/src/service.rs b/crates/temps-config/src/service.rs index 1f09afdd6..39a20bd02 100644 --- a/crates/temps-config/src/service.rs +++ b/crates/temps-config/src/service.rs @@ -317,9 +317,6 @@ impl ServerConfig { } } -// Default domain for local development (resolves to 127.0.0.1) -pub const DEFAULT_LOCAL_DOMAIN: &str = "localho.st"; - /// Service that provides centralized access to configuration paths and settings /// Handles path resolution, persistent settings, and ensures consistency across the application /// How long a cached `AppSettings` snapshot is served before `get_settings` @@ -851,7 +848,7 @@ impl ConfigService { } /// Get the full deployment URL for a given deployment slug - /// Always returns [protocol]://{slug}.{preview_domain} + /// using the configured public hostname strategy. /// Get the deployment URL by deployment ID pub async fn get_deployment_url( &self, @@ -901,28 +898,22 @@ impl ConfigService { ("http".to_string(), Some(self.proxy_port())) }; - // Use preview_domain if set, otherwise fallback to DEFAULT_LOCAL_DOMAIN - let preview_domain = if !settings.preview_domain.is_empty() { - settings.preview_domain.trim_start_matches("*.").to_string() - } else { - DEFAULT_LOCAL_DOMAIN.to_string() - }; + let hostname = settings + .public_hostnames + .deployment_hostname(&settings.preview_domain, deployment_slug); - // Construct the URL as [protocol]://{slug}.{preview_domain}[:port] + // Construct the URL as [protocol]://{host}[:port] // Only include port if it's non-standard (not 443 for https, not 80 for http) let url = if let Some(port) = port { let is_standard_port = (protocol == "https" && port == 443) || (protocol == "http" && port == 80); if is_standard_port { - format!("{}://{}.{}", protocol, deployment_slug, preview_domain) + format!("{}://{}", protocol, hostname) } else { - format!( - "{}://{}.{}:{}", - protocol, deployment_slug, preview_domain, port - ) + format!("{}://{}:{}", protocol, hostname, port) } } else { - format!("{}://{}.{}", protocol, deployment_slug, preview_domain) + format!("{}://{}", protocol, hostname) }; Ok(url) diff --git a/crates/temps-core/src/app_settings.rs b/crates/temps-core/src/app_settings.rs index c04f24102..0cb2758a6 100644 --- a/crates/temps-core/src/app_settings.rs +++ b/crates/temps-core/src/app_settings.rs @@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use utoipa::ToSchema; +use crate::PublicHostnameSettings; + /// Application settings stored in the database /// All fields have sensible defaults for easy onboarding #[derive(Debug, Clone, Serialize, ToSchema, Deserialize)] @@ -17,6 +19,7 @@ pub struct AppSettings { /// `external_url`, which is the public-facing address. pub internal_url: Option, pub preview_domain: String, + pub public_hostnames: PublicHostnameSettings, // Screenshot settings pub screenshots: ScreenshotSettings, @@ -714,6 +717,7 @@ impl Default for AppSettings { external_url: None, internal_url: None, preview_domain: DEFAULT_LOCAL_DOMAIN.to_string(), + public_hostnames: PublicHostnameSettings::default(), screenshots: ScreenshotSettings::default(), letsencrypt: LetsEncryptSettings::default(), dns_provider: DnsProviderSettings::default(), diff --git a/crates/temps-core/src/lib.rs b/crates/temps-core/src/lib.rs index 921b1a5fe..17542df0f 100644 --- a/crates/temps-core/src/lib.rs +++ b/crates/temps-core/src/lib.rs @@ -15,6 +15,7 @@ pub mod on_demand; pub mod openapi; pub mod plugin; pub mod problemdetails; +pub mod public_hostname; pub mod retry; pub mod telemetry; pub mod tls; @@ -50,6 +51,7 @@ pub use error::*; pub use error_builder::*; pub use jobs::*; pub use on_demand::*; +pub use public_hostname::{PublicHostnameContext, PublicHostnameSettings, PublicHostnameStrategy}; pub use telemetry::{NoopTelemetryReporter, TelemetryEvent, TelemetryEventKind, TelemetryReporter}; pub use traces::{ TraceQueryFilter, TraceReader, TraceReaderError, TraceSpanDto, TraceSpanEventDto, diff --git a/crates/temps-core/src/public_hostname.rs b/crates/temps-core/src/public_hostname.rs new file mode 100644 index 000000000..12f835452 --- /dev/null +++ b/crates/temps-core/src/public_hostname.rs @@ -0,0 +1,377 @@ +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use utoipa::ToSchema; + +const DEFAULT_BASE_DOMAIN: &str = "localho.st"; +const DNS_LABEL_MAX_LEN: usize = 63; +const SHORT_HASH_LEN: usize = 8; + +/// Public hostname generation mode for Temps-managed preview routes. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PublicHostnameStrategy { + /// Preserve Temps' existing generated hostname layout. + Standard, + /// Force generated hostnames to one label below `preview_domain`. + Flat, +} + +impl Default for PublicHostnameStrategy { + fn default() -> Self { + Self::Standard + } +} + +/// Operator-configurable templates for generated public hostnames. +/// +/// Templates may use `{base_domain}`, `{environment}`, `{service}`, +/// `{deployment}`, `{project}`, `{app}`, `{branch}`, `{preview_slug}`, and +/// `{short_hash}`. When `strategy = flat`, all generated labels before +/// `{base_domain}` are collapsed into a single DNS label. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq, Eq)] +#[serde(default)] +pub struct PublicHostnameSettings { + pub strategy: PublicHostnameStrategy, + pub environment_template: Option, + pub service_template: Option, + pub deployment_template: Option, +} + +impl Default for PublicHostnameSettings { + fn default() -> Self { + Self { + strategy: PublicHostnameStrategy::Standard, + environment_template: None, + service_template: None, + deployment_template: None, + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct PublicHostnameContext<'a> { + pub app: Option<&'a str>, + pub project: Option<&'a str>, + pub environment: Option<&'a str>, + pub service: Option<&'a str>, + pub deployment: Option<&'a str>, + pub branch: Option<&'a str>, + pub preview_slug: Option<&'a str>, +} + +impl PublicHostnameSettings { + /// Normalize the configured preview domain into the base domain used for + /// generated public hosts. Accepts both `example.com` and `*.example.com`. + pub fn base_domain(&self, preview_domain: &str) -> String { + normalize_base_domain(preview_domain) + } + + pub fn environment_hostname(&self, preview_domain: &str, environment: &str) -> String { + let template = self + .environment_template + .as_deref() + .unwrap_or("{environment}.{base_domain}"); + self.render_hostname( + preview_domain, + template, + PublicHostnameContext { + environment: Some(environment), + preview_slug: Some(environment), + ..Default::default() + }, + ) + } + + pub fn service_hostname( + &self, + preview_domain: &str, + environment: &str, + service: &str, + ) -> String { + let template = self + .service_template + .as_deref() + .unwrap_or(match self.strategy { + PublicHostnameStrategy::Standard => "{service}-{environment}.{base_domain}", + PublicHostnameStrategy::Flat => "{environment}-{service}.{base_domain}", + }); + self.render_hostname( + preview_domain, + template, + PublicHostnameContext { + environment: Some(environment), + service: Some(service), + preview_slug: Some(environment), + ..Default::default() + }, + ) + } + + pub fn deployment_hostname(&self, preview_domain: &str, deployment: &str) -> String { + let template = self + .deployment_template + .as_deref() + .unwrap_or("{deployment}.{base_domain}"); + self.render_hostname( + preview_domain, + template, + PublicHostnameContext { + deployment: Some(deployment), + ..Default::default() + }, + ) + } + + pub fn project_deployment_hostname( + &self, + preview_domain: &str, + project: &str, + environment: &str, + deployment: &str, + ) -> String { + self.render_hostname( + preview_domain, + "{project}-{environment}-{deployment}.{base_domain}", + PublicHostnameContext { + app: Some(project), + project: Some(project), + environment: Some(environment), + deployment: Some(deployment), + preview_slug: Some(environment), + ..Default::default() + }, + ) + } + + pub fn render_hostname( + &self, + preview_domain: &str, + template: &str, + context: PublicHostnameContext<'_>, + ) -> String { + let base_domain = self.base_domain(preview_domain); + let rendered = render_template(template, &base_domain, &context); + normalize_hostname( + &rendered, + &base_domain, + matches!(self.strategy, PublicHostnameStrategy::Flat), + ) + } +} + +fn normalize_base_domain(preview_domain: &str) -> String { + let trimmed = preview_domain + .trim() + .trim_start_matches("*.") + .trim_end_matches('.') + .to_ascii_lowercase(); + + if trimmed.is_empty() { + DEFAULT_BASE_DOMAIN.to_string() + } else { + trimmed + } +} + +fn render_template( + template: &str, + base_domain: &str, + context: &PublicHostnameContext<'_>, +) -> String { + let seed = [ + base_domain, + context.app.unwrap_or(""), + context.project.unwrap_or(""), + context.environment.unwrap_or(""), + context.service.unwrap_or(""), + context.deployment.unwrap_or(""), + context.branch.unwrap_or(""), + context.preview_slug.unwrap_or(""), + ] + .join("|"); + let short_hash = short_hash(&seed); + + let replacements = [ + ("{base_domain}", base_domain), + ("{app}", context.app.or(context.project).unwrap_or("")), + ("{project}", context.project.or(context.app).unwrap_or("")), + ("{environment}", context.environment.unwrap_or("")), + ("{env}", context.environment.unwrap_or("")), + ("{service}", context.service.unwrap_or("")), + ("{deployment}", context.deployment.unwrap_or("")), + ("{branch}", context.branch.unwrap_or("")), + ( + "{preview_slug}", + context.preview_slug.or(context.environment).unwrap_or(""), + ), + ( + "{preview}", + context.preview_slug.or(context.environment).unwrap_or(""), + ), + ("{short_hash}", short_hash.as_str()), + ]; + + replacements + .iter() + .fold(template.to_string(), |acc, (needle, value)| { + acc.replace(needle, value) + }) +} + +fn normalize_hostname(raw: &str, base_domain: &str, force_single_label: bool) -> String { + let host = raw + .trim() + .trim_start_matches("*.") + .trim_end_matches('.') + .to_ascii_lowercase(); + let base_domain = normalize_base_domain(base_domain); + let suffix = format!(".{base_domain}"); + + let relative = if host == base_domain { + String::new() + } else if host.ends_with(&suffix) { + host[..host.len() - suffix.len()].to_string() + } else { + host + }; + + let raw_labels: Vec<&str> = relative + .split('.') + .filter(|label| !label.is_empty()) + .collect(); + if raw_labels.is_empty() { + return base_domain; + } + + let labels = if force_single_label { + vec![dns_label(&raw_labels.join("-"), &relative)] + } else { + raw_labels + .iter() + .map(|label| dns_label(label, label)) + .collect() + }; + + format!("{}.{}", labels.join("."), base_domain) +} + +fn dns_label(label: &str, hash_seed: &str) -> String { + let sanitized = sanitize_label(label); + if sanitized.len() <= DNS_LABEL_MAX_LEN { + return sanitized; + } + + let suffix = format!("-{}", short_hash(hash_seed)); + let max_prefix_len = DNS_LABEL_MAX_LEN.saturating_sub(suffix.len()); + let prefix = sanitized + .chars() + .take(max_prefix_len) + .collect::() + .trim_end_matches('-') + .to_string(); + + if prefix.is_empty() { + short_hash(hash_seed) + } else { + format!("{prefix}{suffix}") + } +} + +fn sanitize_label(label: &str) -> String { + let mut output = String::new(); + let mut previous_hyphen = false; + + for ch in label.chars() { + let lower = ch.to_ascii_lowercase(); + if lower.is_ascii_alphanumeric() { + output.push(lower); + previous_hyphen = false; + } else if lower == '-' && !previous_hyphen { + output.push('-'); + previous_hyphen = true; + } else if !previous_hyphen { + output.push('-'); + previous_hyphen = true; + } + } + + let trimmed = output.trim_matches('-').to_string(); + if trimmed.is_empty() { + "x".to_string() + } else { + trimmed + } +} + +fn short_hash(seed: &str) -> String { + let digest = Sha256::digest(seed.as_bytes()); + format!("{digest:x}").chars().take(SHORT_HASH_LEN).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn base_domain_strips_wildcard_prefix() { + let settings = PublicHostnameSettings::default(); + assert_eq!(settings.base_domain("*.Example.COM."), "example.com"); + } + + #[test] + fn standard_service_hostname_preserves_existing_order() { + let settings = PublicHostnameSettings::default(); + assert_eq!( + settings.service_hostname("*.example.com", "staging", "files"), + "files-staging.example.com" + ); + } + + #[test] + fn flat_service_hostname_uses_environment_first() { + let settings = PublicHostnameSettings { + strategy: PublicHostnameStrategy::Flat, + ..Default::default() + }; + assert_eq!( + settings.service_hostname("example.com", "staging", "files"), + "staging-files.example.com" + ); + } + + #[test] + fn flat_strategy_collapses_nested_template_to_one_label() { + let settings = PublicHostnameSettings { + strategy: PublicHostnameStrategy::Flat, + service_template: Some("{service}.{environment}.{base_domain}".to_string()), + ..Default::default() + }; + assert_eq!( + settings.service_hostname("example.com", "preview-123", "api"), + "api-preview-123.example.com" + ); + } + + #[test] + fn long_generated_label_gets_stable_hash_suffix() { + let settings = PublicHostnameSettings { + strategy: PublicHostnameStrategy::Flat, + ..Default::default() + }; + let host = settings.service_hostname( + "example.com", + "preview-this-branch-name-is-deliberately-long-and-keeps-going", + "extremely-long-service-name-that-would-overflow-the-dns-label", + ); + let label = host.split('.').next().unwrap(); + assert!(label.len() <= DNS_LABEL_MAX_LEN); + assert_eq!( + host, + settings.service_hostname( + "example.com", + "preview-this-branch-name-is-deliberately-long-and-keeps-going", + "extremely-long-service-name-that-would-overflow-the-dns-label", + ) + ); + } +} diff --git a/crates/temps-deployments/src/handlers/deployments.rs b/crates/temps-deployments/src/handlers/deployments.rs index e0cc3a729..48641c743 100644 --- a/crates/temps-deployments/src/handlers/deployments.rs +++ b/crates/temps-deployments/src/handlers/deployments.rs @@ -22,7 +22,7 @@ use futures::SinkExt; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; use temps_auth::permission_guard; use temps_auth::RequireAuth; -use temps_core::{AuditContext, RequestMetadata}; +use temps_core::{AppSettings, AuditContext, RequestMetadata}; use tracing::{debug, error, info, warn}; use utoipa::OpenApi; @@ -37,6 +37,36 @@ use crate::handlers::types::{ use temps_core::problemdetails; use temps_core::problemdetails::Problem; +fn public_url_for_hostname(settings: &AppSettings, hostname: &str) -> String { + let (protocol, port) = if let Some(ref external_url) = settings.external_url { + if let Ok(parsed) = url::Url::parse(external_url) { + (parsed.scheme().to_string(), parsed.port()) + } else if external_url.starts_with("http://") { + ("http".to_string(), None) + } else { + ("https".to_string(), None) + } + } else { + ("https".to_string(), None) + }; + + let port = + port.filter(|p| !((protocol == "https" && *p == 443) || (protocol == "http" && *p == 80))); + + match port { + Some(port) => format!("{}://{}:{}", protocol, hostname, port), + None => format!("{}://{}", protocol, hostname), + } +} + +fn public_service_url(settings: &AppSettings, environment: &str, service: &str) -> String { + let hostname = + settings + .public_hostnames + .service_hostname(&settings.preview_domain, environment, service); + public_url_for_hostname(settings, &hostname) +} + #[derive(OpenApi)] #[openapi( paths( @@ -812,34 +842,14 @@ pub async fn list_containers( } } - // Resolve preview_domain, URL scheme, and env subdomain for per-service URLs. - let settings_row = temps_entities::settings::Entity::find() + // Resolve public hostname settings and env subdomain for per-service URLs. + let app_settings = temps_entities::settings::Entity::find() .one(state.db.as_ref()) .await .ok() - .flatten(); - let preview_domain = settings_row - .as_ref() - .and_then(|s| { - s.data - .get("preview_domain") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - }) - .unwrap_or_else(|| "localho.st".to_string()); - // Derive the URL scheme from external_url so HTTP-only installs - // (sslip.io quick/local modes) don't emit dead https:// links. - let url_scheme = settings_row - .as_ref() - .and_then(|s| s.data.get("external_url").and_then(|v| v.as_str())) - .map(|u| { - if u.starts_with("http://") { - "http" - } else { - "https" - } - }) - .unwrap_or("https"); + .flatten() + .map(|s| AppSettings::from_json(s.data)) + .unwrap_or_default(); let env_subdomain = temps_entities::environments::Entity::find_by_id(environment_id) .one(state.db.as_ref()) @@ -876,15 +886,9 @@ pub async fn list_containers( if !is_public { return None; } - env_subdomain.as_ref().map(|sub| { - let label = format!("{}-{}", svc, sub); - let label = if label.len() > 63 { - label[..63].trim_end_matches('-').to_string() - } else { - label - }; - format!("{}://{}.{}", url_scheme, label, preview_domain) - }) + env_subdomain + .as_ref() + .map(|sub| public_service_url(&app_settings, sub, svc)) }); ContainerInfoResponse::from_info(info, node_name, service_name, service_url) }) @@ -1613,31 +1617,13 @@ pub async fn get_container_detail( .unwrap_or(false); if is_public { - let settings_row2 = temps_entities::settings::Entity::find() + let app_settings = temps_entities::settings::Entity::find() .one(state.db.as_ref()) .await .ok() - .flatten(); - let preview_domain = settings_row2 - .as_ref() - .and_then(|s| { - s.data - .get("preview_domain") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - }) - .unwrap_or_else(|| "localho.st".to_string()); - let url_scheme2 = settings_row2 - .as_ref() - .and_then(|s| s.data.get("external_url").and_then(|v| v.as_str())) - .map(|u| { - if u.starts_with("http://") { - "http" - } else { - "https" - } - }) - .unwrap_or("https"); + .flatten() + .map(|s| AppSettings::from_json(s.data)) + .unwrap_or_default(); let env_subdomain = temps_entities::environments::Entity::find_by_id(environment_id) .one(state.db.as_ref()) @@ -1646,15 +1632,7 @@ pub async fn get_container_detail( .flatten() .map(|e| e.subdomain); - env_subdomain.map(|sub| { - let label = format!("{}-{}", svc_name, sub); - let label = if label.len() > 63 { - label[..63].trim_end_matches('-').to_string() - } else { - label - }; - format!("{}://{}.{}", url_scheme2, label, preview_domain) - }) + env_subdomain.map(|sub| public_service_url(&app_settings, &sub, svc_name)) } else { None } diff --git a/crates/temps-deployments/src/handlers/nodes.rs b/crates/temps-deployments/src/handlers/nodes.rs index 8809f3b27..38fa185d0 100644 --- a/crates/temps-deployments/src/handlers/nodes.rs +++ b/crates/temps-deployments/src/handlers/nodes.rs @@ -27,6 +27,7 @@ use crate::services::node_service::{ HeartbeatRequest, NodeError, NodeService, RegisterNodeRequest, }; use temps_core::problemdetails::{self, Problem}; +use temps_core::AppSettings; use temps_deployer::ContainerDeployer; /// App state for node registration handlers @@ -1419,23 +1420,18 @@ async fn edge_routes( }); } - // 2. Preview domain routes: {subdomain}.{preview_domain} for all active environments + // 2. Preview domain routes for all active environments // (mirrors Section 4 of the control-plane route table) { use temps_entities::settings; - let preview_domain = settings::Entity::find() + let app_settings = settings::Entity::find() .one(app_state.db.as_ref()) .await .ok() .flatten() - .and_then(|s| { - s.data - .get("preview_domain") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - }) - .unwrap_or_else(|| "localho.st".to_string()); + .map(|s| AppSettings::from_json(s.data)) + .unwrap_or_default(); let all_envs = environments::Entity::find() .filter(environments::Column::Subdomain.is_not_null()) @@ -1454,7 +1450,9 @@ async fn edge_routes( })?; for env in &all_envs { - let full_domain = format!("{}.{}", env.subdomain, preview_domain); + let full_domain = app_settings + .public_hostnames + .environment_hostname(&app_settings.preview_domain, &env.subdomain); // Skip if already added from environment_domains if routes.iter().any(|r| r.domain == full_domain) { continue; diff --git a/crates/temps-deployments/src/services/services.rs b/crates/temps-deployments/src/services/services.rs index 7beaecd78..0353d1003 100644 --- a/crates/temps-deployments/src/services/services.rs +++ b/crates/temps-deployments/src/services/services.rs @@ -2584,8 +2584,9 @@ impl DeploymentService { async fn compute_deployment_url(&self, deployment_slug: &str) -> anyhow::Result { let settings = self.config_service.get_settings().await.unwrap_or_default(); - let base_domain = settings.preview_domain; - let domain = format!("{}.{}", deployment_slug, base_domain); + let domain = settings + .public_hostnames + .deployment_hostname(&settings.preview_domain, deployment_slug); // Determine protocol and port from external_url if set, otherwise default to http let (protocol, port) = if let Some(ref url) = settings.external_url { @@ -2633,8 +2634,9 @@ impl DeploymentService { async fn compute_environment_url(&self, env_subdomain: &str) -> anyhow::Result { let settings = self.config_service.get_settings().await.unwrap_or_default(); - let base_domain = settings.preview_domain; - let domain = format!("{}.{}", env_subdomain, base_domain); + let domain = settings + .public_hostnames + .environment_hostname(&settings.preview_domain, env_subdomain); // Determine protocol and port from external_url if set, otherwise default to http let (protocol, port) = if let Some(ref url) = settings.external_url { @@ -2795,8 +2797,6 @@ impl DeploymentService { .await .map_err(|e| DeploymentError::Other(format!("Failed to get settings: {}", e)))?; - let base_domain = settings.preview_domain.trim_start_matches("*.").to_string(); - // Get pipeline id from deployment let deployment = deployments::Entity::find_by_id(deployment_id) .one(self.db.as_ref()) @@ -2805,9 +2805,12 @@ impl DeploymentService { DeploymentError::NotFound(format!("Deployment {} not found", deployment_id)) })?; - let domain = format!( - "{}-{}-{}.{}", - project.slug, environment.slug, deployment.id, base_domain + let deployment_label = deployment.id.to_string(); + let domain = settings.public_hostnames.project_deployment_hostname( + &settings.preview_domain, + &project.slug, + &environment.slug, + &deployment_label, ); // Remove any existing domains for this deployment diff --git a/crates/temps-environments/src/services/environment_service.rs b/crates/temps-environments/src/services/environment_service.rs index 1d5c7ae40..501fcd698 100644 --- a/crates/temps-environments/src/services/environment_service.rs +++ b/crates/temps-environments/src/services/environment_service.rs @@ -128,8 +128,9 @@ impl EnvironmentService { } }; - // Use external_url if configured, otherwise fall back to preview_domain - let base_domain = settings.preview_domain.clone(); + let domain = settings + .public_hostnames + .environment_hostname(&settings.preview_domain, environment_slug); // Determine protocol - use https if external_url is configured, otherwise http let protocol = if settings.external_url.is_some() { @@ -146,11 +147,8 @@ impl EnvironmentService { // host/port (typically 443) and the internal proxy port is irrelevant. let port_suffix = self.port_suffix(protocol, settings.external_url.is_some()); - // ://.[:port] - format!( - "{}://{}.{}{}", - protocol, environment_slug, base_domain, port_suffix - ) + // ://[:port] + format!("{}://{}{}", protocol, domain, port_suffix) } /// Returns `:` when the proxy listens on a non-default port for the @@ -178,8 +176,9 @@ impl EnvironmentService { /// Compute the full FQDN for an environment (without protocol) pub async fn compute_environment_fqdn(&self, environment_slug: &str) -> String { let settings = self.config_service.get_settings().await.unwrap_or_default(); - let base_domain = settings.preview_domain.clone(); - format!("{}.{}", environment_slug, base_domain) + settings + .public_hostnames + .environment_hostname(&settings.preview_domain, environment_slug) } /// Compute the URL for a user-supplied custom domain (verbatim host). diff --git a/crates/temps-routes/src/route_table.rs b/crates/temps-routes/src/route_table.rs index a11262fb5..d1b4ed26c 100644 --- a/crates/temps-routes/src/route_table.rs +++ b/crates/temps-routes/src/route_table.rs @@ -25,7 +25,7 @@ use sqlx::postgres::{PgListener, PgPool}; use std::collections::HashMap; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; -use temps_core::DeploymentMode; +use temps_core::{AppSettings, DeploymentMode}; use temps_entities::custom_routes::RouteType; use temps_entities::{deployments, environments, nodes, projects}; use tracing::{debug, error, info, warn}; @@ -542,19 +542,20 @@ impl CachedPeerTable { // Node cache: maps node_id -> private_address for multi-node routing let mut nodes_cache: HashMap = HashMap::new(); - // Fetch preview_domain from settings - let preview_domain = settings::Entity::find() + // Fetch public hostname settings once so generated route hosts match + // the URLs emitted by the API/UI. + let app_settings = settings::Entity::find() .one(self.db.as_ref()) .await? - .and_then(|s| { - s.data - .get("preview_domain") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - }) - .unwrap_or_else(|| "localho.st".to_string()); + .map(|s| AppSettings::from_json(s.data)) + .unwrap_or_default(); + let preview_domain = app_settings.preview_domain.clone(); + let public_hostnames = app_settings.public_hostnames.clone(); - debug!("Loaded preview_domain from settings: {}", preview_domain); + debug!( + "Loaded public hostname settings from settings: preview_domain={}, strategy={:?}", + preview_domain, public_hostnames.strategy + ); debug!("Loading route table from database..."); @@ -977,7 +978,8 @@ impl CachedPeerTable { deployment_id, wake_timeout_seconds: wake_timeout, }); - let full_domain = format!("{}.{}", main_url, preview_domain); + let full_domain = + public_hostnames.environment_hostname(&preview_domain, main_url); sleeping_environments.push(SleepingEnvironmentEntry { domain: full_domain, environment_id: env.id, @@ -1176,7 +1178,8 @@ impl CachedPeerTable { } // Also add route with preview_domain suffix if configured - let full_domain = format!("{}.{}", main_url, preview_domain); + let full_domain = + public_hostnames.environment_hostname(&preview_domain, main_url); if !routes.contains_key(&full_domain) { routes.insert( full_domain.clone(), @@ -1329,15 +1332,11 @@ impl CachedPeerTable { cert_eligible: true, }; - // Route: {service}-{env_subdomain}.{preview_domain} - // DNS labels must be ≤63 chars, truncate if needed - let svc_label = format!("{}-{}", pub_service, main_url); - let svc_label = if svc_label.len() > 63 { - svc_label[..63].trim_end_matches('-').to_string() - } else { - svc_label - }; - let svc_domain = format!("{}.{}", svc_label, preview_domain); + let svc_domain = public_hostnames.service_hostname( + &preview_domain, + main_url, + pub_service, + ); if let std::collections::hash_map::Entry::Vacant(e) = routes.entry(svc_domain.clone()) { @@ -1450,7 +1449,8 @@ impl CachedPeerTable { // Generate a fallback route using deployment slug if no other routes exist // This ensures every active deployment is accessible - let fallback_domain = format!("{}.{}", deployment.slug, preview_domain); + let fallback_domain = + public_hostnames.deployment_hostname(&preview_domain, &deployment.slug); if !routes.contains_key(&fallback_domain) { routes.insert( diff --git a/crates/temps-sandbox/src/services/preview_urls.rs b/crates/temps-sandbox/src/services/preview_urls.rs index bdb441461..1607359f3 100644 --- a/crates/temps-sandbox/src/services/preview_urls.rs +++ b/crates/temps-sandbox/src/services/preview_urls.rs @@ -79,11 +79,7 @@ pub async fn load(platform_config: &Arc) -> PreviewUrlParts { ("https".to_string(), None) }; - let domain = if s.preview_domain.is_empty() { - "localho.st".to_string() - } else { - s.preview_domain.trim_start_matches("*.").to_string() - }; + let domain = s.public_hostnames.base_domain(&s.preview_domain); let port = port.filter(|p| { !((protocol == "https" && *p == 443) || (protocol == "http" && *p == 80)) diff --git a/docs/advanced/networking/page.mdx b/docs/advanced/networking/page.mdx index c428ede7b..4d7e7b179 100644 --- a/docs/advanced/networking/page.mdx +++ b/docs/advanced/networking/page.mdx @@ -18,3 +18,44 @@ Cover advanced networking for complex deployments. **Network Nick** - Needs VPNs, private networks, firewall config, advanced networking. ## Key Content: Port configuration, internal networking, VPC, firewall rules, proxy configuration, load balancing + +## Flattened Preview Hostnames + +Temps normally generates preview hostnames from the platform preview domain, +for example `my-app-staging.example.com` under `example.com`. Compose services +with public ports also get generated hostnames. By default those service hosts +use the existing `service-environment.example.com` order. + +Some proxied DNS/TLS providers, including Cloudflare Universal SSL on free and +basic plans, only cover the apex plus one wildcard label such as +`*.example.com`. They do not cover deeper hosts such as +`api.staging.example.com`. Use the flat hostname strategy when generated +service hostnames must stay exactly one label beneath the preview domain. + +Configure this in **Settings** > **Preview Domain**: + +```json +{ + "preview_domain": "*.example.com", + "public_hostnames": { + "strategy": "flat", + "environment_template": "{environment}.{base_domain}", + "service_template": "{environment}-{service}.{base_domain}", + "deployment_template": "{deployment}.{base_domain}" + } +} +``` + +With `strategy = "flat"`, generated labels before `{base_domain}` are collapsed +into a single DNS label. For example: + +| Purpose | Standard | Flat wildcard | +| --- | --- | --- | +| Environment | `staging.example.com` | `staging.example.com` | +| Public compose service | `api-staging.example.com` | `staging-api.example.com` | +| Nested custom template | `api.staging.example.com` | `api-staging.example.com` | + +Supported placeholders are `{base_domain}`, `{environment}`, `{service}`, +`{deployment}`, `{project}`, `{app}`, `{branch}`, `{preview_slug}`, and +`{short_hash}`. Generated DNS labels are sanitized and truncated to 63 +characters with a stable short hash suffix when needed. diff --git a/web/src/api/client/types.gen.ts b/web/src/api/client/types.gen.ts index 8490c45a6..49fb4799d 100644 --- a/web/src/api/client/types.gen.ts +++ b/web/src/api/client/types.gen.ts @@ -16343,6 +16343,7 @@ export type AppSettingsResponse = { multi_node: MultiNodeSettingsMasked; preview_domain: string; preview_gateway: PreviewGatewaySettingsMasked; + public_hostnames: PublicHostnameSettings; rate_limiting: RateLimitSettings; /** * When enabled, Admin-role accounts without MFA enrolled are rejected @@ -16358,6 +16359,26 @@ export type AppSettingsResponse = { setup_complete: boolean; }; +/** + * Operator-configurable templates for generated public hostnames. + * + * Templates may use `{base_domain}`, `{environment}`, `{service}`, + * `{deployment}`, `{project}`, `{app}`, `{branch}`, `{preview_slug}`, and + * `{short_hash}`. When `strategy = flat`, all generated labels before + * `{base_domain}` are collapsed into a single DNS label. + */ +export type PublicHostnameSettings = { + deployment_template?: string | null; + environment_template?: string | null; + service_template?: string | null; + strategy?: PublicHostnameStrategy; +}; + +/** + * Public hostname generation mode for Temps-managed preview routes. + */ +export type PublicHostnameStrategy = 'standard' | 'flat'; + /** * Global AI configuration settings. Controls the default config repo * containing `.claude/` directory (skills, MCP servers, plugins) that @@ -16462,6 +16483,7 @@ export type AppSettings = { on_demand_tls?: OnDemandTlsSettings; preview_domain?: string; preview_gateway?: PreviewGatewaySettings; + public_hostnames?: PublicHostnameSettings; rate_limiting?: RateLimitSettings; /** * When `true`, any user holding the `Admin` role must have MFA enrolled diff --git a/web/src/api/platformSettings.ts b/web/src/api/platformSettings.ts index 470a33931..6e304cd06 100644 --- a/web/src/api/platformSettings.ts +++ b/web/src/api/platformSettings.ts @@ -123,12 +123,22 @@ export interface MonitoringSettings { clickhouse_url?: string | null } +export type PublicHostnameStrategy = 'standard' | 'flat' + +export interface PublicHostnameSettings { + strategy: PublicHostnameStrategy + environment_template?: string | null + service_template?: string | null + deployment_template?: string | null +} + // Re-export the types from the API for consistency export interface PlatformSettings extends AppSettingsResponse { external_url: string | null internal_url: string | null letsencrypt: LetsEncryptSettings preview_domain: string + public_hostnames: PublicHostnameSettings screenshots: ScreenshotSettings security_headers: SecurityHeadersSettings rate_limiting: RateLimitSettings @@ -187,6 +197,7 @@ export async function updatePlatformSettings( internal_url: updated.internal_url, letsencrypt: updated.letsencrypt, preview_domain: updated.preview_domain, + public_hostnames: updated.public_hostnames, screenshots: updated.screenshots, security_headers: updated.security_headers, rate_limiting: updated.rate_limiting, diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 6faacc583..a31a1d3b1 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -40,9 +40,18 @@ import { toast } from 'sonner' type SettingsFormData = Pick< PlatformSettings, - 'external_url' | 'internal_url' | 'preview_domain' | 'screenshots' + | 'external_url' + | 'internal_url' + | 'preview_domain' + | 'public_hostnames' + | 'screenshots' > +function optionalTemplate(value: string | null | undefined): string | null { + const trimmed = value?.trim() ?? '' + return trimmed.length > 0 ? trimmed : null +} + export function Settings() { const { setBreadcrumbs } = useBreadcrumbs() const { data: settings, isLoading, error } = useSettings() @@ -61,6 +70,12 @@ export function Settings() { external_url: '', internal_url: '', preview_domain: 'localho.st', + public_hostnames: { + strategy: 'standard', + environment_template: '', + service_template: '', + deployment_template: '', + }, screenshots: { enabled: false, provider: 'local', @@ -70,6 +85,10 @@ export function Settings() { }) const screenshots = useWatch({ control, name: 'screenshots' }) + const hostnameStrategy = useWatch({ + control, + name: 'public_hostnames.strategy', + }) useEffect(() => { setBreadcrumbs([{ label: 'Settings' }]) @@ -83,6 +102,14 @@ export function Settings() { external_url: settings.external_url || '', internal_url: settings.internal_url || '', preview_domain: settings.preview_domain || 'localho.st', + public_hostnames: { + strategy: settings.public_hostnames?.strategy || 'standard', + environment_template: + settings.public_hostnames?.environment_template || '', + service_template: settings.public_hostnames?.service_template || '', + deployment_template: + settings.public_hostnames?.deployment_template || '', + }, screenshots: settings.screenshots || { enabled: false, provider: 'local', @@ -94,11 +121,39 @@ export function Settings() { const onSubmit = async (data: SettingsFormData) => { try { - await updateSettings.mutateAsync(data) - reset(data) + const normalized: SettingsFormData = { + ...data, + public_hostnames: { + strategy: data.public_hostnames?.strategy || 'standard', + environment_template: optionalTemplate( + data.public_hostnames?.environment_template + ), + service_template: optionalTemplate( + data.public_hostnames?.service_template + ), + deployment_template: optionalTemplate( + data.public_hostnames?.deployment_template + ), + }, + } + await updateSettings.mutateAsync(normalized) + reset({ + ...normalized, + public_hostnames: { + ...normalized.public_hostnames, + environment_template: + normalized.public_hostnames.environment_template || '', + service_template: normalized.public_hostnames.service_template || '', + deployment_template: + normalized.public_hostnames.deployment_template || '', + }, + }) toast.success('Settings saved successfully') } catch (err: any) { - const detail = err?.body?.detail || err?.message || 'Failed to save settings. Please try again.' + const detail = + err?.body?.detail || + err?.message || + 'Failed to save settings. Please try again.' toast.error(detail) } } @@ -147,11 +202,16 @@ export function Settings() { if (!value) return true // optional const trimmed = value.trim() if (!trimmed) return true - if (!trimmed.startsWith('http://') && !trimmed.startsWith('https://')) + if ( + !trimmed.startsWith('http://') && + !trimmed.startsWith('https://') + ) return 'Must start with http:// or https://' if (trimmed.includes('#') || trimmed.includes('?')) return 'Must not contain # or ? characters' - try { new URL(trimmed) } catch { + try { + new URL(trimmed) + } catch { return 'Must be a valid URL' } return true @@ -159,7 +219,9 @@ export function Settings() { })} /> {errors.external_url && ( -

{errors.external_url.message}

+

+ {errors.external_url.message} +

)}

Used for OAuth callbacks, webhooks, and external integrations @@ -177,11 +239,16 @@ export function Settings() { if (!value) return true // optional — falls back to default const trimmed = value.trim() if (!trimmed) return true - if (!trimmed.startsWith('http://') && !trimmed.startsWith('https://')) + if ( + !trimmed.startsWith('http://') && + !trimmed.startsWith('https://') + ) return 'Must start with http:// or https://' if (trimmed.includes('#') || trimmed.includes('?')) return 'Must not contain # or ? characters' - try { new URL(trimmed) } catch { + try { + new URL(trimmed) + } catch { return 'Must be a valid URL' } return true @@ -189,12 +256,17 @@ export function Settings() { })} /> {errors.internal_url && ( -

{errors.internal_url.message}

+

+ {errors.internal_url.message} +

)}

How service containers reach the Temps API from inside the Docker network (OTLP metrics ingest, agent callbacks). Leave blank to use{' '} - http://host.docker.internal:<proxy-port>. + + http://host.docker.internal:<proxy-port> + + .

@@ -210,7 +282,7 @@ export function Settings() { Configure the domain used for deployment previews - +
+ +
+ + +

+ Flat wildcard keeps generated service hostnames one label under + the preview domain for providers such as Cloudflare Universal SSL. +

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
@@ -312,8 +438,9 @@ export function Settings() { Route Table - Manually refresh the proxy route table from the database. Use this if - routes appear out of sync after deployments or configuration changes. + Manually refresh the proxy route table from the database. Use this + if routes appear out of sync after deployments or configuration + changes. @@ -362,7 +489,11 @@ export function Settings() {

You have unsaved changes

- + + + + + {/* Edit Dialog */} diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index a31a1d3b1..ecad888d6 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -40,14 +40,10 @@ import { toast } from 'sonner' type SettingsFormData = Pick< PlatformSettings, - | 'external_url' - | 'internal_url' - | 'preview_domain' - | 'public_hostnames' - | 'screenshots' + 'external_url' | 'internal_url' | 'preview_domain' | 'edge_target' | 'screenshots' > -function optionalTemplate(value: string | null | undefined): string | null { +function optionalString(value: string | null | undefined): string | null { const trimmed = value?.trim() ?? '' return trimmed.length > 0 ? trimmed : null } @@ -70,12 +66,7 @@ export function Settings() { external_url: '', internal_url: '', preview_domain: 'localho.st', - public_hostnames: { - strategy: 'standard', - environment_template: '', - service_template: '', - deployment_template: '', - }, + edge_target: '', screenshots: { enabled: false, provider: 'local', @@ -85,10 +76,6 @@ export function Settings() { }) const screenshots = useWatch({ control, name: 'screenshots' }) - const hostnameStrategy = useWatch({ - control, - name: 'public_hostnames.strategy', - }) useEffect(() => { setBreadcrumbs([{ label: 'Settings' }]) @@ -102,14 +89,7 @@ export function Settings() { external_url: settings.external_url || '', internal_url: settings.internal_url || '', preview_domain: settings.preview_domain || 'localho.st', - public_hostnames: { - strategy: settings.public_hostnames?.strategy || 'standard', - environment_template: - settings.public_hostnames?.environment_template || '', - service_template: settings.public_hostnames?.service_template || '', - deployment_template: - settings.public_hostnames?.deployment_template || '', - }, + edge_target: settings.edge_target || '', screenshots: settings.screenshots || { enabled: false, provider: 'local', @@ -123,30 +103,12 @@ export function Settings() { try { const normalized: SettingsFormData = { ...data, - public_hostnames: { - strategy: data.public_hostnames?.strategy || 'standard', - environment_template: optionalTemplate( - data.public_hostnames?.environment_template - ), - service_template: optionalTemplate( - data.public_hostnames?.service_template - ), - deployment_template: optionalTemplate( - data.public_hostnames?.deployment_template - ), - }, + edge_target: optionalString(data.edge_target), } await updateSettings.mutateAsync(normalized) reset({ ...normalized, - public_hostnames: { - ...normalized.public_hostnames, - environment_template: - normalized.public_hostnames.environment_template || '', - service_template: normalized.public_hostnames.service_template || '', - deployment_template: - normalized.public_hostnames.deployment_template || '', - }, + edge_target: normalized.edge_target || '', }) toast.success('Settings saved successfully') } catch (err: any) { @@ -298,58 +260,21 @@ export function Settings() {
- - + +

- Flat wildcard keeps generated service hostnames one label under - the preview domain for providers such as Cloudflare Universal SSL. + Public address that generated DNS records point at when a managed + domain opts into record sync. An IP creates A/AAAA records; a + hostname creates CNAME records. Leave blank to disable DNS sync. + The Standard vs Flat hostname layout is configured per managed + domain under DNS providers.

- -
-
- - -
-
- - -
-
- - -
-
From 9b2dea6ce3299dc8dccd40afd3735c4da2fb076e Mon Sep 17 00:00:00 2001 From: Ben Herila Date: Mon, 22 Jun 2026 09:26:41 +0000 Subject: [PATCH 3/9] fix(dns): never delete untagged records, exclude prod from hostname sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two safety bugs found testing against a live zone (careowner.com): - The stale-record cleanup deleted any single-label record under the managed domain that Temps didn't generate — which would have deleted app.careowner.com (prod, on another VM) and every other apex record. Deletion is now limited to records Temps tagged as managed (`metadata["comment"] == temps:managed`); the cloudflare crate exposes no record comment, so this is a no-op there and pre-existing/user records are NEVER deleted. The sync only creates/updates. - The sync enumerated all environments, so it tried to point prod-env hosts at the staging/preview edge_target. Production environments (slug/name prod|production) are now excluded. Also: add a proper create-vs-update path (previously existing records were never updated), and enumerate from environments.subdomain instead of environment_domains (which can hold user custom FQDNs). Adds CF-free unit tests via an in-memory mock DnsProvider covering: untagged records are never deleted, only tagged-stale are deleted, create/update/no-op transitions, dry-run writes nothing, A/AAAA/CNAME selection, and prod exclusion. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../temps-dns/src/services/hostname_sync.rs | 448 +++++++++++++++--- 1 file changed, 387 insertions(+), 61 deletions(-) diff --git a/crates/temps-dns/src/services/hostname_sync.rs b/crates/temps-dns/src/services/hostname_sync.rs index bf0c54169..0ddfca8ac 100644 --- a/crates/temps-dns/src/services/hostname_sync.rs +++ b/crates/temps-dns/src/services/hostname_sync.rs @@ -6,18 +6,28 @@ //! reconciles one proxied record per generated hostname against the provider's //! live zone, pointing each at the configured `edge_target` (an `A`/`AAAA` //! record for an IP, otherwise a `CNAME`). +//! +//! Safety rules (learned the hard way against a live zone): +//! - **Never delete pre-existing/user records.** Deletion is limited to records +//! Temps itself tagged as managed. Providers whose API exposes no record +//! comment (e.g. the cloudflare crate we use) can't carry that tag, so for +//! them deletion is effectively a no-op — the sync only creates/updates. +//! - **Production environments are excluded** from the generated-DNS sync. The +//! `edge_target` is the staging/preview edge; production hosts live elsewhere +//! and must not be pointed at it. use std::collections::HashMap; use std::net::IpAddr; use sea_orm::{DatabaseConnection, EntityTrait}; use temps_core::PublicHostnameStrategy; -use temps_entities::{environment_domains, environments, preset::PresetConfig, projects}; +use temps_entities::{environments, preset::PresetConfig, projects}; -use crate::providers::{DnsProvider, DnsRecordContent, DnsRecordRequest, DnsRecordType}; +use crate::providers::{DnsProvider, DnsRecord, DnsRecordContent, DnsRecordRequest, DnsRecordType}; -/// Cloudflare/record comment used to tag records Temps manages, so the sync only -/// ever deletes its own records and never user-created ones. +/// Record comment Temps stamps on records it manages, so the sync only ever +/// deletes its own records and never user-created ones. Surfaced via a record's +/// `metadata["comment"]` when the provider exposes it. pub const MANAGED_TAG: &str = "temps:managed"; /// A generated public hostname under a managed domain. @@ -58,9 +68,29 @@ pub struct HostnameModeResult { pub zone_access_ok: Option, } +/// Whether an environment is a production environment (and so excluded from the +/// generated-DNS sync, which targets the staging/preview edge). Matches `prod` +/// or `production` on either the slug or the display name, case-insensitively. +pub fn is_production_env(slug: &str, name: &str) -> bool { + let is_prod = |s: &str| { + let s = s.trim().to_ascii_lowercase(); + s == "prod" || s == "production" + }; + is_prod(slug) || is_prod(name) +} + +/// Whether an environment's generated hostnames should be synced to the edge. +fn should_sync_environment(slug: &str, name: &str) -> bool { + !is_production_env(slug, name) +} + /// Enumerate every generated public hostname under `preview_domain` for the -/// given strategy. Returns environment hostnames and per-public-service -/// hostnames; the latter are the only ones whose layout depends on `strategy`. +/// given strategy, **excluding production environments**. Returns environment +/// hostnames and per-public-service hostnames; the latter are the only ones +/// whose layout depends on `strategy`. +/// +/// Uses `environments.subdomain` as the canonical per-environment label (not +/// `environment_domains`, which can also hold user-supplied custom FQDNs). pub async fn enumerate_generated_hosts( db: &DatabaseConnection, preview_domain: &str, @@ -71,15 +101,6 @@ pub async fn enumerate_generated_hosts( .await .unwrap_or_default(); - // environment_id -> main_url (stable per-env label, e.g. "project-staging") - let main_urls: HashMap = environment_domains::Entity::find() - .all(db) - .await - .unwrap_or_default() - .into_iter() - .map(|d| (d.environment_id, d.domain)) - .collect(); - // project_id -> public compose service names let public_services: HashMap> = projects::Entity::find() .all(db) @@ -99,16 +120,19 @@ pub async fn enumerate_generated_hosts( let mut hosts = Vec::new(); for env in envs { - let main_url = match main_urls.get(&env.id) { - Some(u) => u.as_str(), - None => continue, - }; + if env.deleted_at.is_some() { + continue; + } + if !should_sync_environment(&env.slug, &env.name) { + continue; + } + let label = env.subdomain.as_str(); // Environment host (strategy-independent, included for DNS sync coverage). hosts.push(GeneratedHost { kind: "environment", owner_id: env.id, - fqdn: PublicHostnameStrategy::Standard.environment_hostname(preview_domain, main_url), + fqdn: PublicHostnameStrategy::Standard.environment_hostname(preview_domain, label), }); if let Some(services) = public_services.get(&env.project_id) { @@ -116,7 +140,7 @@ pub async fn enumerate_generated_hosts( hosts.push(GeneratedHost { kind: "service", owner_id: env.id, - fqdn: strategy.service_hostname(preview_domain, main_url, service), + fqdn: strategy.service_hostname(preview_domain, label, service), }); } } @@ -136,7 +160,8 @@ pub async fn compute_hostname_changes( if target == PublicHostnameStrategy::Standard { return Vec::new(); } - let before = enumerate_generated_hosts(db, preview_domain, PublicHostnameStrategy::Standard).await; + let before = + enumerate_generated_hosts(db, preview_domain, PublicHostnameStrategy::Standard).await; let after = enumerate_generated_hosts(db, preview_domain, target).await; before @@ -183,12 +208,37 @@ fn desired_content(edge_target: &str) -> (DnsRecordType, DnsRecordContent, Strin } } -/// Reconcile the provider's DNS zone so every generated hostname under -/// `base_domain` has one proxied record pointing at `edge_target`. +/// Extract the comparable value (address/target) from a record's content. +fn record_value(content: &DnsRecordContent) -> Option { + match content { + DnsRecordContent::A { address } => Some(address.clone()), + DnsRecordContent::AAAA { address } => Some(address.clone()), + DnsRecordContent::CNAME { target } => Some(target.clone()), + _ => None, + } +} + +/// Whether a record was tagged by Temps as managed (and so is eligible for +/// deletion when no longer desired). Pre-existing/user records are never tagged +/// and are therefore never deleted. +fn record_is_managed(record: &DnsRecord) -> bool { + record + .metadata + .get("comment") + .map(|c| c == MANAGED_TAG) + .unwrap_or(false) +} + +/// Reconcile the provider's DNS zone so every desired generated hostname has a +/// proxied record pointing at `edge_target`. +/// +/// - **Creates** a record for a desired host that doesn't exist. +/// - **Updates** a desired host whose record points somewhere else. +/// - **Deletes** ONLY records Temps tagged as managed that are no longer desired +/// — never pre-existing/user records. /// -/// Returns the set of changes. When `dry_run` is true, nothing is written. -/// Only records that match a generated hostname under this domain are ever -/// deleted, so user-created records are never touched. +/// When `dry_run` is true, nothing is written; the returned [`RecordChange`] +/// list is the plan. pub async fn reconcile_zone_records( provider: &dyn DnsProvider, base_domain: &str, @@ -202,24 +252,33 @@ pub async fn reconcile_zone_records( .map(|h| h.fqdn.to_ascii_lowercase()) .collect(); - // Index existing records by fqdn (only those under this base domain). let existing = provider.list_records(base_domain).await?; - let existing_fqdns: std::collections::HashSet = existing + let existing_by_fqdn: HashMap = existing .iter() - .map(|r| r.fqdn.to_ascii_lowercase()) + .map(|r| (r.fqdn.to_ascii_lowercase(), r)) .collect(); let (record_type, _content, type_str) = desired_content(edge_target); + let proxied = provider.capabilities().proxy; let mut changes = Vec::new(); - // Create records for desired hostnames that don't yet exist. + // Create or update desired hosts. Use the upsert `set_record`. for host in desired_hosts { let fqdn = host.fqdn.to_ascii_lowercase(); - if existing_fqdns.contains(&fqdn) { - continue; - } + let action = match existing_by_fqdn.get(&fqdn) { + None => Some("create"), + Some(rec) => { + if record_value(&rec.content).as_deref() != Some(edge_target) { + Some("update") + } else { + None // already correct + } + } + }; + let Some(action) = action else { continue }; + changes.push(RecordChange { - action: "create".to_string(), + action: action.to_string(), name: host.fqdn.clone(), record_type: type_str.clone(), value: edge_target.to_string(), @@ -234,22 +293,18 @@ pub async fn reconcile_zone_records( name, content, ttl: None, - proxied: provider.capabilities().proxy, + proxied, }, ) .await?; } } - // Delete stale records that look like Temps-generated hosts under this base - // domain but are no longer desired. We scope deletion to records whose name - // matches the generated single-label pattern, never user records. + // Delete ONLY Temps-tagged records that are no longer desired. Untagged + // (pre-existing/user) records are never deleted. for record in &existing { let fqdn = record.fqdn.to_ascii_lowercase(); - if desired_fqdns.contains(&fqdn) { - continue; - } - if !is_generated_candidate(&record.fqdn, &suffix) { + if desired_fqdns.contains(&fqdn) || !record_is_managed(record) { continue; } changes.push(RecordChange { @@ -282,22 +337,192 @@ fn relative_name(fqdn: &str, suffix: &str) -> String { } } -/// Whether a record fqdn looks like a Temps-generated host (exactly one label -/// below the base domain). This intentionally excludes deeper user records and -/// the apex from deletion candidates. -fn is_generated_candidate(fqdn: &str, suffix: &str) -> bool { - let name = relative_name(fqdn, suffix); - !name.is_empty() && name != "@" && !name.contains('.') -} - #[cfg(test)] mod tests { use super::*; + use crate::errors::DnsError; + use crate::providers::{ + DnsProvider, DnsProviderCapabilities, DnsProviderType, DnsRecord, DnsRecordContent, + DnsRecordRequest, DnsRecordType, DnsZone, + }; + use async_trait::async_trait; + use std::sync::Mutex; + + fn host(fqdn: &str) -> GeneratedHost { + GeneratedHost { + kind: "environment", + owner_id: 1, + fqdn: fqdn.to_string(), + } + } + + fn record(name: &str, base: &str, ip: &str, managed: bool) -> DnsRecord { + let fqdn = if name == "@" { + base.to_string() + } else { + format!("{name}.{base}") + }; + let mut metadata = HashMap::new(); + if managed { + metadata.insert("comment".to_string(), MANAGED_TAG.to_string()); + } + DnsRecord { + id: Some(format!("id-{name}")), + zone: base.to_string(), + name: name.to_string(), + fqdn, + content: DnsRecordContent::A { + address: ip.to_string(), + }, + ttl: 1, + proxied: true, + metadata, + } + } + + /// In-memory DnsProvider for CF-free reconciliation tests. + struct MockProvider { + records: Mutex>, + } + + impl MockProvider { + fn new(records: Vec) -> Self { + Self { + records: Mutex::new(records), + } + } + fn fqdns(&self) -> Vec { + let mut v: Vec = self + .records + .lock() + .unwrap() + .iter() + .map(|r| r.fqdn.clone()) + .collect(); + v.sort(); + v + } + fn value_of(&self, fqdn: &str) -> Option { + self.records + .lock() + .unwrap() + .iter() + .find(|r| r.fqdn == fqdn) + .and_then(|r| record_value(&r.content)) + } + } + + #[async_trait] + impl DnsProvider for MockProvider { + fn provider_type(&self) -> DnsProviderType { + DnsProviderType::Cloudflare + } + fn capabilities(&self) -> DnsProviderCapabilities { + DnsProviderCapabilities { + proxy: true, + ..Default::default() + } + } + async fn test_connection(&self) -> Result { + Ok(true) + } + async fn list_zones(&self) -> Result, DnsError> { + Ok(vec![]) + } + async fn get_zone(&self, _domain: &str) -> Result, DnsError> { + Ok(None) + } + async fn list_records(&self, _domain: &str) -> Result, DnsError> { + Ok(self.records.lock().unwrap().clone()) + } + async fn get_record( + &self, + _domain: &str, + _name: &str, + _record_type: DnsRecordType, + ) -> Result, DnsError> { + Ok(None) + } + async fn create_record( + &self, + domain: &str, + request: DnsRecordRequest, + ) -> Result { + self.set_record(domain, request).await + } + async fn update_record( + &self, + domain: &str, + _record_id: &str, + request: DnsRecordRequest, + ) -> Result { + self.set_record(domain, request).await + } + async fn delete_record(&self, _domain: &str, _record_id: &str) -> Result<(), DnsError> { + Ok(()) + } + async fn set_record( + &self, + domain: &str, + request: DnsRecordRequest, + ) -> Result { + let fqdn = if request.name == "@" { + domain.to_string() + } else { + format!("{}.{}", request.name, domain) + }; + let mut recs = self.records.lock().unwrap(); + if let Some(r) = recs.iter_mut().find(|r| r.fqdn == fqdn) { + r.content = request.content.clone(); + r.proxied = request.proxied; + return Ok(r.clone()); + } + let new = DnsRecord { + id: Some(format!("id-{}", request.name)), + zone: domain.to_string(), + name: request.name.clone(), + fqdn: fqdn.clone(), + content: request.content.clone(), + ttl: request.ttl.unwrap_or(1), + proxied: request.proxied, + metadata: HashMap::new(), + }; + recs.push(new.clone()); + Ok(new) + } + async fn remove_record( + &self, + domain: &str, + name: &str, + _record_type: DnsRecordType, + ) -> Result<(), DnsError> { + let fqdn = if name == "@" { + domain.to_string() + } else { + format!("{}.{}", name, domain) + }; + self.records.lock().unwrap().retain(|r| r.fqdn != fqdn); + Ok(()) + } + } + + #[test] + fn production_environments_are_excluded() { + assert!(is_production_env("prod", "prod")); + assert!(is_production_env("production", "Production")); + assert!(is_production_env("staging", "Production")); // name matches + assert!(!is_production_env("staging", "staging")); + assert!(!is_production_env("preview", "preview")); + assert!(!is_production_env("staging-t", "staging-t")); + } #[test] fn desired_content_picks_record_type() { - assert!(matches!(desired_content("192.0.2.1").0, DnsRecordType::A)); - assert!(matches!(desired_content("2001:db8::1").0, DnsRecordType::AAAA)); + assert!(matches!(desired_content("35.163.83.53").0, DnsRecordType::A)); + assert!(matches!( + desired_content("2001:db8::1").0, + DnsRecordType::AAAA + )); assert!(matches!( desired_content("edge.temps.sh").0, DnsRecordType::CNAME @@ -306,15 +531,116 @@ mod tests { #[test] fn relative_name_strips_suffix() { - assert_eq!(relative_name("api-staging.example.com", ".example.com"), "api-staging"); - assert_eq!(relative_name("example.com", ".example.com"), "@"); + assert_eq!( + relative_name("careowner-staging.cp.careowner.com", ".careowner.com"), + "careowner-staging.cp" + ); + assert_eq!(relative_name("careowner.com", ".careowner.com"), "@"); } - #[test] - fn generated_candidate_excludes_apex_and_deep_names() { - assert!(is_generated_candidate("api-staging.example.com", ".example.com")); - assert!(!is_generated_candidate("example.com", ".example.com")); - // Deep (nested) names are not single-label generated candidates. - assert!(!is_generated_candidate("api.staging.example.com", ".example.com")); + // The bug we discovered against careowner.com: a domain-wide sync must NEVER + // delete pre-existing single-label records like app.careowner.com. + #[tokio::test] + async fn reconcile_never_deletes_untagged_records() { + let base = "careowner.com"; + let provider = MockProvider::new(vec![ + record("app", base, "10.0.0.1", false), // prod, other VM + record("www", base, "10.0.0.2", false), // user record + record("sentry", base, "10.0.0.3", false), // user record + record("careowner-staging.cp", base, "9.9.9.9", false), // stale generated, untagged + ]); + let desired = vec![host("careowner-staging.cp.careowner.com")]; + + let changes = + reconcile_zone_records(&provider, base, &desired, "35.163.83.53", false) + .await + .unwrap(); + + // Only the staging record is updated; nothing is deleted. + assert!(changes.iter().all(|c| c.action != "delete"), "{changes:?}"); + assert!(changes + .iter() + .any(|c| c.action == "update" && c.name == "careowner-staging.cp.careowner.com")); + + // app / www / sentry survive untouched. + let fqdns = provider.fqdns(); + for keep in ["app.careowner.com", "www.careowner.com", "sentry.careowner.com"] { + assert!(fqdns.contains(&keep.to_string()), "{keep} was removed!"); + } + assert_eq!( + provider.value_of("app.careowner.com").as_deref(), + Some("10.0.0.1") + ); + // staging now points at the edge. + assert_eq!( + provider.value_of("careowner-staging.cp.careowner.com").as_deref(), + Some("35.163.83.53") + ); + } + + #[tokio::test] + async fn reconcile_creates_missing_and_skips_correct() { + let base = "careowner.com"; + let provider = MockProvider::new(vec![ + record("app", base, "10.0.0.1", false), + // already-correct staging record → no change + record("careowner-staging.cp", base, "35.163.83.53", false), + ]); + let desired = vec![ + host("careowner-staging.cp.careowner.com"), // unchanged + host("careowner-preview.cp.careowner.com"), // new → create + ]; + + let changes = + reconcile_zone_records(&provider, base, &desired, "35.163.83.53", false) + .await + .unwrap(); + + assert_eq!(changes.len(), 1); + assert_eq!(changes[0].action, "create"); + assert_eq!(changes[0].name, "careowner-preview.cp.careowner.com"); + assert!(provider + .fqdns() + .contains(&"careowner-preview.cp.careowner.com".to_string())); + } + + #[tokio::test] + async fn reconcile_dry_run_writes_nothing() { + let base = "careowner.com"; + let provider = MockProvider::new(vec![record("app", base, "10.0.0.1", false)]); + let before = provider.fqdns(); + let desired = vec![host("careowner-staging.cp.careowner.com")]; + + let changes = + reconcile_zone_records(&provider, base, &desired, "35.163.83.53", true) + .await + .unwrap(); + + assert_eq!(changes.len(), 1); + assert_eq!(changes[0].action, "create"); + // dry run: zone unchanged. + assert_eq!(provider.fqdns(), before); + } + + #[tokio::test] + async fn reconcile_deletes_only_tagged_stale_records() { + let base = "careowner.com"; + let provider = MockProvider::new(vec![ + record("app", base, "10.0.0.1", false), // untagged → keep + record("old-preview.cp", base, "9.9.9.9", true), // tagged + not desired → delete + ]); + let desired = vec![host("careowner-staging.cp.careowner.com")]; + + let changes = + reconcile_zone_records(&provider, base, &desired, "35.163.83.53", false) + .await + .unwrap(); + + assert!(changes + .iter() + .any(|c| c.action == "delete" && c.name == "old-preview.cp.careowner.com")); + let fqdns = provider.fqdns(); + assert!(fqdns.contains(&"app.careowner.com".to_string())); + assert!(!fqdns.contains(&"old-preview.cp.careowner.com".to_string())); } } From ec33915b4e54aa0db1c59c3358389e7933c5baed Mon Sep 17 00:00:00 2001 From: Ben Herila Date: Fri, 26 Jun 2026 17:13:13 +0000 Subject: [PATCH 4/9] style(dns): cargo fmt flat-hostname changes --- crates/temps-core/src/public_hostname.rs | 7 ++- crates/temps-dns/src/handlers/mod.rs | 36 ++++++++++--- .../temps-dns/src/services/hostname_sync.rs | 51 ++++++++++--------- .../src/services/provider_service.rs | 14 ++--- crates/temps-routes/src/route_table.rs | 12 ++--- 5 files changed, 78 insertions(+), 42 deletions(-) diff --git a/crates/temps-core/src/public_hostname.rs b/crates/temps-core/src/public_hostname.rs index e63e54ca1..68758d47f 100644 --- a/crates/temps-core/src/public_hostname.rs +++ b/crates/temps-core/src/public_hostname.rs @@ -61,7 +61,12 @@ impl PublicHostnameStrategy { /// Per-service public host. This is the only layout that differs between /// strategies: Standard yields `{service}-{environment}.base`, Flat yields /// `{environment}-{service}.base`. - pub fn service_hostname(self, preview_domain: &str, environment: &str, service: &str) -> String { + pub fn service_hostname( + self, + preview_domain: &str, + environment: &str, + service: &str, + ) -> String { let base = normalize_base_domain(preview_domain); let raw = match self { PublicHostnameStrategy::Standard => format!("{service}-{environment}.{base}"), diff --git a/crates/temps-dns/src/handlers/mod.rs b/crates/temps-dns/src/handlers/mod.rs index 52b493ddc..96cc02b33 100644 --- a/crates/temps-dns/src/handlers/mod.rs +++ b/crates/temps-dns/src/handlers/mod.rs @@ -800,7 +800,10 @@ async fn add_managed_domain( ) .await?; - Ok((StatusCode::CREATED, Json(ManagedDomainResponse::from(managed)))) + Ok(( + StatusCode::CREATED, + Json(ManagedDomainResponse::from(managed)), + )) } /// List managed domains for a provider @@ -825,8 +828,10 @@ async fn list_managed_domains( let domains = state.provider_service.list_managed_domains(id).await?; - let responses: Vec = - domains.into_iter().map(ManagedDomainResponse::from).collect(); + let responses: Vec = domains + .into_iter() + .map(ManagedDomainResponse::from) + .collect(); Ok(Json(responses)) } @@ -933,7 +938,15 @@ async fn update_managed_domain( ) .await?; - log_managed_domain_audit(&state, &auth, &metadata, provider_id, &domain, "DNS_MANAGED_DOMAIN_UPDATED").await; + log_managed_domain_audit( + &state, + &auth, + &metadata, + provider_id, + &domain, + "DNS_MANAGED_DOMAIN_UPDATED", + ) + .await; Ok(Json(ManagedDomainResponse::from(updated))) } @@ -1022,10 +1035,21 @@ async fn apply_hostname_mode( })) .await { - tracing::error!("Failed to enqueue route reload after hostname mode change: {}", e); + tracing::error!( + "Failed to enqueue route reload after hostname mode change: {}", + e + ); } - log_managed_domain_audit(&state, &auth, &metadata, provider_id, &domain, "DNS_HOSTNAME_MODE_APPLIED").await; + log_managed_domain_audit( + &state, + &auth, + &metadata, + provider_id, + &domain, + "DNS_HOSTNAME_MODE_APPLIED", + ) + .await; Ok(Json(HostnamePreviewResponse::from(result))) } diff --git a/crates/temps-dns/src/services/hostname_sync.rs b/crates/temps-dns/src/services/hostname_sync.rs index 0ddfca8ac..0d3f957d5 100644 --- a/crates/temps-dns/src/services/hostname_sync.rs +++ b/crates/temps-dns/src/services/hostname_sync.rs @@ -518,7 +518,10 @@ mod tests { #[test] fn desired_content_picks_record_type() { - assert!(matches!(desired_content("35.163.83.53").0, DnsRecordType::A)); + assert!(matches!( + desired_content("35.163.83.53").0, + DnsRecordType::A + )); assert!(matches!( desired_content("2001:db8::1").0, DnsRecordType::AAAA @@ -544,17 +547,16 @@ mod tests { async fn reconcile_never_deletes_untagged_records() { let base = "careowner.com"; let provider = MockProvider::new(vec![ - record("app", base, "10.0.0.1", false), // prod, other VM - record("www", base, "10.0.0.2", false), // user record - record("sentry", base, "10.0.0.3", false), // user record + record("app", base, "10.0.0.1", false), // prod, other VM + record("www", base, "10.0.0.2", false), // user record + record("sentry", base, "10.0.0.3", false), // user record record("careowner-staging.cp", base, "9.9.9.9", false), // stale generated, untagged ]); let desired = vec![host("careowner-staging.cp.careowner.com")]; - let changes = - reconcile_zone_records(&provider, base, &desired, "35.163.83.53", false) - .await - .unwrap(); + let changes = reconcile_zone_records(&provider, base, &desired, "35.163.83.53", false) + .await + .unwrap(); // Only the staging record is updated; nothing is deleted. assert!(changes.iter().all(|c| c.action != "delete"), "{changes:?}"); @@ -564,7 +566,11 @@ mod tests { // app / www / sentry survive untouched. let fqdns = provider.fqdns(); - for keep in ["app.careowner.com", "www.careowner.com", "sentry.careowner.com"] { + for keep in [ + "app.careowner.com", + "www.careowner.com", + "sentry.careowner.com", + ] { assert!(fqdns.contains(&keep.to_string()), "{keep} was removed!"); } assert_eq!( @@ -573,7 +579,9 @@ mod tests { ); // staging now points at the edge. assert_eq!( - provider.value_of("careowner-staging.cp.careowner.com").as_deref(), + provider + .value_of("careowner-staging.cp.careowner.com") + .as_deref(), Some("35.163.83.53") ); } @@ -591,10 +599,9 @@ mod tests { host("careowner-preview.cp.careowner.com"), // new → create ]; - let changes = - reconcile_zone_records(&provider, base, &desired, "35.163.83.53", false) - .await - .unwrap(); + let changes = reconcile_zone_records(&provider, base, &desired, "35.163.83.53", false) + .await + .unwrap(); assert_eq!(changes.len(), 1); assert_eq!(changes[0].action, "create"); @@ -611,10 +618,9 @@ mod tests { let before = provider.fqdns(); let desired = vec![host("careowner-staging.cp.careowner.com")]; - let changes = - reconcile_zone_records(&provider, base, &desired, "35.163.83.53", true) - .await - .unwrap(); + let changes = reconcile_zone_records(&provider, base, &desired, "35.163.83.53", true) + .await + .unwrap(); assert_eq!(changes.len(), 1); assert_eq!(changes[0].action, "create"); @@ -626,15 +632,14 @@ mod tests { async fn reconcile_deletes_only_tagged_stale_records() { let base = "careowner.com"; let provider = MockProvider::new(vec![ - record("app", base, "10.0.0.1", false), // untagged → keep + record("app", base, "10.0.0.1", false), // untagged → keep record("old-preview.cp", base, "9.9.9.9", true), // tagged + not desired → delete ]); let desired = vec![host("careowner-staging.cp.careowner.com")]; - let changes = - reconcile_zone_records(&provider, base, &desired, "35.163.83.53", false) - .await - .unwrap(); + let changes = reconcile_zone_records(&provider, base, &desired, "35.163.83.53", false) + .await + .unwrap(); assert!(changes .iter() diff --git a/crates/temps-dns/src/services/provider_service.rs b/crates/temps-dns/src/services/provider_service.rs index 385b813c7..be8f5514d 100644 --- a/crates/temps-dns/src/services/provider_service.rs +++ b/crates/temps-dns/src/services/provider_service.rs @@ -20,9 +20,7 @@ use crate::providers::{ AzureProvider, CloudflareProvider, DigitalOceanProvider, DnsProvider, DnsProviderType, GcpProvider, ManualDnsProvider, NamecheapProvider, ProviderCredentials, Route53Provider, }; -use crate::services::hostname_sync::{ - self, HostnameModeResult, -}; +use crate::services::hostname_sync::{self, HostnameModeResult}; use temps_core::{AppSettings, PublicHostnameStrategy}; /// Service for managing DNS providers @@ -512,7 +510,10 @@ impl DnsProviderService { // Normalize the requested mode; unknown values fall back to standard. let mode = temps_core::PublicHostnameStrategy::from_db_str( - request.generated_hostname_mode.as_deref().unwrap_or("standard"), + request + .generated_hostname_mode + .as_deref() + .unwrap_or("standard"), ) .as_db_str() .to_string(); @@ -649,8 +650,9 @@ impl DnsProviderService { let mut active: dns_managed_domains::ActiveModel = managed.into(); if let Some(mode) = request.generated_hostname_mode { - active.generated_hostname_mode = - Set(PublicHostnameStrategy::from_db_str(&mode).as_db_str().to_string()); + active.generated_hostname_mode = Set(PublicHostnameStrategy::from_db_str(&mode) + .as_db_str() + .to_string()); } if let Some(sync) = request.sync_generated_records { active.sync_generated_records = Set(sync); diff --git a/crates/temps-routes/src/route_table.rs b/crates/temps-routes/src/route_table.rs index 3b9a03033..b8578d11d 100644 --- a/crates/temps-routes/src/route_table.rs +++ b/crates/temps-routes/src/route_table.rs @@ -996,8 +996,8 @@ impl CachedPeerTable { deployment_id, wake_timeout_seconds: wake_timeout, }); - let full_domain = - PublicHostnameStrategy::Standard.environment_hostname(&preview_domain, main_url); + let full_domain = PublicHostnameStrategy::Standard + .environment_hostname(&preview_domain, main_url); sleeping_environments.push(SleepingEnvironmentEntry { domain: full_domain, environment_id: env.id, @@ -1196,8 +1196,8 @@ impl CachedPeerTable { } // Also add route with preview_domain suffix if configured - let full_domain = - PublicHostnameStrategy::Standard.environment_hostname(&preview_domain, main_url); + let full_domain = PublicHostnameStrategy::Standard + .environment_hostname(&preview_domain, main_url); if !routes.contains_key(&full_domain) { routes.insert( full_domain.clone(), @@ -1469,8 +1469,8 @@ impl CachedPeerTable { // Generate a fallback route using deployment slug if no other routes exist // This ensures every active deployment is accessible - let fallback_domain = - PublicHostnameStrategy::Standard.deployment_hostname(&preview_domain, &deployment.slug); + let fallback_domain = PublicHostnameStrategy::Standard + .deployment_hostname(&preview_domain, &deployment.slug); if !routes.contains_key(&fallback_domain) { routes.insert( From db72d9f5dd91dde960b0adb41caed6c8273991b9 Mon Sep 17 00:00:00 2001 From: Ben Herila Date: Fri, 3 Jul 2026 22:04:18 +0000 Subject: [PATCH 5/9] fix: resolve clippy warnings (derivable_impls, redundant match arm) --- crates/temps-core/src/public_hostname.rs | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/crates/temps-core/src/public_hostname.rs b/crates/temps-core/src/public_hostname.rs index 68758d47f..794d4dfdb 100644 --- a/crates/temps-core/src/public_hostname.rs +++ b/crates/temps-core/src/public_hostname.rs @@ -11,22 +11,17 @@ const SHORT_HASH_LEN: usize = 8; /// The mode is stored per managed domain (`dns_managed_domains.generated_hostname_mode`) /// rather than globally, so a provider such as Cloudflare can offer the flat layout /// required by its Universal SSL wildcard cert without changing every domain's behaviour. -#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, ToSchema, PartialEq, Eq, Hash)] #[serde(rename_all = "snake_case")] pub enum PublicHostnameStrategy { /// Preserve Temps' existing generated hostname layout (`{service}-{env}.base`). + #[default] Standard, /// Force generated service hostnames to one label below `preview_domain` /// (`{env}-{service}.base`) so a single-label wildcard cert covers them. Flat, } -impl Default for PublicHostnameStrategy { - fn default() -> Self { - Self::Standard - } -} - impl PublicHostnameStrategy { /// Stable string used to persist the strategy in `dns_managed_domains`. pub fn as_db_str(self) -> &'static str { @@ -185,9 +180,6 @@ fn sanitize_label(label: &str) -> String { if lower.is_ascii_alphanumeric() { output.push(lower); previous_hyphen = false; - } else if lower == '-' && !previous_hyphen { - output.push('-'); - previous_hyphen = true; } else if !previous_hyphen { output.push('-'); previous_hyphen = true; From b67085a3bfd42ee8514865ddd6b9e62afa793d6a Mon Sep 17 00:00:00 2001 From: Ben Herila Date: Fri, 3 Jul 2026 22:07:21 +0000 Subject: [PATCH 6/9] fix(dns): remove unnecessary clone on Copy type DnsRecordType --- crates/temps-config/src/handler.rs | 4 ++-- crates/temps-dns/src/services/hostname_sync.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/temps-config/src/handler.rs b/crates/temps-config/src/handler.rs index ba39e2b90..15e5e85e2 100644 --- a/crates/temps-config/src/handler.rs +++ b/crates/temps-config/src/handler.rs @@ -16,8 +16,8 @@ use temps_core::error_builder::ErrorBuilder; use temps_core::{ problemdetails::Problem, AiConfigSettings, AppSettings, AuditContext, AuditLogger, AuditOperation, BuildLimitsSettings, ClusterDnsSettings, ContainerLogSettings, - DiskSpaceAlertSettings, LetsEncryptSettings, MetricsStoreKind, RateLimitSettings, - PublicHostnameSettings, PublicHostnameStrategy, RequestMetadata, ScreenshotSettings, + DiskSpaceAlertSettings, LetsEncryptSettings, MetricsStoreKind, PublicHostnameSettings, + PublicHostnameStrategy, RateLimitSettings, RequestMetadata, ScreenshotSettings, SecurityHeadersSettings, }; use tracing::{error, info}; diff --git a/crates/temps-dns/src/services/hostname_sync.rs b/crates/temps-dns/src/services/hostname_sync.rs index 0d3f957d5..75463818d 100644 --- a/crates/temps-dns/src/services/hostname_sync.rs +++ b/crates/temps-dns/src/services/hostname_sync.rs @@ -316,7 +316,7 @@ pub async fn reconcile_zone_records( if !dry_run { let name = relative_name(&record.fqdn, &suffix); provider - .remove_record(base_domain, &name, record_type.clone()) + .remove_record(base_domain, &name, record_type) .await?; } } From 8e5f226c19574c3a440d195f675e0924406cfac4 Mon Sep 17 00:00:00 2001 From: Ben Herila Date: Tue, 7 Jul 2026 05:52:12 +0000 Subject: [PATCH 7/9] fix(core): update pki and hostname hashing for dependency bumps --- crates/temps-core/src/node_pki.rs | 18 +++++++----------- crates/temps-core/src/public_hostname.rs | 2 +- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/crates/temps-core/src/node_pki.rs b/crates/temps-core/src/node_pki.rs index 7c6644214..0ea656167 100644 --- a/crates/temps-core/src/node_pki.rs +++ b/crates/temps-core/src/node_pki.rs @@ -16,9 +16,10 @@ //! Wiring it into the agent's TLS listener, the control-plane client, and the //! enrollment handshake happens in the respective crates. +use rcgen::string::Ia5String; use rcgen::{ BasicConstraints, CertificateParams, CertificateSigningRequestParams, DistinguishedName, - DnType, ExtendedKeyUsagePurpose, Ia5String, IsCa, KeyPair, KeyUsagePurpose, SanType, + DnType, ExtendedKeyUsagePurpose, IsCa, Issuer, KeyPair, KeyUsagePurpose, SanType, }; use sha2::{Digest, Sha256}; use thiserror::Error; @@ -167,14 +168,11 @@ pub fn sign_node_csr( context: "CA key".into(), reason: e.to_string(), })?; - let ca_params = - CertificateParams::from_ca_cert_pem(ca_cert_pem).map_err(|e| PkiError::PemParse { + let ca_issuer = + Issuer::from_ca_cert_pem(ca_cert_pem, ca_key).map_err(|e| PkiError::PemParse { context: "CA certificate".into(), reason: e.to_string(), })?; - let ca_cert = ca_params - .self_signed(&ca_key) - .map_err(|e| PkiError::CertBuild(e.to_string()))?; // Parse the CSR; constrain the leaf to client+server auth. let mut csr = @@ -202,11 +200,9 @@ pub fn sign_node_csr( } csr.params.subject_alt_names = sans; - let leaf = csr - .signed_by(&ca_cert, &ca_key) - .map_err(|e| PkiError::CsrSign { - reason: e.to_string(), - })?; + let leaf = csr.signed_by(&ca_issuer).map_err(|e| PkiError::CsrSign { + reason: e.to_string(), + })?; let cert_pem = leaf.pem(); let fingerprint = { diff --git a/crates/temps-core/src/public_hostname.rs b/crates/temps-core/src/public_hostname.rs index 794d4dfdb..3b52c4582 100644 --- a/crates/temps-core/src/public_hostname.rs +++ b/crates/temps-core/src/public_hostname.rs @@ -196,7 +196,7 @@ fn sanitize_label(label: &str) -> String { fn short_hash(seed: &str) -> String { let digest = Sha256::digest(seed.as_bytes()); - format!("{digest:x}").chars().take(SHORT_HASH_LEN).collect() + hex::encode(digest).chars().take(SHORT_HASH_LEN).collect() } #[cfg(test)] From 2c71f3aafce6fad28314861f073b1408ee6a9213 Mon Sep 17 00:00:00 2001 From: Ben Herila Date: Tue, 7 Jul 2026 05:56:33 +0000 Subject: [PATCH 8/9] fix(settings): align flat hostname api after rebase --- crates/temps-config/src/handler.rs | 6 ++---- web/src/api/client/types.gen.ts | 20 +++----------------- 2 files changed, 5 insertions(+), 21 deletions(-) diff --git a/crates/temps-config/src/handler.rs b/crates/temps-config/src/handler.rs index 15e5e85e2..188dc8566 100644 --- a/crates/temps-config/src/handler.rs +++ b/crates/temps-config/src/handler.rs @@ -16,9 +16,8 @@ use temps_core::error_builder::ErrorBuilder; use temps_core::{ problemdetails::Problem, AiConfigSettings, AppSettings, AuditContext, AuditLogger, AuditOperation, BuildLimitsSettings, ClusterDnsSettings, ContainerLogSettings, - DiskSpaceAlertSettings, LetsEncryptSettings, MetricsStoreKind, PublicHostnameSettings, - PublicHostnameStrategy, RateLimitSettings, RequestMetadata, ScreenshotSettings, - SecurityHeadersSettings, + DiskSpaceAlertSettings, LetsEncryptSettings, MetricsStoreKind, PublicHostnameStrategy, + RateLimitSettings, RequestMetadata, ScreenshotSettings, SecurityHeadersSettings, }; use tracing::{error, info}; use utoipa::{OpenApi, ToSchema}; @@ -397,7 +396,6 @@ impl AppSettingsResponse { crate::disk_status::DiskSpaceCheckResult, ContainerLogSettings, ClusterDnsSettings, - PublicHostnameSettings, PublicHostnameStrategy, DnsProviderSettingsMasked, DockerRegistrySettingsMasked, diff --git a/web/src/api/client/types.gen.ts b/web/src/api/client/types.gen.ts index ccc22536d..d0cd3b391 100644 --- a/web/src/api/client/types.gen.ts +++ b/web/src/api/client/types.gen.ts @@ -16383,6 +16383,7 @@ export type AppSettingsResponse = { * effective backend and warns when it diverges from the configured store. */ effective_metrics_store: MetricsStoreKind; + edge_target?: string | null; external_url?: string | null; insecure_tls: boolean; internal_url?: string | null; @@ -16391,7 +16392,6 @@ export type AppSettingsResponse = { multi_node: MultiNodeSettingsMasked; preview_domain: string; preview_gateway: PreviewGatewaySettingsMasked; - public_hostnames: PublicHostnameSettings; rate_limiting: RateLimitSettings; /** * When enabled, Admin-role accounts without MFA enrolled are rejected @@ -16407,23 +16407,9 @@ export type AppSettingsResponse = { setup_complete: boolean; }; -/** - * Operator-configurable templates for generated public hostnames. - * - * Templates may use `{base_domain}`, `{environment}`, `{service}`, - * `{deployment}`, `{project}`, `{app}`, `{branch}`, `{preview_slug}`, and - * `{short_hash}`. When `strategy = flat`, all generated labels before - * `{base_domain}` are collapsed into a single DNS label. - */ -export type PublicHostnameSettings = { - deployment_template?: string | null; - environment_template?: string | null; - service_template?: string | null; - strategy?: PublicHostnameStrategy; -}; - /** * Public hostname generation mode for Temps-managed preview routes. + * Stored per managed domain (`generated_hostname_mode`), not globally. */ export type PublicHostnameStrategy = 'standard' | 'flat'; @@ -16503,6 +16489,7 @@ export type AppSettings = { disk_space_alert?: DiskSpaceAlertSettings; dns_provider?: DnsProviderSettings; docker_registry?: DockerRegistrySettings; + edge_target?: string | null; external_url?: string | null; /** * Skip TLS certificate verification on outbound HTTP clients built by the @@ -16531,7 +16518,6 @@ export type AppSettings = { on_demand_tls?: OnDemandTlsSettings; preview_domain?: string; preview_gateway?: PreviewGatewaySettings; - public_hostnames?: PublicHostnameSettings; rate_limiting?: RateLimitSettings; /** * When `true`, any user holding the `Admin` role must have MFA enrolled From c2c9fa72763264e00b8c30780080319d6751fa40 Mon Sep 17 00:00:00 2001 From: Ben Herila Date: Tue, 7 Jul 2026 07:05:04 +0000 Subject: [PATCH 9/9] fix(proxy): update test cert generation for rcgen 0.14 --- crates/temps-proxy/src/tls_cert_loader.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/temps-proxy/src/tls_cert_loader.rs b/crates/temps-proxy/src/tls_cert_loader.rs index 8424a9d16..8a69959ce 100644 --- a/crates/temps-proxy/src/tls_cert_loader.rs +++ b/crates/temps-proxy/src/tls_cert_loader.rs @@ -493,11 +493,11 @@ mod tests { /// `domain` using rcgen + the test EncryptionService. Returns /// `(cert_pem, encrypted_key_pem)` ready to embed in a mock [`domains::Model`]. fn make_test_cert(domain: &str, enc: &temps_core::EncryptionService) -> (String, String) { - let rcgen::CertifiedKey { cert, key_pair } = + let rcgen::CertifiedKey { cert, signing_key } = rcgen::generate_simple_self_signed(vec![domain.to_string()]) .expect("rcgen should generate a test certificate"); let cert_pem = cert.pem(); - let key_pem = key_pair.serialize_pem(); + let key_pem = signing_key.serialize_pem(); let encrypted_key = enc.encrypt_string(&key_pem).expect("encrypt test key"); (cert_pem, encrypted_key) }