diff --git a/deploy/helm/llm-request-router/README.md b/deploy/helm/llm-request-router/README.md index 8b0f3ccd8..d7c90b0e3 100644 --- a/deploy/helm/llm-request-router/README.md +++ b/deploy/helm/llm-request-router/README.md @@ -40,6 +40,10 @@ exposes a dashed-IP SRV alias. A multi-replica StatefulSet can instead run without the backend router and retain direct headless Service SRV discovery. `llmRequestRouter.discovery.watchHeartbeatMs` controls the maximum interval between unchanged Watch snapshots from both Stargate and the backend router. +`llmRequestRouter.discovery.remoteWatchUrls` accepts only explicit `https://` +Watch URIs. Development plaintext endpoints require an explicit `http://` URI +and `allowInsecureRemoteWatchHttp=true`; scheme-less and unsupported values are +rejected instead of defaulting to plaintext. `llmRequestRouter.kubernetes.advertisedHostnameTemplate` supports the Stargate placeholders `{pod_name}` and `{namespace}`. Stargate resolves both placeholders @@ -133,7 +137,7 @@ dial addresses to the external endpoints that workers can resolve: llmRequestRouter: backendRouter: enabled: true - pylonGrpcDialAddress: llm-router.example.com:443 + pylonGrpcDialAddress: https://llm-router.example.com:443 pylonReverseTunnelDialAddress: llm-router.example.com:8080 ``` diff --git a/deploy/helm/llm-request-router/llm-request-router/templates/_helpers.tpl b/deploy/helm/llm-request-router/llm-request-router/templates/_helpers.tpl index d3d2da83d..7f5d5c52f 100644 --- a/deploy/helm/llm-request-router/llm-request-router/templates/_helpers.tpl +++ b/deploy/helm/llm-request-router/llm-request-router/templates/_helpers.tpl @@ -80,6 +80,31 @@ app.kubernetes.io/managed-by: {{ .Release.Service }} {{- dig "workload" "kind" "Deployment" .Values.llmRequestRouter | toString -}} {{- end -}} +{{- define "llm-request-router.isExplicitHttpUri" -}} +{{- $uri := . | toString | trim -}} +{{- $authorityValid := regexMatch "^https?://([A-Za-z0-9._~-]+|\\[[0-9A-Fa-f:.]+\\])(:[0-9]+)?/?$" $uri -}} +{{- $portMatch := regexFind ":[0-9]+/?$" $uri -}} +{{- $portText := $portMatch | trimPrefix ":" | trimSuffix "/" -}} +{{- $portValid := or + (not $portMatch) + (and (le (len $portText) 5) (le ($portText | int) 65535)) -}} +{{- if and $authorityValid $portValid -}}true{{- end -}} +{{- end -}} + +{{- define "llm-request-router.validateRemoteWatchUrls" -}} +{{- $discovery := .Values.llmRequestRouter.discovery | default dict -}} +{{- $allowHttp := dig "allowInsecureRemoteWatchHttp" false $discovery -}} +{{- range $remoteWatchUrl := dig "remoteWatchUrls" (list) $discovery -}} +{{- $remoteWatchUrl = $remoteWatchUrl | toString | trim -}} +{{- if ne (include "llm-request-router.isExplicitHttpUri" $remoteWatchUrl) "true" -}} +{{- fail "llmRequestRouter.discovery.remoteWatchUrls entries must be explicit http:// or https:// URIs" -}} +{{- end -}} +{{- if and (hasPrefix "http://" $remoteWatchUrl) (not $allowHttp) -}} +{{- fail "llmRequestRouter.discovery.remoteWatchUrls requires https://; set allowInsecureRemoteWatchHttp=true only for development plaintext endpoints" -}} +{{- end -}} +{{- end -}} +{{- end -}} + {{/* An unset backendRouter.enabled follows the workload contract: a multi-replica Deployment needs the EndpointSlice router, while StatefulSet and single-replica @@ -297,9 +322,12 @@ externally reachable address. {{- $backendRouter := .Values.llmRequestRouter.backendRouter | default dict -}} {{- $configured := dig "pylonGrpcDialAddress" "" $backendRouter | toString | trim -}} {{- if $configured -}} +{{- if ne (include "llm-request-router.isExplicitHttpUri" $configured) "true" -}} +{{- fail "llmRequestRouter.backendRouter.pylonGrpcDialAddress must be an explicit http:// or https:// URI" -}} +{{- end -}} {{- $configured -}} {{- else -}} -{{- printf "%s.%s.svc.cluster.local:%v" (include "llm-request-router.backendRouterName" .) (include "llm-request-router.namespace" .) (dig "service" "grpcPort" 50071 $backendRouter) -}} +{{- printf "http://%s.%s.svc.cluster.local:%v" (include "llm-request-router.backendRouterName" .) (include "llm-request-router.namespace" .) (dig "service" "grpcPort" 50071 $backendRouter) -}} {{- end -}} {{- end -}} diff --git a/deploy/helm/llm-request-router/llm-request-router/templates/backend-router.yaml b/deploy/helm/llm-request-router/llm-request-router/templates/backend-router.yaml index 8f838a32c..22879ddd5 100644 --- a/deploy/helm/llm-request-router/llm-request-router/templates/backend-router.yaml +++ b/deploy/helm/llm-request-router/llm-request-router/templates/backend-router.yaml @@ -84,6 +84,12 @@ spec: - --advertised-grpc-port={{ .Values.llmRequestRouter.service.grpcPort }} - --grpc-pylon-dial-addr={{ include "llm-request-router.backendRouterGrpcDialAddress" . }} - --watch-heartbeat-ms={{ .Values.llmRequestRouter.discovery.watchHeartbeatMs }} + {{- range .Values.llmRequestRouter.discovery.remoteWatchUrls }} + - --remote-stargate-url={{ . }} + {{- end }} + {{- if .Values.llmRequestRouter.discovery.allowInsecureRemoteWatchHttp }} + - --allow-insecure-remote-watch-http + {{- end }} - --grpc-port-name=grpc - --quic-port-name=quic - --tunnel-protocol=raw-quic diff --git a/deploy/helm/llm-request-router/llm-request-router/templates/deployment.yaml b/deploy/helm/llm-request-router/llm-request-router/templates/deployment.yaml index 1d52f0dc9..dafb7873a 100644 --- a/deploy/helm/llm-request-router/llm-request-router/templates/deployment.yaml +++ b/deploy/helm/llm-request-router/llm-request-router/templates/deployment.yaml @@ -17,6 +17,7 @@ {{- if not (has $workloadKind (list "Deployment" "StatefulSet")) -}} {{- fail (printf "llmRequestRouter.workload.kind must be Deployment or StatefulSet, got %q" $workloadKind) -}} {{- end }} +{{- include "llm-request-router.validateRemoteWatchUrls" . }} apiVersion: apps/v1 kind: {{ $workloadKind }} metadata: @@ -124,6 +125,12 @@ spec: {{- with dig "discovery" "watchHeartbeatMs" "" .Values.llmRequestRouter }} - --watch-heartbeat-ms={{ . }} {{- end }} + {{- range .Values.llmRequestRouter.discovery.remoteWatchUrls }} + - --remote-stargate-url={{ . }} + {{- end }} + {{- if .Values.llmRequestRouter.discovery.allowInsecureRemoteWatchHttp }} + - --allow-insecure-remote-watch-http + {{- end }} - --shutdown-drain-timeout-ms={{ .Values.llmRequestRouter.shutdown.drainTimeoutMs }} - --quic-connect-timeout-ms={{ .Values.llmRequestRouter.transport.quicConnectTimeoutMs }} - --quic-request-timeout-ms={{ .Values.llmRequestRouter.transport.quicRequestTimeoutMs }} diff --git a/deploy/helm/llm-request-router/llm-request-router/values.yaml b/deploy/helm/llm-request-router/llm-request-router/values.yaml index 1f3941693..a27aed76a 100644 --- a/deploy/helm/llm-request-router/llm-request-router/values.yaml +++ b/deploy/helm/llm-request-router/llm-request-router/values.yaml @@ -119,10 +119,10 @@ llmRequestRouter: repository: "" tag: "" pullPolicy: "" - # Addresses workers dial to reach the router. Both default to the - # backend-router Service inside the cluster, which is correct when workers - # run alongside the control plane. Override both with externally reachable - # addresses when workers run in a separate cluster or region. + # Addresses workers dial to reach the router. The gRPC address is an + # explicit HTTP(S) URI; the reverse-tunnel address remains host:port. Both + # default to the backend-router Service inside the cluster. Override both + # with externally reachable addresses for a separate cluster or region. pylonGrpcDialAddress: "" pylonReverseTunnelDialAddress: "" serviceAccount: @@ -238,6 +238,10 @@ llmRequestRouter: # Maximum interval between unchanged WatchStargates snapshots. This is # shared by Stargate and the EndpointSlice backend router. watchHeartbeatMs: 5000 + # Recursive remote Watch endpoints must include their HTTP transport + # scheme. HTTPS is required unless the development-only opt-in is enabled. + remoteWatchUrls: [] + allowInsecureRemoteWatchHttp: false transport: quicConnectTimeoutMs: 2000 diff --git a/deploy/helm/llm-request-router/scripts/check-backend-router-render.sh b/deploy/helm/llm-request-router/scripts/check-backend-router-render.sh index 366041f33..19833c708 100755 --- a/deploy/helm/llm-request-router/scripts/check-backend-router-render.sh +++ b/deploy/helm/llm-request-router/scripts/check-backend-router-render.sh @@ -20,7 +20,7 @@ helm template llm-request-router "$chart_dir" \ --set llmRequestRouter.image.repository=nvcf/stargate \ --set llmRequestRouter.backendRouter.enabled=true \ --set llmRequestRouter.backendRouter.image.tag=next \ - --set llmRequestRouter.backendRouter.pylonGrpcDialAddress=llm-router.example.invalid:443 \ + --set llmRequestRouter.backendRouter.pylonGrpcDialAddress=https://llm-router.example.invalid:443 \ --set llmRequestRouter.backendRouter.pylonReverseTunnelDialAddress=llm-router.example.invalid:8080 \ --set llmRequestRouter.certificate.enabled=true \ --set llmRequestRouter.certificate.issuerRef.name=test-issuer \ @@ -166,13 +166,13 @@ assert_zero_config_contains() { fi } -assert_zero_config_contains "--grpc-pylon-dial-addr=llm-request-router-backend-router.nvcf.svc.cluster.local:50071" \ +assert_zero_config_contains "--grpc-pylon-dial-addr=http://llm-request-router-backend-router.nvcf.svc.cluster.local:50071" \ "gRPC dial address must default to the in-cluster backend-router Service" assert_zero_config_contains "--reverse-tunnel-pylon-dial-addr=llm-request-router-backend-router.nvcf.svc.cluster.local:50072" \ "reverse-tunnel dial address must default to the in-cluster backend-router Service" # An explicitly configured address must still win over the default. -assert_contains "--grpc-pylon-dial-addr=llm-router.example.invalid:443" \ +assert_contains "--grpc-pylon-dial-addr=https://llm-router.example.invalid:443" \ "configured gRPC dial address must override the in-cluster default" # Each replica terminates QUIC itself and cannot resume another replica's @@ -199,7 +199,7 @@ assert_contains "--advertised-hostname-template={pod_name}.llm-request-router-he "backend router authority and SNI template must match Stargate" assert_contains "--advertised-grpc-port=50071" \ "backend router Watch snapshots must advertise the Stargate gRPC port" -assert_contains "--grpc-pylon-dial-addr=llm-router.example.invalid:443" \ +assert_contains "--grpc-pylon-dial-addr=https://llm-router.example.invalid:443" \ "backend router Watch snapshots must preserve the Pylon dial endpoint" assert_contains "- '*.llm-request-router-headless.nvcf.svc.cluster.local'" \ "request-router certificate must cover pod-specific backend routing hostnames" @@ -207,7 +207,7 @@ assert_contains "image: registry.example.invalid/nvcf/stargate:next" \ "backend router must use its explicitly pinned Stargate image" assert_contains "app.kubernetes.io/version: \"next\"" \ "backend router labels must identify the explicitly pinned image version" -assert_contains "--grpc-pylon-dial-addr=llm-router.example.invalid:443" \ +assert_contains "--grpc-pylon-dial-addr=https://llm-router.example.invalid:443" \ "Stargate must advertise the external gRPC endpoint to pylon" assert_contains "--reverse-tunnel-pylon-dial-addr=llm-router.example.invalid:8080" \ "Stargate must advertise the external reverse-tunnel endpoint to pylon" @@ -238,7 +238,7 @@ assert_render_fails "llmRequestRouter.kubernetes.advertisedHostnameTemplate must --set-string 'llmRequestRouter.kubernetes.advertisedHostnameTemplate=\{pod_name\}\{pod_name\}' \ --set llmRequestRouter.backendRouter.enabled=true \ --set llmRequestRouter.backendRouter.image.tag=next \ - --set llmRequestRouter.backendRouter.pylonGrpcDialAddress=llm-router.example.invalid:443 \ + --set llmRequestRouter.backendRouter.pylonGrpcDialAddress=https://llm-router.example.invalid:443 \ --set llmRequestRouter.backendRouter.pylonReverseTunnelDialAddress=llm-router.example.invalid:8080 assert_render_fails "llmRequestRouter.transport.reverseTunnelListenAddr port 50073 must match llmRequestRouter.service.reverseTunnelPort 50072 when backend routing is enabled" \ @@ -271,7 +271,7 @@ assert_render_fails "llmRequestRouter.backendRouter.serviceAccount.name is requi --set llmRequestRouter.image.repository=nvcf/stargate \ --set llmRequestRouter.backendRouter.enabled=true \ --set llmRequestRouter.backendRouter.image.tag=next \ - --set llmRequestRouter.backendRouter.pylonGrpcDialAddress=llm-router.example.invalid:443 \ + --set llmRequestRouter.backendRouter.pylonGrpcDialAddress=https://llm-router.example.invalid:443 \ --set llmRequestRouter.backendRouter.pylonReverseTunnelDialAddress=llm-router.example.invalid:8080 \ --set llmRequestRouter.backendRouter.serviceAccount.create=false @@ -280,7 +280,7 @@ assert_render_fails "llmRequestRouter.backendRouter.serviceAccount.name is requi --set llmRequestRouter.image.repository=nvcf/stargate \ --set llmRequestRouter.backendRouter.enabled=true \ --set llmRequestRouter.backendRouter.image.tag=next \ - --set llmRequestRouter.backendRouter.pylonGrpcDialAddress=llm-router.example.invalid:443 \ + --set llmRequestRouter.backendRouter.pylonGrpcDialAddress=https://llm-router.example.invalid:443 \ --set llmRequestRouter.backendRouter.pylonReverseTunnelDialAddress=llm-router.example.invalid:8080 \ --set llmRequestRouter.backendRouter.serviceAccount.create=false \ --set llmRequestRouter.rbac.create=false @@ -291,7 +291,7 @@ helm template llm-request-router "$chart_dir" \ --set llmRequestRouter.image.repository=nvcf/stargate \ --set llmRequestRouter.backendRouter.enabled=true \ --set llmRequestRouter.backendRouter.image.tag=next \ - --set llmRequestRouter.backendRouter.pylonGrpcDialAddress=llm-router.example.invalid:443 \ + --set llmRequestRouter.backendRouter.pylonGrpcDialAddress=https://llm-router.example.invalid:443 \ --set llmRequestRouter.backendRouter.pylonReverseTunnelDialAddress=llm-router.example.invalid:8080 \ --set llmRequestRouter.backendRouter.serviceAccount.create=false \ --set llmRequestRouter.backendRouter.serviceAccount.name=external-backend-router \ @@ -314,7 +314,7 @@ helm template llm-request-router "$chart_dir" \ --set llmRequestRouter.image.repository=nvcf/stargate \ --set llmRequestRouter.backendRouter.enabled=true \ --set llmRequestRouter.backendRouter.image.tag=next \ - --set llmRequestRouter.backendRouter.pylonGrpcDialAddress=llm-router.example.invalid:443 \ + --set llmRequestRouter.backendRouter.pylonGrpcDialAddress=https://llm-router.example.invalid:443 \ --set llmRequestRouter.backendRouter.pylonReverseTunnelDialAddress=llm-router.example.invalid:8080 \ --set llmRequestRouter.certificate.enabled=true \ --set llmRequestRouter.certificate.issuerRef.name=test-issuer \ @@ -342,7 +342,7 @@ assert_render_fails "llmRequestRouter.kubernetes.advertisedHostnameTemplate must --set llmRequestRouter.kubernetes.advertisedHostnameTemplate=llm-request-router.nvcf.svc.cluster.local \ --set llmRequestRouter.backendRouter.enabled=true \ --set llmRequestRouter.backendRouter.image.tag=next \ - --set llmRequestRouter.backendRouter.pylonGrpcDialAddress=llm-router.example.invalid:443 \ + --set llmRequestRouter.backendRouter.pylonGrpcDialAddress=https://llm-router.example.invalid:443 \ --set llmRequestRouter.backendRouter.pylonReverseTunnelDialAddress=llm-router.example.invalid:8080 assert_render_fails "llmRequestRouter backend routing requires a TLS Secret and cert/key paths when tls.quicInsecure is false" \ @@ -350,7 +350,7 @@ assert_render_fails "llmRequestRouter backend routing requires a TLS Secret and --set llmRequestRouter.image.repository=nvcf/stargate \ --set llmRequestRouter.backendRouter.enabled=true \ --set llmRequestRouter.backendRouter.image.tag=next \ - --set llmRequestRouter.backendRouter.pylonGrpcDialAddress=llm-router.example.invalid:443 \ + --set llmRequestRouter.backendRouter.pylonGrpcDialAddress=https://llm-router.example.invalid:443 \ --set llmRequestRouter.backendRouter.pylonReverseTunnelDialAddress=llm-router.example.invalid:8080 \ --set llmRequestRouter.tls.quicInsecure=false @@ -359,7 +359,7 @@ assert_render_fails "llmRequestRouter backend routing requires tls.secretName (o --set llmRequestRouter.image.repository=nvcf/stargate \ --set llmRequestRouter.backendRouter.enabled=true \ --set llmRequestRouter.backendRouter.image.tag=next \ - --set llmRequestRouter.backendRouter.pylonGrpcDialAddress=llm-router.example.invalid:443 \ + --set llmRequestRouter.backendRouter.pylonGrpcDialAddress=https://llm-router.example.invalid:443 \ --set llmRequestRouter.backendRouter.pylonReverseTunnelDialAddress=llm-router.example.invalid:8080 \ --set llmRequestRouter.tls.certPath=/etc/stargate/tls/tls.crt @@ -368,7 +368,7 @@ assert_render_fails "llmRequestRouter.tls.certPath and llmRequestRouter.tls.keyP --set llmRequestRouter.image.repository=nvcf/stargate \ --set llmRequestRouter.backendRouter.enabled=true \ --set llmRequestRouter.backendRouter.image.tag=next \ - --set llmRequestRouter.backendRouter.pylonGrpcDialAddress=llm-router.example.invalid:443 \ + --set llmRequestRouter.backendRouter.pylonGrpcDialAddress=https://llm-router.example.invalid:443 \ --set llmRequestRouter.backendRouter.pylonReverseTunnelDialAddress=llm-router.example.invalid:8080 \ --set llmRequestRouter.tls.secretName=stargate-quic-tls \ --set llmRequestRouter.tls.certPath=/etc/stargate/tls/tls.crt \ @@ -388,7 +388,7 @@ assert_render_fails "llmRequestRouter.tls.mountPath must match the directory con --set llmRequestRouter.image.repository=nvcf/stargate \ --set llmRequestRouter.backendRouter.enabled=true \ --set llmRequestRouter.backendRouter.image.tag=next \ - --set llmRequestRouter.backendRouter.pylonGrpcDialAddress=llm-router.example.invalid:443 \ + --set llmRequestRouter.backendRouter.pylonGrpcDialAddress=https://llm-router.example.invalid:443 \ --set llmRequestRouter.backendRouter.pylonReverseTunnelDialAddress=llm-router.example.invalid:8080 \ --set llmRequestRouter.tls.secretName=stargate-quic-tls \ --set llmRequestRouter.tls.mountPath=/var/run/stargate \ @@ -402,7 +402,7 @@ single_replica="$(helm template llm-request-router "$chart_dir" \ --set llmRequestRouter.replicaCount=1 \ --set llmRequestRouter.backendRouter.enabled=true \ --set llmRequestRouter.backendRouter.image.tag=next \ - --set llmRequestRouter.backendRouter.pylonGrpcDialAddress=llm-router.example.invalid:443 \ + --set llmRequestRouter.backendRouter.pylonGrpcDialAddress=https://llm-router.example.invalid:443 \ --set llmRequestRouter.backendRouter.pylonReverseTunnelDialAddress=llm-router.example.invalid:8080)" if ! grep -Fq -- "--advertised-hostname-template={pod_name}.llm-request-router-headless.nvcf.svc.cluster.local" <<<"$single_replica"; then echo "FAIL: backend routing must retain per-pod authority and SNI for one replica" >&2 diff --git a/deploy/helm/llm-request-router/scripts/check-multi-replica-render.sh b/deploy/helm/llm-request-router/scripts/check-multi-replica-render.sh index eb63b07f1..61ea250df 100755 --- a/deploy/helm/llm-request-router/scripts/check-multi-replica-render.sh +++ b/deploy/helm/llm-request-router/scripts/check-multi-replica-render.sh @@ -81,7 +81,7 @@ default_backend_kind="$(yq -r 'select(.kind == "Deployment" and .metadata.name = default_args="$(workload_args "${default_manifest}" Deployment)" default_backend_args="$(backend_router_args "${default_manifest}")" printf '%s\n' "${default_args}" | grep -qx -- "--advertised-hostname-template={pod_name}.llm-request-router-headless.${namespace}.svc.cluster.local" || fail "default Deployment missing per-pod advertised hostname template" -printf '%s\n' "${default_args}" | grep -qx -- "--grpc-pylon-dial-addr=llm-request-router-backend-router.${namespace}.svc.cluster.local:50071" || fail "default Deployment missing inferred backend-router gRPC dial address" +printf '%s\n' "${default_args}" | grep -qx -- "--grpc-pylon-dial-addr=http://llm-request-router-backend-router.${namespace}.svc.cluster.local:50071" || fail "default Deployment missing explicit inferred backend-router gRPC dial URI" printf '%s\n' "${default_args}" | grep -qx -- "--watch-heartbeat-ms=5000" || fail "default Deployment missing Watch heartbeat arg" printf '%s\n' "${default_backend_args}" | grep -qx -- "--watch-heartbeat-ms=5000" || fail "backend router missing Watch heartbeat arg" @@ -92,7 +92,7 @@ render "${multi_deployment_manifest}" \ multi_deployment_args="$(workload_args "${multi_deployment_manifest}" Deployment)" printf '%s\n' "${multi_deployment_args}" | grep -qx -- "--advertised-hostname-template={pod_name}.llm-request-router-headless.${namespace}.svc.cluster.local" || fail "multi-replica Deployment missing per-pod advertised hostname template" -printf '%s\n' "${multi_deployment_args}" | grep -qx -- "--grpc-pylon-dial-addr=llm-request-router-backend-router.${namespace}.svc.cluster.local:50071" || fail "multi-replica Deployment missing backend-router gRPC dial address" +printf '%s\n' "${multi_deployment_args}" | grep -qx -- "--grpc-pylon-dial-addr=http://llm-request-router-backend-router.${namespace}.svc.cluster.local:50071" || fail "multi-replica Deployment missing explicit backend-router gRPC dial URI" statefulset_manifest="${tmp_dir}/statefulset.yaml" render "${statefulset_manifest}" \ @@ -138,4 +138,37 @@ assert_render_fails "llmRequestRouter.discovery.disableDnsDiscovery cannot be tr assert_render_fails "llmRequestRouter.discovery.watchHeartbeatMs must be greater than 0" \ --set llmRequestRouter.discovery.watchHeartbeatMs=0 +secure_remote_manifest="${tmp_dir}/secure-remote.yaml" +render "${secure_remote_manifest}" \ + --set-string 'llmRequestRouter.discovery.remoteWatchUrls[0]=https://region-b.example.test:50071' + +secure_remote_args="$(workload_args "${secure_remote_manifest}" Deployment)" +secure_remote_backend_args="$(backend_router_args "${secure_remote_manifest}")" +printf '%s\n' "${secure_remote_args}" | grep -qx -- '--remote-stargate-url=https://region-b.example.test:50071' || fail "Stargate missing secure remote Watch URI" +printf '%s\n' "${secure_remote_backend_args}" | grep -qx -- '--remote-stargate-url=https://region-b.example.test:50071' || fail "backend router missing secure remote Watch URI" + +assert_render_fails "llmRequestRouter.discovery.remoteWatchUrls requires https://; set allowInsecureRemoteWatchHttp=true only for development plaintext endpoints" \ + --set-string 'llmRequestRouter.discovery.remoteWatchUrls[0]=http://127.0.0.1:50071' + +development_remote_manifest="${tmp_dir}/development-remote.yaml" +render "${development_remote_manifest}" \ + --set llmRequestRouter.discovery.allowInsecureRemoteWatchHttp=true \ + --set-string 'llmRequestRouter.discovery.remoteWatchUrls[0]=http://127.0.0.1:50071' +development_remote_args="$(workload_args "${development_remote_manifest}" Deployment)" +development_remote_backend_args="$(backend_router_args "${development_remote_manifest}")" +printf '%s\n' "${development_remote_args}" | grep -qx -- '--remote-stargate-url=http://127.0.0.1:50071' || fail "development HTTP remote Watch URI was not rendered" +printf '%s\n' "${development_remote_args}" | grep -qx -- '--allow-insecure-remote-watch-http' || fail "Stargate missing development HTTP opt-in" +printf '%s\n' "${development_remote_backend_args}" | grep -qx -- '--allow-insecure-remote-watch-http' || fail "backend router missing development HTTP opt-in" + +for invalid_remote_url in \ + 'region-b.example.test:50071' \ + 'ftp://region-b.example.test:50071' \ + 'https://user@region-b.example.test:50071' \ + 'https://:50071' \ + 'https://region-b.example.test:65536'; do + assert_render_fails "llmRequestRouter.discovery.remoteWatchUrls entries must be explicit http:// or https:// URIs" \ + --set llmRequestRouter.discovery.allowInsecureRemoteWatchHttp=true \ + --set-string "llmRequestRouter.discovery.remoteWatchUrls[0]=${invalid_remote_url}" +done + echo "dual workload render checks passed" diff --git a/deploy/stacks/self-managed/environments/base.yaml b/deploy/stacks/self-managed/environments/base.yaml index 6b6b65401..1b1789ba6 100644 --- a/deploy/stacks/self-managed/environments/base.yaml +++ b/deploy/stacks/self-managed/environments/base.yaml @@ -314,6 +314,15 @@ addons: # window; Kubernetes cannot mutate one workload kind into the other. kind: Deployment + discovery: + disableDnsDiscovery: false + # Maximum interval between unchanged local and remote Watch snapshots. + watchHeartbeatMs: 5000 + # Recursive Watch endpoints require an explicit HTTPS URI. Plaintext + # HTTP is available only through the development-only opt-in below. + remoteWatchUrls: [] + allowInsecureRemoteWatchHttp: false + # Authority and SNI aware backend router. A worker holds one registration # stream and one reverse QUIC tunnel per router replica, and each must # reach the specific replica named by the gRPC authority or QUIC SNI, so diff --git a/deploy/stacks/self-managed/global.yaml.gotmpl b/deploy/stacks/self-managed/global.yaml.gotmpl index 29b22f69b..410ace25b 100644 --- a/deploy/stacks/self-managed/global.yaml.gotmpl +++ b/deploy/stacks/self-managed/global.yaml.gotmpl @@ -878,6 +878,10 @@ llmRequestRouter: replicaCount: {{ dig "addons" "llm" "requestRouter" "replicaCount" 3 .Values }} workload: kind: {{ dig "addons" "llm" "requestRouter" "workload" "kind" "Deployment" .Values | quote }} + {{- with dig "addons" "llm" "requestRouter" "discovery" dict .Values }} + discovery: + {{- toYaml . | nindent 4 }} + {{- end }} service: grpcPort: {{ $llmRequestRouterGrpcPort }} {{- if .Values.global.imagePullSecrets }} diff --git a/deploy/stacks/self-managed/tests/llm-router-local-chart.sh b/deploy/stacks/self-managed/tests/llm-router-local-chart.sh index d10557764..fb1da8104 100755 --- a/deploy/stacks/self-managed/tests/llm-router-local-chart.sh +++ b/deploy/stacks/self-managed/tests/llm-router-local-chart.sh @@ -31,6 +31,10 @@ test "$actual" = "$chart_path" || { --environment default \ --state-values-set addons.llm.enabled=true \ --state-values-set-string "addons.llm.requestRouter.chartPath=$chart_path" \ + --state-values-set addons.llm.requestRouter.discovery.disableDnsDiscovery=true \ + --state-values-set addons.llm.requestRouter.discovery.watchHeartbeatMs=7000 \ + --state-values-set-string 'addons.llm.requestRouter.discovery.remoteWatchUrls[0]=https://region-b.example.invalid:50071' \ + --state-values-set addons.llm.requestRouter.discovery.allowInsecureRemoteWatchHttp=true \ --state-values-set ingress.gatewayApi.gateways.shared.name=shared-gw \ --state-values-set ingress.gatewayApi.gateways.shared.namespace=envoy-gateway-system \ --state-values-set ingress.gatewayApi.gateways.grpc.name=grpc-gw \ @@ -51,6 +55,30 @@ test "$default_workload_kind" = "Deployment" || { exit 1 } +remote_watch_url="$(yq -r '.llmRequestRouter.discovery.remoteWatchUrls[0]' "$values_file")" +test "$remote_watch_url" = "https://region-b.example.invalid:50071" || { + echo "llm-router-local-chart: expected remote Watch URL forwarding, got ${remote_watch_url:-missing}" >&2 + exit 1 +} + +allow_insecure_remote_watch_http="$(yq -r '.llmRequestRouter.discovery.allowInsecureRemoteWatchHttp' "$values_file")" +test "$allow_insecure_remote_watch_http" = "true" || { + echo "llm-router-local-chart: expected development HTTP opt-in forwarding, got ${allow_insecure_remote_watch_http:-missing}" >&2 + exit 1 +} + +disable_dns_discovery="$(yq -r '.llmRequestRouter.discovery.disableDnsDiscovery' "$values_file")" +test "$disable_dns_discovery" = "true" || { + echo "llm-router-local-chart: expected DNS discovery override forwarding, got ${disable_dns_discovery:-missing}" >&2 + exit 1 +} + +watch_heartbeat_ms="$(yq -r '.llmRequestRouter.discovery.watchHeartbeatMs' "$values_file")" +test "$watch_heartbeat_ms" = "7000" || { + echo "llm-router-local-chart: expected Watch heartbeat forwarding, got ${watch_heartbeat_ms:-missing}" >&2 + exit 1 +} + (cd "$stack_dir" && HELMFILE_ENV=base helmfile \ --file helmfile.d/02-core.yaml.gotmpl \ --environment default \ diff --git a/deploy/stacks/self-managed/tests/llm-router-split-cluster.sh b/deploy/stacks/self-managed/tests/llm-router-split-cluster.sh index 8212d9826..8bb93c2fc 100755 --- a/deploy/stacks/self-managed/tests/llm-router-split-cluster.sh +++ b/deploy/stacks/self-managed/tests/llm-router-split-cluster.sh @@ -35,7 +35,7 @@ printf '%s\n' \ ' requestRouter:' \ " chartPath: $router_chart_path" \ ' backendRouter:' \ - ' pylonGrpcDialAddress: llm-grpc.example.com:50071' \ + ' pylonGrpcDialAddress: https://llm-grpc.example.com:50071' \ ' pylonReverseTunnelDialAddress: llm-quic.example.com:50072' \ 'ingress:' \ ' gatewayApi:' \ @@ -123,7 +123,7 @@ assert_file_value "$work_dir/api-values.yaml" \ 'llm-grpc.example.com:50071' assert_file_value "$work_dir/router-values.yaml" \ '.llmRequestRouter.backendRouter.pylonGrpcDialAddress' \ - 'llm-grpc.example.com:50071' + 'https://llm-grpc.example.com:50071' assert_file_value "$work_dir/router-values.yaml" \ '.llmRequestRouter.backendRouter.pylonReverseTunnelDialAddress' \ 'llm-quic.example.com:50072' @@ -140,6 +140,13 @@ assert_file_value "$work_dir/router-values.yaml" \ '.llmRequestRouter.tls.quicInsecure' \ 'false' +helm template llm-request-router "$router_chart_path" \ + --namespace nvcf \ + --values "$work_dir/router-values.yaml" \ + >"$work_dir/router-manifest.yaml" +test -s "$work_dir/router-manifest.yaml" || + fail "request-router source chart did not render from generated stack values" + assert_partial_backend_override_rejected() { local missing_key="$1" local case_name="$2" diff --git a/docs/user/llm-function-enablement.md b/docs/user/llm-function-enablement.md index ff8ebc997..a107ec8e0 100644 --- a/docs/user/llm-function-enablement.md +++ b/docs/user/llm-function-enablement.md @@ -62,8 +62,10 @@ verification. Pylon reads the file once during startup. An unreadable path fails startup with the configured path in the error. Invalid PEM, untrusted chains, and hostname mismatches prevent the gRPC watch and registration connections. Replace or rotate the bundle with a rolling restart of the worker -pods. The dial address must be an `https://` URI. A scheme-less `host:port` -address preserves Pylon's existing plaintext HTTP behavior, even on port 443. +pods. The `pylonGrpcDialAddress` override must be an explicit `https://` URI +when a custom CA is configured. The separate +`global.workerEndpoints.llmRequestRouterAddress` input remains a scheme-less +`host:port` initial address. For gRPC, TLS SNI and hostname verification always use the external HTTPS dial hostname. After discovery, Pylon separately sends the concrete request-router @@ -215,7 +217,7 @@ addons: enabled: true requestRouter: backendRouter: - pylonGrpcDialAddress: llm-grpc.example.com:50071 + pylonGrpcDialAddress: https://llm-grpc.example.com:50071 pylonReverseTunnelDialAddress: llm-quic.example.com:50072 ingress: @@ -241,11 +243,32 @@ both to use the in-cluster backend-router Service. Helmfile rendering rejects a partial override. The gRPC worker address normally uses the same TCP endpoint as `pylonGrpcDialAddress`. -The scheme-less self-managed worker address in this example is plaintext gRPC. -Port 443 alone does not make it HTTPS, and the self-managed profile validator -accepts `host:port`, not a URI. The public ACM and private-CA NLB listener modes -described above apply when the deployment supplies Pylon an `https://` dial -URI through a supported configuration path. +To recursively discover request routers in another region, set explicit remote +Watch dial URIs on the self-managed operator surface: + +```yaml +addons: + llm: + requestRouter: + discovery: + remoteWatchUrls: + - https://region-b-watch.example.com:50071 +``` + +Each URI must use `https://`. Development-only plaintext endpoints require an +explicit `http://` URI and +`addons.llm.requestRouter.discovery.allowInsecureRemoteWatchHttp: true`. +Scheme-less and unsupported endpoints are rejected rather than defaulting to +plaintext. For an HTTPS URI, the dial hostname selects TLS SNI. Identities +advertised by the remote Watch response remain the HTTP/2 authorities used for +registration. + +The scheme-less `global.workerEndpoints.llmRequestRouterAddress` in this +example is the self-managed profile's initial `host:port` input. The +`pylonGrpcDialAddress` override requires an explicit `http://` or `https://` +URI; this example uses HTTPS. Port 443 alone does not select TLS. The public ACM +and private-CA NLB listener modes described above require an `https://` dial +URI. For an HTTPS dial URI, the gRPC dial hostname is the TLS SNI and must be a SAN on the NLB listener certificate. It does not replace the advertised @@ -840,10 +863,15 @@ For transport TLS failures, check: cluster clock. Renew the certificate and use the [transport TLS rotation runbook](./runbooks/transport-tls-rotation.md) to verify that the replacement becomes active. -- Missing trust bundle: verify `ConfigMap/nvcf-transport-trust-bundle`, compare - its fingerprint with the compute-plane profile, and confirm both - `STARGATE_TLS_CERT_PATH` and `STARGATE_GRPC_TLS_CA_CERT_PATH` in the - `llm-worker` container. Restart the worker after changing either bundle. +- System trust mode: confirm `STARGATE_TLS_CERT_PATH` and + `STARGATE_GRPC_TLS_CA_CERT_PATH` are both unset so Pylon uses its enabled + system and public roots. +- Bundle trust mode: verify `ConfigMap/nvcf-transport-trust-bundle`, compare its + fingerprint with the compute-plane profile, and confirm + `STARGATE_TLS_CERT_PATH` in the `llm-worker` container. The + `STARGATE_GRPC_TLS_CA_CERT_PATH` override is optional; when unset, the gRPC + registration and watch paths reuse the existing Stargate bundle. Restart the + worker after changing either bundle. Useful logs: diff --git a/src/libraries/rust/stargate/crates/protocol/src/lib.rs b/src/libraries/rust/stargate/crates/protocol/src/lib.rs index f5e1949c7..4eacb5e0a 100644 --- a/src/libraries/rust/stargate/crates/protocol/src/lib.rs +++ b/src/libraries/rust/stargate/crates/protocol/src/lib.rs @@ -46,6 +46,83 @@ pub use webtransport_http::{ pub const HTTP3_ALPN: &[u8] = b"h3"; pub const WEBTRANSPORT_BIDI_STREAM_TYPE: u64 = 0x41; +const EXPLICIT_HTTP_URI_ERROR: &str = "URI must be an explicit http:// or https:// authority"; + +/// Parses an HTTP(S) dial URI without inventing a transport scheme. +/// +/// gRPC control-plane endpoints are authorities, not HTTP resource paths. Keeping this +/// validation shared prevents a scheme-less remote Watch address from silently becoming +/// plaintext in one component while another component treats it as TLS. +pub fn parse_explicit_http_uri(value: &str) -> Result { + let value = value.trim(); + let uri = value + .parse::() + .map_err(|_| EXPLICIT_HTTP_URI_ERROR.to_string())?; + if !matches!(uri.scheme_str(), Some("http" | "https")) { + return Err(EXPLICIT_HTTP_URI_ERROR.to_string()); + } + let authority = uri + .authority() + .filter(|authority| !authority.host().is_empty()) + .ok_or_else(|| EXPLICIT_HTTP_URI_ERROR.to_string())?; + if authority.as_str().contains('@') { + return Err(EXPLICIT_HTTP_URI_ERROR.to_string()); + } + let authority_value = authority.as_str(); + let has_explicit_port = if authority_value.starts_with('[') { + authority_value + .split_once(']') + .is_some_and(|(_, suffix)| suffix.starts_with(':')) + } else { + authority_value.contains(':') + }; + if has_explicit_port && authority.port_u16().filter(|port| *port > 0).is_none() { + return Err(EXPLICIT_HTTP_URI_ERROR.to_string()); + } + if uri + .path_and_query() + .is_some_and(|path_and_query| path_and_query.as_str() != "/") + { + return Err(EXPLICIT_HTTP_URI_ERROR.to_string()); + } + Ok(value.to_string()) +} + +#[cfg(test)] +mod explicit_http_uri_tests { + use super::parse_explicit_http_uri; + + #[test] + fn accepts_only_explicit_http_authority_uris() { + for valid in [ + "https://region-b.example.test:50071", + "http://127.0.0.1:50071", + "https://[::1]", + "https://[::1]:50071/", + ] { + assert_eq!(parse_explicit_http_uri(valid).as_deref(), Ok(valid)); + } + + for invalid in [ + "region-b.example.test:50071", + "ftp://region-b.example.test:50071", + "https://", + "https://user@region-b.example.test:50071", + "https://region-b.example.test:50071/watch", + "https://region-b.example.test:50071?region=b", + "https://region-b.example.test:0", + "https://[::1]:0", + "https://region-b.example.test:", + "https://region-b.example.test:65536", + ] { + assert!( + parse_explicit_http_uri(invalid).is_err(), + "unexpected valid URI: {invalid}" + ); + } + } +} + /// Which side establishes the long-lived tunnel connection. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum BackendConnectivity { diff --git a/src/libraries/rust/stargate/crates/pylon-lib/src/registration/discovery.rs b/src/libraries/rust/stargate/crates/pylon-lib/src/registration/discovery.rs index 7b99f2759..03c81ce3e 100644 --- a/src/libraries/rust/stargate/crates/pylon-lib/src/registration/discovery.rs +++ b/src/libraries/rust/stargate/crates/pylon-lib/src/registration/discovery.rs @@ -22,8 +22,10 @@ use tokio_util::sync::CancellationToken; use stargate_proto::pb::stargate_control_plane_client::StargateControlPlaneClient; use stargate_proto::pb::{WatchStargatesRequest, WatchStargatesResponse}; +use stargate_protocol::parse_explicit_http_uri; use stargate_runtime::{OwnedTask, TASK_SHUTDOWN_TIMEOUT}; +use tracing::warn; use super::grpc_endpoint::{StargateGrpcEndpoint, log_stargate_grpc_connect_attempt}; use super::topology::{RegistrationRouterTopology, publish_registration_router_topology}; @@ -239,7 +241,7 @@ pub(super) fn apply_watch_endpoint_update( } pub(super) fn watch_endpoint_snapshot_from_response( - _watch_url: &str, + watch_url: &str, response: WatchStargatesResponse, ) -> WatchEndpointSnapshot { WatchEndpointSnapshot { @@ -248,7 +250,27 @@ pub(super) fn watch_endpoint_snapshot_from_response( .into_iter() .filter_map(stargate_info_registration_router) .collect(), - watch_urls: normalize_string_set(response.watch_stargate_urls), + watch_urls: response + .watch_stargate_urls + .into_iter() + .enumerate() + .filter_map( + |(rejected_watch_url_index, remote_watch_url)| match parse_explicit_http_uri( + &remote_watch_url, + ) { + Ok(remote_watch_url) => Some(remote_watch_url), + Err(error) => { + warn!( + source_watch_url = watch_url, + rejected_watch_url_index, + %error, + "ignoring invalid recursive Stargate Watch URI" + ); + None + } + }, + ) + .collect(), } } diff --git a/src/libraries/rust/stargate/crates/pylon-lib/src/registration/grpc_endpoint.rs b/src/libraries/rust/stargate/crates/pylon-lib/src/registration/grpc_endpoint.rs index 78dfc2240..341f3f83c 100644 --- a/src/libraries/rust/stargate/crates/pylon-lib/src/registration/grpc_endpoint.rs +++ b/src/libraries/rust/stargate/crates/pylon-lib/src/registration/grpc_endpoint.rs @@ -16,6 +16,7 @@ use std::fmt; use anyhow::Context; +use stargate_protocol::parse_explicit_http_uri; use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint}; use super::normalize_addr; @@ -39,7 +40,7 @@ impl StargateGrpcEndpoint { let dial_addr = if dial_addr.is_empty() { authority_addr.clone() } else { - dial_addr + parse_explicit_http_uri(&dial_addr).ok()? }; Some(Self { authority_addr, @@ -85,6 +86,9 @@ impl StargateGrpcEndpoint { .ca_certificate(Certificate::from_pem(ca_cert_pem)), ) .context("configure custom CA for stargate gRPC endpoint")?, + (Some("http"), Some(_)) => { + anyhow::bail!("custom CA for stargate gRPC requires an HTTPS dial endpoint") + } _ => Endpoint::new(dial_uri).context("configure stargate gRPC endpoint")?, }; if let Some(origin) = origin { diff --git a/src/libraries/rust/stargate/crates/pylon-lib/src/registration/tests.rs b/src/libraries/rust/stargate/crates/pylon-lib/src/registration/tests.rs index 7a61d30cd..5af16b3c3 100644 --- a/src/libraries/rust/stargate/crates/pylon-lib/src/registration/tests.rs +++ b/src/libraries/rust/stargate/crates/pylon-lib/src/registration/tests.rs @@ -241,7 +241,7 @@ async fn tls_connect_error(server: &TestTlsControlPlane, ca_cert_pem: Option<&[u } fn grpc_endpoint(authority_addr: &str) -> StargateGrpcEndpoint { - StargateGrpcEndpoint::new(authority_addr.to_string(), authority_addr.to_string()) + StargateGrpcEndpoint::new(authority_addr.to_string(), "") .expect("test endpoint authority should be non-empty") } @@ -522,10 +522,28 @@ fn reverse_tunnel_config_uses_registration_upstream_and_preserves_forwarding() { #[test] fn stargate_grpc_endpoint_rejects_empty_authority_and_formats_dial_overrides() { - assert!(StargateGrpcEndpoint::new(" ", "stargate-grpc-lb:443").is_none()); + assert!(StargateGrpcEndpoint::new(" ", "https://stargate-grpc-lb:443").is_none()); + assert!(StargateGrpcEndpoint::new("router-a:50071", "stargate-grpc-lb:443").is_none()); assert_eq!( - grpc_endpoint_with_dial("router-a:50071", "stargate-grpc-lb:443").to_string(), - "router-a:50071 via stargate-grpc-lb:443" + grpc_endpoint_with_dial("router-a:50071", "https://stargate-grpc-lb:443").to_string(), + "router-a:50071 via https://stargate-grpc-lb:443" + ); +} + +#[test] +fn stargate_grpc_endpoint_rejects_custom_ca_for_plaintext_http() { + let endpoint = grpc_endpoint_with_dial("router-a:50071", "http://stargate-grpc-lb:50071"); + + let error = endpoint + .channel_endpoint(Some(b"private CA contents must not be logged")) + .err() + .expect("custom CA with plaintext HTTP should be rejected"); + + assert!( + error + .to_string() + .contains("custom CA for stargate gRPC requires an HTTPS dial endpoint"), + "unexpected error: {error:#}" ); } @@ -745,9 +763,9 @@ fn watch_response_separates_registration_routers_from_recursive_seeds() { stargates: vec![stargate_info( "stargate-0", "stargate-0.region-a:50071", - "lb.region-a:443", + "https://lb.region-a:443", )], - watch_stargate_urls: vec!["stargate.region-b:50071".to_string()], + watch_stargate_urls: vec!["https://stargate.region-b:50071".to_string()], }, ); @@ -755,12 +773,37 @@ fn watch_response_separates_registration_routers_from_recursive_seeds() { snapshot.registration_routers, BTreeMap::from([( "stargate-0".to_string(), - grpc_endpoint_with_dial("stargate-0.region-a:50071", "lb.region-a:443") + grpc_endpoint_with_dial("stargate-0.region-a:50071", "https://lb.region-a:443") )]) ); assert_eq!( snapshot.watch_urls, - BTreeSet::from(["stargate.region-b:50071".to_string()]) + BTreeSet::from(["https://stargate.region-b:50071".to_string()]) + ); +} + +#[test] +fn watch_response_rejects_non_uri_recursive_seeds() { + let snapshot = watch_endpoint_snapshot_from_response( + "seed-a", + WatchStargatesResponse { + stargates: vec![], + watch_stargate_urls: vec![ + "https://stargate.region-b:50071".to_string(), + " http://127.0.0.1:50071 ".to_string(), + "stargate.region-c:50071".to_string(), + "ftp://stargate.region-d:50071".to_string(), + "https://".to_string(), + ], + }, + ); + + assert_eq!( + snapshot.watch_urls, + BTreeSet::from([ + "http://127.0.0.1:50071".to_string(), + "https://stargate.region-b:50071".to_string(), + ]) ); } diff --git a/src/libraries/rust/stargate/crates/stargate-k8s-router/src/grpc.rs b/src/libraries/rust/stargate/crates/stargate-k8s-router/src/grpc.rs index 6bc63243c..3dc44d378 100644 --- a/src/libraries/rust/stargate/crates/stargate-k8s-router/src/grpc.rs +++ b/src/libraries/rust/stargate/crates/stargate-k8s-router/src/grpc.rs @@ -13,6 +13,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +use std::collections::BTreeSet; use std::pin::Pin; use std::time::Duration; @@ -61,6 +62,7 @@ pub struct GrpcRouterConfig { pub advertised_hostname_template: String, pub advertised_grpc_port: u16, pub grpc_pylon_dial_addr: String, + pub remote_watch_urls: Vec, pub target_namespace: String, pub connect_timeout: Duration, pub watch_heartbeat_interval: Duration, @@ -72,6 +74,7 @@ pub struct RouterControlPlane { advertised_hostname_template: String, advertised_grpc_port: u16, grpc_pylon_dial_addr: String, + remote_watch_urls: Vec, target_namespace: String, watch_heartbeat_interval: Duration, hostname_matcher: Option, @@ -89,6 +92,12 @@ impl RouterControlPlane { advertised_hostname_template: config.advertised_hostname_template, advertised_grpc_port: config.advertised_grpc_port, grpc_pylon_dial_addr: config.grpc_pylon_dial_addr, + remote_watch_urls: config + .remote_watch_urls + .into_iter() + .collect::>() + .into_iter() + .collect(), target_namespace: config.target_namespace, watch_heartbeat_interval: config.watch_heartbeat_interval, hostname_matcher, @@ -101,6 +110,7 @@ impl RouterControlPlane { let advertised_hostname_template = self.advertised_hostname_template.clone(); let advertised_grpc_port = self.advertised_grpc_port; let grpc_pylon_dial_addr = self.grpc_pylon_dial_addr.clone(); + let remote_watch_urls = self.remote_watch_urls.clone(); let target_namespace = self.target_namespace.clone(); let watch_heartbeat_interval = self.watch_heartbeat_interval; Box::pin(async_stream::try_stream! { @@ -118,6 +128,7 @@ impl RouterControlPlane { advertised_grpc_port, &grpc_pylon_dial_addr, &target_namespace, + &remote_watch_urls, ); } loop { @@ -139,6 +150,7 @@ impl RouterControlPlane { advertised_grpc_port, &grpc_pylon_dial_addr, &target_namespace, + &remote_watch_urls, ); } } @@ -260,6 +272,7 @@ fn watch_response_from_snapshot( advertised_grpc_port: u16, grpc_pylon_dial_addr: &str, target_namespace: &str, + remote_watch_urls: &[String], ) -> WatchStargatesResponse { let stargates = snapshot .ready_targets() @@ -281,7 +294,7 @@ fn watch_response_from_snapshot( .collect(); WatchStargatesResponse { stargates, - watch_stargate_urls: Vec::new(), + watch_stargate_urls: remote_watch_urls.to_vec(), } } @@ -533,6 +546,7 @@ mod tests { advertised_hostname_template: "{pod_name}.stargate.external".to_string(), advertised_grpc_port: 50071, grpc_pylon_dial_addr: "https://stargate-router.external:443".to_string(), + remote_watch_urls: Vec::new(), target_namespace: String::new(), connect_timeout: Duration::from_secs(2), watch_heartbeat_interval: Duration::from_secs(5), @@ -614,10 +628,12 @@ mod tests { let recorder_b = Recorder::default(); let fake_a = start_fake_stargate("stargate-0", recorder_a.clone()).await; let fake_b = start_fake_stargate("stargate-1", recorder_b.clone()).await; - let router = start_router(snapshot(&[ - ("stargate-0", fake_a.addr), - ("stargate-1", fake_b.addr), - ])) + let mut config = router_config(); + config.remote_watch_urls = vec!["https://region-b.example.test:50071".to_string()]; + let router = start_router_with_config( + snapshot(&[("stargate-0", fake_a.addr), ("stargate-1", fake_b.addr)]), + config, + ) .await; let mut client = router.client("stargate.stargate-local.svc.cluster.local"); @@ -641,6 +657,10 @@ mod tests { assert!(first.stargates.iter().all(|stargate| { stargate.grpc_pylon_dial_addr == "https://stargate-router.external:443" })); + assert_eq!( + first.watch_stargate_urls, + ["https://region-b.example.test:50071"] + ); assert_eq!(recorder_a.watch_hits.load(Ordering::Relaxed), 0); assert_eq!(recorder_b.watch_hits.load(Ordering::Relaxed), 0); } @@ -717,6 +737,7 @@ mod tests { .to_string(), advertised_grpc_port: 50071, grpc_pylon_dial_addr: "https://stargate-router.external:443".to_string(), + remote_watch_urls: Vec::new(), target_namespace: "prod".to_string(), connect_timeout: Duration::from_secs(2), watch_heartbeat_interval: Duration::from_secs(5), diff --git a/src/libraries/rust/stargate/crates/stargate-k8s-router/src/main.rs b/src/libraries/rust/stargate/crates/stargate-k8s-router/src/main.rs index d13dfe2bf..b406269a4 100644 --- a/src/libraries/rust/stargate/crates/stargate-k8s-router/src/main.rs +++ b/src/libraries/rust/stargate/crates/stargate-k8s-router/src/main.rs @@ -29,6 +29,7 @@ use stargate_k8s_router::metrics::RouterMetrics; use stargate_k8s_router::quic::{QuicRouterConfig, serve_quic_router}; use stargate_k8s_router::watcher::run_endpoint_slice_watcher; use stargate_k8s_router::webtransport::{WebTransportRouterConfig, serve_webtransport_router}; +use stargate_protocol::parse_explicit_http_uri; use stargate_runtime::{ CriticalTaskFailureReceiver, CriticalTaskGroup, wait_for_termination_signal, }; @@ -74,6 +75,22 @@ struct Args { grpc_pylon_dial_addr: String, #[arg(long, default_value_t = 50071, value_name = "PORT")] advertised_grpc_port: u16, + /// Additional recursive WatchStargates endpoints for remote regions. Repeatable. + #[arg( + long, + env = "STARGATE_REMOTE_WATCH_URLS", + value_delimiter = ',', + value_parser = parse_remote_watch_url, + value_name = "URI" + )] + remote_stargate_url: Vec, + /// Permit explicit plaintext HTTP remote Watch endpoints for development only. + #[arg( + long, + default_value_t = false, + env = "STARGATE_ALLOW_INSECURE_REMOTE_WATCH_HTTP" + )] + allow_insecure_remote_watch_http: bool, #[arg(long, default_value = "grpc", value_name = "NAME")] grpc_port_name: String, #[arg(long, default_value = "quic", value_name = "NAME")] @@ -109,6 +126,11 @@ struct RouterStartupConfig { tunnel: RouterTunnelConfig, } +fn parse_remote_watch_url(value: &str) -> std::result::Result { + parse_explicit_http_uri(value) + .map_err(|_| "remote Watch URL must be an explicit http:// or https:// URI".to_string()) +} + /// One wire protocol per UDP listener, preventing Raw QUIC and WebTransport settings from mixing. enum RouterTunnelConfig { RawQuic(QuicRouterConfig), @@ -117,10 +139,12 @@ enum RouterTunnelConfig { impl RouterStartupConfig { fn from_args(args: Args) -> Result { - ensure!( - !args.grpc_pylon_dial_addr.trim().is_empty(), - "--grpc-pylon-dial-addr must not be empty" - ); + let grpc_pylon_dial_addr = + parse_explicit_http_uri(&args.grpc_pylon_dial_addr).map_err(|_| { + anyhow::anyhow!( + "--grpc-pylon-dial-addr must be an explicit http:// or https:// URI" + ) + })?; ensure!( args.advertised_grpc_port > 0, "--advertised-grpc-port must be greater than 0" @@ -129,6 +153,14 @@ impl RouterStartupConfig { args.watch_heartbeat_ms > 0, "--watch-heartbeat-ms must be greater than 0" ); + ensure!( + args.allow_insecure_remote_watch_http + || !args + .remote_stargate_url + .iter() + .any(|url| url.starts_with("http://")), + "http:// remote Watch URLs require --allow-insecure-remote-watch-http" + ); let relay_endpoint_config = relay_endpoint_config_from_args(&args)?; let server_identity_reloader = server_identity_reloader_from_args(&args)?; // Read the mounted pair once. The reloader validated and owns these @@ -147,7 +179,8 @@ impl RouterStartupConfig { let grpc = GrpcRouterConfig { advertised_hostname_template: args.advertised_hostname_template.clone(), advertised_grpc_port: args.advertised_grpc_port, - grpc_pylon_dial_addr: args.grpc_pylon_dial_addr, + grpc_pylon_dial_addr, + remote_watch_urls: args.remote_stargate_url, target_namespace: args.target_namespace.clone(), connect_timeout: Duration::from_millis(args.connect_timeout_ms), watch_heartbeat_interval: Duration::from_millis(args.watch_heartbeat_ms), @@ -364,6 +397,7 @@ fn log_startup(config: &RouterStartupConfig) { advertised_hostname_template = %config.grpc.advertised_hostname_template, advertised_grpc_port = config.grpc.advertised_grpc_port, grpc_pylon_dial_addr = %config.grpc.grpc_pylon_dial_addr, + remote_watch_url_count = config.grpc.remote_watch_urls.len(), grpc_port_name = %config.target_build_config.grpc_port_name, quic_port_name = %config.target_build_config.quic_port_name, connect_timeout_ms = config.grpc.connect_timeout.as_millis(), @@ -494,7 +528,17 @@ mod tests { .expect("empty Pylon dial address must be rejected"); assert_eq!( empty.to_string(), - "--grpc-pylon-dial-addr must not be empty" + "--grpc-pylon-dial-addr must be an explicit http:// or https:// URI" + ); + + let mut scheme_less_args = router_args(&[]); + scheme_less_args.grpc_pylon_dial_addr = "stargate-router.example:443".to_string(); + let scheme_less = RouterStartupConfig::from_args(scheme_less_args) + .err() + .expect("scheme-less Pylon dial address must be rejected"); + assert_eq!( + scheme_less.to_string(), + "--grpc-pylon-dial-addr must be an explicit http:// or https:// URI" ); } @@ -509,6 +553,61 @@ mod tests { ); } + #[test] + fn router_cli_requires_explicit_permitted_remote_watch_uris() { + let secure = RouterStartupConfig::from_args( + Args::try_parse_from(router_argv(&[ + "--remote-stargate-url", + "https://region-b.example.test:50071", + ])) + .expect("explicit HTTPS Watch URI should parse"), + ) + .expect("explicit HTTPS Watch URI should be permitted"); + assert_eq!( + secure.grpc.remote_watch_urls, + ["https://region-b.example.test:50071"] + ); + + let plaintext = RouterStartupConfig::from_args(router_args(&[ + "--remote-stargate-url", + "http://127.0.0.1:50071", + ])) + .err() + .expect("plaintext Watch URI should require a development opt-in"); + assert_eq!( + plaintext.to_string(), + "http:// remote Watch URLs require --allow-insecure-remote-watch-http" + ); + + let development = startup_config(&[ + "--allow-insecure-remote-watch-http", + "--remote-stargate-url", + "http://127.0.0.1:50071", + ]); + assert_eq!( + development.grpc.remote_watch_urls, + ["http://127.0.0.1:50071"] + ); + + for invalid in [ + "region-b.example.test:50071", + "ftp://region-b.example.test:50071", + "https://", + ] { + let error = match Args::try_parse_from(router_argv(&["--remote-stargate-url", invalid])) + { + Ok(_) => panic!("non-HTTP(S) remote Watch endpoint should be rejected"), + Err(error) => error, + }; + assert!( + error + .to_string() + .contains("remote Watch URL must be an explicit http:// or https:// URI"), + "unexpected parse error: {error}" + ); + } + } + #[test] fn raw_quic_rejects_the_webtransport_only_upstream_trust_option() { let upstream_ca = test_file(b"upstream-ca-bytes"); diff --git a/src/libraries/rust/stargate/crates/stargate/src/control_plane/watch_stargates.rs b/src/libraries/rust/stargate/crates/stargate/src/control_plane/watch_stargates.rs index aac57468b..685a79a50 100644 --- a/src/libraries/rust/stargate/crates/stargate/src/control_plane/watch_stargates.rs +++ b/src/libraries/rust/stargate/crates/stargate/src/control_plane/watch_stargates.rs @@ -20,10 +20,11 @@ use std::time::{Duration, Instant}; use futures::{Stream, stream}; use tokio::sync::watch; use tonic::Status; -use tracing::debug; +use tracing::{debug, warn}; use url::Url; use stargate_proto::pb::{StargateInfo, WatchStargatesResponse}; +use stargate_protocol::parse_explicit_http_uri; use crate::discovery::Discovery; use stargate_runtime::CriticalTaskGroup; @@ -153,11 +154,18 @@ fn normalize_remote_watch_urls( excluded_endpoint_keys: &BTreeSet, ) -> Vec { let mut deduped: BTreeMap = BTreeMap::new(); - for raw_url in urls { - let url = raw_url.trim().to_string(); - if url.is_empty() { - continue; - } + for (rejected_watch_url_index, raw_url) in urls.into_iter().enumerate() { + let url = match parse_explicit_http_uri(&raw_url) { + Ok(url) => url, + Err(error) => { + warn!( + rejected_watch_url_index, + %error, + "ignoring invalid remote Stargate Watch URI" + ); + continue; + } + }; let key = watch_endpoint_key(&url).unwrap_or_else(|| url.clone()); if excluded_endpoint_keys.contains(&key) { continue; @@ -240,9 +248,9 @@ mod tests { fn watch_stargates_response_sorts_and_dedupes_local_and_remote_entries() { let remote_watch_urls = normalize_remote_watch_urls( vec![ - "remote-b.stargate:50071".to_string(), - "remote-a.stargate:50071".to_string(), - "remote-b.stargate:50071".to_string(), + "https://remote-b.stargate:50071".to_string(), + "https://remote-a.stargate:50071".to_string(), + "https://remote-b.stargate:50071".to_string(), ], &BTreeSet::new(), ); @@ -264,7 +272,10 @@ mod tests { assert_eq!(ids, vec!["stargate-0", "stargate-1"]); assert_eq!( response.watch_stargate_urls, - vec!["remote-a.stargate:50071", "remote-b.stargate:50071"] + vec![ + "https://remote-a.stargate:50071", + "https://remote-b.stargate:50071" + ] ); } @@ -289,12 +300,12 @@ mod tests { let response = build_watch_stargates_response( vec![stargate("stargate-0", "stargate-0.region-a:50071", "")], &[], - Some(" stargate-grpc-lb.region-a:443 "), + Some(" https://stargate-grpc-lb.region-a:443 "), ); assert_eq!( response.stargates[0].grpc_pylon_dial_addr, - "stargate-grpc-lb.region-a:443" + "https://stargate-grpc-lb.region-a:443" ); } @@ -306,19 +317,21 @@ mod tests { ); let urls = normalize_remote_watch_urls( vec![ - " remote-b:50071 ".to_string(), - "remote-a:50071".to_string(), - "remote-b:50071".to_string(), + " https://remote-b:50071 ".to_string(), + "http://remote-a:50071".to_string(), + "https://remote-b:50071".to_string(), String::new(), - "10.0.0.1:50071".to_string(), "http://10.0.0.1:50071".to_string(), - "stargate-headless.ns.svc.cluster.local:50071".to_string(), - "stargate.ns.svc.cluster.local:50071".to_string(), + "https://stargate-headless.ns.svc.cluster.local:50071".to_string(), + "http://stargate.ns.svc.cluster.local:50071".to_string(), ], &excluded, ); - assert_eq!(urls, vec!["remote-a:50071", "remote-b:50071"]); + assert_eq!( + urls, + vec!["http://remote-a:50071", "https://remote-b:50071"] + ); } #[test] diff --git a/src/libraries/rust/stargate/crates/stargate/src/main.rs b/src/libraries/rust/stargate/crates/stargate/src/main.rs index da89e9e0e..63bd207c5 100644 --- a/src/libraries/rust/stargate/crates/stargate/src/main.rs +++ b/src/libraries/rust/stargate/crates/stargate/src/main.rs @@ -20,6 +20,7 @@ use anyhow::{Context, Result}; use stargate::registration::{ DEFAULT_REGISTRATION_UPDATE_IDLE_TIMEOUT, DEFAULT_REGISTRATION_UPDATE_MAX_IDLE_TIMEOUT, }; +use stargate_protocol::parse_explicit_http_uri; use stargate_protocol::{BackendConnectivity, TunnelTransportProtocol}; use stargate_runtime::wait_for_termination_signal; use tracing::{error, info, warn}; @@ -53,6 +54,17 @@ fn parse_nonzero_usize(value: &str) -> std::result::Result { .ok_or_else(|| "value must be greater than 0".to_string()) } +fn parse_remote_watch_url(value: &str) -> std::result::Result { + parse_explicit_http_uri(value) + .map_err(|_| "remote Watch URL must be an explicit http:// or https:// URI".to_string()) +} + +fn parse_grpc_pylon_dial_uri(value: &str) -> std::result::Result { + parse_explicit_http_uri(value).map_err(|_| { + "Pylon gRPC dial address must be an explicit http:// or https:// URI".to_string() + }) +} + #[derive(clap::Parser, Debug)] #[command(name = "stargate")] struct Args { @@ -79,11 +91,19 @@ struct Args { long, env = "STARGATE_REMOTE_WATCH_URLS", value_delimiter = ',', + value_parser = parse_remote_watch_url, value_name = "URL" )] remote_stargate_url: Vec, + /// Permit explicit plaintext HTTP remote Watch endpoints for development only. + #[arg( + long, + default_value_t = false, + env = "STARGATE_ALLOW_INSECURE_REMOTE_WATCH_HTTP" + )] + allow_insecure_remote_watch_http: bool, /// Optional TCP load-balancer dial address for pylons; per-pod addresses remain the advertised gRPC authority/SNI identity. - #[arg(long, value_name = "ADDR")] + #[arg(long, value_parser = parse_grpc_pylon_dial_uri, value_name = "URI")] grpc_pylon_dial_addr: Option, /// Backend hostname template supporting `{pod_name}` and `{namespace}`; its rendered host is the pylon gRPC authority and reverse QUIC SNI. #[arg(long, value_name = "TEMPLATE")] @@ -717,7 +737,7 @@ mod tests { assert_eq!(defaults.reverse_tunnel_pylon_dial_addr, None); assert_eq!(defaults.grpc_pylon_dial_addr, None); let args = parse_args( - "--grpc-pylon-dial-addr stargate-grpc-lb.stargate.svc.cluster.local:443 \ + "--grpc-pylon-dial-addr https://stargate-grpc-lb.stargate.svc.cluster.local:443 \ --reverse-tunnel-listen-addr 0.0.0.0:50072 \ --reverse-tunnel-pylon-dial-addr stargate-quic-lb.stargate.svc.cluster.local:50072", ); @@ -727,9 +747,50 @@ mod tests { ); assert_eq!( args.grpc_pylon_dial_addr.as_deref(), - Some("stargate-grpc-lb.stargate.svc.cluster.local:443") + Some("https://stargate-grpc-lb.stargate.svc.cluster.local:443") + ); + + assert_parse_error( + "--grpc-pylon-dial-addr stargate-grpc-lb.stargate.svc.cluster.local:443", + "Pylon gRPC dial address must be an explicit http:// or https:// URI", ); } + + #[tokio::test] + async fn remote_watch_url_cli_requires_an_explicit_permitted_http_uri() { + let args = parse_args("--remote-stargate-url https://region-b.example.test:50071"); + assert_eq!( + args.remote_stargate_url, + ["https://region-b.example.test:50071"] + ); + + let plaintext = + parse_args("--remote-stargate-url http://127.0.0.1:50071 --disable-dns-discovery"); + let error = startup::validate_discovery_args(&plaintext) + .expect_err("plaintext Watch URI should require a development opt-in"); + assert_error_contains( + &error, + "http:// remote Watch URLs require --allow-insecure-remote-watch-http", + ); + + let development = parse_args( + "--allow-insecure-remote-watch-http \ + --remote-stargate-url http://127.0.0.1:50071", + ); + startup::validate_discovery_args(&development) + .expect("development HTTP opt-in should permit plaintext Watch URIs"); + + for invalid in [ + "region-b.example.test:50071", + "ftp://region-b.example.test:50071", + "https://", + ] { + assert_parse_error( + &format!("--remote-stargate-url {invalid}"), + "remote Watch URL must be an explicit http:// or https:// URI", + ); + } + } fn test_resolver(_: Duration) -> Result { Ok(hickory_resolver::TokioAsyncResolver::tokio( Default::default(), diff --git a/src/libraries/rust/stargate/crates/stargate/src/main/startup.rs b/src/libraries/rust/stargate/crates/stargate/src/main/startup.rs index 7c79ecd8f..3e09caf9a 100644 --- a/src/libraries/rust/stargate/crates/stargate/src/main/startup.rs +++ b/src/libraries/rust/stargate/crates/stargate/src/main/startup.rs @@ -259,6 +259,14 @@ pub(super) fn make_discovery_with_resolver_and_addresses( } pub(super) fn validate_discovery_args(args: &Args) -> Result<()> { + ensure!( + args.allow_insecure_remote_watch_http + || !args + .remote_stargate_url + .iter() + .any(|url| url.starts_with("http://")), + "http:// remote Watch URLs require --allow-insecure-remote-watch-http" + ); ensure!( !(args.disable_dns_discovery && args.enable_dev_peer_forwarding), "--enable-dev-peer-forwarding cannot be combined with --disable-dns-discovery" diff --git a/src/libraries/rust/stargate/crates/stargate/src/runtime.rs b/src/libraries/rust/stargate/crates/stargate/src/runtime.rs index d2f6b8eef..17295cdce 100644 --- a/src/libraries/rust/stargate/crates/stargate/src/runtime.rs +++ b/src/libraries/rust/stargate/crates/stargate/src/runtime.rs @@ -23,7 +23,7 @@ use anyhow::{Context, Result, ensure}; use tracing::info; use stargate_forwarding::ForwardingResolver; -use stargate_protocol::BackendConnectivity; +use stargate_protocol::{BackendConnectivity, parse_explicit_http_uri}; pub use stargate_runtime::CriticalTaskFailure; use stargate_runtime::CriticalTaskGroup; @@ -240,6 +240,18 @@ impl StargateRuntime { } pub async fn start(self) -> Result { + if let Some(grpc_pylon_dial_addr) = self + .config + .grpc_pylon_dial_addr + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + ensure!( + parse_explicit_http_uri(grpc_pylon_dial_addr).is_ok(), + "Pylon gRPC dial address must be an explicit http:// or https:// URI" + ); + } let grpc_listen_addr = self.listeners.grpc_addr(); let model_discovery_listen_addr = self.listeners.model_discovery_addr(); let http_listen_addr = self.listeners.http_addr(); @@ -670,6 +682,41 @@ mod tests { assert!(error.to_string().contains("gRPC")); } + #[tokio::test] + async fn runtime_rejects_a_scheme_less_pylon_grpc_dial_override() { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + + let active_calls = Arc::new(AtomicUsize::new(0)); + let mut config = test_runtime_config("test-invalid-pylon-grpc-dial"); + config.grpc_pylon_dial_addr = Some("stargate-router.example:443".to_string()); + let listeners = + BoundStargateListeners::bind(&mut config).expect("test listeners should bind"); + let runtime = StargateRuntime::new( + config, + Box::new(BlockingDiscovery { + active_calls, + self_info: StargateInfo::default(), + }), + listeners, + None, + ); + + let error = match runtime.start().await { + Ok(handle) => { + handle.begin_shutdown(); + let _ = handle.wait_for_shutdown(Duration::from_secs(2)).await; + panic!("scheme-less Pylon gRPC dial override should fail startup") + } + Err(error) => error, + }; + assert!( + error + .to_string() + .contains("Pylon gRPC dial address must be an explicit http:// or https:// URI"), + "unexpected error: {error}" + ); + } + #[tokio::test] async fn shutdown_cancels_in_flight_discovery_poll() { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); diff --git a/src/libraries/rust/stargate/crates/stargate/tests/suite/model_routing.rs b/src/libraries/rust/stargate/crates/stargate/tests/suite/model_routing.rs index fc0097e6c..f3166c86f 100644 --- a/src/libraries/rust/stargate/crates/stargate/tests/suite/model_routing.rs +++ b/src/libraries/rust/stargate/crates/stargate/tests/suite/model_routing.rs @@ -48,7 +48,10 @@ impl GlobalWatchNode { make_stargate_runtime_with_shared_discovery_and_remote_watch_urls( id, peers, - remote.into_iter().map(|addr| addr.to_string()).collect(), + remote + .into_iter() + .map(|addr| format!("http://{addr}")) + .collect(), ); Self { grpc_addr, diff --git a/src/libraries/rust/stargate/crates/stargate/tests/suite/stats_discovery.rs b/src/libraries/rust/stargate/crates/stargate/tests/suite/stats_discovery.rs index 5eb2c8341..315fe250b 100644 --- a/src/libraries/rust/stargate/crates/stargate/tests/suite/stats_discovery.rs +++ b/src/libraries/rust/stargate/crates/stargate/tests/suite/stats_discovery.rs @@ -166,9 +166,11 @@ async fn watch_stargates_returns_remote_watch_urls_without_remote_registration_t "test-sg-watch-remote", peers, vec![ - " remote-b:50071 ".to_string(), - "remote-a:50071".to_string(), - "remote-b:50071".to_string(), + " https://remote-b:50071 ".to_string(), + "http://remote-a:50071".to_string(), + "https://remote-b:50071".to_string(), + "remote-c:50071".to_string(), + "ftp://remote-d:50071".to_string(), String::new(), ], ); @@ -180,7 +182,7 @@ async fn watch_stargates_returns_remote_watch_urls_without_remote_registration_t assert_eq!(msg.stargates[0].stargate_id, "test-sg-watch-remote"); assert_eq!( msg.watch_stargate_urls, - vec!["remote-a:50071", "remote-b:50071"] + vec!["http://remote-a:50071", "https://remote-b:50071"] ); shutdown([handle]).await;