Skip to content

feat: add gateway discriminator to exposed service hostnames for multi-gateway clusters #4840

Open

Description

@jhjaggars

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_host or 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

  1. 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.
  2. Reuse gateway_jwt.gateway_id — already validated as DNS-1123 and Helm defaults it to chart fullname. But it is security-sensitive (sandbox JWT iss/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.
  3. CLI gateway registration name — not viable; it is client-local metadata that doesn't exist at gateway runtime.
  4. 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, Helm route_test.yaml:97-107). Would require per-gateway certs like *.gateway-a.example.com, defeating the stated goal.
  5. 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 is 19+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 like gateway-a--*.apps.example.com — controller-specific regex Host matching or a different routing design may be required.
  6. 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-safe a-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_url does 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-memory hash → endpoint map 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-host HTTPRoute/Ingress rules 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 list mitigates 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_http end-to-end — TOML field (config_file.rs:125-132) → clap flag + env (cli.rs:199-217) → precedence merge (cli.rs:650-687) → Config builder (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_id flow 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.rs is 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_name fits 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:45 and architecture/gateway.md:613-620 document 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 tests
  • grpc/service.rs:344-896 — name validation, endpoint lifecycle, workspace isolation
  • config.rs:1638-1680 — default service routing and SAN-derived base domain tests
  • cli.rs:1800-1848 — TOML/env precedence tests
  • crates/openshell-cli/src/run.rs:8062-8102 — CLI URL port-rewrite tests
  • lib.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 to gateway_jwt.gateway_id, hostname grammar and certificate requirements
  • docs/sandboxes/manage-gateways.mdx — new discriminator, ingress requirements; also fix the existing hostname examples that omit the workspace segment
  • docs/kubernetes/ingress.mdx — currently documents only GRPCRoute ingress; needs the shared-ingress service-routing story
  • docs/kubernetes/setup.mdx — Helm value table entry
  • architecture/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

  1. jhjaggars commented on Oct 22, 2026

    @jhjaggars
    ContributorAuthor

    🏗️ build-plan

    Implementation Plan

    Issue type: feat
    Complexity: High
    Confidence: Medium-high

    Summary

    Add an opt-in service_routing_gateway_name setting 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 persisted ServiceEndpoint records.

    Scope

    • crates/openshell-core/src/config.rs → add ServiceRoutingConfig.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 to ServerState, initialize it with the configured routing name, and hydrate it from persisted ServiceEndpoint records 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 with ComputeRuntime and remove entries when sandbox cleanup deletes its owned ServiceEndpoint records.
    • crates/openshell-server/src/config_file.rs → add optional [openshell.gateway] service_routing_gateway_name TOML 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 into Config.
    • 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 documented server.serviceRoutingGatewayName: ""; empty remains backward-compatible and does not emit TOML.
    • deploy/helm/openshell/templates/_toml.tpl → map a non-empty legacy chart value to openshell.gateway.service_routing_gateway_name, while preserving explicit gatewayConfig.openshell.gateway.service_routing_gateway_name precedence and omitting the field when absent/empty.
    • deploy/helm/openshell/tests/gateway_config_test.yaml → verify default omission, non-empty rendering, and explicit schema-v2 gatewayConfig precedence.
    • 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 to gateway_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-host HTTPRoute/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 → add server.serviceRoutingGatewayName to the chart-values reference.

    Implementation Steps

    1. Add the configuration contract in openshell-core.

      • Extend ServiceRoutingConfig (currently crates/openshell-core/src/config.rs:311-319) with service_routing_gateway_name: Option<String>, defaulting to None.
      • Add Config::with_service_routing_gateway_name(...), keeping with_server_sans limited to deriving base_domains so it preserves this new setting.
      • Add ServiceRoutingConfig::validate() (or an equivalent public validation helper used by the server) that accepts None and otherwise requires an RFC/DNS-1123-label-shaped value: 1–63 ASCII bytes, lowercase a-z, 0-9, or -, and no leading/trailing -. Reuse the existing private is_dns_label behavior at config.rs:1173-1180.
      • Reject empty strings rather than treating them as enabled; the absence of the setting is represented only by None. This keeps the optional field unambiguous and produces ingress-rule-friendly operator identifiers.
    2. Plumb the value through TOML, CLI, environment, and server startup.

      • Add GatewayFileSection.service_routing_gateway_name: Option<String> beside server_sans and enable_loopback_service_http (config_file.rs:152-162).
      • Add RunArgs.service_routing_gateway_name: Option<String> with Clap long = "service-routing-gateway-name" and env = "OPENSHELL_SERVICE_ROUTING_GATEWAY_NAME", adjacent to server_sans (cli.rs:276-301).
      • In merge_file_into_args, copy the TOML field only if args.service_routing_gateway_name.is_none() and arg_defaulted(matches, "service_routing_gateway_name"), matching the existing source-precedence convention: CLI flag > env > TOML > built-in default.
      • In prepare_server_config_with_drivers, apply the setting after with_server_sans/loopback routing setup, then validate the effective config.service_routing before startup continues. This makes invalid TOML fail both preflight and startup; Clap can additionally use the same core validation helper for CLI/env input.
      • Do not derive this setting from Config.name or GatewayJwtConfig.gateway_id; it stays unset unless explicitly configured.
    3. Implement deterministic hash labels without adding a dependency.

      • In service_routing.rs, add a pure helper such as service_route_hash_label(gateway_name, workspace, sandbox, service) -> String.
      • Serialize the exact tuple as gateway_name, workspace, sandbox, service joined by literal NUL bytes (gateway\0workspace\0sandbox\0service), with "" for unnamed services; SHA-256 the bytes; take digest[..10]; encode those 80 bits with the lowercase RFC-4648 alphabet abcdefghijklmnopqrstuvwxyz234567, no padding. Ten bytes encode to exactly 16 characters.
      • Use the workspace sha2 dependency already present in openshell-server; no base32 encoder exists in workspace manifests or Cargo.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.
      • Update endpoint_host so Some(service_routing_gateway_name) emits <hash>.<first-base-domain> and None preserves the current byte-for-byte readable format. endpoint_url remains dynamically generated and requires no proto or persistence change.
      • Retain MAX_ROUTABLE_NAME_LEN at grpc/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.
    4. Introduce and hydrate the hash-to-endpoint index.

      • Add a ServiceRouteIndex in service_routing.rs, owned by ServerState as an Arc around a lock-protected HashMap<String, ServiceRouteTarget>. The value must include workspace, sandbox, and service; it is the input used by the existing load_endpoint path, not a persisted copy of ServiceEndpoint.
      • Add index operations to:
        • calculate/register an endpoint’s label and reject a different existing target under the same label;
        • remove only when the stored target matches the deleting endpoint;
        • rebuild from Store::collect_messages::<ServiceEndpoint>(ObjectListQuery::AllWorkspaces);
        • clear/no-op when service_routing_gateway_name is unset.
      • Initialize the index in ServerState::new_with_credentials and call its asynchronous rebuild after ensure_default_workspace but before listener binding in lib.rs (currently between lib.rs:827-850). Treat a persisted ambiguous hash as startup configuration/state failure rather than picking an arbitrary route.
      • The current server has no existing endpoint-routing map: ServerState only has service_upstreams at lib.rs:312-314, while endpoint resolution is currently a direct persistence lookup in service_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.
    5. Resolve configured hash hosts while retaining legacy host compatibility.

      • Preserve a legacy parsing path for <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 --.
      • Refactor parse_host/classification into a syntactic host parser plus an asynchronous state-aware resolver. Hash lookup needs the route index and Store; the current parse_host(host, &ServiceRoutingConfig) at service_routing.rs:203-232 cannot resolve an opaque label by configuration alone.
      • For configured hash candidates, first consult the index; on a miss, refresh it from the shared store and retry. This is required for multi-replica Postgres deployments, where the replica receiving an HTTP request may not be the replica that handled ExposeService. If the hash remains absent, return None, allowing the normal HTTP router to handle it and ultimately return its ordinary 404 rather than treating it as a service endpoint.
      • Make is_sandbox_service_request, proxy_sandbox_service_request, and emit_cross_origin_service_http_rejection use this resolver. Update http.rs middleware (http.rs:202-231) to await classification/resolution. Keep request_host behavior unchanged so both Host and HTTP/2 URI authority work.
    6. Keep the index correct across endpoint lifecycle paths and enforce collisions.

      • In grpc::service::expose_service_endpoint (grpc/service.rs:86-185), calculate/reserve the route before put_if. If the same hash maps to a different (workspace, sandbox, service), return Status::already_exists with a clear "service routing hostname hash collision" message and do not write the new record.
      • On persistence failure, roll back the reservation only if it still belongs to the attempted target. On success, commit/update the target and generate the response URL through the existing endpoint_url.
      • In handle_delete_service (grpc/service.rs:295-349), remove the matching index entry only after delete_if reports success.
      • ComputeRuntime::cleanup_sandbox_service_endpoints (compute/mod.rs:5675-5703) also directly deletes service records during sandbox cleanup. Give ComputeRuntime the same shared index (using an initialization method/OnceLock pattern like its existing shared registries) and remove each successfully deleted endpoint entry there. This prevents stale labels after sandbox deletion.
      • Because endpoint data is shared in a database but the index is process-local, retain the resolver’s refresh-on-hash-miss behavior. The local register/remove operations provide immediate consistency on the mutating replica; a store refresh converges other replicas without adding a persistence schema.
    7. Add Helm plumbing without chart-managed ingress objects.

      • Add 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.
      • Update templates/_toml.tpl, not the thin templates/gateway-config.yaml wrapper, because gateway-config.yaml merely includes openshell.gatewayConfigToml at line 22 and all legacy-value translation happens in _toml.tpl:100-119.
      • Only set service_routing_gateway_name when the chart value is non-empty and no explicit gatewayConfig.openshell.gateway.service_routing_gateway_name value exists. Do not add ingress, HTTPRoute, or wildcard routing automation to the chart.
    8. Update user-facing documentation and run focused verification.

      • Document the new hostname grammar, hash input and stability properties, migration behavior, exact-host ingress requirements, and the operational consequence that changing the configured routing name changes hash URLs.
      • Correct the existing readable-format documentation to include the workspace segment everywhere it presents the grammar.
      • Run formatter and targeted Rust/Helm/doc checks after the corresponding tests below pass.

    Test Plan

    Unit

    • crates/openshell-core/src/config.rs

      • Add service_routing_gateway_name_defaults_to_none.
      • Add service_routing_gateway_name_accepts_dns_label.
      • Add service_routing_gateway_name_rejects_empty_uppercase_invalid_hyphen_and_overlong_values.
      • Confirm with_server_sans does not overwrite the configured routing name.
    • crates/openshell-server/src/service_routing.rs

      • Add a fixed-vector test for gateway-a\0default\0my-sandbox\0web, asserting SHA-256 prefix label enh47uqyj7bpyttz; assert unnamed service uses the empty fourth component and produces eiud2z5apv3yeucg.
      • Add hash-host URL tests for named and unnamed services, asserting exactly one 16-character [a-z2-7]{16} label before the base domain.
      • Preserve existing legacy URL tests unchanged for unset configuration.
      • Add configured-hash parsing tests for Host and HTTP/2 authority forms, backed by a populated ServiceRouteIndex.
      • Add legacy_host_is_accepted_when_hash_routing_is_configured.
      • Add unknown_hash_host_is_not_classified_as_service_request and assert it resolves to None.
      • Add index rebuild tests using test_store() and persisted endpoints, including restart-style rehydration.
      • Add collision coverage by directly seeding the route-index map with a different ServiceRouteTarget under 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.
      • Add remove-after-delete and remove-after-sandbox-cleanup coverage.
    • crates/openshell-server/src/grpc/service.rs

      • Add expose_service_rejects_route_hash_collision_before_persistence.
      • Extend endpoint lifecycle coverage to assert an exposed configured gateway returns the hash URL, GetService/ListServices regenerate the same URL, and successful deletion removes the lookup.
      • Exercise the same behavior through create-time service_exposures, since grpc/sandbox.rs:729-771 delegates to expose_service_endpoint.
    • crates/openshell-server/src/http.rs

      • Add/extend middleware coverage proving a configured known hash is routed as a service request while a syntactically valid but unknown hash proceeds through normal non-service handling.
    • crates/openshell-server/src/config_file.rs

      • Parse and serialize service_routing_gateway_name = "gateway-west".
    • crates/openshell-server/src/cli.rs

      • Add file_populates_service_routing_gateway_name.
      • Add service_routing_gateway_name_cli_and_env_override_file_value, using the existing parse_with_args, Environment, and arg_defaulted convention.
      • Add preparation coverage that asserts the effective value reaches prepared.config.service_routing and invalid TOML fails preparation/preflight.
    • crates/openshell-cli/src/run.rs

      • Add service_url_for_gateway_preserves_hashed_service_label for a URL such as https://enh47uqyj7bpyttz.apps.example.com:8080/, proving only the port is normalized.

    Integration

    • Start an in-memory ServerState with service_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.
    • Persist an endpoint, build a fresh state/index against the same store, rebuild the index, and verify the emitted hash host still resolves. This covers the required restart path without changing persistence schema.
    • For shared-store behavior, simulate a second state/index against the same test store: expose through one state, resolve the hash through the other, and verify miss-triggered index refresh finds the endpoint.

    E2E

    • No new runtime-driver E2E lane is required: this change does not alter sandbox/supervisor protocols, persistence schemas, or service relay transport.
    • Run focused server tests (cargo test -p openshell-server service_routing, service gRPC tests, and HTTP routing tests), core tests, and CLI tests.
    • Run Helm unit tests for deploy/helm/openshell/tests/gateway_config_test.yaml.
    • Run the repository’s applicable formatting, Helm lint/template, and documentation-link checks for changed files.

    Risks & Open Questions

    • Deviations: docs/reference/gateway-config.mdx, docs/sandboxes/manage-gateways.mdx, and architecture/gateway.md do not exist on current upstream/main (commit 05050a6d6). The canonical current equivalents are docs/how-it-works/gateways/configuration.mdx, docs/how-it-works/gateways/overview.mdx, and docs/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.
    • Deviations: deploy/helm/openshell/templates/gateway-config.yaml is only a 22-line ConfigMap wrapper. The actual TOML translation is in deploy/helm/openshell/templates/_toml.tpl, so that is the required Helm-template change.
    • The current gateway configuration is immutable for process lifetime; there is no general gateway TOML hot-reload mechanism. "Rebuilt when config changes" therefore means rebuilt during the next process startup after a configuration change, not a live in-process config update.
    • Hash labels cannot be reversed from configuration alone. This forces parse_host callers to receive routing state/store access through an asynchronous resolver; a synchronous signature limited to &ServiceRoutingConfig is insufficient.
    • In HA, the index is per process while ServiceEndpoint persistence 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.
    • The 80-bit truncation makes accidental collisions extraordinarily unlikely, but the implementation must still detect collisions locally and during startup hydration. A simultaneous cross-replica collision remains impractical but cannot be made globally atomic without either broader mutation-lock coordination or persisted hash uniqueness, both outside the approved no-schema-change design.
    • Operators must assign distinct service_routing_gateway_name values to gateways sharing a base domain; including the name only produces cross-gateway uniqueness when the configured names are themselves unique.
    • No LSM impact expected: this change only changes configuration, URL construction, HTTP host classification, and in-memory routing state; it does not touch process identity, /proc, exec, or sandbox LSM policy paths.

    Documentation Impact

    • Add the field to the canonical gateway TOML reference with:
      • [openshell.gateway] service_routing_gateway_name = "gateway-west";
      • default unset;
      • validation ([a-z0-9]([-a-z0-9]*[a-z0-9])?, maximum 63 bytes);
      • precedence: --service-routing-gateway-name > OPENSHELL_SERVICE_ROUTING_GATEWAY_NAME > TOML > unset;
      • hash URL example such as https://enh47uqyj7bpyttz.apps.example.com/;
      • explicit statement that this is neither [openshell.gateway].name nor [openshell.gateway.gateway_jwt].gateway_id.
    • Update current gateway/sandbox service documentation to state the legacy grammar accurately: <workspace>--<sandbox>[--<service>].<base-domain>. Current docs/how-it-works/gateways/overview.mdx:45 incorrectly omits <workspace>, while implementation has always included it (service_routing.rs:193-197).
    • Explain compatibility: unset preserves existing readable URLs; configured gateways emit hashes but still accept legacy readable hosts during migration; changing the configured routing gateway name deliberately changes all hash URLs.
    • Add Kubernetes ingress guidance that the chart does not create routes for service hostnames. Operators should use exact emitted hash hosts in HTTPRoute/Ingress rules targeting the corresponding gateway Service, while a *.apps.example.com certificate remains sufficient because every generated service hostname is exactly one label under the base domain.
    • Explain the multi-gateway pattern: share one wildcard certificate/base domain, configure unique routing gateway names, and create exact-host rules per gateway/service endpoint. Add the Helm value comment and setup-table entry.

    Key design decisions verified in code

    • Current URL and persistence behavior: on upstream/main, endpoint_host builds workspace--sandbox[--service].base-domain at crates/openshell-server/src/service_routing.rs:186-198; endpoint_url regenerates a URL at :158-173; parse_host reverses the readable host at :203-232. ServiceEndpoint persistence uses endpoint_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.
    • Configuration threading: ServiceRoutingConfig currently has only base_domains and enable_loopback_service_http (crates/openshell-core/src/config.rs:311-319); Config::with_server_sans writes its base_domains at :1082-1095; startup applies service-routing values at crates/openshell-server/src/cli.rs:556-568. TOML service fields are in GatewayFileSection at config_file.rs:152-162, and file precedence is applied by merge_file_into_args at cli.rs:1191-1235.
    • Validation rule: the existing core DNS-label helper at config.rs:1173-1180 already 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.
    • Route-map location and request path: there is no existing endpoint map. ServerState has only a pooled upstream map (service_upstreams, lib.rs:312-314); HTTP classification currently calls is_sandbox_service_request with 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 new ServerState route 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).
    • Hash specification and dependency choice: sha2 is already a workspace dependency and an openshell-server dependency; 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.
    • Existing name limit: MAX_ROUTABLE_NAME_LEN is 19 at crates/openshell-server/src/grpc/mod.rs:143-148 because 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

    • Complexity: High
    • Confidence: Medium-high
    • Issue type: feat
    • Estimated files: 17 modified files; no new proto, persistence migration, Cargo dependency, chart-managed ingress resource, or LSM-policy file.

    Revision 1 — initial plan, verified against upstream/main @ 05050a6d6

  2. jhjaggars commented on Oct 22, 2026

    @jhjaggars
    ContributorAuthor

    🏗️ build-from-issue-agent

    Implementation Complete

    PR: #4842

    What was built

    An optional service_routing_gateway_name gateway 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

    • Unit: config validation/precedence, fixed hash vectors, hash URL construction, index register/remove/rebuild/collision, host resolution (Host + HTTP/2 authority, legacy + unknown-hash fallthrough), gRPC expose/delete lifecycle, CLI URL normalization
    • Integration: index rehydration (restart path) and cross-replica miss-triggered refresh
    • E2E: N/A — no protocol, persistence, or relay changes

    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-commit has one pre-existing unrelated failure (examples/supervisor-middleware-content-guard/Cargo.lock references yanked clap 4.6.7), which also fails on unmodified main.

    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 README

    The issue will auto-close when the PR is merged.

  3. added
    state:acceptedA maintainer decided OpenShell should pursue this issue
    and removed
    state:triage-neededOpened without agent diagnostics and needs triage
    on Oct 22, 2026
  4. added a commit that references this issue on Oct 23, 2026
    28551bd
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    state:acceptedA maintainer decided OpenShell should pursue this issue

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions