Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions crates/openshell-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,51 @@ pub struct ServiceRoutingConfig {
/// Enable TLS-enabled loopback gateway listeners to also accept plaintext
/// HTTP for sandbox service hostnames.
pub enable_loopback_service_http: bool,

/// Optional gateway name used to derive opaque hashed service route
/// labels.
///
/// When `None` (the default), the gateway keeps generating readable
/// `<workspace>--<sandbox>[--<service>]` service hostnames. When set,
/// newly generated service hostnames use a stable, one-label hash derived
/// from this name plus the workspace, sandbox, and service instead.
///
/// This value is unrelated to [`Config::name`] and
/// [`GatewayJwtConfig::gateway_id`]; it is never derived from them.
pub service_routing_gateway_name: Option<String>,
}

impl ServiceRoutingConfig {
/// Validate the service-routing configuration.
///
/// Currently validates [`Self::service_routing_gateway_name`]: `None` is
/// always valid, while `Some(name)` must be a DNS-1123 label β€” 1–63 ASCII
/// bytes of lowercase `a-z`, `0-9`, or `-`, with no leading or trailing
/// `-`. An empty string is rejected; the setting is represented as
/// disabled only by `None`.
pub fn validate(&self) -> crate::error::Result<()> {
Self::validate_gateway_name(self.service_routing_gateway_name.as_deref())
}

/// Validate an optional service-routing gateway name.
///
/// `None` is always valid. `Some(name)` must be a DNS-1123 label β€” 1–63
/// ASCII bytes of lowercase `a-z`, `0-9`, or `-`, with no leading or
/// trailing `-`. An empty string is rejected; the setting is represented
/// as disabled only by `None`.
pub fn validate_gateway_name(name: Option<&str>) -> crate::error::Result<()> {
if let Some(name) = name
&& !is_dns_label(name)
{
return Err(crate::error::Error::Config {
message: format!(
"service_routing_gateway_name must be a DNS label (1-63 lowercase \
ASCII alphanumeric or '-' bytes, no leading/trailing '-'): {name:?}"
),
});
}
Ok(())
}
}

/// TLS configuration.
Expand Down Expand Up @@ -1125,6 +1170,17 @@ impl Config {
self
}

/// Set the service-routing gateway name used to derive hashed service
/// route labels.
///
/// `None` preserves readable service hostnames. `Some(name)` must pass
/// [`ServiceRoutingConfig::validate`].
#[must_use]
pub fn with_service_routing_gateway_name(mut self, name: Option<impl Into<String>>) -> Self {
self.service_routing.service_routing_gateway_name = name.map(Into::into);
self
}

/// Enable or disable plaintext HTTP routing for loopback sandbox service
/// hostnames on TLS-enabled gateway listeners.
#[must_use]
Expand All @@ -1146,6 +1202,7 @@ impl Default for ServiceRoutingConfig {
Self {
base_domains: default_service_routing_domains(),
enable_loopback_service_http: default_enable_loopback_service_http(),
service_routing_gateway_name: None,
}
}
}
Expand Down Expand Up @@ -1700,6 +1757,67 @@ mod tests {
assert!(!cfg.service_routing.enable_loopback_service_http);
}

#[test]
fn service_routing_gateway_name_defaults_to_none() {
let cfg = Config::new(None);
assert!(cfg.service_routing.service_routing_gateway_name.is_none());
cfg.service_routing.validate().expect("unset is valid");
}

#[test]
fn service_routing_gateway_name_accepts_dns_label() {
for name in ["gateway-west", "g", &"g".repeat(63), "gateway-1-west"] {
let cfg = Config::new(None).with_service_routing_gateway_name(Some(name));
assert_eq!(
cfg.service_routing.service_routing_gateway_name.as_deref(),
Some(name)
);
cfg.service_routing
.validate()
.unwrap_or_else(|e| panic!("{name:?} must be valid: {e}"));
}
}

