Repository navigation
feat: add gateway discriminator to exposed service hostnames for multi-gateway clusters #4840
Description
Problem Statement
When multiple OpenShell gateways run in the same Kubernetes cluster behind a shared ingress with a single wildcard certificate, exposed service URLs are ambiguous. The hostname for an exposed service contains only the workspace name, sandbox name, and service name, so two gateways can emit identical hostnames for identically named workspaces/sandboxes/services. The shared ingress cannot determine which gateway's pods should receive the request. We need a configurable gateway name that is included in the exposed-service hostname so each gateway's URLs are globally unique within the cluster.
Technical Context
Exposed-service hostnames are built by the gateway itself as a single composite DNS label: workspace--sandbox--service.<base-domain> (e.g. default--my-sandbox--web.apps.example.com). The base domain is derived from wildcard certificate SANs. The gateway — not Kubernetes — is the reverse proxy: it parses the Host header, looks up the persisted ServiceEndpoint, and relays HTTP/WebSocket traffic to the sandbox supervisor's loopback port. The Helm chart creates only a GRPCRoute, no HTTP route for browser service traffic; operators configure external ingress themselves. With two gateways sharing an ingress and base domain, the ingress has no discriminator to select the correct backend.
Affected Components
| Component | Key Files | Role |
|---|---|---|
| Gateway service-exposure API | crates/openshell-server/src/grpc/service.rs, proto/openshell.proto |
Creates/persists ServiceEndpoint records; returns dynamically derived URLs via ServiceEndpointResponse.url |
| Hostname construction and routing | crates/openshell-server/src/service_routing.rs |
Builds workspace--sandbox--service.<base-domain> URLs, parses Host header, proxies HTTP/WS traffic over the supervisor relay |
| HTTP/multiplex listener | crates/openshell-server/src/http.rs, multiplex.rs, lib.rs |
Gives service-host requests precedence over normal HTTP routes |
| Gateway runtime config | crates/openshell-core/src/config.rs, crates/openshell-server/src/config_file.rs, cli.rs |
ServiceRoutingConfig (base domains + loopback plaintext toggle); TOML/CLI/env parsing and precedence |
| Helm deployment | deploy/helm/openshell/values.yaml, templates/gateway-config.yaml, templates/grpcroute.yaml |
Renders gateway TOML, certificates, Gateway API Gateway + GRPCRoute only |
| TLS/certificates | crates/openshell-bootstrap/src/pki.rs, crates/openshell-server/src/tls.rs, Helm cert templates |
Wildcard SAN handling; SNI matching is strictly one label deep |
| Kubernetes gateway identity | crates/openshell-core/src/config.rs, crates/openshell-driver-kubernetes/src/config.rs |
Existing gateway_jwt.gateway_id identifies JWT issuer/audience and Kubernetes-owned resources |
| CLI | crates/openshell-cli/src/run.rs |
Prints returned URLs; substitutes only the external gateway port |
| Client gateway registration | crates/openshell-bootstrap/src/metadata.rs |
Local per-user CLI registration name — not gateway-server identity |
| Go SDK | sdk/go/openshell/v1/service_client.go, types/service.go, internal/converter/service.go |
Transparently returns the server-provided URL |
| Rust SDK / TUI / TS / Python SDKs | crates/openshell-sdk, crates/openshell-tui, sdk/typescript, python/openshell |
No hostname-format-specific parsing found; URLs pass through opaquely |
Technical Investigation
Architecture Overview
openshell service expose <sandbox> <port> [service]
│ gRPC ExposeServiceRequest { workspace, sandbox, service, target_port }
▼
openshell-server grpc::service::handle_expose_service
│ ├─ validates names, persists ServiceEndpoint (record id: sandbox or sandbox--service)
│ └─ derives URL from current in-memory gateway Config
▼
ServiceEndpointResponse.url ──▶ CLI prints URL (port rewritten to registered gateway port)
Browser request to URL
│ DNS / wildcard cert / ingress must deliver request to the correct gateway pod
▼
gateway Axum HTTP router
│ ├─ service_routing::parse_host(Host) → (workspace, sandbox, service)
│ ├─ loads ServiceEndpoint, checks enabled + sandbox Ready
│ └─ relays HTTP / WebSocket to 127.0.0.1:<target_port> in sandbox
Key facts:
- The URL is not persisted — Get/List responses regenerate it from current config, so a config change retroactively changes all previously returned URLs.
- Nothing inside OpenShell selects a gateway by hostname; the server parses only workspace/sandbox/service and assumes the request already reached the right gateway.
- The Helm chart creates no per-service
Service/Ingress/HTTPRoute. Sandboxes are not directly exposed; the gateway is the reverse proxy.
Code References
| Location | Description |
|---|---|
crates/openshell-server/src/service_routing.rs:48-63 |
endpoint_url constructs the returned URL (host, scheme, gateway bind port) |
crates/openshell-server/src/service_routing.rs:77-88 |
endpoint_host builds workspace--sandbox[--service].base_domain — the single hostname constructor |
crates/openshell-server/src/service_routing.rs:91-123 |
parse_host — inverse parser; must stay in lockstep with construction |
crates/openshell-server/src/service_routing.rs:125-143 |
Host-header recognition before proxying |
crates/openshell-server/src/service_routing.rs:230-445 |
proxy_to_endpoint and endpoint lookup by workspace + sandbox[--service] key |
crates/openshell-server/src/grpc/service.rs:25-143 |
handle_expose_service: authorization, validation, persistence, URL generation, OCSF event |
crates/openshell-server/src/grpc/service.rs:290-357 |
URL regeneration for Get/List; DNS-label validation rejecting -- |
crates/openshell-server/src/grpc/mod.rs:129-131 |
MAX_ROUTABLE_NAME_LEN = 19 — derived from three 19-char segments + two -- fitting a 63-char DNS label |
crates/openshell-core/src/config.rs:847-863 |
Runtime ServiceRoutingConfig (base_domains, enable_loopback_service_http) |
crates/openshell-core/src/config.rs:1330-1422 |
with_server_sans derives base domains from *.domain SANs; wildcard normalization/validation |
crates/openshell-server/src/config_file.rs:125-132 |
TOML service-routing fields: server_sans, enable_loopback_service_http |
crates/openshell-server/src/cli.rs:199-217, 302-410, 650-687 |
CLI/env flags, config build, TOML merge precedence |
crates/openshell-server/src/tls.rs:289-303 |
SNI wildcard matches one label only: *.example.com covers foo.example.com, not bar.foo.example.com |
deploy/helm/openshell/templates/gateway-config.yaml:65-80 |
Helm renders server_sans from certificate DNS names |
deploy/helm/openshell/templates/grpcroute.yaml |
Chart emits only a GRPCRoute — no HTTP route for exposed services |
crates/openshell-core/src/config.rs:1128-1144 |
Existing gateway_jwt.gateway_id (default openshell) — JWT issuer/audience + K8s ownership identity |
crates/openshell-bootstrap/src/metadata.rs:14-74 |
GatewayMetadata.name — client-local registration name, not usable server-side |
crates/openshell-cli/src/run.rs:2853-2894, 3246-3262 |
CLI prints URL; service_url_for_gateway rewrites only the port |
architecture/gateway.md:613-620 |
Architecture doc for host-based service routing (currently omits the workspace segment) |
Current Behavior
- Hostname format (single composite label):
https://<workspace>--<sandbox>--<service>.<base-domain>:<port>/ - The first valid wildcard-derived domain is selected for URL generation; fallback is built-in
openshell.localhost. - Gateway-side Host parsing recognizes only the three-segment composite; any discriminator added to emitted URLs must also be added to
parse_hostor the gateway will reject its own generated hosts as non-service traffic.
What Would Need to Change
Gateway hostname/routing (crates/openshell-server/src/service_routing.rs): coordinated changes to endpoint_host, endpoint_url, parse_host, host-recognition and proxy tests. The discriminator must be part of both construction and parsing.
Gateway configuration plumbing: ServiceRoutingConfig extension + builder (crates/openshell-core/src/config.rs), optional TOML field in GatewayFileSection (config_file.rs), CLI/env flag + precedence merge (cli.rs), validation.
Helm: new value + ConfigMap mapping adjacent to server.enableLoopbackServiceHttp / enable_loopback_service_http; rendered-TOML tests.
Ingress/edge story: the chart's GRPCRoute is gRPC-only; browser service traffic (HTTP/WS) has no chart-managed route. Either new explicit HTTPRoute support or documented operator-managed ingress configuration is needed. The external ingress must preserve the original Host header.
No persistence or proto schema change inherently required for a static config field — URLs are recomputed dynamically. (This also means config changes invalidate previously issued URLs.)
Downstream consumers: CLI output tests, Go SDK verification; Rust/TS/Python SDKs and TUI pass URLs through without parsing.
Alternative Approaches Considered
- New independent service-routing gateway name (recommended) — an optional config field used exclusively for hostname disambiguation. Does not overload JWT/K8s identity; can be absent for strict backwards compatibility. Costs: new plumbing, and the DNS-length/ingress constraints below remain.
- Reuse
gateway_jwt.gateway_id— already validated as DNS-1123 and Helm defaults it to chart fullname. But it is security-sensitive (sandbox JWTiss/aud), controls K8s resource ownership/managed namespaces, is optional outside JWT-enabled configs, and silent reuse would change hostnames for existing deployments. Viable only if product explicitly declares the two identities are one concept. - CLI gateway registration name — not viable; it is client-local metadata that doesn't exist at gateway runtime.
- Dot-separated label:
<gateway>.<workspace>--<sandbox>--<service>.example.com— incompatible with a single shared wildcard cert. The codebase explicitly enforces one-label-deep wildcard matching (tls.rs:289-303, Helmroute_test.yaml:97-107). Would require per-gateway certs like*.gateway-a.example.com, defeating the stated goal. - Gateway segment inside the existing composite label:
<gateway>--<workspace>--<sandbox>--<service>.base-domain— the only wildcard-cert-compatible layout, but it hits the 63-character DNS label limit: current worst case is19+2+19+2+19 = 61, leaving room for at most 0 additional characters. Needs a new length strategy (lower segment maxima — breaking; dynamic combined-length rejection; shorter name grammar; or encoding redesign). Additionally, standard Kubernetes Ingress/Gateway API host matching supports exact and leading-label wildcards, not partial-label patterns likegateway-a--*.apps.example.com— controller-specific regex Host matching or a different routing design may be required. - Deterministic hash hostname (refined after spike, from maintainer discussion) — hash the tuple
(gateway name, workspace, sandbox, service)(e.g. truncated SHA-256, rendered as lowercase base32 — DNS-safea-z2-7) and use the fixed-length hash as the single label:https://x7q2m9p4k2ab.apps.example.com/.- Solves the DNS label length constraint: a fixed 10–13 char label replaces the 61-of-63-character worst case; no change to the 19-char segment maxima and no dynamic combined-length rejection.
- Solves the wildcard depth constraint: still exactly one label under
*.apps.example.com, so the single shared wildcard certificate keeps working. - Solves cross-gateway uniqueness: different gateway names produce different hashes.
- No persistence/schema change: the hash is deterministic, so it recomputes from config + endpoint record exactly like
endpoint_urldoes today for Get/List; URLs survive restarts and no migration is needed (the key improvement over a random opaque ID). Routing inverts via an in-memoryhash → endpointmap rebuilt from persisted endpoints; expose-time collision detection is cheap. At 13 base32 chars (65 bits) collisions are astronomically unlikely, but a detected collision should reject rather than silently shadow. - Ingress matching: exact-host rules (
x7q2m9p4k2ab.apps.example.com → gateway-a) are fully supported by standard Ingress/Gateway API — no partial-label or regex matching needed. However, rule management does not disappear, it changes shape: hostnames are unique and stable, so per-hostHTTPRoute/Ingressrules work, but something must create them as services are exposed — either the gateway dynamically managing HTTPRoute/Ingress resources (new cluster-facing behavior, RBAC, Helm support) or operator-managed automation. This becomes the main remaining scope decision. - Security bonus: URLs become unguessable capability tokens; today's readable URLs let anyone who knows workspace/sandbox/service names construct a URL to a sandbox service.
- Trade-off — readability: operators lose workspace/sandbox visibility in the URL;
openshell service listmitigates it, but DNS/ingress debugging gets harder. - Fallback: when no gateway name is configured, preserve today's legacy composite format exactly (backwards compatibility). Legacy and hashed hosts are syntactically distinguishable (legacy always contains
--), so a gateway can accept both during transition — though shared-ingress deployments must move fully to hashed hosts, since legacy hosts remain cross-gateway ambiguous. - A readable composite label including the gateway name (alternative 5) remains the human-readable option, at the cost of the length and ingress-matching constraints described above.
Patterns to Follow
- Service-routing config:
server_sans/enable_loopback_service_httpend-to-end — TOML field (config_file.rs:125-132) → clap flag + env (cli.rs:199-217) → precedence merge (cli.rs:650-687) →Configbuilder (cli.rs:302-410) → Helm value →gateway-config.yaml→docs/reference/gateway-config.mdx→ precedence tests (cli.rs:1800-1848). - Gateway identity (for reference, not necessarily reuse):
gateway_jwt.gateway_idflow through core config, JWT issuer, K8s driver, Helm.
Proposed Approach
Preferred direction (post-spike refinement): add an optional gateway configuration field (working name service_routing_gateway_name or similar). When set, exposed-service hostnames become a deterministic hash of (gateway name, workspace, sandbox, service) — a fixed-length lowercase base32 label under the existing wildcard-derived base domain — instead of the readable composite. When unset, preserve the exact current hostname format for backwards compatibility. The gateway routes hashed hosts via an in-memory hash→endpoint map rebuilt from persisted endpoints, and accepts legacy composite hosts during transition. The main remaining design decision is ingress rule management: who creates the per-host HTTPRoute/Ingress rules for hashed hostnames (gateway-managed vs operator-managed), since exact-host rules are standard but must be created as services are exposed. Docs — especially gateway-config.mdx, docs/kubernetes/ingress.mdx, and architecture/gateway.md — must be updated in the same change.
The readable composite alternative (<gateway>--<workspace>--<sandbox>--<service>) remains viable if human-readable URLs are prioritized, but requires resolving the DNS label length and partial-label ingress matching constraints.
Scope Assessment
- Complexity: Medium for server-only hostname/config work; High for the full feature including a supported shared-ingress routing model
- Confidence: High — code locations are precisely identified (
service_routing.rsis the single constructor/parser); the open questions are design decisions, not unknowns - Estimated files to change: ~8–14 source/test/doc/chart files for the server/config/Helm feature; more if adding chart-managed ingress routing
- Issue type:
feat
Risks & Open Questions
- DNS label length: solved by the hash approach (fixed-length label); only relevant if the readable composite alternative is chosen — four segments + three
--delimiters cannot fit 63 characters at current 19-char segment maxima. - Ingress host matching: the hash approach uses exact-host rules (standard Ingress/Gateway API support); remaining decision is who manages rule creation as services are exposed (gateway-managed HTTPRoute/Ingress resources vs operator automation). The readable composite alternative would additionally require partial-label matching, which standard controllers do not support.
- Hash fallback behavior: gateway name unset → legacy composite format preserved exactly. Decide whether legacy-host parsing remains accepted indefinitely or is time-bounded (legacy hosts stay cross-gateway ambiguous, so shared-ingress deployments must move fully to hashed hosts).
- Backwards compatibility: unset name should preserve current URLs (recommended). Enabling/changing the name retroactively changes Get/List URLs and breaks existing bookmarks/active sessions (URLs are recomputed, not persisted). Should legacy-host parsing be accepted during transition? (Accepting it reintroduces cross-gateway ambiguity on a shared ingress.)
- Identity reuse: independent config field vs.
gateway_jwt.gateway_id— coupling a hostname label to JWT/K8s identity has security and operational implications. - Config naming: "gateway name" already has three meanings (client registration name,
gateway_jwt.gateway_id, and this discriminator). Flat[openshell.gateway] service_routing_gateway_namefits today's style; a nested table is cleaner but a new shape. - Field optionality: optional everywhere vs. required when behind a shared ingress vs. required on Kubernetes.
- Docs drift found during investigation:
docs/sandboxes/manage-gateways.mdx:45andarchitecture/gateway.md:613-620document the hostname without the workspace segment, which the implementation always includes — should be corrected regardless of this feature.
Disposition Readiness
- State:
state:validated - Assessment: The problem is confirmed in code (no gateway discriminator exists in hostname construction or routing), the change surface is precisely mapped, and the design constraints (DNS label length, one-label wildcard depth, ingress host matching) are documented with concrete alternatives and trade-offs. Sufficient for a human accept/decline decision.
- Missing evidence: None — though the two blocking design decisions above should be answered during acceptance.
Test Considerations
Existing coverage:
service_routing.rs:853-1060— URL generation,parse_host, malformed delimiters, Host/authority recognition unit testsgrpc/service.rs:344-896— name validation, endpoint lifecycle, workspace isolationconfig.rs:1638-1680— default service routing and SAN-derived base domain testscli.rs:1800-1848— TOML/env precedence testscrates/openshell-cli/src/run.rs:8062-8102— CLI URL port-rewrite testslib.rs:2130-2265— loopback plaintext service behavior- Helm:
gateway_config_test.yaml:383-397(rendered SANs),route_test.yaml:72-107(wildcard depth)
New tests implied: URL construction with/without configured discriminator (hashed and legacy formats); parse/reject of hashed hosts including port and HTTP/2 authority; hash determinism across restarts; hash collision detection at expose-time; TOML/CLI/env precedence; Get/List recomputation; CLI output; Helm rendered-TOML; and — if external routing is included — a two-gateway-behind-one-ingress e2e proving identical workspace/sandbox/service names produce distinct, correctly routed hosts with working TLS and WebSocket traffic. No service-exposure e2e exists today under e2e/; that is a pre-existing gap.
Docs Impact
docs/reference/gateway-config.mdx— new TOML field, defaults, precedence, relation togateway_jwt.gateway_id, hostname grammar and certificate requirementsdocs/sandboxes/manage-gateways.mdx— new discriminator, ingress requirements; also fix the existing hostname examples that omit the workspace segmentdocs/kubernetes/ingress.mdx— currently documents only GRPCRoute ingress; needs the shared-ingress service-routing storydocs/kubernetes/setup.mdx— Helm value table entryarchitecture/gateway.md:613-620— service URL grammar update (also currently omits workspace)rfc/0003-gateway-configuration/README.md— schema/example if a general gateway config field is added
Created by spike investigation. state:validated means the issue is ready for human disposition; a human applies state:accepted or places the issue on the roadmap if OpenShell should pursue the work. To queue unattended agent planning, a human applies agent:plan-requested; on a direct request, an agent warns about missing expected workflow labels and continues without changing them.
Activity
jhjaggars commentedon Oct 22, 2026
Implementation Plan
Issue type:
featComplexity: High
Confidence: Medium-high
Summary
Add an opt-in
service_routing_gateway_namesetting that changes newly generated service URL labels from readable<workspace>--<sandbox>[--<service>]labels to a stable, one-label, 16-character lowercase base32 SHA-256 prefix. Preserve legacy URLs and parsing when unset, retain legacy-host routing during transition when configured, and add an in-memory hash-to-route index hydrated from persistedServiceEndpointrecords.Scope
crates/openshell-core/src/config.rs→ addServiceRoutingConfig.service_routing_gateway_name: Option<String>, an infallible builder, DNS-label validation, defaults, and core tests.crates/openshell-server/src/service_routing.rs→ implement SHA-256/NUL-delimited tuple hashing and fixed 10-byte lowercase RFC-4648 base32 encoding; generate hashed hosts; add hash-route index/rebuild/register/remove/resolve logic; retain legacy parsing; make hash resolution state/store-aware; extend routing tests.crates/openshell-server/src/lib.rs→ add the shared service-route index toServerState, initialize it with the configured routing name, and hydrate it from persistedServiceEndpointrecords before accepting traffic.crates/openshell-server/src/http.rs→ update HTTP middleware to await state-aware service-host resolution so configured hash hosts route and unknown hashes fall through to normal routing/404 behavior.crates/openshell-server/src/grpc/service.rs→ reserve/check the route hash before persistence, update the index after successful expose/update, remove it after successful service deletion, and return a clear collision status without persisting the conflicting endpoint.crates/openshell-server/src/compute/mod.rs→ share the route index withComputeRuntimeand remove entries when sandbox cleanup deletes its ownedServiceEndpointrecords.crates/openshell-server/src/config_file.rs→ add optional[openshell.gateway] service_routing_gateway_nameTOML deserialization/serialization coverage.crates/openshell-server/src/cli.rs→ add--service-routing-gateway-name/OPENSHELL_SERVICE_ROUTING_GATEWAY_NAME, merge the TOML value only when CLI/env did not supply it, validate the effective value, and wire it intoConfig.crates/openshell-cli/src/run.rs→ add URL-normalization tests proving CLI output preserves the opaque hashed hostname while replacing only the externally reachable gateway port.deploy/helm/openshell/values.yaml→ add documentedserver.serviceRoutingGatewayName: ""; empty remains backward-compatible and does not emit TOML.deploy/helm/openshell/templates/_toml.tpl→ map a non-empty legacy chart value toopenshell.gateway.service_routing_gateway_name, while preserving explicitgatewayConfig.openshell.gateway.service_routing_gateway_nameprecedence and omitting the field when absent/empty.deploy/helm/openshell/tests/gateway_config_test.yaml→ verify default omission, non-empty rendering, and explicit schema-v2gatewayConfigprecedence.docs/how-it-works/gateways/configuration.mdx→ document the TOML field, validation, default, CLI/env precedence, examples, gateway-name uniqueness guidance, and that it is unrelated togateway_jwt.gateway_id.docs/how-it-works/gateways/overview.mdx→ correct the existing hostname grammar to include the mandatory workspace segment and describe legacy versus configured hashed service labels.docs/how-it-works/sandboxes/overview.mdx→ update the service-exposure section to explain that callers should consume printed URLs rather than construct hostnames.docs/kubernetes/ingress.mdx→ add operator-managed exact-hostHTTPRoute/Ingress guidance for emitted hash labels, wildcard-certificate requirements, and using distinct routing gateway names when multiple gateways share a base domain.docs/kubernetes/setup.mdx→ addserver.serviceRoutingGatewayNameto the chart-values reference.Implementation Steps
Add the configuration contract in
openshell-core.ServiceRoutingConfig(currentlycrates/openshell-core/src/config.rs:311-319) withservice_routing_gateway_name: Option<String>, defaulting toNone.Config::with_service_routing_gateway_name(...), keepingwith_server_sanslimited to derivingbase_domainsso it preserves this new setting.ServiceRoutingConfig::validate()(or an equivalent public validation helper used by the server) that acceptsNoneand otherwise requires an RFC/DNS-1123-label-shaped value: 1–63 ASCII bytes, lowercasea-z,0-9, or-, and no leading/trailing-. Reuse the existing privateis_dns_labelbehavior atconfig.rs:1173-1180.None. This keeps the optional field unambiguous and produces ingress-rule-friendly operator identifiers.Plumb the value through TOML, CLI, environment, and server startup.
GatewayFileSection.service_routing_gateway_name: Option<String>besideserver_sansandenable_loopback_service_http(config_file.rs:152-162).RunArgs.service_routing_gateway_name: Option<String>with Claplong = "service-routing-gateway-name"andenv = "OPENSHELL_SERVICE_ROUTING_GATEWAY_NAME", adjacent toserver_sans(cli.rs:276-301).merge_file_into_args, copy the TOML field only ifargs.service_routing_gateway_name.is_none()andarg_defaulted(matches, "service_routing_gateway_name"), matching the existing source-precedence convention: CLI flag > env > TOML > built-in default.prepare_server_config_with_drivers, apply the setting afterwith_server_sans/loopback routing setup, then validate the effectiveconfig.service_routingbefore startup continues. This makes invalid TOML fail both preflight and startup; Clap can additionally use the same core validation helper for CLI/env input.Config.nameorGatewayJwtConfig.gateway_id; it stays unset unless explicitly configured.Implement deterministic hash labels without adding a dependency.
service_routing.rs, add a pure helper such asservice_route_hash_label(gateway_name, workspace, sandbox, service) -> String.gateway_name,workspace,sandbox,servicejoined by literal NUL bytes (gateway\0workspace\0sandbox\0service), with""for unnamed services; SHA-256 the bytes; takedigest[..10]; encode those 80 bits with the lowercase RFC-4648 alphabetabcdefghijklmnopqrstuvwxyz234567, no padding. Ten bytes encode to exactly 16 characters.sha2dependency already present inopenshell-server; no base32 encoder exists in workspace manifests orCargo.lock. Hand-roll the fixed-size encoder using a small constant alphabet and bit accumulator rather than adding a new crate for one 10-byte encoding operation.endpoint_hostsoSome(service_routing_gateway_name)emits<hash>.<first-base-domain>andNonepreserves the current byte-for-byte readable format.endpoint_urlremains dynamically generated and requires no proto or persistence change.MAX_ROUTABLE_NAME_LENatgrpc/mod.rs:143-148: legacy labels remain routable during the transition, so the legacy 63-character DNS-label constraint still applies even though hash labels themselves do not need it.Introduce and hydrate the hash-to-endpoint index.
ServiceRouteIndexinservice_routing.rs, owned byServerStateas anArcaround a lock-protectedHashMap<String, ServiceRouteTarget>. The value must includeworkspace,sandbox, andservice; it is the input used by the existingload_endpointpath, not a persisted copy ofServiceEndpoint.Store::collect_messages::<ServiceEndpoint>(ObjectListQuery::AllWorkspaces);service_routing_gateway_nameis unset.ServerState::new_with_credentialsand call its asynchronous rebuild afterensure_default_workspacebut before listener binding inlib.rs(currently betweenlib.rs:827-850). Treat a persisted ambiguous hash as startup configuration/state failure rather than picking an arbitrary route.ServerStateonly hasservice_upstreamsatlib.rs:312-314, while endpoint resolution is currently a direct persistence lookup inservice_routing.rs:606-621. The new index must therefore be hydrated at startup; only updating it on future exposes would break all existing endpoints after a gateway restart.Resolve configured hash hosts while retaining legacy host compatibility.
<workspace>--<sandbox>[--<service>].<base-domain>, including configured gateways. Legacy hosts remain syntactically distinguishable because hash labels are exactly 16 lowercase base32 characters and readable labels contain--.parse_host/classification into a syntactic host parser plus an asynchronous state-aware resolver. Hash lookup needs the route index andStore; the currentparse_host(host, &ServiceRoutingConfig)atservice_routing.rs:203-232cannot resolve an opaque label by configuration alone.ExposeService. If the hash remains absent, returnNone, allowing the normal HTTP router to handle it and ultimately return its ordinary 404 rather than treating it as a service endpoint.is_sandbox_service_request,proxy_sandbox_service_request, andemit_cross_origin_service_http_rejectionuse this resolver. Updatehttp.rsmiddleware (http.rs:202-231) to await classification/resolution. Keeprequest_hostbehavior unchanged so bothHostand HTTP/2 URI authority work.Keep the index correct across endpoint lifecycle paths and enforce collisions.
grpc::service::expose_service_endpoint(grpc/service.rs:86-185), calculate/reserve the route beforeput_if. If the same hash maps to a different(workspace, sandbox, service), returnStatus::already_existswith a clear "service routing hostname hash collision" message and do not write the new record.endpoint_url.handle_delete_service(grpc/service.rs:295-349), remove the matching index entry only afterdelete_ifreports success.ComputeRuntime::cleanup_sandbox_service_endpoints(compute/mod.rs:5675-5703) also directly deletes service records during sandbox cleanup. GiveComputeRuntimethe same shared index (using an initialization method/OnceLockpattern like its existing shared registries) and remove each successfully deleted endpoint entry there. This prevents stale labels after sandbox deletion.Add Helm plumbing without chart-managed ingress objects.
server.serviceRoutingGatewayName: ""and comments explaining that it is optional, must be unique among gateways sharing a service base domain, and causes opaque service labels only when non-empty.templates/_toml.tpl, not the thintemplates/gateway-config.yamlwrapper, becausegateway-config.yamlmerely includesopenshell.gatewayConfigTomlat line 22 and all legacy-value translation happens in_toml.tpl:100-119.service_routing_gateway_namewhen the chart value is non-empty and no explicitgatewayConfig.openshell.gateway.service_routing_gateway_namevalue exists. Do not add ingress,HTTPRoute, or wildcard routing automation to the chart.Update user-facing documentation and run focused verification.
Test Plan
Unit
crates/openshell-core/src/config.rsservice_routing_gateway_name_defaults_to_none.service_routing_gateway_name_accepts_dns_label.service_routing_gateway_name_rejects_empty_uppercase_invalid_hyphen_and_overlong_values.with_server_sansdoes not overwrite the configured routing name.crates/openshell-server/src/service_routing.rsgateway-a\0default\0my-sandbox\0web, asserting SHA-256 prefix labelenh47uqyj7bpyttz; assert unnamed service uses the empty fourth component and produceseiud2z5apv3yeucg.[a-z2-7]{16}label before the base domain.Hostand HTTP/2 authority forms, backed by a populatedServiceRouteIndex.legacy_host_is_accepted_when_hash_routing_is_configured.unknown_hash_host_is_not_classified_as_service_requestand assert it resolves toNone.test_store()and persisted endpoints, including restart-style rehydration.ServiceRouteTargetunder the attempted endpoint’s generated label, then assert registration is rejected. Do not weaken/truncate production hashes just to make a probabilistic collision test feasible.crates/openshell-server/src/grpc/service.rsexpose_service_rejects_route_hash_collision_before_persistence.GetService/ListServicesregenerate the same URL, and successful deletion removes the lookup.service_exposures, sincegrpc/sandbox.rs:729-771delegates toexpose_service_endpoint.crates/openshell-server/src/http.rscrates/openshell-server/src/config_file.rsservice_routing_gateway_name = "gateway-west".crates/openshell-server/src/cli.rsfile_populates_service_routing_gateway_name.service_routing_gateway_name_cli_and_env_override_file_value, using the existingparse_with_args,Environment, andarg_defaultedconvention.prepared.config.service_routingand invalid TOML fails preparation/preflight.crates/openshell-cli/src/run.rsservice_url_for_gateway_preserves_hashed_service_labelfor a URL such ashttps://enh47uqyj7bpyttz.apps.example.com:8080/, proving only the port is normalized.Integration
ServerStatewithservice_routing_gateway_name = Some("gateway-a"), expose an endpoint, then resolve a request by its emitted hash host to the expected workspace/sandbox/service tuple.E2E
cargo test -p openshell-server service_routing, service gRPC tests, and HTTP routing tests), core tests, and CLI tests.deploy/helm/openshell/tests/gateway_config_test.yaml.Risks & Open Questions
docs/reference/gateway-config.mdx,docs/sandboxes/manage-gateways.mdx, andarchitecture/gateway.mddo not exist on currentupstream/main(commit05050a6d6). The canonical current equivalents aredocs/how-it-works/gateways/configuration.mdx,docs/how-it-works/gateways/overview.mdx, anddocs/how-it-works/sandboxes/overview.mdx; there is no standalone gateway architecture grammar document to update. The plan updates those canonical pages instead of creating duplicate/unlinked documentation.deploy/helm/openshell/templates/gateway-config.yamlis only a 22-line ConfigMap wrapper. The actual TOML translation is indeploy/helm/openshell/templates/_toml.tpl, so that is the required Helm-template change.parse_hostcallers to receive routing state/store access through an asynchronous resolver; a synchronous signature limited to&ServiceRoutingConfigis insufficient.ServiceEndpointpersistence is shared. A hash miss must refresh from store before concluding the host is unknown. This creates a bounded extra lookup/scan for unknown hash-shaped hostnames; consider rate-limiting or negative-cache behavior only if profiling shows it is needed.service_routing_gateway_namevalues to gateways sharing a base domain; including the name only produces cross-gateway uniqueness when the configured names are themselves unique./proc,exec, or sandbox LSM policy paths.Documentation Impact
[openshell.gateway] service_routing_gateway_name = "gateway-west";unset;[a-z0-9]([-a-z0-9]*[a-z0-9])?, maximum 63 bytes);--service-routing-gateway-name>OPENSHELL_SERVICE_ROUTING_GATEWAY_NAME> TOML > unset;https://enh47uqyj7bpyttz.apps.example.com/;[openshell.gateway].namenor[openshell.gateway.gateway_jwt].gateway_id.<workspace>--<sandbox>[--<service>].<base-domain>. Currentdocs/how-it-works/gateways/overview.mdx:45incorrectly omits<workspace>, while implementation has always included it (service_routing.rs:193-197).HTTPRoute/Ingress rules targeting the corresponding gateway Service, while a*.apps.example.comcertificate remains sufficient because every generated service hostname is exactly one label under the base domain.Key design decisions verified in code
upstream/main,endpoint_hostbuildsworkspace--sandbox[--service].base-domainatcrates/openshell-server/src/service_routing.rs:186-198;endpoint_urlregenerates a URL at:158-173;parse_hostreverses the readable host at:203-232.ServiceEndpointpersistence usesendpoint_key(sandbox, service)(:150-156) and workspace-scoped lookups (:606-621), so URL data is not persisted and no proto/persistence schema change is needed.ServiceRoutingConfigcurrently has onlybase_domainsandenable_loopback_service_http(crates/openshell-core/src/config.rs:311-319);Config::with_server_sanswrites itsbase_domainsat:1082-1095; startup applies service-routing values atcrates/openshell-server/src/cli.rs:556-568. TOML service fields are inGatewayFileSectionatconfig_file.rs:152-162, and file precedence is applied bymerge_file_into_argsatcli.rs:1191-1235.config.rs:1173-1180already enforces lowercase ASCII alphanumeric/hyphen labels, no edge hyphens, and 63-byte maximum. Reusing it for the optional routing gateway name is consistent with existing domain/SAN validation and keeps the setting suitable for ingress operations.ServerStatehas only a pooled upstream map (service_upstreams,lib.rs:312-314); HTTP classification currently callsis_sandbox_service_requestwith only&state.config.service_routing(http.rs:202-212), and service proxying subsequently loads the endpoint from persistence (service_routing.rs:238-255,:337-350). The feature therefore requires a newServerStateroute index plus startup hydration, state/store-aware host resolution, and lifecycle updates in both service RPC deletion (grpc/service.rs:295-349) and sandbox-owned endpoint cleanup (compute/mod.rs:5675-5703).sha2is already a workspace dependency and anopenshell-serverdependency; no base32/data-encoding crate is present in workspace manifests. A local fixed-size lowercase RFC-4648 encoder is preferable to adding a dependency for a single 10-byte/16-character conversion.MAX_ROUTABLE_NAME_LENis 19 atcrates/openshell-server/src/grpc/mod.rs:143-148because legacy three-segment labels must fit DNS’s 63-character label limit. Keep it while legacy host parsing remains supported.Complexity / Confidence / Issue type / Estimated files
featRevision 1 — initial plan, verified against
upstream/main@05050a6d6jhjaggars commentedon Oct 22, 2026
Implementation Complete
PR: #4842
What was built
An optional
service_routing_gateway_namegateway setting (TOML/CLI/env/Helm) that, when configured, changes exposed-service hostnames to a deterministic 16-character base32 SHA-256 label of(gateway name, workspace, sandbox, service)under the same wildcard-cert base domain. Unset gateways keep the exact legacy readable format; configured gateways still accept legacy hosts. Hashed hosts resolve via an in-memory, store-hydrated route index with expose-time collision rejection.Tests
Local verification:
cargo test -p openshell-core(559),cargo test -p openshell-server(2205),cargo test -p openshell-cli(targeted),mise run helm:test(345), clippy-D warnings, fmt — all green.mise run pre-commithas one pre-existing unrelated failure (examples/supervisor-middleware-content-guard/Cargo.lockreferences yankedclap 4.6.7), which also fails on unmodifiedmain.Docs updated
docs/how-it-works/gateways/configuration.mdx,docs/how-it-works/gateways/overview.mdx(also fixes the pre-existing workspace-segment omission),docs/how-it-works/sandboxes/overview.mdx,docs/kubernetes/ingress.mdx,docs/kubernetes/setup.mdx, Helm chart READMEThe issue will auto-close when the PR is merged.
feat(gateway): add hashed service-routing hostnames for multi-gateway…