Skip to content
Draft
29 changes: 29 additions & 0 deletions CHANGELOG.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 19 additions & 2 deletions crates/temps-config/src/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
RequestMetadata, ScreenshotSettings, SecurityHeadersSettings,
DiskSpaceAlertSettings, LetsEncryptSettings, MetricsStoreKind, PublicHostnameStrategy,
RateLimitSettings, RequestMetadata, ScreenshotSettings, SecurityHeadersSettings,
};
use tracing::{error, info};
use utoipa::{OpenApi, ToSchema};
Expand Down Expand Up @@ -82,6 +82,8 @@ pub struct AppSettingsResponse {
pub external_url: Option<String>,
pub internal_url: Option<String>,
pub preview_domain: String,
/// Public edge target that synced DNS records point at (IP → A/AAAA, else CNAME).
pub edge_target: Option<String>,

// Screenshot settings
pub screenshots: ScreenshotSettings,
Expand Down Expand Up @@ -265,6 +267,7 @@ impl From<AppSettings> for AppSettingsResponse {
external_url: settings.external_url,
internal_url: settings.internal_url,
preview_domain: settings.preview_domain,
edge_target: settings.edge_target,
screenshots: settings.screenshots,
letsencrypt: settings.letsencrypt,
dns_provider: DnsProviderSettingsMasked {
Expand Down Expand Up @@ -393,6 +396,7 @@ impl AppSettingsResponse {
crate::disk_status::DiskSpaceCheckResult,
ContainerLogSettings,
ClusterDnsSettings,
PublicHostnameStrategy,
DnsProviderSettingsMasked,
DockerRegistrySettingsMasked,
AgentSandboxSettingsMasked,
Expand Down Expand Up @@ -748,6 +752,17 @@ fn preserve_self_recorded_fields(incoming: &mut AppSettings, current: &AppSettin
incoming.console_version = current.console_version.clone();
}

/// Normalize the edge target: trim whitespace and treat an empty string as
/// `None` so an operator clearing the field disables DNS record sync.
fn normalize_edge_target(settings: &mut AppSettings) {
if let Some(value) = settings.edge_target.take() {
let trimmed = value.trim().to_string();
if !trimmed.is_empty() {
settings.edge_target = Some(trimmed);
}
}
}

/// Update application settings
#[utoipa::path(
tag = "Settings",
Expand Down Expand Up @@ -972,6 +987,8 @@ async fn update_settings(
}
}

normalize_edge_target(&mut settings);

match app_state.config_service.update_settings(settings).await {
Ok(_) => {
let audit = SettingsUpdatedAudit {
Expand Down
28 changes: 10 additions & 18 deletions crates/temps-config/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ pub const SQLITE_DB_NAME: &str = "temps.db";

use rand::Rng;
use serde_derive::{Deserialize, Serialize};
use temps_core::AppSettings;
use temps_core::{AppSettings, PublicHostnameStrategy};

#[derive(Error, Debug)]
pub enum ConfigServiceError {
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -901,28 +898,23 @@ 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()
};
// Deployment hostnames are identical across hostname strategies (single
// label below the base domain), so no per-domain resolution is needed here.
let hostname = PublicHostnameStrategy::Standard
.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)
Expand Down
6 changes: 6 additions & 0 deletions crates/temps-core/src/app_settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ pub struct AppSettings {
/// `external_url`, which is the public-facing address.
pub internal_url: Option<String>,
pub preview_domain: String,
/// Public edge target that generated DNS records point at when a managed
/// domain opts into automatic record sync. An IPv4/IPv6 address produces an
/// `A`/`AAAA` record; anything else is treated as a `CNAME` target. `None`
/// disables DNS record sync regardless of per-domain opt-in.
pub edge_target: Option<String>,

// Screenshot settings
pub screenshots: ScreenshotSettings,
Expand Down Expand Up @@ -714,6 +719,7 @@ impl Default for AppSettings {
external_url: None,
internal_url: None,
preview_domain: DEFAULT_LOCAL_DOMAIN.to_string(),
edge_target: None,
screenshots: ScreenshotSettings::default(),
letsencrypt: LetsEncryptSettings::default(),
dns_provider: DnsProviderSettings::default(),
Expand Down
6 changes: 6 additions & 0 deletions crates/temps-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ pub mod on_demand;
pub mod openapi;
pub mod plugin;
pub mod problemdetails;
pub mod public_hostname;
pub mod public_hostname_resolver;
pub mod retry;
pub mod telemetry;
pub mod tls;
Expand Down Expand Up @@ -50,6 +52,10 @@ pub use error::*;
pub use error_builder::*;
pub use jobs::*;
pub use on_demand::*;
pub use public_hostname::{base_domain as public_base_domain, PublicHostnameStrategy};
pub use public_hostname_resolver::{
match_strategy, PublicHostnameResolver, StandardHostnameResolver,
};
pub use telemetry::{NoopTelemetryReporter, TelemetryEvent, TelemetryEventKind, TelemetryReporter};
pub use traces::{
TraceQueryFilter, TraceReader, TraceReaderError, TraceSpanDto, TraceSpanEventDto,
Expand Down
18 changes: 7 additions & 11 deletions crates/temps-core/src/node_pki.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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 = {
Expand Down
Loading
Loading