#[test]
fn service_routing_gateway_name_rejects_empty_uppercase_invalid_hyphen_and_overlong_values() {
for name in [
"",
"Gateway-West",
"-gateway",
"gateway-",
"gateway_west",
"gateway west",
&"g".repeat(64),
] {
let cfg = Config::new(None).with_service_routing_gateway_name(Some(name));
let error = cfg
.service_routing
.validate()
.expect_err(&format!("{name:?} must be rejected"));
let message = error.to_string();
assert!(message.contains("service_routing_gateway_name"), "{name:?}");
}
}

#[test]
fn server_sans_update_preserves_service_routing_gateway_name() {
let cfg = Config::new(None)
.with_service_routing_gateway_name(Some("gateway-west"))
.with_server_sans(["*.apps.example.com"]);

assert_eq!(
cfg.service_routing.service_routing_gateway_name.as_deref(),
Some("gateway-west")
);
assert_eq!(
cfg.service_routing.base_domains,
vec![
"apps.example.com".to_string(),
DEFAULT_SERVICE_ROUTING_DOMAIN.to_string()
]
);
}

#[test]
fn service_routing_domains_are_derived_from_wildcard_server_sans() {
let cfg = Config::new(None).with_server_sans([
Expand Down
150 changes: 150 additions & 0 deletions crates/openshell-server/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,12 @@ struct RunArgs {
)]
server_sans: Vec<String>,

/// Gateway name used to derive opaque hashed sandbox service route
/// labels. Unset keeps readable `<workspace>--<sandbox>[--<service>]`
/// service hostnames.
#[arg(long, env = "OPENSHELL_SERVICE_ROUTING_GATEWAY_NAME")]
service_routing_gateway_name: Option<String>,

/// Enable plaintext HTTP routing for loopback sandbox service URLs.
#[arg(
long,
Expand Down Expand Up @@ -568,8 +574,13 @@ fn prepare_server_config_with_drivers(
.unwrap_or_default(),
)
.with_server_sans(args.server_sans.clone())
.with_service_routing_gateway_name(args.service_routing_gateway_name.clone())
.with_loopback_service_http(args.enable_loopback_service_http)
.with_websocket_tunnel(args.enable_websocket_tunnel);
config
.service_routing
.validate()
.map_err(|error| miette::miette!("invalid service routing configuration: {error}"))?;
if let Some(sources) = file
.as_ref()
.and_then(|file| file.openshell.gateway.provider_profile_sources.clone())
Expand Down Expand Up @@ -1044,6 +1055,10 @@ fn validate_preflight_semantics(
args.grpc_rate_limit_requests,
args.grpc_rate_limit_window_seconds,
)?;
openshell_core::config::ServiceRoutingConfig::validate_gateway_name(
args.service_routing_gateway_name.as_deref(),
)
.map_err(|error| miette::miette!("invalid service routing configuration: {error}"))?;
GuestTlsPaths::validate_configuration(Some(gateway), args.disable_tls)
.map_err(|error| miette::miette!("invalid gateway guest TLS configuration: {error}"))?;

Expand Down Expand Up @@ -1252,6 +1267,12 @@ fn merge_file_into_args(args: &mut RunArgs, file: &GatewayFileSection, matches:
{
args.server_sans.clone_from(sans);
}
if let Some(name) = &file.service_routing_gateway_name
&& args.service_routing_gateway_name.is_none()
&& arg_defaulted(matches, "service_routing_gateway_name")
{
args.service_routing_gateway_name = Some(name.clone());
}
if let Some(enabled) = file.enable_loopback_service_http
&& arg_defaulted(matches, "enable_loopback_service_http")
{
Expand Down Expand Up @@ -2509,6 +2530,10 @@ mod tests {
"rate-limit",
"[openshell]\nversion = 2\n[openshell.gateway]\nname = 'secret-semantic-marker'\ngrpc_rate_limit_requests = 10\n",
),
(
"service-routing",
"[openshell]\nversion = 2\n[openshell.gateway]\nservice_routing_gateway_name = 'Not A Label'\n",
),
(
"guest-tls",
"[openshell]\nversion = 2\n[openshell.gateway]\nguest_tls_ca = '/tls/ca.pem'\ndisable_tls = true\n",
Expand Down Expand Up @@ -3657,6 +3682,131 @@ enable_loopback_service_http = false
});
}

#[test]
fn file_populates_service_routing_gateway_name() {
Environment::new()
.remove("OPENSHELL_SERVICE_ROUTING_GATEWAY_NAME")
.run(|| {
let (mut args, matches) =
parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]);
let file = config_file_from_toml(
"[openshell.gateway]\nservice_routing_gateway_name = \"gateway-west\"\n",
);
merge_file_into_args(&mut args, &file.openshell.gateway, &matches);

assert_eq!(
args.service_routing_gateway_name.as_deref(),
Some("gateway-west")
);
});
}

