diff --git a/components/control-plane/internal/gateway/console.go b/components/control-plane/internal/gateway/console.go index 9e724cafb..754447dc0 100644 --- a/components/control-plane/internal/gateway/console.go +++ b/components/control-plane/internal/gateway/console.go @@ -830,6 +830,35 @@ func reconcileConsoleExposure(ctx context.Context, dynamicClient dynamic.Interfa inactiveKind = "OpenShift Route" case IngressModeRoute: desired = buildConsoleOpenShiftRoute(namespace, host) + // Give the console Route its own publicly-trusted certificate when + // GATEWAY_ROUTE_TLS_ISSUER names a cert-manager ClusterIssuer. + // + // Without this the Route rides the router default certificate, which is + // only valid for the cluster ingress domain. Clusters whose gateway hosts + // live under a CUSTOM domain (e.g. *.gwlb.. on IBM ROKS) + // therefore serve a name-mismatched certificate and every browser refuses + // the console. Gateway-API clusters do not hit this because a wildcard + // certificate on the shared Gateway listener already covers the host, and + // HTTP-01 cannot issue wildcards -- so a per-host certificate is the only + // option where DNS-01 is unavailable. + if issuer := routeTLSIssuer(); issuer != "" { + annotateRouteForIssuer(desired, issuer) + // reconcileResource replaces the whole Route, and the spec built above + // intentionally omits certificate/key: openshift-routes co-owns this + // Route and injects them. Carry any injection forward, otherwise every + // reconcile strips the certificate and the console flaps. + if cert, key := readInjectedRouteCert(ctx, dynamicClient, namespace, consoleName); cert != "" { + tls, _, _ := unstructured.NestedMap(desired.Object, "spec", "tls") + if tls == nil { + tls = map[string]interface{}{} + } + tls["certificate"] = cert + if key != "" { + tls["key"] = key + } + _ = unstructured.SetNestedMap(desired.Object, tls, "spec", "tls") + } + } inactiveGVR = consoleHTTPRouteGVR inactiveKind = "HTTPRoute" default: @@ -1005,3 +1034,17 @@ func deleteConsole(ctx context.Context, dynamicClient dynamic.Interface, clients return errors.Join(errs...) } + +// annotateRouteForIssuer marks a Route for the cert-manager openshift-routes +// controller, which mints a certificate from the named ClusterIssuer and +// injects it into spec.tls.{certificate,key}. +func annotateRouteForIssuer(route *unstructured.Unstructured, issuer string) { + annotations, _, _ := unstructured.NestedStringMap(route.Object, "metadata", "annotations") + if annotations == nil { + annotations = map[string]string{} + } + annotations["cert-manager.io/issuer-name"] = issuer + annotations["cert-manager.io/issuer-kind"] = "ClusterIssuer" + annotations["cert-manager.io/issuer-group"] = "cert-manager.io" + _ = unstructured.SetNestedStringMap(route.Object, annotations, "metadata", "annotations") +} diff --git a/components/control-plane/internal/gateway/console_test.go b/components/control-plane/internal/gateway/console_test.go index f669592b3..a252d37dd 100644 --- a/components/control-plane/internal/gateway/console_test.go +++ b/components/control-plane/internal/gateway/console_test.go @@ -640,3 +640,79 @@ func TestReconcileConsoleSecret_PreservesCookieOnUpdate(t *testing.T) { t.Errorf("client-secret = %q, want refreshed %q", v, "client-v2") } } + +// TestReconcileConsoleExposureTLSIssuer covers GATEWAY_ROUTE_TLS_ISSUER on the +// console Route. Without a per-host certificate the console rides the router +// default certificate, which only covers the cluster ingress domain -- so on a +// custom gateway domain every browser rejects the console. +func TestReconcileConsoleExposureTLSIssuer(t *testing.T) { + const ( + namespace = "openshell-abc" + host = "console-openshell-abc.gwlb.hyp8.example.com" + ) + + t.Run("issuer annotates the console Route", func(t *testing.T) { + t.Setenv("GATEWAY_ROUTE_TLS_ISSUER", "letsencrypt-http01") + dc := newConsoleRouteDynamicClient() + + if err := reconcileConsoleExposure(context.Background(), dc, namespace, host, IngressModeRoute); err != nil { + t.Fatalf("reconcileConsoleExposure: %v", err) + } + + route, err := dc.Resource(consoleOpenShiftRouteGVR).Namespace(namespace).Get(context.Background(), consoleName, metav1.GetOptions{}) + if err != nil { + t.Fatalf("get console Route: %v", err) + } + ann, _, _ := unstructured.NestedStringMap(route.Object, "metadata", "annotations") + if ann["cert-manager.io/issuer-name"] != "letsencrypt-http01" { + t.Errorf("issuer-name annotation = %q, want letsencrypt-http01", ann["cert-manager.io/issuer-name"]) + } + if ann["cert-manager.io/issuer-kind"] != "ClusterIssuer" { + t.Errorf("issuer-kind annotation = %q, want ClusterIssuer", ann["cert-manager.io/issuer-kind"]) + } + }) + + t.Run("no issuer leaves the Route unannotated", func(t *testing.T) { + t.Setenv("GATEWAY_ROUTE_TLS_ISSUER", "") + dc := newConsoleRouteDynamicClient() + + if err := reconcileConsoleExposure(context.Background(), dc, namespace, host, IngressModeRoute); err != nil { + t.Fatalf("reconcileConsoleExposure: %v", err) + } + + route, err := dc.Resource(consoleOpenShiftRouteGVR).Namespace(namespace).Get(context.Background(), consoleName, metav1.GetOptions{}) + if err != nil { + t.Fatalf("get console Route: %v", err) + } + ann, _, _ := unstructured.NestedStringMap(route.Object, "metadata", "annotations") + if _, ok := ann["cert-manager.io/issuer-name"]; ok { + t.Errorf("unexpected issuer annotation: %v", ann) + } + }) + + // reconcileResource replaces the whole Route and the built spec omits + // certificate/key, which openshift-routes owns. Losing the injection on each + // reconcile would make the console flap between a valid and a default cert. + t.Run("carries forward an injected certificate", func(t *testing.T) { + t.Setenv("GATEWAY_ROUTE_TLS_ISSUER", "letsencrypt-http01") + existing := buildConsoleOpenShiftRoute(namespace, host) + _ = unstructured.SetNestedField(existing.Object, "CERT", "spec", "tls", "certificate") + _ = unstructured.SetNestedField(existing.Object, "KEY", "spec", "tls", "key") + existing.SetGroupVersionKind(consoleOpenShiftRouteGVR.GroupVersion().WithKind("Route")) + dc := newConsoleRouteDynamicClient(existing) + + if err := reconcileConsoleExposure(context.Background(), dc, namespace, host, IngressModeRoute); err != nil { + t.Fatalf("reconcileConsoleExposure: %v", err) + } + + route, err := dc.Resource(consoleOpenShiftRouteGVR).Namespace(namespace).Get(context.Background(), consoleName, metav1.GetOptions{}) + if err != nil { + t.Fatalf("get console Route: %v", err) + } + cert, _, _ := unstructured.NestedString(route.Object, "spec", "tls", "certificate") + key, _, _ := unstructured.NestedString(route.Object, "spec", "tls", "key") + if cert != "CERT" || key != "KEY" { + t.Errorf("injected cert not carried forward: cert=%q key=%q", cert, key) + } + }) +} diff --git a/components/control-plane/internal/gateway/reconciler.go b/components/control-plane/internal/gateway/reconciler.go index 09a888c28..37fa843a4 100644 --- a/components/control-plane/internal/gateway/reconciler.go +++ b/components/control-plane/internal/gateway/reconciler.go @@ -621,7 +621,7 @@ func reconcileRouteResources(ctx context.Context, dynamicClient dynamic.Interfac // co-owns this Route (we own termination + destinationCACertificate, it // owns the edge cert), so carry forward any certificate/key it has already // injected -- otherwise each reconcile strips the cert and flaps h2. - if cert, key := readInjectedRouteCert(ctx, dynamicClient, namespace); cert != "" { + if cert, key := readInjectedRouteCert(ctx, dynamicClient, namespace, gatewayRouteName); cert != "" { tlsConfig["certificate"] = cert if key != "" { tlsConfig["key"] = key @@ -1663,6 +1663,11 @@ const ( const ( RouteTerminationPassthrough = "passthrough" RouteTerminationReencrypt = "reencrypt" + + // gatewayRouteName is the OpenShift Route serving the gateway itself. + // openshift-routes injects the issued certificate into it, and each reconcile + // must carry that injection forward (see readInjectedRouteCert). + gatewayRouteName = "openshell-gateway" ) // routeTermination resolves the Route TLS termination for the "route" ingress @@ -1692,9 +1697,9 @@ func routeTLSIssuer() string { // The route reconcile does a full replace, so reconcileRouteResources carries // these forward to avoid clobbering the injected edge certificate (which would // flap ALPN h2, and hence gRPC, on every reconcile). -func readInjectedRouteCert(ctx context.Context, dynamicClient dynamic.Interface, namespace string) (string, string) { +func readInjectedRouteCert(ctx context.Context, dynamicClient dynamic.Interface, namespace, routeName string) (string, string) { gvr := schema.GroupVersionResource{Group: "route.openshift.io", Version: "v1", Resource: "routes"} - existing, err := dynamicClient.Resource(gvr).Namespace(namespace).Get(ctx, "openshell-gateway", metav1.GetOptions{}) + existing, err := dynamicClient.Resource(gvr).Namespace(namespace).Get(ctx, routeName, metav1.GetOptions{}) if err != nil { return "", "" } diff --git a/tests/e2e/drivers/kind.sh b/tests/e2e/drivers/kind.sh index c88a9d32b..99f9a22b3 100755 --- a/tests/e2e/drivers/kind.sh +++ b/tests/e2e/drivers/kind.sh @@ -12,6 +12,15 @@ # Kind uses locally issued certificates. Other drivers may override this seam # while reusing the production OIDC and role-assignment behavior below. +# Auto-detect container engine (docker or podman) if not explicitly set. +if [[ -z "${CONTAINER_ENGINE:-}" ]]; then + if command -v podman &>/dev/null; then + export CONTAINER_ENGINE=podman + elif command -v docker &>/dev/null; then + export CONTAINER_ENGINE=docker + fi +fi + # Kind's self-signed CA is not in the system trust store. Instruct the # openshell CLI to skip TLS verification for gateway connections. curl already # uses -sk (insecure) for all driver requests; this extends the same treatment @@ -76,18 +85,33 @@ _kind_start_gw_socat() { _KINDCCM_GW_PORT="${socat_port}" } _driver_curl() { - _kind_discover_port - local connect_args=() - if [[ -n "${_KINDCCM_PORT}" && "${_KINDCCM_PORT}" != "443" ]]; then - connect_args+=( - --connect-to "api.hypershell.localhost:443:127.0.0.1:${_KINDCCM_PORT}" - --connect-to "keycloak.hypershell.localhost:443:127.0.0.1:${_KINDCCM_PORT}" - --connect-to "console.hypershell.localhost:443:127.0.0.1:${_KINDCCM_PORT}" - --connect-to "health.hypershell.localhost:443:127.0.0.1:${_KINDCCM_PORT}" - --connect-to "observability.hypershell.localhost:443:127.0.0.1:${_KINDCCM_PORT}" - ) + # Try direct HTTPRoute access first. This works in most setups where the + # routes are directly accessible on port 443 (docker, podman, or iptables- + # forwarded 443). If direct access fails (connection refused/timeout), fall + # back to port-remapping via the ephemeral kindccm-gw port. + local output status + output=$(curl -sk --ipv4 "$@" 2>&1) + status=$? + + # If direct access failed due to connection error, try port-remapping + if [[ $status -eq 7 ]]; then + _kind_discover_port + local connect_args=() + if [[ -n "${_KINDCCM_PORT}" && "${_KINDCCM_PORT}" != "443" ]]; then + connect_args+=( + --connect-to "api.hypershell.localhost:443:127.0.0.1:${_KINDCCM_PORT}" + --connect-to "keycloak.hypershell.localhost:443:127.0.0.1:${_KINDCCM_PORT}" + --connect-to "console.hypershell.localhost:443:127.0.0.1:${_KINDCCM_PORT}" + --connect-to "health.hypershell.localhost:443:127.0.0.1:${_KINDCCM_PORT}" + --connect-to "observability.hypershell.localhost:443:127.0.0.1:${_KINDCCM_PORT}" + ) + output=$(curl -sk --ipv4 "${connect_args[@]}" "$@" 2>&1) + status=$? + fi fi - curl -sk --ipv4 "${connect_args[@]}" "$@" + + echo "$output" + return $status } # discover_api_host - find the HyperShell API server base URL.