From bf954e4c506a4244fe20551cde0a33b10effc493 Mon Sep 17 00:00:00 2001 From: Taylor Mutch Date: Mon, 26 Oct 2026 16:13:14 -0700 Subject: [PATCH 1/3] feat(services): publish authoritative application origins Signed-off-by: Taylor Mutch --- crates/openshell-cli/src/run.rs | 57 ++- .../tests/provider_commands_integration.rs | 2 + .../sandbox_create_lifecycle_integration.rs | 8 + .../sandbox_exec_streaming_integration.rs | 2 + .../sandbox_name_fallback_integration.rs | 2 + crates/openshell-core/src/app_listener.rs | 199 +++++++++++ crates/openshell-crypto/README.md | 1 + .../openshell-driver-kubernetes/src/config.rs | 1 + crates/openshell-sdk/src/client.rs | 2 + crates/openshell-sdk/src/types.rs | 6 + crates/openshell-sdk/tests/client_mock.rs | 22 +- crates/openshell-server/README.md | 17 + .../src/grpc/mutation_replay.rs | 5 +- .../src/grpc/mutation_replay/ordinary.rs | 28 +- .../src/grpc/mutation_replay/tests.rs | 59 ++++ crates/openshell-server/src/grpc/sandbox.rs | 57 ++- crates/openshell-server/src/grpc/service.rs | 157 ++++++++- .../src/grpc/service_advertisement.rs | 18 +- .../src/grpc/service_discovery.rs | 102 +++++- .../openshell-server/src/service_routing.rs | 118 +++---- crates/openshell-server/src/storage_proto.rs | 2 +- crates/openshell-supervisor/README.md | 6 + .../openshell-supervisor/src/app_listener.rs | 324 ++++++++++++++---- docs/how-it-works/gateways/configuration.mdx | 43 ++- proto/openshell.proto | 12 +- proto/sandbox.proto | 4 + python/openshell/sandbox.py | 32 +- python/openshell/sandbox_test.py | 29 ++ sdk/typescript/src/client.test.ts | 4 + sdk/typescript/src/client.ts | 15 +- skills/debug-openshell-cluster/SKILL.md | 20 +- 31 files changed, 1155 insertions(+), 199 deletions(-) diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index eb224685ed..fdbf04ee57 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -723,10 +723,17 @@ pub async fn sandbox_create( Err(status) => return Err(miette::miette!(status.to_string())), }; let response = response.into_inner(); + let application_service_urls = response.application_service_urls; let service_urls = response .service_urls .into_iter() - .map(|(service, url)| (service, service_url_for_gateway(&url, &effective_server))) + .map(|(service, url)| { + let url = application_service_urls + .get(&service) + .cloned() + .unwrap_or_else(|| service_url_for_gateway(&url, &effective_server)); + (service, url) + }) .collect::>(); let sandbox = response .sandbox @@ -1115,10 +1122,7 @@ pub async fn sandbox_create( "\u{2713}".green().bold(), ); if let Some(url) = service_urls.get("").filter(|url| !url.is_empty()) { - eprintln!( - " Access at: {}", - service_url_for_gateway(url, &effective_server) - ); + eprintln!(" Access at: {url}"); } } @@ -4369,7 +4373,7 @@ pub async fn service_expose( ); } if !response.url.is_empty() { - let url = service_url_for_gateway(&response.url, server); + let url = preferred_service_url(&response, server); println!(" URL: {}", url.cyan()); } Ok(()) @@ -4582,7 +4586,7 @@ fn print_service_endpoint_table( let url = if response.url.is_empty() { String::new() } else { - service_url_for_gateway(&response.url, gateway_endpoint) + preferred_service_url(response, gateway_endpoint) }; Some(( workspace.to_string(), @@ -4673,7 +4677,7 @@ fn service_endpoint_to_json( let url = if response.url.is_empty() { String::new() } else { - service_url_for_gateway(&response.url, gateway_endpoint) + preferred_service_url(response, gateway_endpoint) }; Some(serde_json::json!({ @@ -4683,6 +4687,8 @@ fn service_endpoint_to_json( "target_port": endpoint.target_port, "authorization_mode": service_authorization_mode_name(endpoint.authorization_mode), "url": url, + "application_url": response.application_url, + "gateway_url": service_url_for_gateway(&response.gateway_url, gateway_endpoint), })) } @@ -4697,13 +4703,16 @@ fn service_authorization_mode_name(mode: i32) -> &'static str { } } -/// Read gcloud Application Default Credentials from disk. -/// -/// Returns `(client_id, client_secret, refresh_token)`. -/// -/// Checks `GOOGLE_APPLICATION_CREDENTIALS` first; falls back to -/// `$CLOUDSDK_CONFIG/application_default_credentials.json` when set, then to -/// `~/.config/gcloud/application_default_credentials.json`. +/// Application origins are independent of the gateway transport. Only legacy +/// gateway URLs inherit a caller's external gateway port. +fn preferred_service_url(response: &ServiceEndpointResponse, gateway: &str) -> String { + if response.application_url.is_empty() { + service_url_for_gateway(&response.url, gateway) + } else { + response.application_url.clone() + } +} + fn service_url_for_gateway(service_url: &str, gateway_endpoint: &str) -> String { let (Ok(mut service_url), Ok(gateway_endpoint)) = ( url::Url::parse(service_url), @@ -7332,6 +7341,8 @@ mod tests { #[test] fn service_endpoint_json_has_raw_fields_and_normalized_url() { let response = ServiceEndpointResponse { + application_url: String::new(), + gateway_url: String::new(), endpoint: Some(ServiceEndpoint { metadata: Some(ObjectMeta { workspace: "team-a".to_string(), @@ -7357,6 +7368,8 @@ mod tests { "target_port": 8080, "authorization_mode": "bearer_passthrough", "url": "https://api.openshell.localhost:17670/", + "application_url": "", + "gateway_url": "", }) ); assert!(service_endpoint_to_json(&ServiceEndpointResponse::default(), "unused").is_none()); @@ -8116,6 +8129,20 @@ mod tests { assert!(rootfs_tar_sources_supported_for_gateway(None)); } + #[test] + fn application_origin_is_preserved_when_gateway_uses_a_port_forward() { + let response = ServiceEndpointResponse { + application_url: "https://default--demo--web.services.example.com:10443/".into(), + gateway_url: "https://default--demo--web.api.example.com:8080/".into(), + url: "https://default--demo--web.services.example.com:10443/".into(), + ..Default::default() + }; + assert_eq!( + super::preferred_service_url(&response, "https://localhost:29092"), + response.application_url + ); + } + #[test] fn service_url_for_gateway_uses_external_gateway_port() { assert_eq!( diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index 89247ba718..e10ed8ffbe 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -363,6 +363,8 @@ impl OpenShell for TestOpenShell { let name = request.name; // Return a minimal sandbox with metadata for CAS operations Ok(Response::new(SandboxResponse { + application_service_urls: HashMap::new(), + gateway_service_urls: HashMap::new(), sandbox: Some(Sandbox { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: format!("sb-{name}"), diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index 587910fbaf..360fba98f2 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -223,6 +223,8 @@ impl OpenShell for TestOpenShell { }, ); Ok(Response::new(SandboxResponse { + application_service_urls: HashMap::new(), + gateway_service_urls: HashMap::new(), sandbox: Some(sandbox), service_urls, })) @@ -287,6 +289,8 @@ impl OpenShell for TestOpenShell { }; sandbox.set_phase(SandboxPhase::Ready as i32); Ok(Response::new(SandboxResponse { + application_service_urls: HashMap::new(), + gateway_service_urls: HashMap::new(), sandbox: Some(sandbox), service_urls: HashMap::new(), })) @@ -523,6 +527,8 @@ impl OpenShell for TestOpenShell { .push(request.into_inner()); Ok(Response::new( openshell_core::proto::ServiceEndpointResponse { + application_url: String::new(), + gateway_url: String::new(), url: "https://default--sandbox.openshell.localhost:17670/".to_string(), ..Default::default() }, @@ -535,6 +541,8 @@ impl OpenShell for TestOpenShell { ) -> Result, Status> { Ok(Response::new( openshell_core::proto::ServiceEndpointResponse { + application_url: String::new(), + gateway_url: String::new(), url: "https://default--sandbox.openshell.localhost:17670/".to_string(), ..Default::default() }, diff --git a/crates/openshell-cli/tests/sandbox_exec_streaming_integration.rs b/crates/openshell-cli/tests/sandbox_exec_streaming_integration.rs index 772a26e58e..131e479e5e 100644 --- a/crates/openshell-cli/tests/sandbox_exec_streaming_integration.rs +++ b/crates/openshell-cli/tests/sandbox_exec_streaming_integration.rs @@ -182,6 +182,8 @@ macro_rules! mock_gateway { async fn get_sandbox(&self, _: Request) -> Result, Status> { self.calls.lock().unwrap().lookups += 1; Ok(Response::new(proto::SandboxResponse { +application_service_urls: std::collections::HashMap::new(), +gateway_service_urls: std::collections::HashMap::new(), sandbox: Some(proto::Sandbox { metadata: Some(proto::datamodel::v1::ObjectMeta { id: "test-id".to_string(), diff --git a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs index 497f3ce778..968169fed0 100644 --- a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs +++ b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs @@ -172,6 +172,8 @@ impl OpenShell for TestOpenShell { let name = request.name.clone(); *self.state.last_get_name.lock().await = Some(name.clone()); Ok(Response::new(SandboxResponse { + application_service_urls: std::collections::HashMap::new(), + gateway_service_urls: std::collections::HashMap::new(), sandbox: Some(Sandbox { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "test-id".to_string(), diff --git a/crates/openshell-core/src/app_listener.rs b/crates/openshell-core/src/app_listener.rs index bece387213..26bc4a0a69 100644 --- a/crates/openshell-core/src/app_listener.rs +++ b/crates/openshell-core/src/app_listener.rs @@ -4,6 +4,7 @@ //! Driver-independent application listener configuration. use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; /// Explicitly enabled application ingress. Deployment details such as TLS /// Secret names belong to the driver, not to this common configuration. @@ -13,6 +14,8 @@ pub struct AppListenerConfig { pub enabled: bool, pub port: u16, pub domain: String, + /// Public HTTPS origin, independently of the supervisor's listener port. + pub public_url: Option, } impl Default for AppListenerConfig { @@ -21,6 +24,7 @@ impl Default for AppListenerConfig { enabled: false, port: 8443, domain: String::new(), + public_url: None, } } } @@ -28,6 +32,9 @@ impl Default for AppListenerConfig { impl AppListenerConfig { pub fn validate(&self) -> Result<(), String> { if !self.enabled { + if self.public_url.is_some() { + return Err("supervisor.app_listener.public_url requires enabled = true".into()); + } return Ok(()); } if self.port < 1024 { @@ -46,8 +53,124 @@ impl AppListenerConfig { { return Err("supervisor.app_listener.domain must be a lowercase DNS name".into()); } + if let Some(origin) = &self.public_url { + let parsed = url::Url::parse(origin) + .map_err(|_| "supervisor.app_listener.public_url must be an HTTPS origin")?; + if parsed.scheme() != "https" + || parsed.host_str() != Some(self.domain.as_str()) + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.path() != "/" + || parsed.query().is_some() + || parsed.fragment().is_some() + || parsed.port() == Some(0) + { + return Err("supervisor.app_listener.public_url must be an HTTPS origin matching domain, without credentials, path, query, or fragment".into()); + } + } Ok(()) } + + /// Canonical SNI/Host shared by URL publication, configuration and discovery. + /// A discriminator selects the gateway's hashed mode; otherwise use readable names. + pub fn service_hostname( + &self, + workspace: &str, + sandbox: &str, + service: &str, + discriminator: Option<&str>, + ) -> String { + let label = service_route_label(discriminator, workspace, sandbox, service); + format!("{label}.{}", self.domain) + } + + /// Configured address only; this does not imply installation or readiness. + pub fn service_url( + &self, + workspace: &str, + sandbox: &str, + service: &str, + discriminator: Option<&str>, + ) -> Option { + if !self.enabled { + return None; + } + let mut origin = url::Url::parse(self.public_url.as_deref()?).ok()?; + origin + .set_host(Some(&self.service_hostname( + workspace, + sandbox, + service, + discriminator, + ))) + .ok()?; + Some(origin.into()) + } +} + +/// Shared naming policy for gateway-mediated and direct application URLs. +/// The ingress implementation does not participate in the public service identity. +pub fn service_route_label( + discriminator: Option<&str>, + workspace: &str, + sandbox: &str, + service: &str, +) -> String { + discriminator.map_or_else( + || { + if service.is_empty() { + format!("{workspace}--{sandbox}") + } else { + format!("{workspace}--{sandbox}--{service}") + } + }, + |gateway| service_route_hash_label(gateway, workspace, sandbox, Some(service)), + ) +} + +/// Returns the stable, one-label DNS name for a service route. +/// +/// This is a non-security naming digest, not authentication or proof of authority. +/// Preserve the existing SHA-256 input encoding and 80-bit base32 label from #4842. +pub fn service_route_hash_label( + gateway_name: &str, + workspace: &str, + sandbox: &str, + service: Option<&str>, +) -> String { + let mut hasher = Sha256::new(); + hasher.update(gateway_name.as_bytes()); + hasher.update([0]); + hasher.update(workspace.as_bytes()); + hasher.update([0]); + hasher.update(sandbox.as_bytes()); + hasher.update([0]); + hasher.update(service.unwrap_or("").as_bytes()); + let digest = hasher.finalize(); + base32_lower_no_padding(&digest[..10]) +} + +fn base32_lower_no_padding(bytes: &[u8]) -> String { + const ALPHABET: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567"; + let mut output = String::with_capacity(16); + let mut accumulator = 0_u32; + let mut bits = 0_u8; + for byte in bytes { + accumulator = (accumulator << 8) | u32::from(*byte); + bits += 8; + while bits >= 5 { + bits -= 5; + output.push(char::from( + ALPHABET[((accumulator >> bits) & 0x1f) as usize], + )); + } + } + if bits > 0 { + output.push(char::from( + ALPHABET[((accumulator << (5 - bits)) & 0x1f) as usize], + )); + } + output } /// A synchronous view of installed ingress, evaluated on the sandbox instance @@ -60,6 +183,82 @@ pub type InstallationSource = std::sync::Arc< mod tests { use super::*; + #[test] + fn legacy_service_route_wire_payload_keeps_readable_routing_default() { + use prost::Message as _; + let legacy = b"\x0a\x08endpoint\x12\x03web\x18\x90\x3f\x20\x01\x28\x02"; + let route = crate::proto::SandboxServiceRoute::decode(legacy.as_slice()).unwrap(); + assert_eq!(route.endpoint_id, "endpoint"); + assert_eq!(route.name, "web"); + assert_eq!(route.target_port, 8080); + assert!(route.application_hostname.is_empty()); + } + + #[test] + fn application_urls_share_gateway_hash_labels_and_preserve_public_origin() { + let config = AppListenerConfig { + enabled: true, + domain: "apps.example.com".into(), + public_url: Some("https://apps.example.com:10443".into()), + ..Default::default() + }; + assert_eq!( + config + .service_url("default", "my-sandbox", "web", Some("gateway-a")) + .unwrap(), + "https://enh47uqyj7bpyttz.apps.example.com:10443/" + ); + assert_eq!( + config + .service_url("default", "my-sandbox", "", Some("gateway-a")) + .unwrap(), + "https://eiud2z5apv3yeucg.apps.example.com:10443/" + ); + assert_ne!( + config.service_hostname("default", "my-sandbox", "web", Some("gateway-a")), + config.service_hostname("default", "my-sandbox", "web", Some("gateway-b")) + ); + } + + #[test] + fn public_origin_is_explicit_https_and_matches_listener_domain() { + let mut config = AppListenerConfig { + enabled: true, + domain: "services.example.com".into(), + public_url: Some("https://services.example.com:10443".into()), + ..Default::default() + }; + config.validate().unwrap(); + assert_eq!( + config + .service_url("default", "demo", "web", None) + .as_deref(), + Some("https://default--demo--web.services.example.com:10443/") + ); + config.public_url = Some("https://services.example.com:443/".into()); + assert_eq!( + config.service_url("default", "demo", "", None).as_deref(), + Some("https://default--demo.services.example.com/") + ); + for bad in [ + "http://services.example.com", + "https://other.example.com", + "https://user:password@services.example.com", + "https://services.example.com/path", + "https://services.example.com/?query", + "https://services.example.com/#fragment", + "https://services.example.com:0", + "not-a-url", + ] { + config.public_url = Some(bad.into()); + assert!(config.validate().is_err(), "{bad}"); + } + config.public_url = Some("https://services.example.com".into()); + config.enabled = false; + assert!(config.validate().is_err()); + assert!(config.service_url("default", "demo", "web", None).is_none()); + } + #[test] fn only_explicit_enablement_requires_a_valid_endpoint() { assert!(!AppListenerConfig::default().enabled); diff --git a/crates/openshell-crypto/README.md b/crates/openshell-crypto/README.md index 8d411cf80d..2af0cbd57e 100644 --- a/crates/openshell-crypto/README.md +++ b/crates/openshell-crypto/README.md @@ -250,6 +250,7 @@ not a whole-application crypto coverage claim: | DNS policy contract fingerprints (`openshell-supervisor-network::policy_dns`) | These bind cached DNS state to policy and are security-relevant. They remain outside this migration; migrating the cache identity API and its failure handling is required for exclusive backend ownership. | | Credential-storage names (`credentials`, Kubernetes Secrets, Vault) | Deterministic namespace derivation, not encryption or proof of authorization. These retain their current hashing implementation and stored names; backend coverage is not asserted for them. | | Provider-refresh advisory-lock keys | Deterministic database lock selection for coordinating refreshes, not authentication or encryption. These remain outside the selected-backend contract. | +| Service-routing hostname labels (`openshell-core::app_listener`) | Deterministic public DNS naming, not authentication or proof of authorization. The existing truncated SHA-256/base32 encoding remains unchanged and uses `sha2`; gateway and application paths share the implementation. | | Endpoint IDs and pagination fingerprints | Deterministic identification/request matching, not authentication. These remain outside the selected-backend contract. | | Debug token fingerprints | Diagnostic display only; direct hashing remains outside the contract even though the input is a credential. | | Destination-trust generation labels (`openshell-server` network trust, Podman operator staging, Kubernetes ConfigMap naming, VM staged-snapshot checks) | Deterministic identity derivation for naming and generation equality, not encryption or proof of authorization. Trust decisions use the gateway's normalization, immutable staging, exact-byte validation, and TLS verification rather than these digests. These retain `sha2` directly. | diff --git a/crates/openshell-driver-kubernetes/src/config.rs b/crates/openshell-driver-kubernetes/src/config.rs index 28d3161f42..bfee161c63 100644 --- a/crates/openshell-driver-kubernetes/src/config.rs +++ b/crates/openshell-driver-kubernetes/src/config.rs @@ -1141,6 +1141,7 @@ mod tests { assert!(!config.app_listener.common.enabled); assert!(config.validate().is_ok()); config.app_listener.common = openshell_core::app_listener::AppListenerConfig { + public_url: None, enabled: true, port: 9443, domain: "services.example.com".into(), diff --git a/crates/openshell-sdk/src/client.rs b/crates/openshell-sdk/src/client.rs index da415535d9..c329cfc439 100644 --- a/crates/openshell-sdk/src/client.rs +++ b/crates/openshell-sdk/src/client.rs @@ -1588,6 +1588,8 @@ fn sandbox_from_response(sandbox: Option) -> Result fn sandbox_from_create_response(response: proto::SandboxResponse) -> Result { let mut sandbox = sandbox_from_response(response.sandbox)?; sandbox.service_urls = response.service_urls; + sandbox.application_service_urls = response.application_service_urls; + sandbox.gateway_service_urls = response.gateway_service_urls; Ok(sandbox) } diff --git a/crates/openshell-sdk/src/types.rs b/crates/openshell-sdk/src/types.rs index 571ee42b68..97c1fc7574 100644 --- a/crates/openshell-sdk/src/types.rs +++ b/crates/openshell-sdk/src/types.rs @@ -402,6 +402,10 @@ pub struct SandboxRef { /// Service URLs returned by sandbox creation, keyed by service name. The /// empty key identifies the unnamed service. Non-create reads leave this empty. pub service_urls: HashMap, + /// Configured application origins; these do not establish installation readiness. + pub application_service_urls: HashMap, + /// Gateway-routed alternatives. + pub gateway_service_urls: HashMap, pub restart_count: u32, pub next_restart_at_ms: Option, pub main_process_started_at_ms: Option, @@ -455,6 +459,8 @@ impl SandboxRef { .then_some(sandbox.host_key_fingerprint), created_from_workload_template, service_urls: HashMap::new(), + application_service_urls: HashMap::new(), + gateway_service_urls: HashMap::new(), restart_count, next_restart_at_ms, main_process_started_at_ms, diff --git a/crates/openshell-sdk/tests/client_mock.rs b/crates/openshell-sdk/tests/client_mock.rs index f98a180eea..50314c2385 100644 --- a/crates/openshell-sdk/tests/client_mock.rs +++ b/crates/openshell-sdk/tests/client_mock.rs @@ -359,7 +359,7 @@ impl OpenShell for TestOpenShell { } else { req.name.clone() }; - let service_urls = req + let service_urls: HashMap = req .service_exposures .iter() .map(|exposure| { @@ -371,6 +371,11 @@ impl OpenShell for TestOpenShell { .collect(); *self.state.last_create.lock().await = Some(req); Ok(Response::new(proto::SandboxResponse { + application_service_urls: service_urls.clone(), + gateway_service_urls: HashMap::from([( + "web".into(), + "https://web.gateway.test:8080/".into(), + )]), sandbox: Some(sandbox_with_phase(&name, proto::SandboxPhase::Provisioning)), service_urls, })) @@ -441,6 +446,8 @@ impl OpenShell for TestOpenShell { ); *self.state.last_stop.lock().await = Some(request); Ok(Response::new(proto::SandboxResponse { + application_service_urls: HashMap::new(), + gateway_service_urls: HashMap::new(), sandbox: Some(sandbox), service_urls: HashMap::new(), })) @@ -458,6 +465,8 @@ impl OpenShell for TestOpenShell { ); *self.state.last_start.lock().await = Some(request); Ok(Response::new(proto::SandboxResponse { + application_service_urls: HashMap::new(), + gateway_service_urls: HashMap::new(), sandbox: Some(sandbox), service_urls: HashMap::new(), })) @@ -475,6 +484,8 @@ impl OpenShell for TestOpenShell { ); *self.state.last_suspend.lock().await = Some(request); Ok(Response::new(proto::SandboxResponse { + application_service_urls: HashMap::new(), + gateway_service_urls: HashMap::new(), sandbox: Some(sandbox), service_urls: HashMap::new(), })) @@ -492,6 +503,8 @@ impl OpenShell for TestOpenShell { ); *self.state.last_resume.lock().await = Some(request); Ok(Response::new(proto::SandboxResponse { + application_service_urls: HashMap::new(), + gateway_service_urls: HashMap::new(), sandbox: Some(sandbox), service_urls: HashMap::new(), })) @@ -539,6 +552,8 @@ impl OpenShell for TestOpenShell { sandbox.metadata.as_mut().unwrap().id.clone_from(id); } let mut response = Response::new(proto::SandboxResponse { + application_service_urls: HashMap::new(), + gateway_service_urls: HashMap::new(), sandbox: Some(sandbox), service_urls: HashMap::new(), }); @@ -1294,6 +1309,11 @@ async fn create_sandbox_passes_spec_through() { result.service_urls.get("web").map(String::as_str), Some("https://web.example.test/") ); + assert_eq!(result.application_service_urls, result.service_urls); + assert_eq!( + result.gateway_service_urls["web"], + "https://web.gateway.test:8080/" + ); let observed = state.last_create.lock().await.clone().unwrap(); assert_eq!(observed.name, "my-box"); diff --git a/crates/openshell-server/README.md b/crates/openshell-server/README.md index d8b27b05a8..83b391febd 100644 --- a/crates/openshell-server/README.md +++ b/crates/openshell-server/README.md @@ -330,3 +330,20 @@ Cache reuse adds at most one refresh interval to collection freshness; consumers must independently honor the advertised lease expiry. Reconnect and relist on stream termination; revisions are opaque equality tokens, not resumable cursors or an ordering across gateway replicas. + +## Application URL publication + +`supervisor.app_listener.public_url` is an optional HTTPS origin matching the +listener domain. It requires explicit listener enablement. Service responses +return preferred `url`, configured `application_url`, and a gateway alternative +`gateway_url`. Sandbox creation supplies equivalent service-name maps. Current +deployment origins are recomputed for durable replay without repeating the +mutation or changing resource identities. These are configured addresses, not +installation, ingress acceptance, or application health signals. + +Application hostname mode follows `service_hostname_format` for both access +paths. The gateway sends each canonical app hostname in `SandboxServiceRoute`; +URL responses and discovery agree with that installed name. A mode or gateway +name change changes configuration identity, withdrawing old discovery evidence +until the supervisor acknowledges the replacement. Hash inputs and encoding are +shared with gateway-mediated routing in `openshell-core::app_listener`. diff --git a/crates/openshell-server/src/grpc/mutation_replay.rs b/crates/openshell-server/src/grpc/mutation_replay.rs index 837d26466c..6d302bf342 100644 --- a/crates/openshell-server/src/grpc/mutation_replay.rs +++ b/crates/openshell-server/src/grpc/mutation_replay.rs @@ -127,6 +127,7 @@ pub(super) trait Mutation: Message + Default + Send + Sync + 'static { request: Request, ) -> impl Future, Status>> + Send; fn capture(response: &Response) -> Result; + fn refresh_replay(_state: &ServerState, _response: &mut Self::Output) {} fn restore( store: &Store, success: Success, @@ -314,7 +315,9 @@ async fn execute_owned( return Err(replay_unavailable()); } let success = previous.success.ok_or_else(uncertain)?; - let mut response = Response::new(M::restore(&state.store, success).await?); + let mut output = M::restore(&state.store, success).await?; + M::refresh_replay(state, &mut output); + let mut response = Response::new(output); response.metadata_mut().insert( "openshell-replayed", "true".parse().expect("static metadata"), diff --git a/crates/openshell-server/src/grpc/mutation_replay/ordinary.rs b/crates/openshell-server/src/grpc/mutation_replay/ordinary.rs index a88c6abb9d..beaa178d49 100644 --- a/crates/openshell-server/src/grpc/mutation_replay/ordinary.rs +++ b/crates/openshell-server/src/grpc/mutation_replay/ordinary.rs @@ -335,7 +335,7 @@ async fn profile_scope( } macro_rules! mutation { - ($req:ty, $resp:ty, $method:literal, $handler:path, $auth:expr, $capture:expr, $restore:expr $(, $replay_method:literal)?) => { + ($req:ty, $resp:ty, $method:literal, $handler:path, $auth:expr, $capture:expr, $restore:expr $(, refresh = $refresh:expr)? $(, replay_method = $replay_method:literal)?) => { impl Mutation for $req { type Output = $resp; const METHOD: &'static str = $method; @@ -363,12 +363,15 @@ macro_rules! mutation { async fn restore(store: &Store, success: Success) -> Result { ($restore)(store, outcome(success)?).await } + fn refresh_replay(_state: &ServerState, _response: &mut Self::Output) { + $(($refresh)(_state, _response);)? + } } }; } macro_rules! scoped_mutation { - ($req:ty, $resp:ty, $method:literal, $handler:path, $role:ident, $capture:expr, $restore:expr) => { + ($req:ty, $resp:ty, $method:literal, $handler:path, $role:ident, $capture:expr, $restore:expr $(, refresh = $refresh:expr)?) => { mutation!( $req, $resp, @@ -384,13 +387,13 @@ macro_rules! scoped_mutation { .await }, $capture, - $restore + $restore $(, refresh = $refresh)? ); }; } macro_rules! sandbox_scoped_mutation { - ($req:ty, $resp:ty, $method:literal, $handler:path, $role:ident, $capture:expr, $restore:expr $(, $replay_method:literal)?) => { + ($req:ty, $resp:ty, $method:literal, $handler:path, $role:ident, $capture:expr, $restore:expr $(, refresh = $refresh:expr)? $(, replay_method = $replay_method:literal)?) => { mutation!( $req, $resp, @@ -407,7 +410,8 @@ macro_rules! sandbox_scoped_mutation { }, $capture, $restore - $(, $replay_method)? + $(, refresh = $refresh)? + $(, replay_method = $replay_method)? ); }; } @@ -442,11 +446,13 @@ macro_rules! sandbox_mutation { return Err(replay_unavailable()); }; Ok(SandboxResponse { + application_service_urls: HashMap::new(), + gateway_service_urls: HashMap::new(), sandbox: Some(live(store, &id).await?), service_urls: HashMap::new(), }) } - $(, $replay_method)? + $(, replay_method = $replay_method)? ); }; } @@ -469,10 +475,13 @@ scoped_mutation!( return Err(replay_unavailable()); }; Ok(SandboxResponse { + application_service_urls: HashMap::new(), + gateway_service_urls: HashMap::new(), sandbox: Some(live(store, &id).await?), service_urls, }) - } + }, + refresh = sandbox::refresh_response_urls ); sandbox_mutation!( openshell_core::proto::UpdateSandboxRequest, @@ -594,10 +603,13 @@ sandbox_scoped_mutation!( }; let _: Sandbox = live(store, &sandbox_id).await?; Ok(ServiceEndpointResponse { + application_url: String::new(), + gateway_url: String::new(), endpoint: Some(reference.restore(store).await?), url, }) - } + }, + refresh = service::refresh_response_urls ); macro_rules! provider_mutation { diff --git a/crates/openshell-server/src/grpc/mutation_replay/tests.rs b/crates/openshell-server/src/grpc/mutation_replay/tests.rs index fe9e2c20d0..39f9f398a2 100644 --- a/crates/openshell-server/src/grpc/mutation_replay/tests.rs +++ b/crates/openshell-server/src/grpc/mutation_replay/tests.rs @@ -93,6 +93,65 @@ fn canonical_payload_ignores_map_order_and_id_but_preserves_presence() { assert_ne!(absent, fingerprint(&template).unwrap()); } +#[tokio::test] +async fn service_url_replay_uses_current_origin_without_repeating_mutation() { + let directory = tempfile::tempdir().unwrap(); + let key = directory.path().join("private-key"); + std::fs::write(&key, b"test-only stable private fingerprint material").unwrap(); + let mut state = test_server_state().await; + let config = &mut Arc::get_mut(&mut state).unwrap().config; + config.gateway_jwt = Some(openshell_core::config::GatewayJwtConfig { + signing_key_path: key, + public_key_path: directory.path().join("public"), + kid_path: directory.path().join("kid"), + gateway_id: "test".into(), + ttl_secs: None, + }); + let request = CreateSandboxRequest { + name: "origin-replay".into(), + spec: Some(SandboxSpec::default()), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + service_exposures: vec![SandboxServiceExposure { + service: "web".into(), + target_port: 8080, + ..Default::default() + }], + request_id: uuid::Uuid::new_v4().to_string(), + ..Default::default() + }; + let original = run(&state, authed_request(request.clone())) + .await + .unwrap() + .into_inner(); + Arc::get_mut(&mut state) + .unwrap() + .config + .supervisor_app_listener = openshell_core::app_listener::AppListenerConfig { + enabled: true, + port: 9443, + domain: "services.example.com".into(), + public_url: Some("https://services.example.com:10443".into()), + }; + let replay = run(&state, authed_request(request)).await.unwrap(); + assert_eq!(replay.metadata().get("openshell-replayed").unwrap(), "true"); + assert_eq!( + original.sandbox.as_ref().unwrap().metadata, + replay.get_ref().sandbox.as_ref().unwrap().metadata + ); + assert_eq!( + replay.get_ref().service_urls["web"], + "https://default--origin-replay--web.services.example.com:10443/" + ); + assert_eq!( + replay.get_ref().application_service_urls, + replay.get_ref().service_urls + ); + assert_eq!( + replay.get_ref().gateway_service_urls, + original.gateway_service_urls + ); +} + #[tokio::test] async fn create_sandbox_replay_preserves_service_urls() { let directory = tempfile::tempdir().unwrap(); diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 86ff12b170..017f5e4684 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -363,6 +363,8 @@ pub(super) async fn handle_update_sandbox( ) .await?; Ok(Response::new(SandboxResponse { + application_service_urls: HashMap::new(), + gateway_service_urls: HashMap::new(), sandbox: Some(sandbox), service_urls: HashMap::new(), })) @@ -823,6 +825,8 @@ async fn handle_create_sandbox_inner( .await?; let mut service_urls = HashMap::with_capacity(request.service_exposures.len()); + let mut application_service_urls = HashMap::new(); + let mut gateway_service_urls = HashMap::new(); for exposure in &request.service_exposures { let endpoint = match super::service::expose_service_endpoint( state, @@ -864,7 +868,12 @@ async fn handle_create_sandbox_inner( return Err(exposure_error); } }; - service_urls.insert(exposure.service.clone(), endpoint.into_inner().url); + let endpoint = endpoint.into_inner(); + if !endpoint.application_url.is_empty() { + application_service_urls.insert(exposure.service.clone(), endpoint.application_url); + } + gateway_service_urls.insert(exposure.service.clone(), endpoint.gateway_url); + service_urls.insert(exposure.service.clone(), endpoint.url); } info!( @@ -873,6 +882,8 @@ async fn handle_create_sandbox_inner( "CreateSandbox request completed successfully" ); Ok(Response::new(SandboxResponse { + application_service_urls, + gateway_service_urls, sandbox: Some(sandbox), service_urls, })) @@ -1051,6 +1062,8 @@ pub(super) async fn handle_get_sandbox( None }; let mut response = Response::new(SandboxResponse { + application_service_urls: HashMap::new(), + gateway_service_urls: HashMap::new(), sandbox: Some(sandbox), service_urls: HashMap::new(), }); @@ -1877,6 +1890,8 @@ async fn handle_stop_sandbox_inner( let sandbox = state.compute.stop_sandbox(workspace, name).await?; info!(sandbox_name = %name, "StopSandbox request completed successfully"); Ok(Response::new(SandboxResponse { + application_service_urls: HashMap::new(), + gateway_service_urls: HashMap::new(), sandbox: Some(sandbox), service_urls: HashMap::new(), })) @@ -1932,6 +1947,8 @@ async fn handle_start_sandbox_inner( .project_endpoint_status(&mut sandbox, remote_authority); info!(sandbox_name = %name, "StartSandbox request completed successfully"); Ok(Response::new(SandboxResponse { + application_service_urls: HashMap::new(), + gateway_service_urls: HashMap::new(), sandbox: Some(sandbox), service_urls: HashMap::new(), })) @@ -1973,6 +1990,8 @@ async fn handle_suspend_sandbox_inner( let sandbox = state.compute.suspend_sandbox(workspace, name).await?; info!(sandbox_name = %name, "SuspendSandbox request completed successfully"); Ok(Response::new(SandboxResponse { + application_service_urls: HashMap::new(), + gateway_service_urls: HashMap::new(), sandbox: Some(sandbox), service_urls: HashMap::new(), })) @@ -2029,6 +2048,8 @@ async fn handle_resume_sandbox_inner( .project_endpoint_status(&mut sandbox, remote_authority); info!(sandbox_name = %name, "ResumeSandbox request completed successfully"); Ok(Response::new(SandboxResponse { + application_service_urls: HashMap::new(), + gateway_service_urls: HashMap::new(), sandbox: Some(sandbox), service_urls: HashMap::new(), })) @@ -4282,6 +4303,40 @@ async fn run_exec_with_russh( // Tests // --------------------------------------------------------------------------- +/// Replays preserve original service names and sandbox identity while using +/// current deployment origins. No service mutation is performed here. +pub(super) fn refresh_response_urls(state: &ServerState, response: &mut SandboxResponse) { + let Some(sandbox) = response.sandbox.as_ref() else { + return; + }; + response.application_service_urls.clear(); + response.gateway_service_urls.clear(); + for (service, url) in &mut response.service_urls { + let gateway = crate::service_routing::endpoint_url( + &state.config, + sandbox.object_workspace(), + sandbox.object_name(), + service, + ) + .unwrap_or_default(); + let application = state.config.supervisor_app_listener.service_url( + sandbox.object_workspace(), + sandbox.object_name(), + service, + state.config.service_routing_discriminator(), + ); + response + .gateway_service_urls + .insert(service.clone(), gateway.clone()); + *url = application.clone().unwrap_or(gateway); + if let Some(application) = application { + response + .application_service_urls + .insert(service.clone(), application); + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/openshell-server/src/grpc/service.rs b/crates/openshell-server/src/grpc/service.rs index d063c315ad..5fa6df67e1 100644 --- a/crates/openshell-server/src/grpc/service.rs +++ b/crates/openshell-server/src/grpc/service.rs @@ -227,14 +227,13 @@ pub(super) async fn expose_service_endpoint( meta.resource_version = result.resource_version; } - let url = service_routing::endpoint_url(&state.config, workspace, sandbox_name, service) - .unwrap_or_default(); - service_routing::emit_service_endpoint_config_event(&endpoint, &url, created); - - Ok(Response::new(ServiceEndpointResponse { - endpoint: Some(endpoint), - url, - })) + let response = service_endpoint_response(state, endpoint); + service_routing::emit_service_endpoint_config_event( + response.endpoint.as_ref().expect("endpoint populated"), + &response.url, + created, + ); + Ok(Response::new(response)) } pub(super) async fn handle_get_service( @@ -438,6 +437,12 @@ pub(super) async fn configured_app_routes( target_port: ep.target_port, authorization_mode: ep.authorization_mode, resource_version: ep.metadata.as_ref().map_or(0, |m| m.resource_version), + application_hostname: state.config.supervisor_app_listener.service_hostname( + sandbox.object_workspace(), + sandbox.object_name(), + &ep.name, + state.config.service_routing_discriminator(), + ), }) .collect(); service_routes.sort_by(|a, b| a.name.cmp(&b.name)); @@ -495,14 +500,41 @@ fn service_endpoint_response( Ok(ServiceAuthorizationMode::Unspecified | ServiceAuthorizationMode::Strip) | Err(_) => ServiceAuthorizationMode::Strip as i32, }; - let workspace = endpoint.object_workspace(); - let url = - service_routing::endpoint_url(&state.config, workspace, &endpoint.sandbox, &endpoint.name) - .unwrap_or_default(); - ServiceEndpointResponse { + let mut response = ServiceEndpointResponse { endpoint: Some(endpoint), - url, - } + ..Default::default() + }; + refresh_response_urls(state, &mut response); + response +} + +/// Recompute presentation from current configuration, including durable replays. +pub(super) fn refresh_response_urls(state: &ServerState, response: &mut ServiceEndpointResponse) { + let Some(endpoint) = response.endpoint.as_ref() else { + return; + }; + response.gateway_url = service_routing::endpoint_url( + &state.config, + endpoint.object_workspace(), + &endpoint.sandbox, + &endpoint.name, + ) + .unwrap_or_default(); + response.application_url = state + .config + .supervisor_app_listener + .service_url( + endpoint.object_workspace(), + &endpoint.sandbox, + &endpoint.name, + state.config.service_routing_discriminator(), + ) + .unwrap_or_default(); + response.url = if response.application_url.is_empty() { + response.gateway_url.clone() + } else { + response.application_url.clone() + }; } #[allow(clippy::result_large_err)] @@ -590,6 +622,7 @@ mod tests { enabled: true, port: 9443, domain: "services.example.com".into(), + public_url: None, }; seed_sandbox(&state, "limited").await; state @@ -705,6 +738,99 @@ mod tests { ); } + #[tokio::test] + async fn configured_application_origin_is_published_before_installation() { + let mut state = test_server_state().await; + Arc::get_mut(&mut state) + .unwrap() + .config + .supervisor_app_listener = openshell_core::app_listener::AppListenerConfig { + enabled: true, + port: 9443, + domain: "services.example.com".into(), + public_url: Some("https://services.example.com:10443".into()), + }; + seed_sandbox(&state, "demo").await; + for name in ["", "web"] { + let created = handle_expose_service( + &state, + authed_request(ExposeServiceRequest { + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + sandbox: "demo".into(), + name: name.into(), + target_port: 8080, + ..Default::default() + }), + ) + .await + .unwrap() + .into_inner(); + let label = if name.is_empty() { + "default--demo" + } else { + "default--demo--web" + }; + assert_eq!( + created.url, + format!("https://{label}.services.example.com:10443/") + ); + assert_eq!(created.application_url, created.url); + assert!(created.gateway_url.contains("openshell.localhost")); + let fetched = handle_get_service( + &state, + authed_request(GetServiceRequest { + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + sandbox: "demo".into(), + name: name.into(), + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(created, fetched); + } + } + + #[tokio::test] + async fn hashed_gateway_url_keeps_the_independent_application_origin() { + let mut state = test_server_state().await; + let config = &mut Arc::get_mut(&mut state).unwrap().config; + config.name = "gateway-a".into(); + config.service_routing.service_hostname_format = ServiceHostnameFormat::Hashed; + config.service_routing.base_domains = vec!["gateway.example.com".into()]; + config.supervisor_app_listener = openshell_core::app_listener::AppListenerConfig { + enabled: true, + port: 9443, + domain: "services.example.com".into(), + public_url: Some("https://services.example.com:10443".into()), + }; + let mut response = ServiceEndpointResponse { + endpoint: Some(ServiceEndpoint { + metadata: Some(ObjectMeta { + workspace: "default".into(), + ..Default::default() + }), + sandbox: "demo".into(), + name: "web".into(), + ..Default::default() + }), + ..Default::default() + }; + refresh_response_urls(&state, &mut response); + let gateway = url::Url::parse(&response.gateway_url).unwrap(); + let label = + service_routing::service_route_hash_label("gateway-a", "default", "demo", Some("web")); + assert_eq!( + gateway.host_str(), + Some(format!("{label}.gateway.example.com").as_str()) + ); + assert_eq!( + response.application_url, + format!("https://{label}.services.example.com:10443/") + ); + assert_eq!(response.url, response.application_url); + } + #[test] fn validates_good_endpoint_name() { validate_endpoint_name("service", "web-api", 28).unwrap(); @@ -717,6 +843,7 @@ mod tests { .expect("unshared test state") .config .supervisor_app_listener = openshell_core::app_listener::AppListenerConfig { + public_url: None, enabled: true, port: 9443, domain: "services.example.com".into(), diff --git a/crates/openshell-server/src/grpc/service_advertisement.rs b/crates/openshell-server/src/grpc/service_advertisement.rs index 3e953c1120..ab5d025779 100644 --- a/crates/openshell-server/src/grpc/service_advertisement.rs +++ b/crates/openshell-server/src/grpc/service_advertisement.rs @@ -968,6 +968,7 @@ mod tests { target_port: 8080, authorization_mode: 1, resource_version: 2, + application_hostname: String::new(), } } @@ -1160,6 +1161,17 @@ pub(super) mod test_support { use super::*; pub async fn installed_fixture() -> (Arc, OwnerGuard, SupervisorServiceAdvertisement) { + installed_fixture_for_gateway( + "gateway-a", + openshell_core::config::ServiceHostnameFormat::Readable, + ) + .await + } + + pub async fn installed_fixture_for_gateway( + gateway: &str, + format: openshell_core::config::ServiceHostnameFormat, + ) -> (Arc, OwnerGuard, SupervisorServiceAdvertisement) { use openshell_core::proto::datamodel::v1::ObjectMeta; use openshell_core::proto::{SandboxPolicy, SandboxSpec, ServiceEndpoint}; let endpoint = openshell_core::proto::compute::v1::SupervisorAppEndpoint { @@ -1185,11 +1197,15 @@ pub(super) mod test_support { .unwrap() .config .supervisor_app_listener = openshell_core::app_listener::AppListenerConfig { + public_url: None, enabled: true, port: 9443, domain: "services.example.com".into(), }; - Arc::get_mut(&mut state).unwrap().admin_role = "openshell-admin".into(); + let inner = Arc::get_mut(&mut state).unwrap(); + inner.config.name = gateway.into(); + inner.config.service_routing.service_hostname_format = format; + inner.admin_role = "openshell-admin".into(); let sandbox = Sandbox { metadata: Some(ObjectMeta { id: "sandbox".into(), diff --git a/crates/openshell-server/src/grpc/service_discovery.rs b/crates/openshell-server/src/grpc/service_discovery.rs index 18eafaf89f..d1b9d02ce4 100644 --- a/crates/openshell-server/src/grpc/service_discovery.rs +++ b/crates/openshell-server/src/grpc/service_discovery.rs @@ -412,12 +412,25 @@ async fn collect(state: &ServerState, workspace: &str) -> Result 63 || label.contains('.') { + return Err(Status::failed_precondition( + "service hostname exceeds DNS label limit", + )); + } let version = installation_version(&installation, bytes)?; routes.push(DiscoveredServiceRoute { workspace: workspace.to_string(), @@ -548,6 +561,83 @@ mod tests { assert_eq!(state.discovery_watches.available_permits(), permits); } + #[tokio::test] + async fn hashed_urls_discovery_and_installed_routes_use_the_same_gateway_identity() { + use super::super::service_advertisement::test_support::installed_fixture_for_gateway; + use openshell_core::config::ServiceHostnameFormat; + let (mut state, owner, report) = + installed_fixture_for_gateway("gateway-a", ServiceHostnameFormat::Hashed).await; + Arc::get_mut(&mut state) + .unwrap() + .config + .supervisor_app_listener + .public_url = Some("https://services.example.com:10443".into()); + accept(&state, &owner, &report, current_time_ms() + 10_000) + .await + .unwrap(); + let first = snapshot(&state, "default").await.unwrap(); + let hostname = &first.routes[0].hostname; + let label = crate::service_routing::service_route_hash_label( + "gateway-a", + "default", + "demo", + Some("web"), + ); + assert_eq!(*hostname, format!("{label}.services.example.com")); + assert_eq!(*hostname, report.routes[0].application_hostname); + let endpoint = state + .store + .get_message::("endpoint") + .await + .unwrap() + .unwrap(); + let mut response = openshell_core::proto::ServiceEndpointResponse { + endpoint: Some(endpoint), + ..Default::default() + }; + super::super::service::refresh_response_urls(&state, &mut response); + assert_eq!( + response.application_url, + format!("https://{hostname}:10443/") + ); + assert_eq!(response.url, response.application_url); + + // A renamed installation cannot advertise a hostname it has not installed. + Arc::get_mut(&mut state).unwrap().config.name = "gateway-b".into(); + assert!(snapshot(&state, "default").await.unwrap().routes.is_empty()); + assert_eq!( + accept(&state, &owner, &report, current_time_ms() + 10_000) + .await + .unwrap_err() + .code(), + tonic::Code::FailedPrecondition + ); + let sandbox = state + .store + .get_message::("sandbox") + .await + .unwrap() + .unwrap(); + let config = super::super::policy::build_sandbox_config_snapshot(&state, &sandbox) + .await + .unwrap(); + let replacement = openshell_core::proto::SupervisorServiceAdvertisement { + config_revision: config.config_revision, + routes: config.service_routes, + lease_sequence: report.lease_sequence + 1, + ..report + }; + accept(&state, &owner, &replacement, current_time_ms() + 10_000) + .await + .unwrap(); + let second = snapshot(&state, "default").await.unwrap(); + assert_ne!(first.routes[0].hostname, second.routes[0].hostname); + assert_ne!( + first.routes[0].installation_version, + second.routes[0].installation_version + ); + } + #[tokio::test] async fn snapshot_is_shared_between_gateway_states_and_rejects_expired_or_replaced_owners() { let (state, owner, report) = installed_fixture().await; diff --git a/crates/openshell-server/src/service_routing.rs b/crates/openshell-server/src/service_routing.rs index 4d8c4a2ace..d5d1ac6880 100644 --- a/crates/openshell-server/src/service_routing.rs +++ b/crates/openshell-server/src/service_routing.rs @@ -9,6 +9,8 @@ use axum::{ }; use http::{HeaderMap, HeaderValue, Method, Request, Response, StatusCode, header}; use hyper_util::rt::TokioIo; +pub use openshell_core::app_listener::service_route_hash_label; +use openshell_core::app_listener::service_route_label; use openshell_core::config::ServiceRoutingConfig; use openshell_core::proto::{ Sandbox, SandboxPhase, ServiceAuthorizationMode, ServiceEndpoint, TcpRelayTarget, relay_open, @@ -19,7 +21,6 @@ use openshell_ocsf::{ HttpActivityBuilder, HttpRequest, HttpResponse as OcsfHttpResponse, NetworkActivityBuilder, OcsfEvent, SeverityId, StateId, StatusId, Url as OcsfUrl, }; -use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::sync::{Arc, Mutex, RwLock}; use std::time::{Duration, Instant}; @@ -198,79 +199,12 @@ fn endpoint_host( service: &str, ) -> Option { let base_domain = config.base_domains.first()?; - Some(discriminator.map_or_else( - || { - format!( - "{}.{base_domain}", - service_route_readable_label(workspace, sandbox, service) - ) - }, - |gateway_name| { - format!( - "{}.{}", - service_route_hash_label( - gateway_name, - workspace, - sandbox, - (!service.is_empty()).then_some(service), - ), - base_domain - ) - }, + Some(format!( + "{}.{base_domain}", + service_route_label(discriminator, workspace, sandbox, service) )) } -/// Readable DNS label shared by discovery and gateway routing. -pub fn service_route_readable_label(workspace: &str, sandbox: &str, service: &str) -> String { - if service.is_empty() { - format!("{workspace}--{sandbox}") - } else { - format!("{workspace}--{sandbox}--{service}") - } -} - -/// Returns the stable, one-label DNS name for a service route. -pub fn service_route_hash_label( - gateway_name: &str, - workspace: &str, - sandbox: &str, - service: Option<&str>, -) -> String { - let mut hasher = Sha256::new(); - hasher.update(gateway_name.as_bytes()); - hasher.update([0]); - hasher.update(workspace.as_bytes()); - hasher.update([0]); - hasher.update(sandbox.as_bytes()); - hasher.update([0]); - hasher.update(service.unwrap_or("").as_bytes()); - let digest = hasher.finalize(); - base32_lower_no_padding(&digest[..10]) -} - -fn base32_lower_no_padding(bytes: &[u8]) -> String { - const ALPHABET: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567"; - let mut output = String::with_capacity(16); - let mut accumulator = 0_u32; - let mut bits = 0_u8; - for byte in bytes { - accumulator = (accumulator << 8) | u32::from(*byte); - bits += 8; - while bits >= 5 { - bits -= 5; - output.push(char::from( - ALPHABET[((accumulator >> bits) & 0x1f) as usize], - )); - } - } - if bits > 0 { - output.push(char::from( - ALPHABET[((accumulator << (5 - bits)) & 0x1f) as usize], - )); - } - output -} - /// A resolved service-route destination. #[derive(Clone, Debug, Eq, PartialEq)] pub struct ServiceRouteTarget { @@ -1536,6 +1470,48 @@ mod tests { ); } + #[test] + fn switching_ingress_preserves_public_urls_in_both_hostname_modes() { + for format in [ + ServiceHostnameFormat::Readable, + ServiceHostnameFormat::Hashed, + ] { + let mut cfg = openshell_core::Config::new(Some(tls_config())) + .with_bind_address("0.0.0.0:443".parse().unwrap()) + .with_server_sans(["*.apps.example.com"]) + .with_name("gateway-a") + .with_service_hostname_format(format); + cfg.supervisor_app_listener = openshell_core::app_listener::AppListenerConfig { + enabled: true, + port: 9443, + domain: "apps.example.com".into(), + public_url: Some("https://apps.example.com".into()), + }; + for workspace in ["default", "team-a"] { + for service in ["", "web"] { + let application_url = cfg + .supervisor_app_listener + .service_url( + workspace, + "my-sandbox", + service, + cfg.service_routing_discriminator(), + ) + .unwrap(); + // Switching to gateway ingress removes listener configuration, + // but preserves the installation identity and public origin. + let listener = std::mem::take(&mut cfg.supervisor_app_listener); + let gateway_url = endpoint_url(&cfg, workspace, "my-sandbox", service).unwrap(); + assert_eq!( + application_url, gateway_url, + "{format:?}: {workspace}/{service}" + ); + cfg.supervisor_app_listener = listener; + } + } + } + } + #[test] fn service_route_hash_label_uses_stable_fixed_vectors() { assert_eq!( diff --git a/crates/openshell-server/src/storage_proto.rs b/crates/openshell-server/src/storage_proto.rs index f4a31e52ed..7b2e02b972 100644 --- a/crates/openshell-server/src/storage_proto.rs +++ b/crates/openshell-server/src/storage_proto.rs @@ -726,7 +726,7 @@ mod tests { overlap_hash.as_str(), ), ( - (344, 31), + (346, 31), (100, 22), (86, 22), PUBLIC_RPC_SCHEMA_SHA256, diff --git a/crates/openshell-supervisor/README.md b/crates/openshell-supervisor/README.md index 1fa7f6ab9e..e7aa34305d 100644 --- a/crates/openshell-supervisor/README.md +++ b/crates/openshell-supervisor/README.md @@ -326,3 +326,9 @@ seconds from issuance, independently of configuration freshness. Expiry closes streams; later renewal cannot revive them. Takeover preserves the workload and public hostname while replacing the app endpoint. Clients reconnect after an interruption; sockets are not migrated. + +The gateway supplies a canonical `application_hostname` with each installed +service route. The listener dispatches exact hashed or readable SNI names to +the correct sandbox instance, validates HTTP Host against SNI, and fences old +streams when a hostname changes. Readable aliases remain accepted during +migration; an older gateway omitting the field retains readable routing. diff --git a/crates/openshell-supervisor/src/app_listener.rs b/crates/openshell-supervisor/src/app_listener.rs index ce63c9bd0b..9356b0d878 100644 --- a/crates/openshell-supervisor/src/app_listener.rs +++ b/crates/openshell-supervisor/src/app_listener.rs @@ -69,6 +69,7 @@ impl Config { }; let address: SocketAddr = address.parse().into_diagnostic()?; openshell_core::app_listener::AppListenerConfig { + public_url: None, enabled: true, port: address.port(), domain: domain.clone(), @@ -280,7 +281,7 @@ impl Routes { return; } if let Some(registration) = &self.registration - && !registration.bind(&result.workspace, &self.stopped) + && !registration.bind(&result.workspace, &routes, &self.stopped) { self.suspend(); return; @@ -366,15 +367,19 @@ impl Routes { domain: &str, ) -> Option<(SandboxServiceRoute, RouteToken)> { let encoded = sni.strip_suffix(&format!(".{domain}"))?; - let service = if encoded == prefix { - "" - } else { - encoded.strip_prefix(&format!("{prefix}--"))? - }; let snapshot = self.sender.borrow(); if self.serving_expiry(&snapshot)? <= Instant::now() { return None; } + let service = if let Some(route) = snapshot.routes.values().find(|route| { + !route.application_hostname.is_empty() && route.application_hostname == sni + }) { + route.name.as_str() + } else if encoded == prefix { + "" + } else { + encoded.strip_prefix(&format!("{prefix}--"))? + }; Some(( snapshot.routes.get(service)?.clone(), RouteToken { @@ -432,12 +437,38 @@ struct Registration { router: Router, sandbox: String, prefix: Mutex, + hostnames: Mutex>, sender: mpsc::Sender, } impl Registration { - fn bind(&self, workspace: &str, stopped: &AtomicBool) -> bool { + fn bind( + &self, + workspace: &str, + routes: &BTreeMap, + stopped: &AtomicBool, + ) -> bool { let next = format!("{workspace}--{}", self.sandbox); + let mut names = vec![next.clone()]; + for route in routes.values() { + if route.application_hostname.is_empty() { + continue; // Older gateways only supplied the readable route identity. + } + let Some(label) = route + .application_hostname + .strip_suffix(&format!(".{}", self.router.domain)) + else { + return false; + }; + if !valid_label(label) || route.application_hostname.len() > 253 { + return false; + } + names.push(route.application_hostname.clone()); + } + names.sort(); + if names.windows(2).any(|pair| pair[0] == pair[1]) { + return false; + } let mut prefix = self .prefix .lock() @@ -448,45 +479,62 @@ impl Registration { if stopped.load(Ordering::Acquire) { return false; } + let mut hostnames = self + .hostnames + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let mut entries = self .router .registrations .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - if entries - .get(&next) - .is_some_and(|sender| !sender.same_channel(&self.sender)) - { + // Check the whole replacement before changing any registration. An exact + // hashed label must never select an arbitrary sandbox on a shared host. + if names.iter().any(|name| { + entries + .get(name) + .is_some_and(|sender| !sender.same_channel(&self.sender)) + }) { return false; } - if *prefix != next { + for name in hostnames.iter() { if entries - .get(&*prefix) - .is_some_and(|s| s.same_channel(&self.sender)) + .get(name) + .is_some_and(|sender| sender.same_channel(&self.sender)) { - entries.remove(&*prefix); + entries.remove(name); } - prefix.clone_from(&next); } - entries.insert(next, self.sender.clone()); + for name in &names { + entries.insert(name.clone(), self.sender.clone()); + } + *prefix = next; + *hostnames = names; true } fn remove(&self) { - let prefix = self + // Use the same lock as bind's stopped check, including exact aliases. + let _prefix = self .prefix .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); + let hostnames = self + .hostnames + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let mut entries = self .router .registrations .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - if entries - .get(&*prefix) - .is_some_and(|s| s.same_channel(&self.sender)) - { - entries.remove(&*prefix); + for name in hostnames.iter() { + if entries + .get(name) + .is_some_and(|sender| sender.same_channel(&self.sender)) + { + entries.remove(name); + } } } } @@ -553,6 +601,7 @@ impl Router { router: self.clone(), sandbox: sandbox.to_string(), prefix: Mutex::new(String::new()), + hostnames: Mutex::new(Vec::new()), sender, }); Ok(InstanceListener { @@ -592,10 +641,15 @@ impl BoundListener { let Ok(Ok(tls)) = tokio::time::timeout(HANDSHAKE_TIMEOUT, acceptor.accept(stream)).await else { return; }; let Some(sni) = tls.get_ref().1.server_name() else { return; }; let Some(label) = sni.strip_suffix(&format!(".{domain}")) else { return; }; - let mut segments = label.split("--"); - let (Some(workspace), Some(sandbox)) = (segments.next(), segments.next()) else { return; }; - let prefix = format!("{workspace}--{sandbox}"); - let sender = dispatch.registrations.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&prefix).cloned(); + let sender = { + let entries = dispatch.registrations.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + entries.get(sni).cloned().or_else(|| { + // Keep readable aliases during migration, as gateway routing does. + let mut segments = label.split("--"); + let (Some(workspace), Some(sandbox)) = (segments.next(), segments.next()) else { return None; }; + entries.get(&format!("{workspace}--{sandbox}")).cloned() + }) + }; if let Some(sender) = sender { let _ = sender.try_send(Accepted { tls, permit }); } }); } @@ -958,6 +1012,7 @@ mod tests { target_port: 8080, authorization_mode: 1, resource_version: 1, + application_hostname: String::new(), }; let mut config = installed(vec![route.clone()]); config.config_revision = 42; @@ -996,6 +1051,7 @@ mod tests { target_port: 8080, authorization_mode: 1, resource_version: 1, + application_hostname: String::new(), }; routes.install(&installed(vec![route.clone()])); assert!( @@ -1232,6 +1288,7 @@ mod tests { target_port: 8080, authorization_mode: 1, resource_version: 1, + application_hostname: String::new(), }; routes.install(&installed(vec![route.clone()])); let connector = Arc::new(Connector { @@ -1357,6 +1414,96 @@ mod tests { ); } + #[tokio::test] + async fn hashed_sni_routes_to_the_correct_instance_and_replacement_withdraws_old_names() { + let f = fixture(); + let hostname = |gateway, sandbox, service| { + format!( + "{}.svc.openshell.internal", + openshell_core::app_listener::service_route_hash_label( + gateway, + "default", + sandbox, + Some(service) + ) + ) + }; + let first_host = hostname("gateway-a", "sandbox-a", "web"); + let mut first = f.route.clone(); + first.application_hostname.clone_from(&first_host); + f.routes.install(&installed(vec![first.clone()])); + assert!( + request(&f, &first_host, &first_host, "") + .await + .starts_with("HTTP/1.1 200") + ); + assert!( + request(&f, &first_host, HOST, "") + .await + .starts_with("HTTP/1.1 421") + ); + // Readable migration aliases remain usable, as with gateway-mediated hosts. + assert!( + request(&f, HOST, HOST, "") + .await + .starts_with("HTTP/1.1 200") + ); + let before = f.connector.calls.load(Ordering::SeqCst); + let foreign = hostname("gateway-b", "sandbox-a", "web"); + assert!( + !request(&f, &foreign, &foreign, "") + .await + .starts_with("HTTP/1.1 200") + ); + assert_eq!(f.connector.calls.load(Ordering::SeqCst), before); + + let second = f.host.router().instance("sandbox-b").unwrap(); + let second_routes = second.routes.clone(); + let connector = Arc::new(Connector { + calls: AtomicUsize::new(0), + }); + let handle = second.start(rpc_test::loopback(connector.clone())); + // A canonical hostname collision cannot steal another instance's mapping. + second_routes.install(&installed(vec![first.clone()])); + assert!(second_routes.sender.borrow().routes.is_empty()); + let second_host = hostname("gateway-a", "sandbox-b", "web"); + second_routes.install(&installed(vec![SandboxServiceRoute { + application_hostname: second_host.clone(), + endpoint_id: "endpoint-b".into(), + ..first.clone() + }])); + assert!( + request(&f, &second_host, &second_host, "") + .await + .starts_with("HTTP/1.1 200") + ); + assert_eq!(connector.calls.load(Ordering::SeqCst), 1); + let (_, token) = f + .routes + .resolve(&first_host, "default--sandbox-a", "svc.openshell.internal") + .unwrap(); + let mut replacement = first.clone(); + replacement.application_hostname.clone_from(&foreign); + f.routes.install(&installed(vec![replacement])); + assert!(!f.routes.valid(&first, token)); + assert!( + !request(&f, &first_host, &first_host, "") + .await + .starts_with("HTTP/1.1 200") + ); + assert!( + request(&f, &foreign, &foreign, "") + .await + .starts_with("HTTP/1.1 200") + ); + drop(handle); + assert!( + !request(&f, &second_host, &second_host, "") + .await + .starts_with("HTTP/1.1 200") + ); + } + #[tokio::test] async fn shared_listener_keeps_instance_connectors_and_lifetimes_separate() { let f = fixture(); @@ -1439,7 +1586,18 @@ mod tests { }); let mut installing_routes = f.routes.clone(); installing_routes.install_probe = Some(probe.clone()); - let snapshot = installed(vec![f.route.clone()]); + let hostname = format!( + "{}.svc.openshell.internal", + openshell_core::app_listener::service_route_hash_label( + "gateway-a", + "default", + "sandbox-a", + Some("web") + ) + ); + let mut route = f.route.clone(); + route.application_hostname.clone_from(&hostname); + let snapshot = installed(vec![route.clone()]); let installer = std::thread::spawn(move || installing_routes.install(&snapshot)); probe.checked_running.wait(); // Complete shutdown after install's optimistic stopped check but @@ -1455,12 +1613,20 @@ mod tests { .unwrap() .contains_key("default--sandbox-a") ); + assert!( + !f.host + .router() + .registrations + .lock() + .unwrap() + .contains_key(&hostname) + ); let replacement = f.host.router().instance("sandbox-a").unwrap(); - replacement.routes.install(&installed(vec![f.route])); + replacement.routes.install(&installed(vec![route])); assert!( replacement .routes - .resolve(HOST, "default--sandbox-a", "svc.openshell.internal") + .resolve(&hostname, "default--sandbox-a", "svc.openshell.internal") .is_some(), "the stopped instance must not prevent its replacement from serving" ); @@ -1475,6 +1641,7 @@ mod tests { target_port: 8080, authorization_mode: 1, resource_version: 1, + application_hostname: String::new(), }; routes.install(&installed(vec![route.clone()])); for _ in 0..10 { @@ -1587,47 +1754,68 @@ mod tests { assert!(!headers.contains_key("x-internal")); } #[tokio::test] - async fn websocket_echo_closes_on_route_withdrawal() { - let f = fixture(); - let stream = openshell_core::net::connect_tcp_nodelay_best_effort(&[f.address]) - .await - .unwrap(); - let name = rustls::pki_types::ServerName::try_from(HOST.to_string()).unwrap(); - let tls = tokio_rustls::TlsConnector::from(f.tls.clone()) - .connect(name, stream) - .await - .unwrap(); - let mut request = format!("wss://{HOST}/echo").into_client_request().unwrap(); - request - .headers_mut() - .insert(header::ORIGIN, format!("https://{HOST}").parse().unwrap()); - let (mut ws, response) = tokio_tungstenite::client_async(request, tls).await.unwrap(); - assert_eq!(response.headers()["x-test-upstream-host"], HOST); - assert_eq!( - response.headers()["x-test-upstream-origin"], - format!("https://{HOST}") - ); - ws.send(tokio_tungstenite::tungstenite::Message::Text( - "hello".into(), - )) - .await - .unwrap(); - assert_eq!( - tokio::time::timeout(Duration::from_secs(2), ws.next()) + async fn readable_and_hashed_websocket_echo_closes_on_route_withdrawal() { + for hashed in [false, true] { + let f = fixture(); + let hostname = if hashed { + format!( + "{}.svc.openshell.internal", + openshell_core::app_listener::service_route_hash_label( + "gateway-a", + "default", + "sandbox-a", + Some("web") + ) + ) + } else { + HOST.to_string() + }; + let mut route = f.route.clone(); + route.application_hostname.clone_from(&hostname); + f.routes.install(&installed(vec![route])); + let stream = openshell_core::net::connect_tcp_nodelay_best_effort(&[f.address]) .await - .unwrap() - .unwrap() - .unwrap() - .to_text() - .unwrap(), - "hello" - ); - f.routes.suspend(); - let closed = tokio::time::timeout(Duration::from_secs(2), ws.next()) + .unwrap(); + let name = rustls::pki_types::ServerName::try_from(hostname.clone()).unwrap(); + let tls = tokio_rustls::TlsConnector::from(f.tls.clone()) + .connect(name, stream) + .await + .unwrap(); + let mut request = format!("wss://{hostname}/echo") + .into_client_request() + .unwrap(); + request.headers_mut().insert( + header::ORIGIN, + format!("https://{hostname}").parse().unwrap(), + ); + let (mut ws, response) = tokio_tungstenite::client_async(request, tls).await.unwrap(); + assert_eq!(response.headers()["x-test-upstream-host"], hostname); + assert_eq!( + response.headers()["x-test-upstream-origin"], + format!("https://{hostname}") + ); + ws.send(tokio_tungstenite::tungstenite::Message::Text( + "hello".into(), + )) .await .unwrap(); - assert!(closed.is_none() || closed.unwrap().is_err()); - assert_eq!(f.connector.calls.load(Ordering::SeqCst), 1); + assert_eq!( + tokio::time::timeout(Duration::from_secs(2), ws.next()) + .await + .unwrap() + .unwrap() + .unwrap() + .to_text() + .unwrap(), + "hello" + ); + f.routes.suspend(); + let closed = tokio::time::timeout(Duration::from_secs(2), ws.next()) + .await + .unwrap(); + assert!(closed.is_none() || closed.unwrap().is_err()); + assert_eq!(f.connector.calls.load(Ordering::SeqCst), 1); + } } type TestWebSocket = diff --git a/docs/how-it-works/gateways/configuration.mdx b/docs/how-it-works/gateways/configuration.mdx index 18a179cf45..f239e699c6 100644 --- a/docs/how-it-works/gateways/configuration.mdx +++ b/docs/how-it-works/gateways/configuration.mdx @@ -1899,6 +1899,7 @@ ingress remains available independently. enabled = true port = 9443 domain = "services.example.com" +public_url = "https://services.example.com" [openshell.drivers.kubernetes.sandbox_runtime.app_listener] tls_secret_name = "openshell-app-tls" @@ -1929,9 +1930,25 @@ Expose services with the existing commands. For direct listener testing, an unnamed service on sandbox `demo` in workspace `default` uses SNI and Host `default--demo.services.example.com`; a service named `web` uses `default--demo--web.services.example.com`. Configure DNS or your ingress to reach -the owning supervisor while preserving that hostname. The discovery API supplies accepted supervisor endpoints. Application URL -publication is separate work; current CLI URLs still identify the existing -gateway path. +the owning supervisor while preserving that hostname. + +Both ingress paths use `[openshell.gateway].service_hostname_format`; the app +listener has no separate naming mode. Keeping the same installation name and +public HTTPS domain/port preserves the URL when switching between gateway and +app-listener ingress. + +With `[openshell.gateway].service_hostname_format = "hashed"`, application URLs +and discovery use the same 16-character label as gateway-mediated URLs. The +label includes the gateway installation name, workspace, sandbox, and service; +application and gateway origins may still have different domains and ports. +Use a unique gateway `name` among installations sharing an application domain. +Supervisors install the gateway-declared hostname and retain readable aliases +for migration. Proxies and controllers consume discovery hostnames as returned. +Upgrade gateways and supervisors together before enabling hashed application +hostnames; older supervisors cannot serve the new hashed routes. + +The discovery API supplies accepted supervisor endpoints. Configure `public_url` +to publish the application origin; otherwise CLI URLs retain the gateway path. Enabled supervisors report installed service routes through their authenticated gateway sessions. The gateway accepts only current configuration and ownership, @@ -1980,3 +1997,23 @@ cached routes never outlive their advertised expiry. Watches recheck bearer credentials and workspace access every second and terminate if either is lost. OIDC token revocation follows the configured verifier's semantics; offline JWT verification cannot detect issuer-side revocation before token expiry. + +Set `public_url` to the external HTTPS origin of your application ingress. Its +hostname must match `domain`; it cannot contain credentials, a path, query, or +fragment. A nondefault public port is supported, for example +`https://services.example.com:10443`. This port is independent of the internal +listener `port` and the gateway API address. Setting `public_url` requires +explicit listener enablement and does not create DNS, certificates, or ingress. + +When configured, service responses return the application address as `url` and +`application_url`, with `gateway_url` available as a separate alternative. +Create-time exposure returns corresponding preferred, application, and gateway +URL maps. CLI and SDK callers preserve the application origin, including its +port, when the gateway is reached through a port-forward. Without `public_url`, +the existing gateway URL behavior remains. Replayed mutations retain their +original resource identities and use the current URL configuration. A configured +URL does not mean that a supervisor installed the route or ingress is ready. + +Upgrade the CLI alongside the gateway when enabling `public_url`. Older CLI +versions can replace the public application port with the gateway connection's +port. diff --git a/proto/openshell.proto b/proto/openshell.proto index 198990a364..99269042fa 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -1610,6 +1610,11 @@ message SandboxResponse { // Service URLs created by CreateSandbox, keyed by service name. The empty // key identifies the unnamed service. Other sandbox RPCs return an empty map. map service_urls = 2; + // Configured application origins for the same services. These are + // authoritative public addresses, not installation or health evidence. + map application_service_urls = 3; + // Gateway-routed alternatives, independent of application ingress. + map gateway_service_urls = 4; } // List sandboxes response. @@ -1992,10 +1997,15 @@ message ServiceEndpoint { ServiceAuthorizationMode authorization_mode = 7; } -// Response containing a service endpoint and, when available, its local URL. +// Service addresses. A configured address does not establish readiness. message ServiceEndpointResponse { ServiceEndpoint endpoint = 1; + // Preferred address: application_url when configured, otherwise gateway_url. string url = 2; + // Authoritative configured public application address. Preserve its port. + string application_url = 3; + // Gateway-routed alternative; existing gateway routing remains available. + string gateway_url = 4; } // Revoke SSH session request. diff --git a/proto/sandbox.proto b/proto/sandbox.proto index b57c5d4cda..b4d9931dd5 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -490,6 +490,10 @@ message SandboxServiceRoute { uint32 target_port = 3; int32 authorization_mode = 4; uint64 resource_version = 5; + // Canonical application SNI/Host selected by the gateway's hostname format. + // Includes the application domain, without a scheme or port. An empty value + // from an older gateway retains readable workspace--sandbox[--service] routing. + string application_hostname = 6; } // Connection details for one operator-registered supervisor middleware service. diff --git a/python/openshell/sandbox.py b/python/openshell/sandbox.py index c02a33cdbf..b82ffb60b1 100644 --- a/python/openshell/sandbox.py +++ b/python/openshell/sandbox.py @@ -589,10 +589,24 @@ class SandboxRef: ) # Stable SSH host identity; absent on older runtimes. host_key_fingerprint: str | None = None + application_service_urls: Mapping[str, str] = field( + default_factory=_ImmutableLabels, compare=False + ) + gateway_service_urls: Mapping[str, str] = field( + default_factory=_ImmutableLabels, compare=False + ) def __post_init__(self) -> None: object.__setattr__(self, "labels", _ImmutableLabels(self.labels)) object.__setattr__(self, "service_urls", _ImmutableLabels(self.service_urls)) + object.__setattr__( + self, + "application_service_urls", + _ImmutableLabels(self.application_service_urls), + ) + object.__setattr__( + self, "gateway_service_urls", _ImmutableLabels(self.gateway_service_urls) + ) @property def phase(self) -> int: @@ -935,7 +949,12 @@ def create( ), timeout=self._timeout, ) - sandbox_ref = _sandbox_ref(response.sandbox, response.service_urls) + sandbox_ref = _sandbox_ref( + response.sandbox, + response.service_urls, + response.application_service_urls, + response.gateway_service_urls, + ) if sandbox_ref.id == "": raise SandboxError("CreateSandbox returned empty sandbox id") return sandbox_ref @@ -964,7 +983,12 @@ def create_from_template( ), timeout=self._timeout, ) - sandbox_ref = _sandbox_ref(response.sandbox, response.service_urls) + sandbox_ref = _sandbox_ref( + response.sandbox, + response.service_urls, + response.application_service_urls, + response.gateway_service_urls, + ) if sandbox_ref.id == "": raise SandboxError("CreateSandbox returned empty sandbox id") return sandbox_ref @@ -1984,6 +2008,8 @@ def _sandbox_conditions( def _sandbox_ref( sandbox: openshell_pb2.Sandbox, service_urls: Mapping[str, str] | None = None, + application_service_urls: Mapping[str, str] | None = None, + gateway_service_urls: Mapping[str, str] | None = None, ) -> SandboxRef: status = sandbox.status if sandbox.HasField("status") else None provenance = ( @@ -2016,6 +2042,8 @@ def _sandbox_ref( labels=sandbox.metadata.labels if sandbox.metadata else {}, created_from_workload_template=provenance, service_urls=service_urls or {}, + application_service_urls=application_service_urls or {}, + gateway_service_urls=gateway_service_urls or {}, host_key_fingerprint=sandbox.host_key_fingerprint or None, ) diff --git a/python/openshell/sandbox_test.py b/python/openshell/sandbox_test.py index 3fed31734e..3dbd3d379e 100644 --- a/python/openshell/sandbox_test.py +++ b/python/openshell/sandbox_test.py @@ -2217,6 +2217,14 @@ def CreateSandbox( dict(request.labels), workspace=_request_workspace(request) or "default", ), + application_service_urls={ + exposure.service: f"https://{exposure.service}.example.test/" + for exposure in request.service_exposures + }, + gateway_service_urls={ + exposure.service: f"https://{exposure.service}.gateway.test:8080/" + for exposure in request.service_exposures + }, service_urls={ exposure.service: f"https://{exposure.service}.example.test/" for exposure in request.service_exposures @@ -2385,6 +2393,27 @@ def test_create_forwards_service_exposures() -> None: "": "https://.example.test/", "metrics": "https://metrics.example.test/", } + assert dict(ref.application_service_urls) == dict(ref.service_urls) + assert ref.gateway_service_urls["metrics"] == "https://metrics.gateway.test:8080/" + with pytest.raises(TypeError): + ref.application_service_urls["metrics"] = "changed" # type: ignore[index] + + +def test_application_addresses_preserve_existing_positional_sandbox_ref() -> None: + original = _sandbox_ref(_make_sandbox_proto("id", "demo")) + ref = SandboxRef( + original.id, + original.name, + original.workspace, + original.status, + {}, + None, + {}, + "SHA256:test-host-identity", + ) + assert ref.host_key_fingerprint == "SHA256:test-host-identity" + assert dict(ref.application_service_urls) == {} + assert dict(ref.gateway_service_urls) == {} def test_create_from_template_forwards_workload_template() -> None: diff --git a/sdk/typescript/src/client.test.ts b/sdk/typescript/src/client.test.ts index 86fdfa3972..a9167ae58b 100644 --- a/sdk/typescript/src/client.test.ts +++ b/sdk/typescript/src/client.test.ts @@ -708,6 +708,8 @@ describe('create', () => { created = req; return { ...readySandbox('sb', 'sb-id'), + applicationServiceUrls: { '': 'https://sb.example.test/', metrics: 'https://metrics.sb.example.test/' }, + gatewayServiceUrls: { '': 'https://sb.gateway.test:8080/' }, serviceUrls: { '': 'https://sb.example.test/', metrics: 'https://metrics.sb.example.test/', @@ -742,6 +744,8 @@ describe('create', () => { authorizationMode: ProtoServiceAuthorizationMode.BEARER_PASSTHROUGH, }, ]); + expect(result.applicationServiceUrls).toEqual(result.serviceUrls); + expect(result.gatewayServiceUrls['']).toBe('https://sb.gateway.test:8080/'); expect(result.serviceUrls).toEqual({ '': 'https://sb.example.test/', metrics: 'https://metrics.sb.example.test/', diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index 205796f1bd..d7fc18463c 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -247,6 +247,8 @@ export interface SandboxRef { createdFromWorkloadTemplate?: SandboxWorkloadTemplateProvenance; /** Service URLs returned by creation, keyed by service name. */ serviceUrls: Record; + applicationServiceUrls: Record; + gatewayServiceUrls: Record; restartCount: number; /** Creation time in epoch milliseconds; absent when the gateway omits it. */ createdAtMs?: number; @@ -624,7 +626,12 @@ function policySourceName(s: PolicySource): PolicySourceName { return POLICY_SOURCE_NAMES[s] ?? 'unspecified'; } -function sandboxRef(sandbox: Sandbox | undefined, serviceUrls: Record = {}): SandboxRef { +function sandboxRef( + sandbox: Sandbox | undefined, + serviceUrls: Record = {}, + applicationServiceUrls: Record = {}, + gatewayServiceUrls: Record = {}, +): SandboxRef { if (!sandbox) throw new SdkError('invalid_config', 'sandbox missing from gateway response'); const meta = sandbox.metadata; if (!meta?.id || !meta.name) { @@ -647,6 +654,8 @@ function sandboxRef(sandbox: Sandbox | undefined, serviceUrls: Record Date: Fri, 30 Oct 2026 21:29:04 -0700 Subject: [PATCH 2/3] test(services): use ty suppression for immutable application URLs Signed-off-by: Taylor Mutch --- python/openshell/sandbox_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/openshell/sandbox_test.py b/python/openshell/sandbox_test.py index 3dbd3d379e..c653205cfe 100644 --- a/python/openshell/sandbox_test.py +++ b/python/openshell/sandbox_test.py @@ -2396,7 +2396,7 @@ def test_create_forwards_service_exposures() -> None: assert dict(ref.application_service_urls) == dict(ref.service_urls) assert ref.gateway_service_urls["metrics"] == "https://metrics.gateway.test:8080/" with pytest.raises(TypeError): - ref.application_service_urls["metrics"] = "changed" # type: ignore[index] + ref.application_service_urls["metrics"] = "changed" # ty: ignore[invalid-assignment] def test_application_addresses_preserve_existing_positional_sandbox_ref() -> None: From 2c15db36666112098a7a2c55629a421a6d061d49 Mon Sep 17 00:00:00 2001 From: Taylor Mutch Date: Mon, 9 Nov 2026 10:46:37 -0800 Subject: [PATCH 3/3] fix(services): align URL schema with separate discovery service Signed-off-by: Taylor Mutch --- .../src/grpc/service_discovery.rs | 38 +++++++++---------- crates/openshell-server/src/storage_proto.rs | 2 +- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/crates/openshell-server/src/grpc/service_discovery.rs b/crates/openshell-server/src/grpc/service_discovery.rs index d1b9d02ce4..dc43d08e3d 100644 --- a/crates/openshell-server/src/grpc/service_discovery.rs +++ b/crates/openshell-server/src/grpc/service_discovery.rs @@ -412,25 +412,25 @@ async fn collect(state: &ServerState, workspace: &str) -> Result 63 || label.contains('.') { - return Err(Status::failed_precondition( - "service hostname exceeds DNS label limit", - )); - } + let hostname = if route.application_hostname.is_empty() { + // Legacy reports can only be current against legacy configuration. + state.config.supervisor_app_listener.service_hostname( + workspace, + sandbox.object_name(), + &route.name, + state.config.service_routing_discriminator(), + ) + } else { + route.application_hostname.clone() + }; + let label = hostname + .strip_suffix(&format!(".{}", installation.service_domain)) + .ok_or_else(|| Status::internal("installed hostname domain mismatch"))?; + if label.is_empty() || label.len() > 63 || label.contains('.') { + return Err(Status::failed_precondition( + "service hostname exceeds DNS label limit", + )); + } let version = installation_version(&installation, bytes)?; routes.push(DiscoveredServiceRoute { workspace: workspace.to_string(), diff --git a/crates/openshell-server/src/storage_proto.rs b/crates/openshell-server/src/storage_proto.rs index 7b2e02b972..66333e4336 100644 --- a/crates/openshell-server/src/storage_proto.rs +++ b/crates/openshell-server/src/storage_proto.rs @@ -179,7 +179,7 @@ mod tests { // updates use a separate capability/service and add one classified RPC. // Discovery RPCs move to ServiceDiscovery; message and durable closures are unchanged. const PUBLIC_RPC_SCHEMA_SHA256: &str = - "daa132037ee47c7a46b08dfab9bab379961bc06ff3e9ebbb329809b724f2a4f6"; + "9570c5a65c405284cecb1d14067f91be2ce5d5dc98f2ceb0d7504ded776a863c"; const DURABLE_SCHEMA_SHA256: &str = "cb15f8f79a855064a43fe8c6cd908bf07eca6604e2bf8ab7d2de0ef33e97da4e"; const PUBLIC_DURABLE_OVERLAP_SHA256: &str =