#[test]
fn service_routing_gateway_name_cli_and_env_override_file_value() {
// CLI flag wins over the file value.
let (mut args, matches) = parse_with_args(&[
"openshell-gateway",
"--db-url",
"sqlite::memory:",
"--service-routing-gateway-name",
"gateway-cli",
]);
let file = config_file_from_toml(
"[openshell.gateway]\nservice_routing_gateway_name = \"gateway-west\"\n",
);
merge_file_into_args(&mut args, &file.openshell.gateway, &matches);
assert_eq!(
args.service_routing_gateway_name.as_deref(),
Some("gateway-cli"),
"CLI flag must win over file value"
);

// Env var wins over the file value.
Environment::new()
.set("OPENSHELL_SERVICE_ROUTING_GATEWAY_NAME", "gateway-env")
.run(|| {
let (mut args, matches) =
parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]);
let file = config_file_from_toml(
"[openshell.gateway]\nservice_routing_gateway_name = \"gateway-west\"\n",
);
merge_file_into_args(&mut args, &file.openshell.gateway, &matches);
assert_eq!(
args.service_routing_gateway_name.as_deref(),
Some("gateway-env"),
"env var must win over file value"
);
});
}

#[test]
fn server_config_preparation_applies_and_validates_service_routing_gateway_name() {
let state = tempfile::tempdir().unwrap();
let local_tls = tempfile::tempdir().unwrap();

Environment::new()
.set("XDG_STATE_HOME", state.path().to_str().unwrap())
.set(
"OPENSHELL_LOCAL_TLS_DIR",
local_tls.path().to_str().unwrap(),
)
.remove("OPENSHELL_SERVICE_ROUTING_GATEWAY_NAME")
.run(|| {
let config_path = state.path().join("gateway.toml");
std::fs::write(
&config_path,
"[openshell]\nversion = 2\n\n[openshell.gateway]\nservice_routing_gateway_name = \"gateway-west\"\n",
)
.unwrap();

let (mut args, matches) = parse_with_args(&[
"openshell-gateway",
"--config",
config_path.to_str().unwrap(),
"--db-url",
"sqlite::memory:",
"--compute-driver",
"local",
"--disable-tls",
]);

let prepared = super::prepare_server_config(&mut args, &matches)
.expect("server config is prepared");
assert_eq!(
prepared
.config
.service_routing
.service_routing_gateway_name
.as_deref(),
Some("gateway-west")
);

let invalid_path = state.path().join("invalid-gateway-name.toml");
std::fs::write(
&invalid_path,
"[openshell]\nversion = 2\n\n[openshell.gateway]\nservice_routing_gateway_name = \"Gateway West\"\n",
)
.unwrap();
let (mut args, matches) = parse_with_args(&[
"openshell-gateway",
"--config",
invalid_path.to_str().unwrap(),
"--db-url",
"sqlite::memory:",
"--compute-driver",
"local",
"--disable-tls",
]);
let error = super::prepare_server_config(&mut args, &matches)
.err()
.expect("invalid routing gateway name must fail preparation");
assert!(
error.to_string().contains("service_routing_gateway_name"),
"unexpected error: {error}"
);
});
}

#[test]
fn canonical_file_driver_selector_populates_cli_args() {
let (mut args, matches) =
Expand Down
Loading
Loading