diff --git a/hack/dev-manifests/uds-operator-config-secret.yaml b/hack/dev-manifests/uds-operator-config-secret.yaml index 6a3921da37..13aef24ddd 100644 --- a/hack/dev-manifests/uds-operator-config-secret.yaml +++ b/hack/dev-manifests/uds-operator-config-secret.yaml @@ -9,3 +9,4 @@ metadata: type: Opaque stringData: devMode: "active" + KEYCLOAK_CLIENT_MODE: "CLIENT_SECRET" diff --git a/schemas/clusterconfig-v1alpha1.schema.json b/schemas/clusterconfig-v1alpha1.schema.json index 1b94bff1c0..c308e63a7b 100644 --- a/schemas/clusterconfig-v1alpha1.schema.json +++ b/schemas/clusterconfig-v1alpha1.schema.json @@ -105,9 +105,25 @@ "type": "string", "description": "Domain all cluster services on the admin gateway will be exposed on" }, + "adminContextPath": { + "type": "string", + "description": "Optional admin URL context path appended after contextPath when pathRouting is enabled" + }, + "contextPath": { + "type": "string", + "description": "Optional public URL context path used when pathRouting is enabled, for example /bar" + }, "domain": { "type": "string", "description": "Domain all cluster services will be exposed on" + }, + "pathRouting": { + "type": "boolean", + "description": "Route built-in public and admin services on a single host using URL paths" + }, + "subdomain": { + "type": "string", + "description": "Optional subdomain used with domain for single-host path routing, for example foo in foo.uds.dev" } }, "required": [ diff --git a/src/grafana/chart/templates/_helpers.tpl b/src/grafana/chart/templates/_helpers.tpl index 01c2b46237..4c09c75075 100644 --- a/src/grafana/chart/templates/_helpers.tpl +++ b/src/grafana/chart/templates/_helpers.tpl @@ -83,6 +83,7 @@ Create the name of the service account to use {{- fail "Missing remoteSelector or remoteNamespace for internal PostgreSQL." -}} {{- end }} {{- end }} + {{- if (empty (compact (values (omit .Values.postgresql "port" "internal" "ssl_mode")))) -}} {{- if .Values.postgresql.internal.enabled -}} {{- fail "Missing configuration for internal postgres host, database, user, and password." -}} @@ -97,3 +98,69 @@ Create the name of the service account to use {{- default "true" "" }} {{- end }} {{- end }} + +{{- define "grafana.pathRouting.enabled" -}} +{{- eq (toString .Values.pathRouting) "true" -}} +{{- end -}} + +{{- define "grafana.contextPath" -}} +{{- $path := .Values.contextPath | default "" -}} +{{- if or (eq $path "") (eq $path "/") (hasPrefix "###ZARF_VAR_" $path) -}} +{{- "" -}} +{{- else -}} +{{- $withLeading := ternary $path (printf "/%s" $path) (hasPrefix "/" $path) -}} +{{- trimSuffix "/" $withLeading -}} +{{- end -}} +{{- end -}} + +{{- define "grafana.adminContextPath" -}} +{{- $path := .Values.adminContextPath | default "/admin" -}} +{{- if hasPrefix "###ZARF_VAR_" $path -}} +{{- "/admin" -}} +{{- else -}} +{{- $withLeading := ternary $path (printf "/%s" $path) (hasPrefix "/" $path) -}} +{{- trimSuffix "/" $withLeading -}} +{{- end -}} +{{- end -}} + +{{- define "grafana.host" -}} +{{- $subdomain := .Values.subdomain | default "" -}} +{{- if or (eq $subdomain "") (hasPrefix "###ZARF_VAR_" $subdomain) -}} +{{- .Values.domain -}} +{{- else -}} +{{- printf "%s.%s" $subdomain .Values.domain -}} +{{- end -}} +{{- end -}} + +{{- define "grafana.adminHost" -}} +{{- $subdomain := .Values.subdomain | default "" -}} +{{- if or (eq $subdomain "") (hasPrefix "###ZARF_VAR_" $subdomain) -}} +{{- include "grafana.adminDomain" . -}} +{{- else -}} +{{- printf "%s.%s" $subdomain (include "grafana.adminDomain" .) -}} +{{- end -}} +{{- end -}} + +{{- define "grafana.adminDomain" -}} +{{- if .Values.adminDomain -}} +{{- tpl .Values.adminDomain . -}} +{{- else -}} +{{- printf "admin.%s" .Values.domain -}} +{{- end -}} +{{- end -}} + +{{- define "grafana.externalUrl" -}} +{{- if eq (include "grafana.pathRouting.enabled" .) "true" -}} +{{- printf "https://%s%s%s/grafana" (include "grafana.adminHost" .) (include "grafana.contextPath" .) (include "grafana.adminContextPath" .) -}} +{{- else -}} +{{- printf "https://grafana.%s" (include "grafana.adminDomain" .) -}} +{{- end -}} +{{- end -}} + +{{- define "grafana.ssoUrl" -}} +{{- if eq (include "grafana.pathRouting.enabled" .) "true" -}} +{{- printf "https://%s%s/sso" (include "grafana.host" .) (include "grafana.contextPath" .) -}} +{{- else -}} +{{- printf "https://sso.%s" .Values.domain -}} +{{- end -}} +{{- end -}} diff --git a/src/grafana/chart/templates/uds-package.yaml b/src/grafana/chart/templates/uds-package.yaml index e4c4e954c2..0e33d51a8f 100644 --- a/src/grafana/chart/templates/uds-package.yaml +++ b/src/grafana/chart/templates/uds-package.yaml @@ -12,13 +12,8 @@ spec: - name: Grafana clientId: uds-core-admin-grafana redirectUris: - {{- if .Values.adminDomain }} - - "https://grafana.{{ .Values.adminDomain }}/login/generic_oauth" - - "https://grafana.{{ .Values.adminDomain }}/login" - {{- else }} - - "https://grafana.admin.{{ .Values.domain }}/login/generic_oauth" - - "https://grafana.admin.{{ .Values.domain }}/login" - {{- end }} + - "{{ include "grafana.externalUrl" . }}/login/generic_oauth" + - "{{ include "grafana.externalUrl" . }}/login" {{- if .Values.sso.groups }} groups: anyOf: @@ -43,10 +38,17 @@ spec: - service: grafana selector: app.kubernetes.io/name: grafana - host: grafana + host: {{ .Values.subdomain | default "grafana" }} gateway: admin port: 80 targetPort: 3000 + {{- if eq (include "grafana.pathRouting.enabled" .) "true" }} + advancedHTTP: + match: + - name: grafana-admin-path + uri: + prefix: {{ printf "%s%s/grafana" (include "grafana.contextPath" .) (include "grafana.adminContextPath" .) | quote }} + {{- end }} {{- if .Values.uptime.enabled }} uptime: checks: @@ -133,4 +135,3 @@ spec: {{- with .Values.additionalNetworkAllow }} {{ toYaml . | nindent 6 }} {{- end }} - diff --git a/src/grafana/chart/tests/grafana_sso_groups_test.yaml b/src/grafana/chart/tests/grafana_sso_groups_test.yaml index 27d58bbe6d..e896f7777a 100644 --- a/src/grafana/chart/tests/grafana_sso_groups_test.yaml +++ b/src/grafana/chart/tests/grafana_sso_groups_test.yaml @@ -1,4 +1,4 @@ -# Copyright 2025 Defense Unicorns +# Copyright 2025-2026 Defense Unicorns # SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial # yaml-language-server: $schema=https://raw.githubusercontent.com/helm-unittest/helm-unittest/main/schema/helm-testsuite.json @@ -26,3 +26,22 @@ tests: - "/UDS Core/Admin" - "/UDS Core/Auditor" - "/UDS Core/Test" + + - it: should render path-routed redirect URIs and admin route + set: + pathRouting: true + subdomain: foo + contextPath: /bar + adminContextPath: /admin + template: uds-package.yaml + asserts: + - equal: + path: spec.sso[0].redirectUris + value: + - https://foo.admin.example.com/bar/admin/grafana/login/generic_oauth + - https://foo.admin.example.com/bar/admin/grafana/login + - equal: + path: spec.network.expose[0].advancedHTTP.match[0].uri.prefix + value: /bar/admin/grafana + - notExists: + path: spec.network.expose[0].advancedHTTP.rewrite diff --git a/src/grafana/chart/tests/grafana_uptime_toggle_test.yaml b/src/grafana/chart/tests/grafana_uptime_toggle_test.yaml index e60b846a34..b576787428 100644 --- a/src/grafana/chart/tests/grafana_uptime_toggle_test.yaml +++ b/src/grafana/chart/tests/grafana_uptime_toggle_test.yaml @@ -65,7 +65,6 @@ tests: path: spec.network.expose any: true content: - host: grafana gateway: admin service: grafana port: 80 diff --git a/src/grafana/chart/values.yaml b/src/grafana/chart/values.yaml index 33c5209357..a9f9a8c132 100644 --- a/src/grafana/chart/values.yaml +++ b/src/grafana/chart/values.yaml @@ -3,6 +3,10 @@ domain: "###ZARF_VAR_DOMAIN###" adminDomain: "###ZARF_VAR_ADMIN_DOMAIN###" +subdomain: "###ZARF_VAR_SUBDOMAIN###" +contextPath: "###ZARF_VAR_CONTEXT_PATH###" +adminContextPath: "###ZARF_VAR_ADMIN_CONTEXT_PATH###" +pathRouting: "###ZARF_VAR_PATH_ROUTING###" # optional extra datasources to be added to configmap extraDatasources: [] diff --git a/src/grafana/values/values.yaml b/src/grafana/values/values.yaml index bc11923310..01a09b7112 100644 --- a/src/grafana/values/values.yaml +++ b/src/grafana/values/values.yaml @@ -1,4 +1,4 @@ -# Copyright 2024 Defense Unicorns +# Copyright 2024-2026 Defense Unicorns # SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial sidecar: @@ -47,7 +47,8 @@ extraConfigmapMounts: grafana.ini: server: - root_url: https://grafana.{{ "###ZARF_VAR_ADMIN_DOMAIN###" | default "admin.###ZARF_VAR_DOMAIN###" }} + root_url: '{{ if eq (toString "###ZARF_VAR_PATH_ROUTING###") "true" }}https://{{ if and "###ZARF_VAR_SUBDOMAIN###" (not (hasPrefix "###ZARF_VAR_" "###ZARF_VAR_SUBDOMAIN###")) }}###ZARF_VAR_SUBDOMAIN###.{{ end }}{{ "###ZARF_VAR_ADMIN_DOMAIN###" | default "admin.###ZARF_VAR_DOMAIN###" }}{{ if and "###ZARF_VAR_CONTEXT_PATH###" (not (hasPrefix "###ZARF_VAR_" "###ZARF_VAR_CONTEXT_PATH###")) }}###ZARF_VAR_CONTEXT_PATH###{{ end }}{{ "###ZARF_VAR_ADMIN_CONTEXT_PATH###" | default "/admin" }}/grafana{{ else }}https://grafana.{{ "###ZARF_VAR_ADMIN_DOMAIN###" | default "admin.###ZARF_VAR_DOMAIN###" }}{{ end }}' + serve_from_sub_path: '{{ eq (toString "###ZARF_VAR_PATH_ROUTING###") "true" }}' # Disable telemetry that doesn't function in the airgap analytics: reporting_enabled: false @@ -70,10 +71,10 @@ grafana.ini: login_attribute_path: preferred_username name_attribute_path: name name: UDS Identity Service - auth_url: https://sso.###ZARF_VAR_DOMAIN###/realms/uds/protocol/openid-connect/auth + auth_url: '{{ if eq (toString "###ZARF_VAR_PATH_ROUTING###") "true" }}https://{{ if and "###ZARF_VAR_SUBDOMAIN###" (not (hasPrefix "###ZARF_VAR_" "###ZARF_VAR_SUBDOMAIN###")) }}###ZARF_VAR_SUBDOMAIN###.{{ end }}###ZARF_VAR_DOMAIN###{{ if and "###ZARF_VAR_CONTEXT_PATH###" (not (hasPrefix "###ZARF_VAR_" "###ZARF_VAR_CONTEXT_PATH###")) }}###ZARF_VAR_CONTEXT_PATH###{{ end }}/sso{{ else }}https://sso.###ZARF_VAR_DOMAIN###{{ end }}/realms/uds/protocol/openid-connect/auth' # Note: this has to be the external URL to ensure that the token issuer checks in Grafana line up with this URL - token_url: https://sso.###ZARF_VAR_DOMAIN###/realms/uds/protocol/openid-connect/token - signout_redirect_url: https://sso.###ZARF_VAR_DOMAIN###/realms/uds/protocol/openid-connect/logout?post_logout_redirect_uri=https%3A%2F%2Fgrafana.admin.###ZARF_VAR_DOMAIN###%2Flogin + token_url: '{{ if eq (toString "###ZARF_VAR_PATH_ROUTING###") "true" }}https://{{ if and "###ZARF_VAR_SUBDOMAIN###" (not (hasPrefix "###ZARF_VAR_" "###ZARF_VAR_SUBDOMAIN###")) }}###ZARF_VAR_SUBDOMAIN###.{{ end }}###ZARF_VAR_DOMAIN###{{ if and "###ZARF_VAR_CONTEXT_PATH###" (not (hasPrefix "###ZARF_VAR_" "###ZARF_VAR_CONTEXT_PATH###")) }}###ZARF_VAR_CONTEXT_PATH###{{ end }}/sso{{ else }}https://sso.###ZARF_VAR_DOMAIN###{{ end }}/realms/uds/protocol/openid-connect/token' + signout_redirect_url: '{{ if eq (toString "###ZARF_VAR_PATH_ROUTING###") "true" }}https://{{ if and "###ZARF_VAR_SUBDOMAIN###" (not (hasPrefix "###ZARF_VAR_" "###ZARF_VAR_SUBDOMAIN###")) }}###ZARF_VAR_SUBDOMAIN###.{{ end }}###ZARF_VAR_DOMAIN###{{ if and "###ZARF_VAR_CONTEXT_PATH###" (not (hasPrefix "###ZARF_VAR_" "###ZARF_VAR_CONTEXT_PATH###")) }}###ZARF_VAR_CONTEXT_PATH###{{ end }}/sso/realms/uds/protocol/openid-connect/logout?post_logout_redirect_uri=https%3A%2F%2F{{ if and "###ZARF_VAR_SUBDOMAIN###" (not (hasPrefix "###ZARF_VAR_" "###ZARF_VAR_SUBDOMAIN###")) }}###ZARF_VAR_SUBDOMAIN###.{{ end }}{{ "###ZARF_VAR_ADMIN_DOMAIN###" | default "admin.###ZARF_VAR_DOMAIN###" }}{{ if and "###ZARF_VAR_CONTEXT_PATH###" (not (hasPrefix "###ZARF_VAR_" "###ZARF_VAR_CONTEXT_PATH###")) }}{{ replace "/" "%2F" "###ZARF_VAR_CONTEXT_PATH###" }}{{ end }}{{ replace "/" "%2F" ("###ZARF_VAR_ADMIN_CONTEXT_PATH###" | default "/admin") }}%2Fgrafana%2Flogin{{ else }}https://sso.###ZARF_VAR_DOMAIN###/realms/uds/protocol/openid-connect/logout?post_logout_redirect_uri=https%3A%2F%2Fgrafana.admin.###ZARF_VAR_DOMAIN###%2Flogin{{ end }}' use_refresh_token: true allow_sign_up: true # Require a UDS Core group to access Grafana diff --git a/src/grafana/zarf.yaml b/src/grafana/zarf.yaml index 622c7cf05d..d7772b25fd 100644 --- a/src/grafana/zarf.yaml +++ b/src/grafana/zarf.yaml @@ -18,6 +18,23 @@ variables: - name: ADMIN_DOMAIN description: "Domain for admin services, defaults to `admin.DOMAIN`" + default: "admin.uds.dev" + + - name: SUBDOMAIN + description: "Optional subdomain for single-host path routing" + default: "" + + - name: CONTEXT_PATH + description: "Optional public context path for single-host path routing" + default: "" + + - name: ADMIN_CONTEXT_PATH + description: "Optional admin context path for single-host path routing" + default: "/admin" + + - name: PATH_ROUTING + description: "Whether to route built-in services on one host using URL paths" + default: "false" components: - name: grafana diff --git a/src/keycloak/chart/templates/_helpers.tpl b/src/keycloak/chart/templates/_helpers.tpl index e280d4e37f..302975b1ad 100644 --- a/src/keycloak/chart/templates/_helpers.tpl +++ b/src/keycloak/chart/templates/_helpers.tpl @@ -75,6 +75,88 @@ Create the service DNS name. {{ include "keycloak.fullname" . }}-headless.{{ .Release.Namespace }}.svc.{{ .Values.clusterDomain }} {{- end }} +{{- define "keycloak.pathRouting.enabled" -}} +{{- eq (toString .Values.pathRouting) "true" -}} +{{- end -}} + +{{- define "keycloak.contextPath" -}} +{{- $path := .Values.contextPath | default "" -}} +{{- if or (eq $path "") (eq $path "/") (hasPrefix "###ZARF_VAR_" $path) -}} +{{- "" -}} +{{- else -}} +{{- $withLeading := ternary $path (printf "/%s" $path) (hasPrefix "/" $path) -}} +{{- trimSuffix "/" $withLeading -}} +{{- end -}} +{{- end -}} + +{{- define "keycloak.adminContextPath" -}} +{{- $path := .Values.adminContextPath | default "/admin" -}} +{{- if hasPrefix "###ZARF_VAR_" $path -}} +{{- "/admin" -}} +{{- else -}} +{{- $withLeading := ternary $path (printf "/%s" $path) (hasPrefix "/" $path) -}} +{{- trimSuffix "/" $withLeading -}} +{{- end -}} +{{- end -}} + +{{- define "keycloak.host" -}} +{{- $subdomain := .Values.subdomain | default "" -}} +{{- if or (eq $subdomain "") (hasPrefix "###ZARF_VAR_" $subdomain) -}} +{{- .Values.domain -}} +{{- else -}} +{{- printf "%s.%s" $subdomain .Values.domain -}} +{{- end -}} +{{- end -}} + +{{- define "keycloak.adminHost" -}} +{{- $subdomain := .Values.subdomain | default "" -}} +{{- if or (eq $subdomain "") (hasPrefix "###ZARF_VAR_" $subdomain) -}} +{{- include "keycloak.adminDomain" . -}} +{{- else -}} +{{- printf "%s.%s" $subdomain (include "keycloak.adminDomain" .) -}} +{{- end -}} +{{- end -}} + +{{- define "keycloak.adminDomain" -}} +{{- if .Values.adminDomain -}} +{{- tpl .Values.adminDomain . -}} +{{- else -}} +{{- printf "admin.%s" .Values.domain -}} +{{- end -}} +{{- end -}} + +{{- define "keycloak.ssoPath" -}} +{{- printf "%s/sso" (include "keycloak.contextPath" .) -}} +{{- end -}} + +{{- define "keycloak.adminPath" -}} +{{- printf "%s%s/keycloak" (include "keycloak.contextPath" .) (include "keycloak.adminContextPath" .) -}} +{{- end -}} + +{{- define "keycloak.ssoPathForwardRegex" -}} +{{- printf "^%s/(.+)$" (include "keycloak.ssoPath" . | regexQuoteMeta) -}} +{{- end -}} + +{{- define "keycloak.adminPathForwardRegex" -}} +{{- printf "^%s/(.+)$" (include "keycloak.adminPath" . | regexQuoteMeta) -}} +{{- end -}} + +{{- define "keycloak.ssoUrl" -}} +{{- if eq (include "keycloak.pathRouting.enabled" .) "true" -}} +{{- printf "https://%s%s" (include "keycloak.host" .) (include "keycloak.ssoPath" .) -}} +{{- else -}} +{{- printf "https://sso.%s" .Values.domain -}} +{{- end -}} +{{- end -}} + +{{- define "keycloak.adminUrl" -}} +{{- if eq (include "keycloak.pathRouting.enabled" .) "true" -}} +{{- printf "https://%s%s" (include "keycloak.adminHost" .) (include "keycloak.adminPath" .) -}} +{{- else -}} +{{- printf "https://keycloak.%s" (include "keycloak.adminDomain" .) -}} +{{- end -}} +{{- end -}} + {{/* Check external PostgreSQL connection information. Fails when required values are missing or if PostgreSQL is configured when devMode is enabled. */}} @@ -217,4 +299,3 @@ Check external PostgreSQL connection information. Fails when required values are {{- "host" -}} {{- end -}} {{- end -}} - diff --git a/src/keycloak/chart/templates/istio-authpol-admin.yaml b/src/keycloak/chart/templates/istio-authpol-admin.yaml index 80b94d5081..7e9a428839 100644 --- a/src/keycloak/chart/templates/istio-authpol-admin.yaml +++ b/src/keycloak/chart/templates/istio-authpol-admin.yaml @@ -21,6 +21,11 @@ spec: paths: - "/admin*" - "/realms/master*" + {{- if eq (include "keycloak.pathRouting.enabled" .) "true" }} + notPaths: + - "/realms/master/protocol/openid-connect/*" + - "/realms/master/login-actions/*" + {{- end }} from: - source: notNamespaces: diff --git a/src/keycloak/chart/templates/path-parameter-envoyfilter.yaml b/src/keycloak/chart/templates/path-parameter-envoyfilter.yaml index 50b18f90fd..da8554991f 100644 --- a/src/keycloak/chart/templates/path-parameter-envoyfilter.yaml +++ b/src/keycloak/chart/templates/path-parameter-envoyfilter.yaml @@ -32,7 +32,9 @@ spec: -- Only apply to keycloak endpoints if host and ( host == "sso.{{ .Values.domain }}" or - host == "keycloak.{{ tpl .Values.adminDomain . }}" + host == "keycloak.{{ tpl .Values.adminDomain . }}"{{- if eq (include "keycloak.pathRouting.enabled" .) "true" }} or + host == "{{ include "keycloak.host" . }}" or + host == "{{ include "keycloak.adminHost" . }}"{{- end }} ) then if path then local invalid_semicolon = string.match(path, "^[^;]*;[^/]*/") @@ -45,4 +47,4 @@ spec: end end end -{{- end }} \ No newline at end of file +{{- end }} diff --git a/src/keycloak/chart/templates/statefulset.yaml b/src/keycloak/chart/templates/statefulset.yaml index d569f7e7db..c95a51f919 100644 --- a/src/keycloak/chart/templates/statefulset.yaml +++ b/src/keycloak/chart/templates/statefulset.yaml @@ -162,6 +162,10 @@ spec: - "--proxy-headers=xforwarded" - "--http-enabled=true" - "--hostname-strict=false" + {{- if eq (include "keycloak.pathRouting.enabled" .) "true" }} + - "--hostname={{ include "keycloak.ssoUrl" . }}" + - "--hostname-admin={{ include "keycloak.adminUrl" . }}" + {{- end }} {{- if .Values.jsonLogFormat }} - "--log-console-output=json" {{- end }} @@ -210,6 +214,12 @@ spec: value: "{{ .Values.domain }}" - name: UDS_ADMIN_DOMAIN value: "{{ tpl .Values.adminDomain . }}" + {{- if eq (include "keycloak.pathRouting.enabled" .) "true" }} + - name: UDS_URL + value: "{{ include "keycloak.ssoUrl" . }}" + - name: UDS_ADMIN_URL + value: "{{ include "keycloak.adminUrl" . }}" + {{- end }} # Enable health and metrics endpoints - name: KC_HEALTH_ENABLED diff --git a/src/keycloak/chart/templates/uds-package.yaml b/src/keycloak/chart/templates/uds-package.yaml index f273e8d801..e49b570e84 100644 --- a/src/keycloak/chart/templates/uds-package.yaml +++ b/src/keycloak/chart/templates/uds-package.yaml @@ -127,7 +127,7 @@ spec: # @lulaStart 506992d2-64cf-4fb3-95de-40a8ad127626 expose: - description: "remove private paths from public gateway" - host: sso + host: {{ .Values.subdomain | default "sso" }} service: keycloak-http selector: app.kubernetes.io/name: keycloak @@ -136,25 +136,33 @@ spec: uptime: checks: paths: - - / - - /realms/uds/.well-known/openid-configuration + - {{ ternary (include "keycloak.ssoPath" .) "/" (eq (include "keycloak.pathRouting.enabled" .) "true") | quote }} + - {{ ternary (printf "%s/realms/%s/.well-known/openid-configuration" (include "keycloak.ssoPath" .) .Values.realm) (printf "/realms/%s/.well-known/openid-configuration" .Values.realm) (eq (include "keycloak.pathRouting.enabled" .) "true") | quote }} {{- end }} advancedHTTP: match: - name: redirect-welcome uri: - exact: / + exact: {{ ternary (include "keycloak.ssoPath" .) "/" (eq (include "keycloak.pathRouting.enabled" .) "true") | quote }} + {{- if eq (include "keycloak.pathRouting.enabled" .) "true" }} + - name: redirect-welcome-slash + uri: + exact: {{ printf "%s/" (include "keycloak.ssoPath" .) | quote }} + {{- end }} - name: redirect-admin uri: - prefix: /admin + prefix: {{ ternary (printf "%s/admin" (include "keycloak.ssoPath" .)) "/admin" (eq (include "keycloak.pathRouting.enabled" .) "true") | quote }} - name: redirect-master-realm uri: - prefix: /realms/master + exact: {{ ternary (printf "%s/realms/master" (include "keycloak.ssoPath" .)) "/realms/master" (eq (include "keycloak.pathRouting.enabled" .) "true") | quote }} + - name: redirect-master-realm-slash + uri: + exact: {{ ternary (printf "%s/realms/master/" (include "keycloak.ssoPath" .)) "/realms/master/" (eq (include "keycloak.pathRouting.enabled" .) "true") | quote }} - name: redirect-metrics uri: - prefix: /metrics + prefix: {{ ternary (printf "%s/metrics" (include "keycloak.ssoPath" .)) "/metrics" (eq (include "keycloak.pathRouting.enabled" .) "true") | quote }} redirect: - uri: "/realms/{{ .Values.realm }}/account" + uri: {{ ternary (printf "%s/realms/%s/account" (include "keycloak.ssoPath" .) .Values.realm) (printf "/realms/%s/account" .Values.realm) (eq (include "keycloak.pathRouting.enabled" .) "true") | quote }} {{- if not .Values.thirdPartyIntegration.tls.tlsCertificateHeader }} headers: request: @@ -168,9 +176,27 @@ spec: service: keycloak-http selector: app.kubernetes.io/name: keycloak - host: sso + host: {{ .Values.subdomain | default "sso" }} port: 8080 - {{- if not .Values.thirdPartyIntegration.tls.tlsCertificateHeader }} + {{- if eq (include "keycloak.pathRouting.enabled" .) "true" }} + advancedHTTP: + match: + - name: keycloak-public-path + uri: + regex: {{ include "keycloak.ssoPathForwardRegex" . | quote }} + rewrite: + uriRegexRewrite: + match: {{ include "keycloak.ssoPathForwardRegex" . | quote }} + rewrite: "/\\1" + {{- if not .Values.thirdPartyIntegration.tls.tlsCertificateHeader }} + headers: + request: + remove: + - istio-mtls-client-certificate + add: + istio-mtls-client-certificate: "%DOWNSTREAM_PEER_CERT%" + {{- end }} + {{- else if not .Values.thirdPartyIntegration.tls.tlsCertificateHeader }} advancedHTTP: headers: request: @@ -180,20 +206,58 @@ spec: istio-mtls-client-certificate: "%DOWNSTREAM_PEER_CERT%" {{- end }} + {{- if eq (include "keycloak.pathRouting.enabled" .) "true" }} + - description: "redirect admin landing path" + service: keycloak-http + selector: + app.kubernetes.io/name: keycloak + gateway: admin + host: {{ .Values.subdomain | default "keycloak" }} + port: 8080 + advancedHTTP: + match: + - name: redirect-admin-welcome + uri: + exact: {{ include "keycloak.adminPath" . | quote }} + - name: redirect-admin-welcome-slash + uri: + exact: {{ printf "%s/" (include "keycloak.adminPath" .) | quote }} + redirect: + uri: {{ printf "%s/admin/master/console/" (include "keycloak.adminPath" .) | quote }} + {{- end }} + - description: "admin access with optional client certificate" service: keycloak-http selector: app.kubernetes.io/name: keycloak gateway: admin - host: keycloak + host: {{ .Values.subdomain | default "keycloak" }} port: 8080 - {{- if .Values.uptime.enabled }} + {{- if and (ne (include "keycloak.pathRouting.enabled" .) "true") .Values.uptime.enabled }} uptime: checks: paths: - / {{- end }} - {{- if not .Values.thirdPartyIntegration.tls.tlsCertificateHeader }} + {{- if eq (include "keycloak.pathRouting.enabled" .) "true" }} + advancedHTTP: + match: + - name: keycloak-admin-path + uri: + regex: {{ include "keycloak.adminPathForwardRegex" . | quote }} + rewrite: + uriRegexRewrite: + match: {{ include "keycloak.adminPathForwardRegex" . | quote }} + rewrite: "/\\1" + {{- if not .Values.thirdPartyIntegration.tls.tlsCertificateHeader }} + headers: + request: + remove: + - istio-mtls-client-certificate + add: + istio-mtls-client-certificate: "%DOWNSTREAM_PEER_CERT%" + {{- end }} + {{- else if not .Values.thirdPartyIntegration.tls.tlsCertificateHeader }} advancedHTTP: headers: request: diff --git a/src/keycloak/chart/templates/uptime-recording-rules.yaml b/src/keycloak/chart/templates/uptime-recording-rules.yaml index 6ebde61b4d..3d9a2eee19 100644 --- a/src/keycloak/chart/templates/uptime-recording-rules.yaml +++ b/src/keycloak/chart/templates/uptime-recording-rules.yaml @@ -34,9 +34,11 @@ spec: uds_layer: identity-authorization uds_component: keycloak-sso-endpoint expr: min(probe_success{job="probe/keycloak/uds-sso-tenant-uptime"}) + {{- if ne (include "keycloak.pathRouting.enabled" .) "true" }} - record: uds:keycloak_admin_endpoint:up labels: uds_layer: identity-authorization uds_component: keycloak-admin-endpoint expr: min(probe_success{job="probe/keycloak/uds-keycloak-admin-uptime"}) + {{- end }} {{- end }} diff --git a/src/keycloak/chart/tests/kc_path_routing_test.yaml b/src/keycloak/chart/tests/kc_path_routing_test.yaml new file mode 100644 index 0000000000..2debd739b6 --- /dev/null +++ b/src/keycloak/chart/tests/kc_path_routing_test.yaml @@ -0,0 +1,116 @@ +# Copyright 2026 Defense Unicorns +# SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial + +# yaml-language-server: $schema=https://raw.githubusercontent.com/helm-unittest/helm-unittest/main/schema/helm-testsuite.json + +suite: Keycloak - Path Routing +set: + domain: example.com + adminDomain: admin.example.com + pathRouting: true + subdomain: foo + contextPath: /bar + adminContextPath: /admin +templates: + - uds-package.yaml + - statefulset.yaml + - path-parameter-envoyfilter.yaml + - istio-authpol-admin.yaml +capabilities: + apiVersions: + - security.istio.io/v1 + +tests: + - it: should render public and admin path routes + template: uds-package.yaml + asserts: + - equal: + path: spec.network.expose[0].advancedHTTP.match[0].uri.exact + value: /bar/sso + - equal: + path: spec.network.expose[0].advancedHTTP.match[1].uri.exact + value: /bar/sso/ + - equal: + path: spec.network.expose[0].advancedHTTP.redirect.uri + value: /bar/sso/realms/uds/account + - equal: + path: spec.network.expose[0].advancedHTTP.match[3].uri.exact + value: /bar/sso/realms/master + - equal: + path: spec.network.expose[0].advancedHTTP.match[4].uri.exact + value: /bar/sso/realms/master/ + - equal: + path: spec.network.expose[1].advancedHTTP.match[0].uri.regex + value: ^/bar/sso/(.+)$ + - equal: + path: spec.network.expose[1].advancedHTTP.rewrite.uriRegexRewrite.match + value: ^/bar/sso/(.+)$ + - equal: + path: spec.network.expose[1].advancedHTTP.rewrite.uriRegexRewrite.rewrite + value: /\1 + - equal: + path: spec.network.expose[2].advancedHTTP.match[0].uri.exact + value: /bar/admin/keycloak + - equal: + path: spec.network.expose[2].advancedHTTP.match[1].uri.exact + value: /bar/admin/keycloak/ + - equal: + path: spec.network.expose[2].advancedHTTP.redirect.uri + value: /bar/admin/keycloak/admin/master/console/ + - equal: + path: spec.network.expose[3].advancedHTTP.match[0].uri.regex + value: ^/bar/admin/keycloak/(.+)$ + - equal: + path: spec.network.expose[3].advancedHTTP.rewrite.uriRegexRewrite.match + value: ^/bar/admin/keycloak/(.+)$ + - equal: + path: spec.network.expose[3].advancedHTTP.rewrite.uriRegexRewrite.rewrite + value: /\1 + + - it: should configure Keycloak frontend and admin URLs + template: statefulset.yaml + asserts: + - contains: + path: spec.template.spec.containers[0].args + content: --hostname=https://foo.example.com/bar/sso + - contains: + path: spec.template.spec.containers[0].args + content: --hostname-admin=https://foo.admin.example.com/bar/admin/keycloak + - contains: + path: spec.template.spec.containers[0].env + content: + name: UDS_URL + value: https://foo.example.com/bar/sso + - contains: + path: spec.template.spec.containers[0].env + content: + name: UDS_ADMIN_URL + value: https://foo.admin.example.com/bar/admin/keycloak + + - it: should protect the shared host from path parameter bypasses + template: path-parameter-envoyfilter.yaml + asserts: + - matchRegex: + path: spec.configPatches[0].patch.value.typed_config.inlineCode + pattern: host == "foo.example.com" + - matchRegex: + path: spec.configPatches[0].patch.value.typed_config.inlineCode + pattern: host == "foo.admin.example.com" + + - it: should allow master realm browser auth endpoints for admin console + template: istio-authpol-admin.yaml + asserts: + - contains: + path: spec.rules[0].to[0].operation.notPaths + content: /realms/master/protocol/openid-connect/* + - contains: + path: spec.rules[0].to[0].operation.notPaths + content: /realms/master/login-actions/* + + - it: should keep master realm browser auth blocked when path routing is disabled + set: + pathRouting: false + template: istio-authpol-admin.yaml + asserts: + - notExists: + path: spec.rules[0].to[0].operation.notPaths diff --git a/src/keycloak/chart/tests/kc_uptime_toggle_test.yaml b/src/keycloak/chart/tests/kc_uptime_toggle_test.yaml index cf0dc06a6a..beb1e118b5 100644 --- a/src/keycloak/chart/tests/kc_uptime_toggle_test.yaml +++ b/src/keycloak/chart/tests/kc_uptime_toggle_test.yaml @@ -8,7 +8,7 @@ templates: - uds-package.yaml tests: - - it: should include uptime blocks when uptime.enabled is true + - it: should include public and admin uptime blocks when uptime.enabled is true set: uptime: enabled: true @@ -24,10 +24,21 @@ tests: - / - /realms/uds/.well-known/openid-configuration + - notContains: + path: spec.network.expose + any: true + content: + gateway: admin + uptime: + checks: + paths: + - /realms/uds/.well-known/openid-configuration + - contains: path: spec.network.expose any: true content: + gateway: admin uptime: checks: paths: @@ -58,7 +69,7 @@ tests: paths: - / - - it: should include uptime blocks when uptime object is overridden without enabled key (defaults preserved) + - it: should include public and admin uptime blocks when uptime object is overridden without enabled key set: uptime: {} template: uds-package.yaml @@ -73,10 +84,53 @@ tests: - / - /realms/uds/.well-known/openid-configuration + - notContains: + path: spec.network.expose + any: true + content: + gateway: admin + uptime: + checks: + paths: + - /realms/uds/.well-known/openid-configuration + - contains: path: spec.network.expose any: true content: + gateway: admin + uptime: + checks: + paths: + - / + + - it: should include only path-aware public uptime block when path routing is enabled + set: + pathRouting: true + subdomain: abc + contextPath: /xyz + adminContextPath: /admin + uptime: + enabled: true + template: uds-package.yaml + asserts: + - contains: + path: spec.network.expose + any: true + content: + host: abc + uptime: + checks: + paths: + - /xyz/sso + - /xyz/sso/realms/uds/.well-known/openid-configuration + + - notContains: + path: spec.network.expose + any: true + content: + host: abc + gateway: admin uptime: checks: paths: @@ -92,7 +146,6 @@ tests: path: spec.network.expose any: true content: - host: sso service: keycloak-http port: 8080 @@ -100,7 +153,6 @@ tests: path: spec.network.expose any: true content: - host: keycloak gateway: admin service: keycloak-http port: 8080 diff --git a/src/keycloak/chart/values.schema.json b/src/keycloak/chart/values.schema.json index a353462f02..5f11efa28f 100644 --- a/src/keycloak/chart/values.schema.json +++ b/src/keycloak/chart/values.schema.json @@ -215,6 +215,28 @@ "adminDomain": { "type": "string" }, + "adminContextPath": { + "type": "string" + }, + "contextPath": { + "type": "string" + }, + "pathRouting": { + "type": [ + "boolean", + "string" + ], + "enum": [ + true, + false, + "true", + "false", + "###ZARF_VAR_PATH_ROUTING###" + ] + }, + "subdomain": { + "type": "string" + }, "enableServiceLinks": { "type": "boolean" }, diff --git a/src/keycloak/chart/values.yaml b/src/keycloak/chart/values.yaml index ec58c4f06c..5e20ac5c98 100644 --- a/src/keycloak/chart/values.yaml +++ b/src/keycloak/chart/values.yaml @@ -16,6 +16,10 @@ configImage: ghcr.io/defenseunicorns/uds/identity-config:0.27.0 domain: "###ZARF_VAR_DOMAIN###" # The admin domain for hosts to trust clients on adminDomain: '{{ "###ZARF_VAR_ADMIN_DOMAIN###" | default "admin.###ZARF_VAR_DOMAIN###" }}' +subdomain: "###ZARF_VAR_SUBDOMAIN###" +contextPath: "###ZARF_VAR_CONTEXT_PATH###" +adminContextPath: "###ZARF_VAR_ADMIN_CONTEXT_PATH###" +pathRouting: "###ZARF_VAR_PATH_ROUTING###" # Additional Istio Gateways that expose Keycloak, to allow for client cert usage # A prefix of `istio-` is required for namespaces to prevent accidental misconfiguration diff --git a/src/keycloak/zarf.yaml b/src/keycloak/zarf.yaml index fe0d880413..4f29e43c0a 100644 --- a/src/keycloak/zarf.yaml +++ b/src/keycloak/zarf.yaml @@ -18,6 +18,23 @@ variables: - name: ADMIN_DOMAIN description: "Domain for admin services, defaults to `admin.DOMAIN`" + default: "admin.uds.dev" + + - name: SUBDOMAIN + description: "Optional subdomain for single-host path routing" + default: "" + + - name: CONTEXT_PATH + description: "Optional public context path for single-host path routing" + default: "" + + - name: ADMIN_CONTEXT_PATH + description: "Optional admin context path for single-host path routing" + default: "/admin" + + - name: PATH_ROUTING + description: "Whether to route built-in services on one host using URL paths" + default: "false" components: - name: keycloak diff --git a/src/pepr/operator/controllers/config/config.ts b/src/pepr/operator/controllers/config/config.ts index 8e567977e0..cca6a681ee 100644 --- a/src/pepr/operator/controllers/config/config.ts +++ b/src/pepr/operator/controllers/config/config.ts @@ -18,6 +18,7 @@ import { Action, AuthServiceEvent } from "../keycloak/authservice/types"; import { initAPIServerCIDR } from "../network/generators/kubeAPI"; import { initAllNodesTarget } from "../network/generators/kubeNodes"; import { registerWatchEventHandlers, watchCfg } from "../utils"; +import { normalizeContextPath } from "../url-utils"; import { Config, KeycloakClientMode } from "./types"; export const configLog = setupLogger(Component.OPERATOR_CONFIG); @@ -26,6 +27,10 @@ export const configLog = setupLogger(Component.OPERATOR_CONFIG); export const UDSConfig: Config = { domain: "", adminDomain: "", + subdomain: "", + contextPath: "", + adminContextPath: "/admin", + pathRouting: false, caBundle: { certs: "", includeDoDCerts: false, @@ -359,17 +364,32 @@ export async function handleCfg(cfg: ClusterConfig, action: ConfigAction) { } } - if (expose.domain !== UDSConfig.domain || expose.adminDomain !== UDSConfig.adminDomain) { - if (expose.domain && expose.domain !== "###ZARF_VAR_DOMAIN###") { - UDSConfig.domain = expose.domain; - } else { - UDSConfig.domain = "uds.dev"; - } - if (expose.adminDomain && expose.adminDomain !== "###ZARF_VAR_ADMIN_DOMAIN###") { - UDSConfig.adminDomain = expose.adminDomain; - } else { - UDSConfig.adminDomain = `admin.${UDSConfig.domain}`; - } + const domain = + expose.domain && expose.domain !== "###ZARF_VAR_DOMAIN###" ? expose.domain : "uds.dev"; + const adminDomain = + expose.adminDomain && expose.adminDomain !== "###ZARF_VAR_ADMIN_DOMAIN###" + ? expose.adminDomain + : `admin.${domain}`; + const subdomain = + expose.subdomain && expose.subdomain !== "###ZARF_VAR_SUBDOMAIN###" ? expose.subdomain : ""; + const contextPath = normalizeContextPath(expose.contextPath); + const adminContextPath = normalizeContextPath(expose.adminContextPath, "/admin"); + const pathRouting = expose.pathRouting === true; + + if ( + domain !== UDSConfig.domain || + adminDomain !== UDSConfig.adminDomain || + subdomain !== UDSConfig.subdomain || + contextPath !== UDSConfig.contextPath || + adminContextPath !== UDSConfig.adminContextPath || + pathRouting !== UDSConfig.pathRouting + ) { + UDSConfig.domain = domain; + UDSConfig.adminDomain = adminDomain; + UDSConfig.subdomain = subdomain; + UDSConfig.contextPath = contextPath; + UDSConfig.adminContextPath = adminContextPath; + UDSConfig.pathRouting = pathRouting; // todo: Add logic to handle domain changes and update across virtualservices, authservice config, etc } diff --git a/src/pepr/operator/controllers/config/types.ts b/src/pepr/operator/controllers/config/types.ts index eb66cb6cb7..fc6177d9fd 100644 --- a/src/pepr/operator/controllers/config/types.ts +++ b/src/pepr/operator/controllers/config/types.ts @@ -20,6 +20,10 @@ export enum KeycloakClientMode { export type Config = { domain: string; adminDomain: string; + subdomain: string; + contextPath: string; + adminContextPath: string; + pathRouting: boolean; caBundle: CABundle; authserviceRedisUri: string; keycloakClientMode: KeycloakClientMode; diff --git a/src/pepr/operator/controllers/domain-utils.spec.ts b/src/pepr/operator/controllers/domain-utils.spec.ts index 0592711ab9..605a1320f6 100644 --- a/src/pepr/operator/controllers/domain-utils.spec.ts +++ b/src/pepr/operator/controllers/domain-utils.spec.ts @@ -6,10 +6,22 @@ import { describe, expect, it } from "vitest"; import { Expose } from "../crd"; import { UDSConfig } from "./config/config"; -import { getFqdn } from "./domain-utils"; +import { + getAdminAppUrl, + getAdminBaseUrl, + getFqdn, + getHost, + getPublicBaseUrl, + getSsoUrl, + normalizeContextPath, +} from "./domain-utils"; UDSConfig.domain = "uds.dev"; UDSConfig.adminDomain = "admin.uds.dev"; +UDSConfig.subdomain = ""; +UDSConfig.contextPath = ""; +UDSConfig.adminContextPath = "/admin"; +UDSConfig.pathRouting = false; describe("getFqdn", () => { it("should return fqdn for tenant gateway", () => { @@ -36,4 +48,57 @@ describe("getFqdn", () => { const expose: Expose = { host: "app", gateway: "custom-gateway", domain: "custom.example.com" }; expect(getFqdn(expose)).toEqual("app.custom.example.com"); }); + + it("should return the shared host for built-in services when path routing is enabled", () => { + UDSConfig.pathRouting = true; + UDSConfig.subdomain = "foo"; + const expose: Expose = { host: "grafana", gateway: "admin" }; + expect(getFqdn(expose)).toEqual("foo.uds.dev"); + UDSConfig.pathRouting = false; + UDSConfig.subdomain = ""; + }); + + it("should preserve package FQDNs when path routing is enabled", () => { + UDSConfig.pathRouting = true; + UDSConfig.subdomain = "foo"; + const expose: Expose = { host: "app", gateway: "tenant" }; + expect(getFqdn(expose)).toEqual("app.uds.dev"); + UDSConfig.pathRouting = false; + UDSConfig.subdomain = ""; + }); +}); + +describe("path routing URL helpers", () => { + it.each([ + [undefined, ""], + ["", ""], + ["/", ""], + ["bar", "/bar"], + ["/bar", "/bar"], + ])("normalizes context path %s", (path, expected) => { + expect(normalizeContextPath(path)).toEqual(expected); + }); + + it("computes single-host public and admin URLs", () => { + UDSConfig.pathRouting = true; + UDSConfig.subdomain = "foo"; + UDSConfig.contextPath = "/bar"; + UDSConfig.adminContextPath = "/admin"; + + expect(getHost()).toEqual("foo.uds.dev"); + expect(getPublicBaseUrl()).toEqual("https://foo.uds.dev/bar"); + expect(getAdminBaseUrl()).toEqual("https://foo.uds.dev/bar/admin"); + expect(getSsoUrl()).toEqual("https://foo.uds.dev/bar/sso"); + expect(getAdminAppUrl("grafana")).toEqual("https://foo.uds.dev/bar/admin/grafana"); + + UDSConfig.pathRouting = false; + UDSConfig.subdomain = ""; + UDSConfig.contextPath = ""; + UDSConfig.adminContextPath = "/admin"; + }); + + it("preserves legacy SSO and admin URLs when path routing is disabled", () => { + expect(getSsoUrl()).toEqual("https://sso.uds.dev"); + expect(getAdminAppUrl("grafana")).toEqual("https://grafana.admin.uds.dev"); + }); }); diff --git a/src/pepr/operator/controllers/domain-utils.ts b/src/pepr/operator/controllers/domain-utils.ts index 2f0a23c83b..241df50ee9 100644 --- a/src/pepr/operator/controllers/domain-utils.ts +++ b/src/pepr/operator/controllers/domain-utils.ts @@ -5,6 +5,35 @@ import { Expose, Gateway } from "../crd"; import { UDSConfig } from "./config/config"; +import { + getAdminAppUrl as getAdminAppUrlForConfig, + getAdminBaseUrl as getAdminBaseUrlForConfig, + getHost as getHostForConfig, + getPublicBaseUrl as getPublicBaseUrlForConfig, + getSsoUrl as getSsoUrlForConfig, +} from "./url-utils"; + +export { defaultAdminContextPath, normalizeContextPath } from "./url-utils"; + +export function getHost(): string { + return getHostForConfig(UDSConfig); +} + +export function getPublicBaseUrl(): string { + return getPublicBaseUrlForConfig(UDSConfig); +} + +export function getAdminBaseUrl(): string { + return getAdminBaseUrlForConfig(UDSConfig); +} + +export function getSsoUrl(): string { + return getSsoUrlForConfig(UDSConfig); +} + +export function getAdminAppUrl(app: string): string { + return getAdminAppUrlForConfig(UDSConfig, app); +} /** * Get the FQDN for an expose entry based on host, domain, and gateway @@ -13,6 +42,10 @@ import { UDSConfig } from "./config/config"; * @returns The fully qualified domain name */ export function getFqdn(expose: Expose): string { + if (UDSConfig.pathRouting && isCorePathRoutedHost(expose)) { + return getHost(); + } + const { gateway = Gateway.Tenant, host } = expose; // Get the correct domain based on gateway or custom domain @@ -26,3 +59,11 @@ export function getFqdn(expose: Expose): string { // Add the host to the domain, unless this is the reserved root domain host (`.`) return host === "." ? domain : `${host}.${domain}`; } + +function isCorePathRoutedHost(expose: Expose): boolean { + const { gateway = Gateway.Tenant, host } = expose; + if (gateway !== Gateway.Tenant && gateway !== Gateway.Admin) { + return false; + } + return host === "sso" || host === "grafana" || host === "keycloak"; +} diff --git a/src/pepr/operator/controllers/keycloak/authservice/authorization-policy.spec.ts b/src/pepr/operator/controllers/keycloak/authservice/authorization-policy.spec.ts index c74007ab2c..6ea120e9b0 100644 --- a/src/pepr/operator/controllers/keycloak/authservice/authorization-policy.spec.ts +++ b/src/pepr/operator/controllers/keycloak/authservice/authorization-policy.spec.ts @@ -22,6 +22,11 @@ import { // Patch UDSConfig for deterministic output beforeAll(() => { UDSConfig.domain = "example.com"; + UDSConfig.adminDomain = "admin.example.com"; + UDSConfig.subdomain = ""; + UDSConfig.contextPath = ""; + UDSConfig.adminContextPath = "/admin"; + UDSConfig.pathRouting = false; }); const labelSelector = { app: "test-app" }; @@ -307,6 +312,26 @@ describe("authorization-policy.ts", () => { expect(nonMetricsFrom.notRequestPrincipals).toEqual(["https://sso.example.com/realms/uds/*"]); }); + it("uses path-routed issuer and JWKS URLs", () => { + UDSConfig.pathRouting = true; + UDSConfig.subdomain = "foo"; + UDSConfig.contextPath = "/bar"; + + const authn = authNRequestAuthentication(labelSelector, name, namespace, true, waypointName); + expect(authn.spec!.jwtRules![0].issuer).toEqual("https://foo.example.com/bar/sso/realms/uds"); + expect(authn.spec!.jwtRules![0].jwksUri).toEqual( + "https://foo.example.com/bar/sso/realms/uds/protocol/openid-connect/certs", + ); + + const authz = jwtAuthZAuthorizationPolicy(labelSelector, name, namespace); + const source = authz.spec!.rules![0].from![0].source as { notRequestPrincipals?: string[] }; + expect(source.notRequestPrincipals).toEqual(["https://foo.example.com/bar/sso/realms/uds/*"]); + + UDSConfig.pathRouting = false; + UDSConfig.subdomain = ""; + UDSConfig.contextPath = ""; + }); + it("ambient authservice policy builds non-metrics operations from monitor exemptions", () => { const pol = authserviceAuthorizationPolicy(labelSelector, name, namespace, true, waypointName, [ { port: "8080", path: "/metrics" }, diff --git a/src/pepr/operator/controllers/keycloak/authservice/authorization-policy.ts b/src/pepr/operator/controllers/keycloak/authservice/authorization-policy.ts index e60ed9608a..b1d14b7d1f 100644 --- a/src/pepr/operator/controllers/keycloak/authservice/authorization-policy.ts +++ b/src/pepr/operator/controllers/keycloak/authservice/authorization-policy.ts @@ -14,6 +14,7 @@ import { } from "../../../crd"; import { UDSConfig } from "../../config/config"; import { matchesLabels } from "../../istio/waypoint-utils"; +import { getSsoUrl } from "../../url-utils"; import { PROMETHEUS_PRINCIPAL, getOwnerRef, purgeOrphans, sanitizeResourceName } from "../../utils"; import { log } from "./authservice"; import { AddOrRemoveClientEvent, Action as AuthServiceAction } from "./types"; @@ -188,9 +189,10 @@ function jwtAuthZAuthorizationPolicy( const nonMetricsOps = buildNonMetricsOperations(monitorExemptions); const rules: NonNullable["rules"] = []; + const issuer = `${getSsoUrl(UDSConfig)}/realms/uds`; const ssoJwtSource = { - notRequestPrincipals: [`https://sso.${UDSConfig.domain}/realms/uds/*`], + notRequestPrincipals: [`${issuer}/*`], }; const prometheusOrSsoJwtSource = { @@ -254,6 +256,7 @@ function authNRequestAuthentication( isAmbient = false, waypointName?: string, ): IstioRequestAuthentication { + const issuer = `${getSsoUrl(UDSConfig)}/realms/uds`; // Create base policy with spec explicitly typed const policy: IstioRequestAuthentication & { spec: NonNullable; @@ -268,8 +271,8 @@ function authNRequestAuthentication( { audiences: [name], forwardOriginalToken: true, - issuer: `https://sso.${UDSConfig.domain}/realms/uds`, - jwksUri: `https://sso.${UDSConfig.domain}/realms/uds/protocol/openid-connect/certs`, + issuer, + jwksUri: `${issuer}/protocol/openid-connect/certs`, }, ], }, diff --git a/src/pepr/operator/controllers/keycloak/authservice/authservice.spec.ts b/src/pepr/operator/controllers/keycloak/authservice/authservice.spec.ts index 5befbc91fa..6bb1c70be4 100644 --- a/src/pepr/operator/controllers/keycloak/authservice/authservice.spec.ts +++ b/src/pepr/operator/controllers/keycloak/authservice/authservice.spec.ts @@ -13,6 +13,7 @@ vi.mock("../../ca-bundles/ca-bundle", () => ({ import { Sso, UDSPackage } from "../../../crd"; import { AuthserviceClient, Mode } from "../../../crd/generated/package-v1alpha1"; import { buildCABundleContent } from "../../ca-bundles/ca-bundle"; +import { UDSConfig } from "../../config/config"; import { cleanupWaypointLabels } from "../../istio/ambient-waypoint"; import { getWaypointName } from "../../istio/waypoint-utils"; import { Client } from "../types"; @@ -120,6 +121,11 @@ vi.mock("./config", () => { vi.mock("../../config/config", () => ({ UDSConfig: { domain: "uds.dev", + adminDomain: "admin.uds.dev", + subdomain: "", + contextPath: "", + adminContextPath: "/admin", + pathRouting: false, }, })); @@ -128,6 +134,11 @@ vi.mock("./authorization-policy", () => ({ updatePolicy: vi.fn().mockResolvedValue(undefined), UDSConfig: { domain: "uds.dev", + adminDomain: "admin.uds.dev", + subdomain: "", + contextPath: "", + adminContextPath: "/admin", + pathRouting: false, }, })); @@ -326,6 +337,18 @@ describe("authservice", () => { beforeEach(async () => { vi.clearAllMocks(); + UDSConfig.domain = "uds.dev"; + UDSConfig.adminDomain = "admin.uds.dev"; + UDSConfig.subdomain = ""; + UDSConfig.contextPath = ""; + UDSConfig.adminContextPath = "/admin"; + UDSConfig.pathRouting = false; + authorizationPolicy.UDSConfig.domain = "uds.dev"; + authorizationPolicy.UDSConfig.adminDomain = "admin.uds.dev"; + authorizationPolicy.UDSConfig.subdomain = ""; + authorizationPolicy.UDSConfig.contextPath = ""; + authorizationPolicy.UDSConfig.adminContextPath = "/admin"; + authorizationPolicy.UDSConfig.pathRouting = false; mockClient = { clientId: "test-client", @@ -494,6 +517,31 @@ describe("authservice", () => { } }); + test("should build authservice chain with path-routed SSO URLs", async () => { + UDSConfig.pathRouting = true; + UDSConfig.subdomain = "foo"; + UDSConfig.contextPath = "/bar"; + authorizationPolicy.UDSConfig.pathRouting = true; + authorizationPolicy.UDSConfig.subdomain = "foo"; + authorizationPolicy.UDSConfig.contextPath = "/bar"; + + const chain = buildChain({ + client: mockClient, + name: "sso-client-test", + action: Action.AddClient, + } as unknown as AuthServiceEvent); + + expect(chain.filters[0].oidc_override?.authorization_uri).toEqual( + "https://foo.uds.dev/bar/sso/realms/uds/protocol/openid-connect/auth", + ); + expect(chain.filters[0].oidc_override?.token_uri).toEqual( + "https://foo.uds.dev/bar/sso/realms/uds/protocol/openid-connect/token", + ); + expect(chain.filters[0].oidc_override?.logout?.redirect_uri).toEqual( + "https://foo.uds.dev/bar/sso/realms/uds/protocol/openid-connect/logout", + ); + }); + test("should test authservice chain removal", async () => { const config = buildConfig(mockConfig as AuthserviceConfig, { client: mockClient, diff --git a/src/pepr/operator/controllers/keycloak/authservice/authservice.ts b/src/pepr/operator/controllers/keycloak/authservice/authservice.ts index 2107daa7ac..bdcb21cab2 100644 --- a/src/pepr/operator/controllers/keycloak/authservice/authservice.ts +++ b/src/pepr/operator/controllers/keycloak/authservice/authservice.ts @@ -10,6 +10,7 @@ import { K8sGateway, UDSPackage } from "../../../crd"; import { AuthserviceClient, Mode } from "../../../crd/generated/package-v1alpha1"; import { cleanupWaypointLabels, setupAmbientWaypoint } from "../../istio/ambient-waypoint"; import { getWaypointName } from "../../istio/waypoint-utils"; +import { getSsoUrl } from "../../url-utils"; import { getAuthserviceClients, purgeOrphans } from "../../utils"; import { Client } from "../types"; import { UDSConfig, updatePolicy } from "./authorization-policy"; @@ -258,6 +259,7 @@ export function buildChain(update: AuthServiceEvent) { // TODO: update to loop and build multiple chains on redirectUris // Parse the hostname from the first client redirect URI const hostname = new URL(update.client!.redirectUris[0]).hostname; + const ssoUrl = getSsoUrl(UDSConfig); const chain: Chain = { name: update.name, @@ -268,15 +270,15 @@ export function buildChain(update: AuthServiceEvent) { filters: [ { oidc_override: { - authorization_uri: `https://sso.${UDSConfig.domain}/realms/${operatorConfig.realm}/protocol/openid-connect/auth`, - token_uri: `https://sso.${UDSConfig.domain}/realms/${operatorConfig.realm}/protocol/openid-connect/token`, + authorization_uri: `${ssoUrl}/realms/${operatorConfig.realm}/protocol/openid-connect/auth`, + token_uri: `${ssoUrl}/realms/${operatorConfig.realm}/protocol/openid-connect/token`, callback_uri: update.client!.redirectUris[0], client_id: update.client!.clientId, client_secret: update.client!.secret, scopes: [], logout: { path: "/logout", - redirect_uri: `https://sso.${UDSConfig.domain}/realms/${operatorConfig.realm}/protocol/openid-connect/logout`, + redirect_uri: `${ssoUrl}/realms/${operatorConfig.realm}/protocol/openid-connect/logout`, }, cookie_name_prefix: update.client!.clientId, }, diff --git a/src/pepr/operator/controllers/keycloak/authservice/config.spec.ts b/src/pepr/operator/controllers/keycloak/authservice/config.spec.ts index 314979d4b2..76f2fb77e8 100644 --- a/src/pepr/operator/controllers/keycloak/authservice/config.spec.ts +++ b/src/pepr/operator/controllers/keycloak/authservice/config.spec.ts @@ -8,10 +8,12 @@ import { afterEach, beforeEach, describe, expect, it, Mock, vi } from "vitest"; import { buildInitialSecret, getAuthserviceConfig, + initializeOperatorConfig, setupAuthserviceSecret, updateAuthServiceSecret, } from "./config"; import { AuthserviceConfig } from "./types"; +import { UDSConfig } from "../../config/config"; const getChain = (name: string) => { return { @@ -119,6 +121,13 @@ describe("AuthService Config Tests", () => { beforeEach(() => { process.env.PEPR_WATCH_MODE = "true"; + UDSConfig.domain = "uds.dev"; + UDSConfig.adminDomain = "admin.uds.dev"; + UDSConfig.subdomain = ""; + UDSConfig.contextPath = ""; + UDSConfig.adminContextPath = "/admin"; + UDSConfig.pathRouting = false; + initializeOperatorConfig(); vi.useFakeTimers(); }); @@ -147,6 +156,24 @@ describe("AuthService Config Tests", () => { }); }); + it("buildInitialSecret should use path-routed SSO URLs", () => { + UDSConfig.pathRouting = true; + UDSConfig.subdomain = "foo"; + UDSConfig.contextPath = "/bar"; + initializeOperatorConfig(); + + const secret = buildInitialSecret(); + expect(secret.default_oidc_config.authorization_uri).toEqual( + "https://foo.uds.dev/bar/sso/realms/uds/protocol/openid-connect/auth", + ); + expect(secret.default_oidc_config.token_uri).toEqual( + "https://foo.uds.dev/bar/sso/realms/uds/protocol/openid-connect/token", + ); + expect(secret.default_oidc_config.jwks_fetcher?.jwks_uri).toEqual( + "https://foo.uds.dev/bar/sso/realms/uds/protocol/openid-connect/certs", + ); + }); + it("setupAuthserviceSecret should skip creation if secret exists", async () => { const getMock = vi.fn<() => Promise>().mockResolvedValue({ metadata: { name: "authservice-uds" }, diff --git a/src/pepr/operator/controllers/keycloak/authservice/config.ts b/src/pepr/operator/controllers/keycloak/authservice/config.ts index 03ca7bcce8..ac240b8aff 100644 --- a/src/pepr/operator/controllers/keycloak/authservice/config.ts +++ b/src/pepr/operator/controllers/keycloak/authservice/config.ts @@ -8,6 +8,7 @@ import { K8s, kind } from "pepr"; import { buildCABundleContent } from "../../ca-bundles/ca-bundle"; import { UDSConfig } from "../../config/config"; +import { getSsoUrl } from "../../url-utils"; import { Client } from "../types"; import { buildChain, log } from "./authservice"; import { Action, AuthserviceConfig } from "./types"; @@ -38,7 +39,7 @@ export function initializeOperatorConfig() { operatorConfig = { namespace: "authservice", secretName: "authservice-uds", - baseDomain: `https://sso.${UDSConfig.domain}`, + baseDomain: getSsoUrl(UDSConfig), realm: "uds", }; @@ -87,6 +88,7 @@ export async function setupAuthserviceSecret() { * @returns {AuthserviceConfig} - The initial configuration for the authservice. */ export function buildInitialSecret(): AuthserviceConfig { + const ssoUrl = getSsoUrl(UDSConfig); const config: AuthserviceConfig = { allow_unmatched_requests: false, listen_address: "0.0.0.0", @@ -94,10 +96,10 @@ export function buildInitialSecret(): AuthserviceConfig { log_level: "info", default_oidc_config: { skip_verify_peer_cert: false, - authorization_uri: `https://sso.${UDSConfig.domain}/realms/${operatorConfig.realm}/protocol/openid-connect/auth`, - token_uri: `https://sso.${UDSConfig.domain}/realms/${operatorConfig.realm}/protocol/openid-connect/token`, + authorization_uri: `${ssoUrl}/realms/${operatorConfig.realm}/protocol/openid-connect/auth`, + token_uri: `${ssoUrl}/realms/${operatorConfig.realm}/protocol/openid-connect/token`, jwks_fetcher: { - jwks_uri: `https://sso.${UDSConfig.domain}/realms/${operatorConfig.realm}/protocol/openid-connect/certs`, + jwks_uri: `${ssoUrl}/realms/${operatorConfig.realm}/protocol/openid-connect/certs`, periodic_fetch_interval_sec: 60, }, client_id: "global_id", @@ -109,7 +111,7 @@ export function buildInitialSecret(): AuthserviceConfig { trusted_certificate_authority: buildCABundleContent(), logout: { path: "/globallogout", - redirect_uri: `https://sso.${UDSConfig.domain}/realms/${operatorConfig.realm}/protocol/openid-connect/token/logout`, + redirect_uri: `${ssoUrl}/realms/${operatorConfig.realm}/protocol/openid-connect/token/logout`, }, absolute_session_timeout: "0", idle_session_timeout: "0", diff --git a/src/pepr/operator/controllers/uptime/config.spec.ts b/src/pepr/operator/controllers/uptime/config.spec.ts index 0f4b6869b5..0ceeb703c8 100644 --- a/src/pepr/operator/controllers/uptime/config.spec.ts +++ b/src/pepr/operator/controllers/uptime/config.spec.ts @@ -63,6 +63,11 @@ describe("updateBlackboxConfig", () => { beforeEach(() => { UDSConfig.domain = "uds.dev"; + UDSConfig.adminDomain = "admin.uds.dev"; + UDSConfig.subdomain = ""; + UDSConfig.contextPath = ""; + UDSConfig.adminContextPath = "/admin"; + UDSConfig.pathRouting = false; secretClient = createMockK8sClient(); vi.mocked(K8s as Mock).mockReturnValue(secretClient); }); @@ -96,6 +101,21 @@ describe("updateBlackboxConfig", () => { }); }); + it("generates OAuth2 config with the path-routed SSO token URL", async () => { + UDSConfig.pathRouting = true; + UDSConfig.subdomain = "foo"; + UDSConfig.contextPath = "/bar"; + secretClient.Get.mockResolvedValue(makeBlackboxSecret({ modules: {} })); + + await updateBlackboxConfig("my-ns", [{ clientId: "app-probe", secret: "mysecret" }]); + + const applied = getAppliedConfig(secretClient); + const oauth2 = applied.modules["http_200x_sso_my-ns_app-probe"].http.oauth2; + expect(oauth2.token_url).toEqual( + "https://foo.uds.dev/bar/sso/realms/uds/protocol/openid-connect/token", + ); + }); + it("removes all SSO modules for a namespace when probeClients is empty", async () => { secretClient.Get.mockResolvedValue( makeBlackboxSecret({ diff --git a/src/pepr/operator/controllers/uptime/config.ts b/src/pepr/operator/controllers/uptime/config.ts index d9eb5f3a29..d29e795b8e 100644 --- a/src/pepr/operator/controllers/uptime/config.ts +++ b/src/pepr/operator/controllers/uptime/config.ts @@ -6,6 +6,7 @@ import { K8s, kind } from "pepr"; import { Component, setupLogger } from "../../../logger"; import { UDSConfig } from "../config/config"; +import { getSsoUrl } from "../url-utils"; import { Mutex } from "../utils"; import type { BlackboxConfig } from "./types"; @@ -60,7 +61,7 @@ export async function updateBlackboxConfig( Object.entries(currentConfig.modules).filter(([name]) => !name.startsWith(namespacePrefix)), ); - const tokenUrl = `https://sso.${UDSConfig.domain}/realms/uds/protocol/openid-connect/token`; + const tokenUrl = `${getSsoUrl(UDSConfig)}/realms/uds/protocol/openid-connect/token`; // Regenerate SSO modules for each probe client in this namespace for (const { clientId, secret: clientSecret } of probeClients) { diff --git a/src/pepr/operator/controllers/url-utils.ts b/src/pepr/operator/controllers/url-utils.ts new file mode 100644 index 0000000000..99aa19007a --- /dev/null +++ b/src/pepr/operator/controllers/url-utils.ts @@ -0,0 +1,40 @@ +/** + * Copyright 2026 Defense Unicorns + * SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial + */ + +import type { Config } from "./config/types"; + +export const defaultAdminContextPath = "/admin"; + +export function normalizeContextPath(path?: string, defaultPath = ""): string { + const rawPath = path || defaultPath; + if (!rawPath || rawPath === "/" || rawPath.startsWith("###ZARF_VAR_")) { + return defaultPath === "/" ? "" : defaultPath; + } + + const withLeadingSlash = rawPath.startsWith("/") ? rawPath : `/${rawPath}`; + return withLeadingSlash.replace(/\/+$/g, ""); +} + +export function getHost(config: Config): string { + return config.subdomain ? `${config.subdomain}.${config.domain}` : config.domain; +} + +export function getPublicBaseUrl(config: Config): string { + return `https://${getHost(config)}${config.contextPath}`; +} + +export function getAdminBaseUrl(config: Config): string { + return `${getPublicBaseUrl(config)}${config.adminContextPath || defaultAdminContextPath}`; +} + +export function getSsoUrl(config: Config): string { + return config.pathRouting ? `${getPublicBaseUrl(config)}/sso` : `https://sso.${config.domain}`; +} + +export function getAdminAppUrl(config: Config, app: string): string { + return config.pathRouting + ? `${getAdminBaseUrl(config)}/${app}` + : `https://${app}.${config.adminDomain}`; +} diff --git a/src/pepr/operator/crd/generated/clusterconfig-v1alpha1.ts b/src/pepr/operator/crd/generated/clusterconfig-v1alpha1.ts index c91eba2709..39d02e4d95 100644 --- a/src/pepr/operator/crd/generated/clusterconfig-v1alpha1.ts +++ b/src/pepr/operator/crd/generated/clusterconfig-v1alpha1.ts @@ -59,10 +59,26 @@ export interface Expose { * Domain all cluster services on the admin gateway will be exposed on */ adminDomain?: string; + /** + * Optional admin URL context path appended after contextPath when pathRouting is enabled + */ + adminContextPath?: string; + /** + * Optional public URL context path used when pathRouting is enabled, for example /bar + */ + contextPath?: string; /** * Domain all cluster services will be exposed on */ domain: string; + /** + * Route built-in public and admin services on a single host using URL paths + */ + pathRouting?: boolean; + /** + * Optional subdomain used with domain for single-host path routing, for example foo in foo.uds.dev + */ + subdomain?: string; } export interface Networking { diff --git a/src/pepr/operator/crd/sources/cluster-config/v1alpha1.ts b/src/pepr/operator/crd/sources/cluster-config/v1alpha1.ts index bc8b9f5b2b..f230567063 100644 --- a/src/pepr/operator/crd/sources/cluster-config/v1alpha1.ts +++ b/src/pepr/operator/crd/sources/cluster-config/v1alpha1.ts @@ -119,6 +119,28 @@ export const v1alpha1: V1CustomResourceDefinitionVersion = { description: "Domain all cluster services on the admin gateway will be exposed on", }, + subdomain: { + type: "string", + description: + "Optional subdomain used with domain for single-host path routing, for example foo in foo.uds.dev", + }, + contextPath: { + type: "string", + description: + "Optional public URL context path used when pathRouting is enabled, for example /bar", + }, + adminContextPath: { + type: "string", + description: + "Optional admin URL context path appended after contextPath when pathRouting is enabled", + default: "/admin", + }, + pathRouting: { + type: "boolean", + description: + "Route built-in public and admin services on a single host using URL paths", + default: false, + }, }, required: ["domain"], }, diff --git a/src/pepr/operator/crd/validators/clusterconfig-validator.spec.ts b/src/pepr/operator/crd/validators/clusterconfig-validator.spec.ts index 8070afd439..087126a37a 100644 --- a/src/pepr/operator/crd/validators/clusterconfig-validator.spec.ts +++ b/src/pepr/operator/crd/validators/clusterconfig-validator.spec.ts @@ -137,6 +137,55 @@ invalid-cert-data }; expect(() => validateCfg(emptyCaBundle)).not.toThrowError(); }); + + it("allows normalized path routing context paths", () => { + const pathCfg = { + ...mockCfg, + spec: { + ...mockCfg.spec!, + expose: { + ...mockCfg.spec!.expose, + pathRouting: true, + subdomain: "foo", + contextPath: "/bar", + adminContextPath: "/admin", + }, + }, + }; + expect(() => validateCfg(pathCfg)).not.toThrowError(); + }); + + it("throws error when context paths do not start with slash", () => { + const pathCfg = { + ...mockCfg, + spec: { + ...mockCfg.spec!, + expose: { + ...mockCfg.spec!.expose, + contextPath: "bar", + }, + }, + }; + expect(() => validateCfg(pathCfg)).toThrowError( + "ClusterConfig: expose.contextPath must start with /", + ); + }); + + it("throws error when context paths end with slash", () => { + const pathCfg = { + ...mockCfg, + spec: { + ...mockCfg.spec!, + expose: { + ...mockCfg.spec!.expose, + adminContextPath: "/admin/", + }, + }, + }; + expect(() => validateCfg(pathCfg)).toThrowError( + "ClusterConfig: expose.adminContextPath must not end with /", + ); + }); }); describe("ClusterConfig Update validation", () => { diff --git a/src/pepr/operator/crd/validators/clusterconfig-validator.ts b/src/pepr/operator/crd/validators/clusterconfig-validator.ts index 0febfeb53d..aa1a8bf4ae 100644 --- a/src/pepr/operator/crd/validators/clusterconfig-validator.ts +++ b/src/pepr/operator/crd/validators/clusterconfig-validator.ts @@ -5,6 +5,7 @@ import { PeprValidateRequest } from "pepr"; import { X509Certificate } from "crypto"; +import { normalizeContextPath } from "../../controllers/url-utils"; import { isBase64 } from "../../controllers/utils"; import { ClusterConfig } from "../generated/clusterconfig-v1alpha1"; @@ -18,6 +19,8 @@ export async function validateCfgUpdate(req: PeprValidateRequest) } export function validateCfg(cfg: ClusterConfig) { + validateExposePaths(cfg); + // Validate that the caBundle.certs is base64 encoded and is a valid cert bundle if (cfg.spec?.caBundle?.certs && cfg.spec.caBundle.certs !== "###ZARF_VAR_CA_BUNDLE_CERTS###") { if (!isBase64(cfg.spec.caBundle.certs)) { @@ -46,3 +49,33 @@ export function validateCfg(cfg: ClusterConfig) { }); } } + +function validateExposePaths(cfg: ClusterConfig) { + const expose = cfg.spec?.expose; + if (!expose) { + return; + } + + for (const [field, value] of Object.entries({ + contextPath: expose.contextPath, + adminContextPath: expose.adminContextPath, + })) { + if (!value || value.startsWith("###ZARF_VAR_")) { + continue; + } + if (!value.startsWith("/")) { + throw new Error(`ClusterConfig: expose.${field} must start with /`); + } + if (value.length > 1 && value.endsWith("/")) { + throw new Error(`ClusterConfig: expose.${field} must not end with /`); + } + } + + const contextPath = normalizeContextPath(expose.contextPath); + const adminContextPath = normalizeContextPath(expose.adminContextPath, "/admin"); + if (contextPath && contextPath === adminContextPath) { + throw new Error( + "ClusterConfig: expose.contextPath and expose.adminContextPath must not collide", + ); + } +} diff --git a/src/pepr/uds-cluster-crds/templates/clusterconfig.uds.dev.yaml b/src/pepr/uds-cluster-crds/templates/clusterconfig.uds.dev.yaml index 627300b2f4..ee916696fe 100644 --- a/src/pepr/uds-cluster-crds/templates/clusterconfig.uds.dev.yaml +++ b/src/pepr/uds-cluster-crds/templates/clusterconfig.uds.dev.yaml @@ -106,6 +106,28 @@ spec: description: >- Domain all cluster services on the admin gateway will be exposed on + subdomain: + type: string + description: >- + Optional subdomain used with domain for single-host path + routing, for example foo in foo.uds.dev + contextPath: + type: string + description: >- + Optional public URL context path used when pathRouting is + enabled, for example /bar + adminContextPath: + type: string + description: >- + Optional admin URL context path appended after + contextPath when pathRouting is enabled + default: /admin + pathRouting: + type: boolean + description: >- + Route built-in public and admin services on a single + host using URL paths + default: false required: - domain policy: diff --git a/src/pepr/uds-operator-config/templates/_helpers.tpl b/src/pepr/uds-operator-config/templates/_helpers.tpl index 3a42d419ca..63072bd262 100644 --- a/src/pepr/uds-operator-config/templates/_helpers.tpl +++ b/src/pepr/uds-operator-config/templates/_helpers.tpl @@ -60,3 +60,12 @@ Create the name of the service account to use {{- default "default" .Values.serviceAccount.name }} {{- end }} {{- end }} + +{{- define "uds-operator-config.adminDomain" -}} +{{- $adminDomain := .Values.cluster.expose.adminDomain | default "" -}} +{{- if or (eq $adminDomain "") (hasPrefix "###ZARF_VAR_" $adminDomain) -}} +{{- printf "admin.%s" .Values.cluster.expose.domain -}} +{{- else -}} +{{- tpl $adminDomain . -}} +{{- end -}} +{{- end }} diff --git a/src/pepr/uds-operator-config/templates/clusterconfig.yaml b/src/pepr/uds-operator-config/templates/clusterconfig.yaml index 05f7cf3d2f..aa43eec92f 100644 --- a/src/pepr/uds-operator-config/templates/clusterconfig.yaml +++ b/src/pepr/uds-operator-config/templates/clusterconfig.yaml @@ -36,10 +36,16 @@ spec: {{- end }} expose: domain: {{ .Values.cluster.expose.domain | quote }} - {{- if .Values.cluster.expose.adminDomain }} - adminDomain: {{ .Values.cluster.expose.adminDomain | quote }} - {{- else }} - adminDomain: "admin.{{ .Values.cluster.expose.domain }}" + adminDomain: {{ include "uds-operator-config.adminDomain" . | quote }} + {{- if .Values.cluster.expose.subdomain }} + subdomain: {{ .Values.cluster.expose.subdomain | quote }} {{- end }} + {{- if .Values.cluster.expose.contextPath }} + contextPath: {{ .Values.cluster.expose.contextPath | quote }} + {{- end }} + {{- if .Values.cluster.expose.adminContextPath }} + adminContextPath: {{ .Values.cluster.expose.adminContextPath | quote }} + {{- end }} + pathRouting: {{ .Values.cluster.expose.pathRouting | default false | toString | eq "true" }} policy: allowAllNsExemptions: {{ .Values.cluster.policy.allowAllNsExemptions }} diff --git a/src/pepr/uds-operator-config/tests/clusterconfig_test.yaml b/src/pepr/uds-operator-config/tests/clusterconfig_test.yaml index 5173f28070..d86b182ceb 100644 --- a/src/pepr/uds-operator-config/tests/clusterconfig_test.yaml +++ b/src/pepr/uds-operator-config/tests/clusterconfig_test.yaml @@ -99,6 +99,28 @@ tests: - notExists: path: spec.networking + - it: should render path routing expose values + set: + cluster: + expose: + domain: "example.com" + subdomain: "foo" + contextPath: "/bar" + adminContextPath: "/admin" + pathRouting: true + asserts: + - isKind: + of: ClusterConfig + - equal: + path: spec.expose + value: + domain: "example.com" + adminDomain: "admin.example.com" + subdomain: "foo" + contextPath: "/bar" + adminContextPath: "/admin" + pathRouting: true + - it: should fail when using removed operator.KUBEAPI_CIDR value set: operator: diff --git a/src/pepr/uds-operator-config/values.schema.json b/src/pepr/uds-operator-config/values.schema.json index a4dbd08cc3..7304b80b38 100644 --- a/src/pepr/uds-operator-config/values.schema.json +++ b/src/pepr/uds-operator-config/values.schema.json @@ -62,6 +62,23 @@ "adminDomain": { "type": "string", "description": "Admin domain for cluster exposure." + }, + "subdomain": { + "type": "string", + "description": "Optional subdomain for path-based single-host routing." + }, + "contextPath": { + "type": "string", + "description": "Optional public context path for path-based single-host routing." + }, + "adminContextPath": { + "type": "string", + "description": "Optional admin context path for path-based single-host routing." + }, + "pathRouting": { + "type": ["boolean", "string"], + "enum": ["true", "false", "###ZARF_VAR_PATH_ROUTING###", true, false], + "description": "Route built-in services on one host using URL paths." } } }, diff --git a/src/pepr/uds-operator-config/values.yaml b/src/pepr/uds-operator-config/values.yaml index 5bfe560b15..8bffff9bfb 100644 --- a/src/pepr/uds-operator-config/values.yaml +++ b/src/pepr/uds-operator-config/values.yaml @@ -21,6 +21,10 @@ cluster: # Domain configuration (admin defaults to `admin.UDS_DOMAIN`) domain: "###ZARF_VAR_DOMAIN###" adminDomain: "###ZARF_VAR_ADMIN_DOMAIN###" + subdomain: "###ZARF_VAR_SUBDOMAIN###" + contextPath: "###ZARF_VAR_CONTEXT_PATH###" + adminContextPath: "###ZARF_VAR_ADMIN_CONTEXT_PATH###" + pathRouting: "###ZARF_VAR_PATH_ROUTING###" policy: allowAllNsExemptions: "###ZARF_VAR_ALLOW_ALL_NS_EXEMPTIONS###" networking: diff --git a/src/pepr/zarf.yaml b/src/pepr/zarf.yaml index a5d93813c2..854ad98b63 100644 --- a/src/pepr/zarf.yaml +++ b/src/pepr/zarf.yaml @@ -15,6 +15,22 @@ variables: - name: ADMIN_DOMAIN description: "Domain for admin services, defaults to `admin.DOMAIN`" + - name: SUBDOMAIN + description: "Optional subdomain for single-host path routing" + default: "" + + - name: CONTEXT_PATH + description: "Optional public context path for single-host path routing" + default: "" + + - name: ADMIN_CONTEXT_PATH + description: "Optional admin context path for single-host path routing" + default: "/admin" + + - name: PATH_ROUTING + description: "Whether to route built-in services on one host using URL paths" + default: "false" + - name: CA_BUNDLE_CERTS description: "Base64 encoded CA certs (bundle) in PEM format that UDS Core will inherently trust. At a minimum this must at least include your Domain CA Cert if using Private PKI for your UDS Core Deployment" default: ""