diff --git a/docs/concepts/core-features/networking.mdx b/docs/concepts/core-features/networking.mdx index 9b95810ee1..a7c72be87b 100644 --- a/docs/concepts/core-features/networking.mdx +++ b/docs/concepts/core-features/networking.mdx @@ -53,6 +53,27 @@ The Tenant and Admin gateways are deployed as separate Istio gateways with their By default, gateways only support HTTP/HTTPS traffic. Non-HTTP TCP ingress (e.g., SSH) requires additional configuration. See [Set up non-HTTP ingress](/how-to-guides/networking/configure-non-http-ingress/). +## Domain-based vs path-based routing + +UDS Core defaults to domain-based routing, where each exposed application uses a dedicated hostname. You can optionally enable path-based routing for built-in services when your ingress model needs fewer DNS entries and explicit path segmentation on shared hosts. + +The routing models differ as follows: + +| Model | URL pattern | Best fit | Notes | +|---|---|---|---| +| Domain-based (default) | `https://app.yourdomain.com` | Most platform and app deployments | Clear isolation by hostname and straightforward DNS ownership | +| Path-based (optional) | `https://yourdomain.com//` | Environments that prefer shared hosts with path segmentation | In this release, built-in Keycloak and Grafana routes are path-aware | + +When path routing is enabled with `pathRouting: true`, UDS Core builds built-in routes from `domain`, `adminDomain`, `contextPath`, and `adminContextPath` values in `ClusterConfig`. + +For example, with `domain=example.mil`, `adminDomain=admin.example.mil`, `contextPath=/platform`, and `adminContextPath=/admin`: + +- Keycloak SSO is exposed at `https://example.mil/platform/sso` +- Keycloak admin is exposed at `https://admin.example.mil/platform/admin/keycloak` +- Grafana is exposed at `https://admin.example.mil/platform/admin/grafana` + +Application teams can still use `Package` route matching (`match.uri`) and rewrites (`advancedHTTP.rewrite`) to route multiple app paths on shared hosts. Reference [Use path-based routing](/how-to-guides/networking/use-path-based-routing/) for the end-to-end configuration flow. + ## How application traffic flows When a team deploys a UDS Package, they declare their networking intent in a `Package` CR. diff --git a/docs/getting-started/production/build-your-bundle.mdx b/docs/getting-started/production/build-your-bundle.mdx index f508d4da2d..cfc4a91905 100644 --- a/docs/getting-started/production/build-your-bundle.mdx +++ b/docs/getting-started/production/build-your-bundle.mdx @@ -243,7 +243,7 @@ packages: ```yaml title="uds-config.yaml" variables: core: - keycloak_db_host: "your-db-host" # hostname or IP of your database server + keycloak_db_host: "your-db-host" # hostname or IP of your database server keycloak_db_username: "keycloak" # database user created in provision-services step keycloak_db_database: "keycloak" # database name created in provision-services step keycloak_db_password: "your-db-password" # password for the database user @@ -401,6 +401,35 @@ variables: > [!NOTE] > The `shared` section values (`domain`) are automatically available to all packages in the bundle. No bundle YAML overrides are needed for domain configuration; they flow through automatically. +## Configure path-based routing (optional) + +UDS Core can route built-in services on shared hosts by URL path instead of subdomain. This is useful when your ingress model prefers one public host and one admin host with explicit path segmentation. + +To enable this behavior, set the `ClusterConfig` expose fields through core overrides: + +```yaml title="uds-bundle.yaml" +packages: + - name: core + overrides: + uds-operator-config: + uds-operator-config: + values: + - path: cluster.expose.pathRouting + value: true + - path: cluster.expose.contextPath + value: "/platform" + - path: cluster.expose.adminContextPath + value: "/admin" +``` + +With `shared.domain: yourdomain.com` and `shared.admin_domain: admin.yourdomain.com`, the built-in routes become: + +- `https://yourdomain.com/platform/sso` +- `https://admin.yourdomain.com/platform/admin/keycloak` +- `https://admin.yourdomain.com/platform/admin/grafana` + +Path-based routing in this release applies to built-in Keycloak and Grafana routes. Reference [Use path-based routing](/how-to-guides/networking/use-path-based-routing/) for the complete workflow, `Package` route matching, and troubleshooting. + ## Build the bundle Once your configuration files are ready, create the deployable bundle artifact. @@ -432,4 +461,3 @@ Once your configuration files are ready, create the deployable bundle artifact. > [!NOTE] > The resulting bundle is self-contained (all images embedded, no internet needed at deploy time), versioned and reproducible, and transferable to airgapped environments or artifact registries. - diff --git a/docs/how-to-guides/networking/expose-apps-on-gateways.mdx b/docs/how-to-guides/networking/expose-apps-on-gateways.mdx index 36e270a092..bd75f443ec 100644 --- a/docs/how-to-guides/networking/expose-apps-on-gateways.mdx +++ b/docs/how-to-guides/networking/expose-apps-on-gateways.mdx @@ -211,7 +211,40 @@ After completing this guide, your application will be accessible through one of All `advancedHTTP` options are composable; you can combine match conditions, headers, CORS, retries, and timeouts in a single expose entry. See the [`Package` CR reference](/reference/operator-and-crds/packages-v1alpha1-cr/) for the full list of supported fields. -4. **Deploy your application** +4. **(Optional) Route multiple applications on a single domain using paths** + + If your ingress design uses shared hosts with URL path segmentation, use `match.uri` to route by path and `advancedHTTP.rewrite` to normalize requests for your backend. + + ```yaml title="uds-package.yaml" + apiVersion: uds.dev/v1alpha1 + kind: Package + metadata: + name: app1 + namespace: app1 + spec: + network: + expose: + - service: app1 + selector: + app.kubernetes.io/name: app1 + host: "." + gateway: tenant + port: 8080 + match: + - uri: + prefix: /app1 + advancedHTTP: + rewrite: + # Strip /app1 before forwarding to the upstream application. + uri: / + ``` + + Use this pattern for each application path (for example, `/app1`, `/app2`) to route multiple services on the same host. + + > [!TIP] + > Reference [Use path-based routing](/how-to-guides/networking/use-path-based-routing/) for built-in service behavior (`pathRouting`, `contextPath`, and `adminContextPath`) and full configuration examples. + +5. **Deploy your application** **(Recommended)** Include the `Package` CR manifest in your [Zarf package](https://docs.zarf.dev/ref/create/) alongside your application's Helm chart and create/deploy. See [Packaging applications](/how-to-guides/packaging-applications/overview/) for general packaging guidance. @@ -276,6 +309,7 @@ curl -v https://my-app.yourdomain.com - [Networking & Service Mesh Concepts](/concepts/core-features/networking/) - how the service mesh, gateways, and network policies work together in UDS Core - [`Package` CR Reference](/reference/operator-and-crds/packages-v1alpha1-cr/) - full CR schema and field reference for network, SSO, and monitoring configuration +- [Use path-based routing](/how-to-guides/networking/use-path-based-routing/) - Configure shared-host path routing and built-in service URL paths. - [Enable the passthrough gateway](/how-to-guides/networking/enable-passthrough-gateway/) - Deploy the optional passthrough gateway for apps that handle their own TLS. - [Define network access](/how-to-guides/networking/define-network-access/) - Configure intra-cluster and external network access for your application. - [Create a custom gateway](/how-to-guides/networking/create-custom-gateways/) - Deploy a gateway with independent domain, TLS, and security controls. diff --git a/docs/how-to-guides/networking/overview.mdx b/docs/how-to-guides/networking/overview.mdx index 363a06c804..328e2fc670 100644 --- a/docs/how-to-guides/networking/overview.mdx +++ b/docs/how-to-guides/networking/overview.mdx @@ -24,6 +24,11 @@ For background on how the service mesh, gateways, and authorization model work, description="Make applications accessible through the tenant or admin gateway." href="/how-to-guides/networking/expose-apps-on-gateways/" /> + ` and `.`. + +With `pathRouting: true`, UDS Core uses shared hosts for exposed services. Use `Package` `match.uri` rules (and rewrites when needed) to route applications by path and avoid collisions. + +## Steps + + + +1. **Enable path routing in your core bundle overrides** + + Set `pathRouting` and optional path prefixes in your core override. Use leading slashes and avoid trailing slashes. + + ```yaml title="uds-bundle.yaml" + packages: + - name: core + repository: registry.defenseunicorns.com/public/core + ref: x.x.x-upstream + overrides: + uds-operator-config: + uds-operator-config: + values: + - path: cluster.expose.pathRouting + value: true + - path: cluster.expose.contextPath + value: "/platform" + - path: cluster.expose.adminContextPath + value: "/admin" + ``` + + > [!IMPORTANT] + > `contextPath` and `adminContextPath` must not collide after normalization. For example, using `/admin` for both fields is invalid. + +2. **Set your shared domains** + + Configure the public and admin domains in your deployment config. + + ```yaml title="uds-config.yaml" + shared: + domain: "example.mil" + admin_domain: "admin.example.mil" + ``` + + With the example values above, built-in paths resolve as follows: + - SSO: `https://example.mil/platform/sso` + - Keycloak admin UI: `https://admin.example.mil/platform/admin/keycloak` + - Grafana: `https://admin.example.mil/platform/admin/grafana` + +3. **Route application paths with `Package` match rules** + + Use URI matching to pin requests to a service path, and rewrite when the backend expects `/` instead of the external path prefix. + + ```yaml title="uds-package.yaml" + apiVersion: uds.dev/v1alpha1 + kind: Package + metadata: + name: app1 + namespace: app1 + spec: + network: + expose: + - service: app1 + selector: + app: app1 + host: "." + gateway: tenant + port: 8080 + match: + - uri: + prefix: /app1 + advancedHTTP: + rewrite: + # Strip the external path prefix before forwarding to the app service. + uri: / + ``` + + If your backend already handles the prefixed path, omit the rewrite. + +4. **Create and deploy your bundle** + + Build and deploy the updated configuration. + + ```bash + uds create --confirm + uds deploy uds-bundle-*.tar.zst --confirm + ``` + + + +## Verification + +Verify the cluster routing configuration and test the built-in endpoints. + +```bash +uds zarf tools kubectl get clusterconfig uds-cluster-config -o yaml | grep -A6 "expose:" +curl -I https://example.mil/platform/sso +curl -I https://admin.example.mil/platform/admin/grafana +``` + +## Troubleshooting + +### Problem: Requests return 404 after enabling path routing + +**Symptom:** Built-in service URLs return `404` or route to the wrong app. + +**Solution:** Confirm `contextPath` and `adminContextPath` include a leading `/`, do not include a trailing `/`, and do not normalize to the same value. + +### Problem: Backend receives an unexpected prefixed path + +**Symptom:** Your app logs show requests such as `/app1/healthz` when it expects `/healthz`. + +**Solution:** Add `advancedHTTP.rewrite.uri: /` to the matching expose entry, then redeploy. + +## Related documentation + +- [Expose applications on gateways](/how-to-guides/networking/expose-apps-on-gateways/) - Configure gateway exposure and route behavior with `Package` resources. +- [Build your bundle](/getting-started/production/build-your-bundle/) - Set core bundle overrides and deploy production-ready bundles. +- [Clusterconfig CR (v1alpha1)](/reference/operator-and-crds/clusterconfig-v1alpha1-cr/) - Field-level reference for `pathRouting`, `contextPath`, and `adminContextPath`. +- [Packages CR (v1alpha1)](/reference/operator-and-crds/packages-v1alpha1-cr/) - Full schema for `match` and `advancedHTTP` route controls. diff --git a/docs/operations/troubleshooting-and-runbooks/overview.mdx b/docs/operations/troubleshooting-and-runbooks/overview.mdx index 347221f3e3..67454c8651 100644 --- a/docs/operations/troubleshooting-and-runbooks/overview.mdx +++ b/docs/operations/troubleshooting-and-runbooks/overview.mdx @@ -37,6 +37,11 @@ If you're setting up UDS Core for the first time, see [How-To Guides](/how-to-gu description="Diagnose and fix admission failures, unexpected mutations, and stuck deployments caused by UDS policies." href="/operations/troubleshooting-and-runbooks/policy-violations/" /> + + +1. **Confirm path routing is enabled and values are correct** + + ```bash + uds zarf tools kubectl get clusterconfig uds-cluster-config -o yaml + ``` + + **What to look for:** + - `spec.expose.pathRouting: true` + - `spec.expose.contextPath` and `spec.expose.adminContextPath` start with `/` + - No trailing `/` on either path + - Paths do not normalize to the same value + +2. **Verify built-in endpoints respond** + + ```bash + curl -Ik https:///sso + curl -Ik https:///keycloak + curl -Ik https:///grafana + ``` + + **What to look for:** `200`, `302`, or expected auth redirects. `404` or `503` indicates routing or gateway mismatch. + +3. **Inspect generated VirtualService rules for your app** + + ```bash + uds zarf tools kubectl get virtualservice -A | grep -i + uds zarf tools kubectl get virtualservice -n -o yaml + ``` + + **What to look for:** expected `match.uri` prefixes and any configured `rewrite` behavior. + + + +## Procedure + +### Cause 1: Invalid expose path configuration + +If `pathRouting` is enabled but paths are malformed or colliding: + + + +1. **Set valid path values** + + Ensure both values use leading slashes and no trailing slashes. + +2. **Apply corrected configuration** + + ```bash + uds zarf tools kubectl apply -f .yaml + ``` + +3. **Wait for reconciliation and retest URLs** + + ```bash + uds zarf tools kubectl get clusterconfig uds-cluster-config -o yaml + curl -Ik https:///sso + ``` + + + +### Cause 2: Route rule mismatch or missing rewrite + +If requests match the host but not the expected backend route: + + + +1. **Check your `Package` `match.uri` values** + + Confirm incoming paths match your configured prefixes or exact values. + +2. **Add rewrite when backend expects `/`** + + ```yaml title="uds-package.yaml (excerpt)" + spec: + network: + expose: + - match: + - uri: + prefix: /app1 + advancedHTTP: + rewrite: + uri: / + ``` + +3. **Redeploy and verify** + + ```bash + uds zarf tools kubectl apply -f uds-package.yaml + curl -Ik https:///app1 + ``` + + + +### Cause 3: Keycloak path-parameter security rejection + +If Keycloak path-routed requests return the path-parameter error: + + + +1. **Inspect request URLs for semicolon path parameters** + + Look for patterns such as `/sso;foo=bar/realms/...`. + +2. **Remove or normalize unsafe path parameters in the caller** + + Update clients, ingress rewrites, or upstream redirects to avoid semicolon parameters in non-final path segments. + +3. **Retest the flow** + + ```bash + curl -Ik "https:///sso/realms/uds/.well-known/openid-configuration" + ``` + + + +## Verification + +After applying a fix, confirm path-routed endpoints and app routes are healthy: + +```bash +curl -Ik https:///sso +curl -Ik https:///keycloak +curl -Ik https:///grafana +uds zarf tools kubectl get virtualservice -A | grep -i +``` + +**Success indicators:** +- Built-in path-routed URLs return expected status codes or redirects +- Application routes resolve to the correct backend service +- No recurring path-parameter `400` responses for normal client traffic + +## Additional help + +If this runbook does not resolve your issue: + +1. Collect the `ClusterConfig` expose values and relevant `VirtualService` snippets +2. Check [UDS Core GitHub Issues](https://github.com/defenseunicorns/uds-core/issues) for known issues +3. Open a new issue with request examples and diagnostics attached + +## Related documentation + +- [Use path-based routing](/how-to-guides/networking/use-path-based-routing/) - Configure `pathRouting`, `contextPath`, and `adminContextPath`. +- [Expose applications on gateways](/how-to-guides/networking/expose-apps-on-gateways/) - Configure `Package` expose rules, URI matching, and rewrites. +- [Clusterconfig CR (v1alpha1)](/reference/operator-and-crds/clusterconfig-v1alpha1-cr/) - Reference for expose path-routing fields and expected formats. +- [Packages CR (v1alpha1)](/reference/operator-and-crds/packages-v1alpha1-cr/) - Full schema for `match` and `advancedHTTP.rewrite` fields. diff --git a/docs/reference/operator-and-crds/clusterconfig-v1alpha1-cr.md b/docs/reference/operator-and-crds/clusterconfig-v1alpha1-cr.md index 3f46946764..2cc25665f2 100644 --- a/docs/reference/operator-and-crds/clusterconfig-v1alpha1-cr.md +++ b/docs/reference/operator-and-crds/clusterconfig-v1alpha1-cr.md @@ -126,7 +126,7 @@ sidebar: - domainstringDomain all cluster services will be exposed onadminDomainstringDomain all cluster services on the admin gateway will be exposed on + domainstringDomain all cluster services will be exposed onadminDomainstringDomain all cluster services on the admin gateway will be exposed oncontextPathstringOptional public URL context path used when pathRouting is enabled, for example /baradminContextPathstringOptional admin URL context path appended after contextPath when pathRouting is enabledpathRoutingbooleanRoute built-in public and admin services on a single host using URL paths diff --git a/schemas/clusterconfig-v1alpha1.schema.json b/schemas/clusterconfig-v1alpha1.schema.json index 1b94bff1c0..b4ce474bf2 100644 --- a/schemas/clusterconfig-v1alpha1.schema.json +++ b/schemas/clusterconfig-v1alpha1.schema.json @@ -101,13 +101,25 @@ "type": "object", "additionalProperties": false, "properties": { + "adminContextPath": { + "type": "string", + "description": "Optional admin URL context path appended after contextPath when pathRouting is enabled" + }, "adminDomain": { "type": "string", "description": "Domain all cluster services on the admin gateway will be exposed on" }, + "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" } }, "required": [ diff --git a/src/grafana/chart/templates/_helpers.tpl b/src/grafana/chart/templates/_helpers.tpl index 01c2b46237..d24a58cd82 100644 --- a/src/grafana/chart/templates/_helpers.tpl +++ b/src/grafana/chart/templates/_helpers.tpl @@ -97,3 +97,59 @@ 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 "" -}} +{{- 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.adminDomain" -}} +{{- if .Values.adminDomain -}} +{{- tpl .Values.adminDomain . -}} +{{- else -}} +{{- printf "admin.%s" .Values.domain -}} +{{- end -}} +{{- end -}} + +{{- define "grafana.host" -}} +{{- .Values.domain -}} +{{- end -}} + +{{- define "grafana.adminHost" -}} +{{- include "grafana.adminDomain" . -}} +{{- 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 aa4c882e7a..d60a5fc139 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,13 +38,20 @@ spec: - service: grafana selector: app.kubernetes.io/name: grafana - host: grafana + host: {{ ternary "." "grafana" (eq (include "grafana.pathRouting.enabled" .) "true") | quote }} gateway: admin port: 80 targetPort: 3000 annotations: uds.dev/title: "Grafana" uds.dev/icon: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzNTEiIGhlaWdodD0iMzY1IiB2aWV3Qm94PSIwIDAgMzUxIDM2NSI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMTc1LjUiIHkxPSIzMCUiIHgyPSIxNzUuNSIgeTI9Ijk5JSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPjxzdG9wIG9mZnNldD0iMCIgc3RvcC1jb2xvcj0iI0YwNUEyOCIvPjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iI0ZCQ0EwQSIvPjwvbGluZWFyR3JhZGllbnQ+PC9kZWZzPjxwYXRoIGZpbGw9InVybCgjZykiIGQ9Ik0zNDIsMTYxLjJjLTAuNi02LjEtMS42LTEzLjEtMy42LTIwLjljLTItNy43LTUtMTYuMi05LjQtMjVjLTQuNC04LjgtMTAuMS0xNy45LTE3LjUtMjYuOCBjLTIuOS0zLjUtNi4xLTYuOS05LjUtMTAuMmM1LjEtMjAuMy02LjItMzcuOS02LjItMzcuOWMtMTkuNS0xLjItMzEuOSw2LjEtMzYuNSw5LjRjLTAuOC0wLjMtMS41LTAuNy0yLjMtMSBjLTMuMy0xLjMtNi43LTIuNi0xMC4zLTMuN2MtMy41LTEuMS03LjEtMi4xLTEwLjgtM2MtMy43LTAuOS03LjQtMS42LTExLjItMi4yYy0wLjctMC4xLTEuMy0wLjItMi0wLjMgYy04LjUtMjcuMi0zMi45LTM4LjYtMzIuOS0zOC42Yy0yNy4zLDE3LjMtMzIuNCw0MS41LTMyLjQsNDEuNXMtMC4xLDAuNS0wLjMsMS40Yy0xLjUsMC40LTMsMC45LTQuNSwxLjNjLTIuMSwwLjYtNC4yLDEuNC02LjIsMi4yIGMtMi4xLDAuOC00LjEsMS42LTYuMiwyLjVjLTQuMSwxLjgtOC4yLDMuOC0xMi4yLDZjLTMuOSwyLjItNy43LDQuNi0xMS40LDcuMWMtMC41LTAuMi0xLTAuNC0xLTAuNGMtMzcuOC0xNC40LTcxLjMsMi45LTcxLjMsMi45IGMtMy4xLDQwLjIsMTUuMSw2NS41LDE4LjcsNzAuMWMtMC45LDIuNS0xLjcsNS0yLjUsNy41Yy0yLjgsOS4xLTQuOSwxOC40LTYuMiwyOC4xYy0wLjIsMS40LTAuNCwyLjgtMC41LDQuMiBDMTguOCwxOTIuNyw4LjUsMjI4LDguNSwyMjhjMjkuMSwzMy41LDYzLjEsMzUuNiw2My4xLDM1LjZjMCwwLDAuMS0wLjEsMC4xLTAuMWM0LjMsNy43LDkuMywxNSwxNC45LDIxLjljMi40LDIuOSw0LjgsNS42LDcuNCw4LjMgYy0xMC42LDMwLjQsMS41LDU1LjYsMS41LDU1LjZjMzIuNCwxLjIsNTMuNy0xNC4yLDU4LjItMTcuN2MzLjIsMS4xLDYuNSwyLjEsOS44LDIuOWMxMCwyLjYsMjAuMiw0LjEsMzAuNCw0LjUgYzIuNSwwLjEsNS4xLDAuMiw3LjYsMC4xbDEuMiwwbDAuOCwwbDEuNiwwbDEuNi0wLjFsMCwwLjFjMTUuMywyMS44LDQyLjEsMjQuOSw0Mi4xLDI0LjljMTkuMS0yMC4xLDIwLjItNDAuMSwyMC4yLTQ0LjRsMCwwIGMwLDAsMC0wLjEsMC0wLjNjMC0wLjQsMC0wLjYsMC0wLjZsMCwwYzAtMC4zLDAtMC42LDAtMC45YzQtMi44LDcuOC01LjgsMTEuNC05LjFjNy42LTYuOSwxNC4zLTE0LjgsMTkuOS0yMy4zIGMwLjUtMC44LDEtMS42LDEuNS0yLjRjMjEuNiwxLjIsMzYuOS0xMy40LDM2LjktMTMuNGMtMy42LTIyLjUtMTYuNC0zMy41LTE5LjEtMzUuNmwwLDBjMCwwLTAuMS0wLjEtMC4zLTAuMiBjLTAuMi0wLjEtMC4yLTAuMi0wLjItMC4yYzAsMCwwLDAsMCwwYy0wLjEtMC4xLTAuMy0wLjItMC41LTAuM2MwLjEtMS40LDAuMi0yLjcsMC4zLTQuMWMwLjItMi40LDAuMi00LjksMC4yLTcuM2wwLTEuOGwwLTAuOSBsMC0wLjVjMC0wLjYsMC0wLjQsMC0wLjZsLTAuMS0xLjVsLTAuMS0yYzAtMC43LTAuMS0xLjMtMC4yLTEuOWMtMC4xLTAuNi0wLjEtMS4zLTAuMi0xLjlsLTAuMi0xLjlsLTAuMy0xLjkgYy0wLjQtMi41LTAuOC00LjktMS40LTcuNGMtMi4zLTkuNy02LjEtMTguOS0xMS0yNy4yYy01LTguMy0xMS4yLTE1LjYtMTguMy0yMS44Yy03LTYuMi0xNC45LTExLjItMjMuMS0xNC45IGMtOC4zLTMuNy0xNi45LTYuMS0yNS41LTcuMmMtNC4zLTAuNi04LjYtMC44LTEyLjktMC43bC0xLjYsMGwtMC40LDBjLTAuMSwwLTAuNiwwLTAuNSwwbC0wLjcsMGwtMS42LDAuMWMtMC42LDAtMS4yLDAuMS0xLjcsMC4xIGMtMi4yLDAuMi00LjQsMC41LTYuNSwwLjljLTguNiwxLjYtMTYuNyw0LjctMjMuOCw5Yy03LjEsNC4zLTEzLjMsOS42LTE4LjMsMTUuNmMtNSw2LTguOSwxMi43LTExLjYsMTkuNmMtMi43LDYuOS00LjIsMTQuMS00LjYsMjEgYy0wLjEsMS43LTAuMSwzLjUtMC4xLDUuMmMwLDAuNCwwLDAuOSwwLDEuM2wwLjEsMS40YzAuMSwwLjgsMC4xLDEuNywwLjIsMi41YzAuMywzLjUsMSw2LjksMS45LDEwLjFjMS45LDYuNSw0LjksMTIuNCw4LjYsMTcuNCBjMy43LDUsOC4yLDkuMSwxMi45LDEyLjRjNC43LDMuMiw5LjgsNS41LDE0LjgsN2M1LDEuNSwxMCwyLjEsMTQuNywyLjFjMC42LDAsMS4yLDAsMS43LDBjMC4zLDAsMC42LDAsMC45LDBjMC4zLDAsMC42LDAsMC45LTAuMSBjMC41LDAsMS0wLjEsMS41LTAuMWMwLjEsMCwwLjMsMCwwLjQtMC4xbDAuNS0wLjFjMC4zLDAsMC42LTAuMSwwLjktMC4xYzAuNi0wLjEsMS4xLTAuMiwxLjctMC4zYzAuNi0wLjEsMS4xLTAuMiwxLjYtMC40IGMxLjEtMC4yLDIuMS0wLjYsMy4xLTAuOWMyLTAuNyw0LTEuNSw1LjctMi40YzEuOC0wLjksMy40LTIsNS0zYzAuNC0wLjMsMC45LTAuNiwxLjMtMWMxLjYtMS4zLDEuOS0zLjcsMC42LTUuMyBjLTEuMS0xLjQtMy4xLTEuOC00LjctMC45Yy0wLjQsMC4yLTAuOCwwLjQtMS4yLDAuNmMtMS40LDAuNy0yLjgsMS4zLTQuMywxLjhjLTEuNSwwLjUtMy4xLDAuOS00LjcsMS4yYy0wLjgsMC4xLTEuNiwwLjItMi41LDAuMyBjLTAuNCwwLTAuOCwwLjEtMS4zLDAuMWMtMC40LDAtMC45LDAtMS4yLDBjLTAuNCwwLTAuOCwwLTEuMiwwYy0wLjUsMC0xLDAtMS41LTAuMWMwLDAtMC4zLDAtMC4xLDBsLTAuMiwwbC0wLjMsMCBjLTAuMiwwLTAuNSwwLTAuNy0wLjFjLTAuNS0wLjEtMC45LTAuMS0xLjQtMC4yYy0zLjctMC41LTcuNC0xLjYtMTAuOS0zLjJjLTMuNi0xLjYtNy0zLjgtMTAuMS02LjZjLTMuMS0yLjgtNS44LTYuMS03LjktOS45IGMtMi4xLTMuOC0zLjYtOC00LjMtMTIuNGMtMC4zLTIuMi0wLjUtNC41LTAuNC02LjdjMC0wLjYsMC4xLTEuMiwwLjEtMS44YzAsMC4yLDAtMC4xLDAtMC4xbDAtMC4ybDAtMC41YzAtMC4zLDAuMS0wLjYsMC4xLTAuOSBjMC4xLTEuMiwwLjMtMi40LDAuNS0zLjZjMS43LTkuNiw2LjUtMTksMTMuOS0yNi4xYzEuOS0xLjgsMy45LTMuNCw2LTQuOWMyLjEtMS41LDQuNC0yLjgsNi44LTMuOWMyLjQtMS4xLDQuOC0yLDcuNC0yLjcgYzIuNS0wLjcsNS4xLTEuMSw3LjgtMS40YzEuMy0wLjEsMi42LTAuMiw0LTAuMmMwLjQsMCwwLjYsMCwwLjksMGwxLjEsMGwwLjcsMGMwLjMsMCwwLDAsMC4xLDBsMC4zLDBsMS4xLDAuMSBjMi45LDAuMiw1LjcsMC42LDguNSwxLjNjNS42LDEuMiwxMS4xLDMuMywxNi4yLDYuMWMxMC4yLDUuNywxOC45LDE0LjUsMjQuMiwyNS4xYzIuNyw1LjMsNC42LDExLDUuNSwxNi45YzAuMiwxLjUsMC40LDMsMC41LDQuNSBsMC4xLDEuMWwwLjEsMS4xYzAsMC40LDAsMC44LDAsMS4xYzAsMC40LDAsMC44LDAsMS4xbDAsMWwwLDEuMWMwLDAuNy0wLjEsMS45LTAuMSwyLjZjLTAuMSwxLjYtMC4zLDMuMy0wLjUsNC45IGMtMC4yLDEuNi0wLjUsMy4yLTAuOCw0LjhjLTAuMywxLjYtMC43LDMuMi0xLjEsNC43Yy0wLjgsMy4xLTEuOCw2LjItMyw5LjNjLTIuNCw2LTUuNiwxMS44LTkuNCwxNy4xIGMtNy43LDEwLjYtMTguMiwxOS4yLTMwLjIsMjQuN2MtNiwyLjctMTIuMyw0LjctMTguOCw1LjdjLTMuMiwwLjYtNi41LDAuOS05LjgsMWwtMC42LDBsLTAuNSwwbC0xLjEsMGwtMS42LDBsLTAuOCwwIGMwLjQsMC0wLjEsMC0wLjEsMGwtMC4zLDBjLTEuOCwwLTMuNS0wLjEtNS4zLTAuM2MtNy0wLjUtMTMuOS0xLjgtMjAuNy0zLjdjLTYuNy0xLjktMTMuMi00LjYtMTkuNC03LjggYy0xMi4zLTYuNi0yMy40LTE1LjYtMzItMjYuNWMtNC4zLTUuNC04LjEtMTEuMy0xMS4yLTE3LjRjLTMuMS02LjEtNS42LTEyLjYtNy40LTE5LjFjLTEuOC02LjYtMi45LTEzLjMtMy40LTIwLjFsLTAuMS0xLjNsMC0wLjMgbDAtMC4zbDAtMC42bDAtMS4xbDAtMC4zbDAtMC40bDAtMC44bDAtMS42bDAtMC4zYzAsMCwwLDAuMSwwLTAuMWwwLTAuNmMwLTAuOCwwLTEuNywwLTIuNWMwLjEtMy4zLDAuNC02LjgsMC44LTEwLjIgYzAuNC0zLjQsMS02LjksMS43LTEwLjNjMC43LTMuNCwxLjUtNi44LDIuNS0xMC4yYzEuOS02LjcsNC4zLTEzLjIsNy4xLTE5LjNjNS43LTEyLjIsMTMuMS0yMy4xLDIyLTMxLjhjMi4yLTIuMiw0LjUtNC4yLDYuOS02LjIgYzIuNC0xLjksNC45LTMuNyw3LjUtNS40YzIuNS0xLjcsNS4yLTMuMiw3LjktNC42YzEuMy0wLjcsMi43LTEuNCw0LjEtMmMwLjctMC4zLDEuNC0wLjYsMi4xLTAuOWMwLjctMC4zLDEuNC0wLjYsMi4xLTAuOSBjMi44LTEuMiw1LjctMi4yLDguNy0zLjFjMC43LTAuMiwxLjUtMC40LDIuMi0wLjdjMC43LTAuMiwxLjUtMC40LDIuMi0wLjZjMS41LTAuNCwzLTAuOCw0LjUtMS4xYzAuNy0wLjIsMS41LTAuMywyLjMtMC41IGMwLjgtMC4yLDEuNS0wLjMsMi4zLTAuNWMwLjgtMC4xLDEuNS0wLjMsMi4zLTAuNGwxLjEtMC4ybDEuMi0wLjJjMC44LTAuMSwxLjUtMC4yLDIuMy0wLjNjMC45LTAuMSwxLjctMC4yLDIuNi0wLjMgYzAuNy0wLjEsMS45LTAuMiwyLjYtMC4zYzAuNS0wLjEsMS4xLTAuMSwxLjYtMC4ybDEuMS0wLjFsMC41LTAuMWwwLjYsMGMwLjktMC4xLDEuNy0wLjEsMi42LTAuMmwxLjMtMC4xYzAsMCwwLjUsMCwwLjEsMGwwLjMsMCBsMC42LDBjMC43LDAsMS41LTAuMSwyLjItMC4xYzIuOS0wLjEsNS45LTAuMSw4LjgsMGM1LjgsMC4yLDExLjUsMC45LDE3LDEuOWMxMS4xLDIuMSwyMS41LDUuNiwzMSwxMC4zIGM5LjUsNC42LDE3LjksMTAuMywyNS4zLDE2LjVjMC41LDAuNCwwLjksMC44LDEuNCwxLjJjMC40LDAuNCwwLjksMC44LDEuMywxLjJjMC45LDAuOCwxLjcsMS42LDIuNiwyLjRjMC45LDAuOCwxLjcsMS42LDIuNSwyLjQgYzAuOCwwLjgsMS42LDEuNiwyLjQsMi41YzMuMSwzLjMsNiw2LjYsOC42LDEwYzUuMiw2LjcsOS40LDEzLjUsMTIuNywxOS45YzAuMiwwLjQsMC40LDAuOCwwLjYsMS4yYzAuMiwwLjQsMC40LDAuOCwwLjYsMS4yIGMwLjQsMC44LDAuOCwxLjYsMS4xLDIuNGMwLjQsMC44LDAuNywxLjUsMS4xLDIuM2MwLjMsMC44LDAuNywxLjUsMSwyLjNjMS4yLDMsMi40LDUuOSwzLjMsOC42YzEuNSw0LjQsMi42LDguMywzLjUsMTEuNyBjMC4zLDEuNCwxLjYsMi4zLDMsMi4xYzEuNS0wLjEsMi42LTEuMywyLjYtMi44QzM0Mi42LDE3MC40LDM0Mi41LDE2Ni4xLDM0MiwxNjEuMnoiLz48L3N2Zz4=" + {{- 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: @@ -135,5 +137,4 @@ spec: # Custom rules for additional networking access {{- with .Values.additionalNetworkAllow }} {{ toYaml . | nindent 6 }} - {{- end }} - + {{- end }} \ No newline at end of file diff --git a/src/grafana/chart/tests/grafana_sso_groups_test.yaml b/src/grafana/chart/tests/grafana_sso_groups_test.yaml index 27d58bbe6d..3f5344e83b 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,21 @@ tests: - "/UDS Core/Admin" - "/UDS Core/Auditor" - "/UDS Core/Test" + + - it: should render path-routed redirect URIs and admin route + set: + pathRouting: true + contextPath: /bar + adminContextPath: /admin + template: uds-package.yaml + asserts: + - equal: + path: spec.sso[0].redirectUris + value: + - https://admin.example.com/bar/admin/grafana/login/generic_oauth + - https://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..830677c5d4 100644 --- a/src/grafana/chart/values.yaml +++ b/src/grafana/chart/values.yaml @@ -3,6 +3,9 @@ domain: "###ZARF_VAR_DOMAIN###" adminDomain: "###ZARF_VAR_ADMIN_DOMAIN###" +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..b52f1422fd 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://{{ "###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 }}{{ if and "###ZARF_VAR_ADMIN_CONTEXT_PATH###" (not (hasPrefix "###ZARF_VAR_" "###ZARF_VAR_ADMIN_CONTEXT_PATH###")) }}###ZARF_VAR_ADMIN_CONTEXT_PATH###{{ end }}/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://###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://###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://###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{{ "###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 }}{{ if and "###ZARF_VAR_ADMIN_CONTEXT_PATH###" (not (hasPrefix "###ZARF_VAR_" "###ZARF_VAR_ADMIN_CONTEXT_PATH###")) }}{{ replace "/" "%2F" "###ZARF_VAR_ADMIN_CONTEXT_PATH###" }}{{ end }}%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..94d52ea8b9 100644 --- a/src/grafana/zarf.yaml +++ b/src/grafana/zarf.yaml @@ -19,6 +19,18 @@ variables: - name: ADMIN_DOMAIN description: "Domain for admin services, defaults to `admin.DOMAIN`" + - 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: "" + + - name: PATH_ROUTING + description: "Whether to route built-in services on one host using URL paths" + default: "false" + components: - name: grafana required: true diff --git a/src/istio/values/config-admin.yaml b/src/istio/values/config-admin.yaml index 0aa0a515da..afa1037b44 100644 --- a/src/istio/values/config-admin.yaml +++ b/src/istio/values/config-admin.yaml @@ -1,9 +1,13 @@ -# Copyright 2024 Defense Unicorns +# Copyright 2024-2026 Defense Unicorns # SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial name: admin # Accommodate a specific admin domain or the default of `admin.DOMAIN` domain: '{{ "###ZARF_VAR_ADMIN_DOMAIN###" | default "admin.###ZARF_VAR_DOMAIN###" }}' +rootDomain: + enabled: ###ZARF_VAR_PATH_ROUTING### + tls: + credentialName: gateway-tls tls: servers: keycloak: diff --git a/src/istio/values/config-tenant.yaml b/src/istio/values/config-tenant.yaml index f0634d4aa8..c17f4cf199 100644 --- a/src/istio/values/config-tenant.yaml +++ b/src/istio/values/config-tenant.yaml @@ -1,7 +1,11 @@ -# Copyright 2024 Defense Unicorns +# Copyright 2024-2026 Defense Unicorns # SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial name: tenant +rootDomain: + enabled: ###ZARF_VAR_PATH_ROUTING### + tls: + credentialName: gateway-tls tls: servers: keycloak: diff --git a/src/keycloak/chart/templates/_helpers.tpl b/src/keycloak/chart/templates/_helpers.tpl index e280d4e37f..a5ffb3cb0b 100644 --- a/src/keycloak/chart/templates/_helpers.tpl +++ b/src/keycloak/chart/templates/_helpers.tpl @@ -75,6 +75,78 @@ 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 "" -}} +{{- 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.adminDomain" -}} +{{- if .Values.adminDomain -}} +{{- tpl .Values.adminDomain . -}} +{{- else -}} +{{- printf "admin.%s" .Values.domain -}} +{{- end -}} +{{- end -}} + +{{- define "keycloak.host" -}} +{{- .Values.domain -}} +{{- end -}} + +{{- define "keycloak.adminHost" -}} +{{- include "keycloak.adminDomain" . -}} +{{- 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. */}} @@ -216,5 +288,4 @@ Check external PostgreSQL connection information. Fails when required values are {{- else -}} {{- "host" -}} {{- end -}} -{{- end -}} - +{{- end -}} \ No newline at end of file diff --git a/src/keycloak/chart/templates/istio-authpol-admin.yaml b/src/keycloak/chart/templates/istio-authpol-admin.yaml index c41b49c24c..ff596e0b9a 100644 --- a/src/keycloak/chart/templates/istio-authpol-admin.yaml +++ b/src/keycloak/chart/templates/istio-authpol-admin.yaml @@ -22,6 +22,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..459afaa04b 100644 --- a/src/keycloak/chart/templates/path-parameter-envoyfilter.yaml +++ b/src/keycloak/chart/templates/path-parameter-envoyfilter.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 {{- if .Values.pathParameterProtection }} @@ -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, "^[^;]*;[^/]*/") 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..63f829551b 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: {{ ternary "." "sso" (eq (include "keycloak.pathRouting.enabled" .) "true") | quote }} service: keycloak-http selector: app.kubernetes.io/name: keycloak @@ -136,25 +136,39 @@ 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 + {{- if eq (include "keycloak.pathRouting.enabled" .) "true" }} + exact: {{ printf "%s/realms/master" (include "keycloak.ssoPath" .) | quote }} + {{- else }} + prefix: "/realms/master" + {{- end }} + {{- if eq (include "keycloak.pathRouting.enabled" .) "true" }} + - name: redirect-master-realm-slash + uri: + exact: {{ printf "%s/realms/master/" (include "keycloak.ssoPath" .) | quote }} + {{- end }} - 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 +182,27 @@ spec: service: keycloak-http selector: app.kubernetes.io/name: keycloak - host: sso + host: {{ ternary "." "sso" (eq (include "keycloak.pathRouting.enabled" .) "true") | quote }} 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 +212,64 @@ spec: istio-mtls-client-certificate: "%DOWNSTREAM_PEER_CERT%" {{- end }} + {{- if eq (include "keycloak.pathRouting.enabled" .) "true" }} + - description: "redirect admin landing and console shortcut paths" + service: keycloak-http + selector: + app.kubernetes.io/name: keycloak + gateway: admin + host: {{ ternary "." "keycloak" (eq (include "keycloak.pathRouting.enabled" .) "true") | quote }} + 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 }} + - name: redirect-admin-console-shortcut + uri: + exact: {{ printf "%s/admin" (include "keycloak.adminPath" .) | quote }} + - name: redirect-admin-console-shortcut-slash + uri: + exact: {{ printf "%s/admin/" (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: {{ ternary "." "keycloak" (eq (include "keycloak.pathRouting.enabled" .) "true") | quote }} 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..3e5882ab4c --- /dev/null +++ b/src/keycloak/chart/tests/kc_path_routing_test.yaml @@ -0,0 +1,138 @@ +# 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 + 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.match[2].uri.exact + value: /bar/admin/keycloak/admin + - equal: + path: spec.network.expose[2].advancedHTTP.match[3].uri.exact + value: /bar/admin/keycloak/admin/ + - 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://example.com/bar/sso + - contains: + path: spec.template.spec.containers[0].args + content: --hostname-admin=https://admin.example.com/bar/admin/keycloak + - contains: + path: spec.template.spec.containers[0].env + content: + name: UDS_URL + value: https://example.com/bar/sso + - contains: + path: spec.template.spec.containers[0].env + content: + name: UDS_ADMIN_URL + value: https://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 == "example.com" + - matchRegex: + path: spec.configPatches[0].patch.value.typed_config.inlineCode + pattern: host == "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 + + - it: should keep legacy master realm redirect matching when path routing is disabled + set: + pathRouting: false + template: uds-package.yaml + asserts: + - equal: + path: spec.network.expose[0].advancedHTTP.match[2].name + value: redirect-master-realm + - equal: + path: spec.network.expose[0].advancedHTTP.match[2].uri.prefix + value: /realms/master + - notContains: + path: spec.network.expose[0].advancedHTTP.match + any: true + content: + name: redirect-master-realm-slash diff --git a/src/keycloak/chart/tests/kc_uptime_toggle_test.yaml b/src/keycloak/chart/tests/kc_uptime_toggle_test.yaml index cf0dc06a6a..e8466a4b6c 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,52 @@ 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 + contextPath: /xyz + adminContextPath: /admin + uptime: + enabled: true + template: uds-package.yaml + asserts: + - contains: + path: spec.network.expose + any: true + content: + host: . + uptime: + checks: + paths: + - /xyz/sso + - /xyz/sso/realms/uds/.well-known/openid-configuration + + - notContains: + path: spec.network.expose + any: true + content: + host: . + gateway: admin uptime: checks: paths: @@ -92,7 +145,6 @@ tests: path: spec.network.expose any: true content: - host: sso service: keycloak-http port: 8080 @@ -100,7 +152,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 0d0cf08c24..1d48eed1c8 100644 --- a/src/keycloak/chart/values.schema.json +++ b/src/keycloak/chart/values.schema.json @@ -221,6 +221,16 @@ "adminDomain": { "type": "string" }, + "adminContextPath": { + "type": "string" + }, + "contextPath": { + "type": "string" + }, + "pathRouting": { + "type": ["boolean", "string"], + "enum": [true, false, "true", "false", "###ZARF_VAR_PATH_ROUTING###"] + }, "enableServiceLinks": { "type": "boolean" }, diff --git a/src/keycloak/chart/values.yaml b/src/keycloak/chart/values.yaml index 9525f2f418..78b9b02c9b 100644 --- a/src/keycloak/chart/values.yaml +++ b/src/keycloak/chart/values.yaml @@ -16,6 +16,9 @@ configImage: ghcr.io/defenseunicorns/uds/identity-config:0.29.0 domain: "###ZARF_VAR_DOMAIN###" # The admin domain for hosts to trust clients on adminDomain: '{{ "###ZARF_VAR_ADMIN_DOMAIN###" | default "admin.###ZARF_VAR_DOMAIN###" }}' +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 e3deed4006..4215707169 100644 --- a/src/keycloak/zarf.yaml +++ b/src/keycloak/zarf.yaml @@ -19,6 +19,18 @@ variables: - name: ADMIN_DOMAIN description: "Domain for admin services, defaults to `admin.DOMAIN`" + - 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: "" + + - name: PATH_ROUTING + description: "Whether to route built-in services on one host using URL paths" + default: "false" + components: - name: keycloak required: true diff --git a/src/pepr/operator/controllers/config/config.ts b/src/pepr/operator/controllers/config/config.ts index 8e567977e0..70e0d0b1aa 100644 --- a/src/pepr/operator/controllers/config/config.ts +++ b/src/pepr/operator/controllers/config/config.ts @@ -17,6 +17,7 @@ import { reconcileAuthservice } from "../keycloak/authservice/authservice"; import { Action, AuthServiceEvent } from "../keycloak/authservice/types"; import { initAPIServerCIDR } from "../network/generators/kubeAPI"; import { initAllNodesTarget } from "../network/generators/kubeNodes"; +import { normalizeContextPath } from "../url-utils"; import { registerWatchEventHandlers, watchCfg } from "../utils"; import { Config, KeycloakClientMode } from "./types"; @@ -26,6 +27,9 @@ export const configLog = setupLogger(Component.OPERATOR_CONFIG); export const UDSConfig: Config = { domain: "", adminDomain: "", + contextPath: "", + adminContextPath: "", + pathRouting: false, caBundle: { certs: "", includeDoDCerts: false, @@ -359,17 +363,28 @@ 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 contextPath = normalizeContextPath(expose.contextPath); + const adminContextPath = normalizeContextPath(expose.adminContextPath, "/admin"); + const pathRouting = expose.pathRouting === true; + + if ( + domain !== UDSConfig.domain || + adminDomain !== UDSConfig.adminDomain || + contextPath !== UDSConfig.contextPath || + adminContextPath !== UDSConfig.adminContextPath || + pathRouting !== UDSConfig.pathRouting + ) { + UDSConfig.domain = domain; + UDSConfig.adminDomain = adminDomain; + 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..1b993a8667 100644 --- a/src/pepr/operator/controllers/config/types.ts +++ b/src/pepr/operator/controllers/config/types.ts @@ -20,6 +20,9 @@ export enum KeycloakClientMode { export type Config = { domain: string; adminDomain: string; + contextPath: string; + adminContextPath: string; + pathRouting: boolean; caBundle: CABundle; authserviceRedisUri: string; keycloakClientMode: KeycloakClientMode; 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..2ebb292b36 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,10 @@ import { // Patch UDSConfig for deterministic output beforeAll(() => { UDSConfig.domain = "example.com"; + UDSConfig.adminDomain = "admin.example.com"; + UDSConfig.contextPath = ""; + UDSConfig.adminContextPath = "/admin"; + UDSConfig.pathRouting = false; }); const labelSelector = { app: "test-app" }; @@ -307,6 +311,24 @@ 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.contextPath = "/bar"; + + const authn = authNRequestAuthentication(labelSelector, name, namespace, true, waypointName); + expect(authn.spec!.jwtRules![0].issuer).toEqual("https://example.com/bar/sso/realms/uds"); + expect(authn.spec!.jwtRules![0].jwksUri).toEqual( + "https://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://example.com/bar/sso/realms/uds/*"]); + + UDSConfig.pathRouting = false; + 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..83910d0009 100644 --- a/src/pepr/operator/controllers/keycloak/authservice/authorization-policy.ts +++ b/src/pepr/operator/controllers/keycloak/authservice/authorization-policy.ts @@ -1,5 +1,5 @@ /** - * Copyright 2024 Defense Unicorns + * Copyright 2024-2026 Defense Unicorns * SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial */ @@ -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..7477d1ec48 100644 --- a/src/pepr/operator/controllers/keycloak/authservice/authservice.spec.ts +++ b/src/pepr/operator/controllers/keycloak/authservice/authservice.spec.ts @@ -1,5 +1,5 @@ /** - * Copyright 2024 Defense Unicorns + * Copyright 2024-2026 Defense Unicorns * SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial */ @@ -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,10 @@ vi.mock("./config", () => { vi.mock("../../config/config", () => ({ UDSConfig: { domain: "uds.dev", + adminDomain: "admin.uds.dev", + contextPath: "", + adminContextPath: "/admin", + pathRouting: false, }, })); @@ -128,6 +133,10 @@ vi.mock("./authorization-policy", () => ({ updatePolicy: vi.fn().mockResolvedValue(undefined), UDSConfig: { domain: "uds.dev", + adminDomain: "admin.uds.dev", + contextPath: "", + adminContextPath: "/admin", + pathRouting: false, }, })); @@ -326,6 +335,16 @@ describe("authservice", () => { beforeEach(async () => { vi.clearAllMocks(); + UDSConfig.domain = "uds.dev"; + UDSConfig.adminDomain = "admin.uds.dev"; + UDSConfig.contextPath = ""; + UDSConfig.adminContextPath = "/admin"; + UDSConfig.pathRouting = false; + authorizationPolicy.UDSConfig.domain = "uds.dev"; + authorizationPolicy.UDSConfig.adminDomain = "admin.uds.dev"; + authorizationPolicy.UDSConfig.contextPath = ""; + authorizationPolicy.UDSConfig.adminContextPath = "/admin"; + authorizationPolicy.UDSConfig.pathRouting = false; mockClient = { clientId: "test-client", @@ -494,6 +513,29 @@ describe("authservice", () => { } }); + test("should build authservice chain with path-routed SSO URLs", async () => { + UDSConfig.pathRouting = true; + UDSConfig.contextPath = "/bar"; + authorizationPolicy.UDSConfig.pathRouting = true; + 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://uds.dev/bar/sso/realms/uds/protocol/openid-connect/auth", + ); + expect(chain.filters[0].oidc_override?.token_uri).toEqual( + "https://uds.dev/bar/sso/realms/uds/protocol/openid-connect/token", + ); + expect(chain.filters[0].oidc_override?.logout?.redirect_uri).toEqual( + "https://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..a11fe096ce 100644 --- a/src/pepr/operator/controllers/keycloak/authservice/authservice.ts +++ b/src/pepr/operator/controllers/keycloak/authservice/authservice.ts @@ -1,5 +1,5 @@ /** - * Copyright 2024 Defense Unicorns + * Copyright 2024-2026 Defense Unicorns * SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial */ @@ -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..83e84a3e14 100644 --- a/src/pepr/operator/controllers/keycloak/authservice/config.spec.ts +++ b/src/pepr/operator/controllers/keycloak/authservice/config.spec.ts @@ -5,9 +5,11 @@ import { K8s, kind } from "pepr"; import { afterEach, beforeEach, describe, expect, it, Mock, vi } from "vitest"; +import { UDSConfig } from "../../config/config"; import { buildInitialSecret, getAuthserviceConfig, + initializeOperatorConfig, setupAuthserviceSecret, updateAuthServiceSecret, } from "./config"; @@ -119,6 +121,12 @@ describe("AuthService Config Tests", () => { beforeEach(() => { process.env.PEPR_WATCH_MODE = "true"; + UDSConfig.domain = "uds.dev"; + UDSConfig.adminDomain = "admin.uds.dev"; + UDSConfig.contextPath = ""; + UDSConfig.adminContextPath = "/admin"; + UDSConfig.pathRouting = false; + initializeOperatorConfig(); vi.useFakeTimers(); }); @@ -147,6 +155,23 @@ describe("AuthService Config Tests", () => { }); }); + it("buildInitialSecret should use path-routed SSO URLs", () => { + UDSConfig.pathRouting = true; + UDSConfig.contextPath = "/bar"; + initializeOperatorConfig(); + + const secret = buildInitialSecret(); + expect(secret.default_oidc_config.authorization_uri).toEqual( + "https://uds.dev/bar/sso/realms/uds/protocol/openid-connect/auth", + ); + expect(secret.default_oidc_config.token_uri).toEqual( + "https://uds.dev/bar/sso/realms/uds/protocol/openid-connect/token", + ); + expect(secret.default_oidc_config.jwks_fetcher?.jwks_uri).toEqual( + "https://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..0ae07effe1 100644 --- a/src/pepr/operator/controllers/uptime/config.spec.ts +++ b/src/pepr/operator/controllers/uptime/config.spec.ts @@ -63,6 +63,10 @@ describe("updateBlackboxConfig", () => { beforeEach(() => { UDSConfig.domain = "uds.dev"; + UDSConfig.adminDomain = "admin.uds.dev"; + UDSConfig.contextPath = ""; + UDSConfig.adminContextPath = "/admin"; + UDSConfig.pathRouting = false; secretClient = createMockK8sClient(); vi.mocked(K8s as Mock).mockReturnValue(secretClient); }); @@ -96,6 +100,20 @@ describe("updateBlackboxConfig", () => { }); }); + it("generates OAuth2 config with the path-routed SSO token URL", async () => { + UDSConfig.pathRouting = true; + 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://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..34bf0a3cfd --- /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 = ""; + +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.domain; +} + +export function getPublicBaseUrl(config: Config): string { + return `https://${getHost(config)}${config.contextPath}`; +} + +export function getAdminBaseUrl(config: Config): string { + return `https://${config.adminDomain}${config.contextPath}${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..db32af5865 100644 --- a/src/pepr/operator/crd/generated/clusterconfig-v1alpha1.ts +++ b/src/pepr/operator/crd/generated/clusterconfig-v1alpha1.ts @@ -55,14 +55,26 @@ export interface CABundle { } export interface Expose { + /** + * Optional admin URL context path appended after contextPath when pathRouting is enabled + */ + adminContextPath?: string; /** * Domain all cluster services on the admin gateway will be exposed on */ adminDomain?: 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; } 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..f91da258c7 100644 --- a/src/pepr/operator/crd/sources/cluster-config/v1alpha1.ts +++ b/src/pepr/operator/crd/sources/cluster-config/v1alpha1.ts @@ -1,5 +1,5 @@ /** - * Copyright 2024 Defense Unicorns + * Copyright 2024-2026 Defense Unicorns * SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial */ @@ -119,6 +119,23 @@ export const v1alpha1: V1CustomResourceDefinitionVersion = { description: "Domain all cluster services on the admin gateway will be exposed on", }, + 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: "", + }, + 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..ee94122ac2 100644 --- a/src/pepr/operator/crd/validators/clusterconfig-validator.spec.ts +++ b/src/pepr/operator/crd/validators/clusterconfig-validator.spec.ts @@ -1,5 +1,5 @@ /** - * Copyright 2025 Defense Unicorns + * Copyright 2025-2026 Defense Unicorns * SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial */ @@ -137,6 +137,54 @@ 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, + 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..8fb404ea9a 100644 --- a/src/pepr/operator/crd/validators/clusterconfig-validator.ts +++ b/src/pepr/operator/crd/validators/clusterconfig-validator.ts @@ -1,10 +1,11 @@ /** - * Copyright 2024 Defense Unicorns + * Copyright 2024-2026 Defense Unicorns * SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial */ -import { PeprValidateRequest } from "pepr"; import { X509Certificate } from "crypto"; +import { PeprValidateRequest } from "pepr"; +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..e83d246249 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,23 @@ spec: description: >- Domain all cluster services on the admin gateway will be exposed on + 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: '' + 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..4a1da384cc 100644 --- a/src/pepr/uds-operator-config/templates/clusterconfig.yaml +++ b/src/pepr/uds-operator-config/templates/clusterconfig.yaml @@ -36,10 +36,15 @@ 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 }} + {{- $contextPath := .Values.cluster.expose.contextPath | default "" }} + {{- if and (ne $contextPath "") (not (hasPrefix "###ZARF_VAR_" $contextPath)) }} + contextPath: {{ $contextPath | quote }} {{- end }} + {{- $adminContextPath := .Values.cluster.expose.adminContextPath | default "" }} + {{- if and (ne $adminContextPath "") (not (hasPrefix "###ZARF_VAR_" $adminContextPath)) }} + adminContextPath: {{ $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..bfc93784e9 100644 --- a/src/pepr/uds-operator-config/tests/clusterconfig_test.yaml +++ b/src/pepr/uds-operator-config/tests/clusterconfig_test.yaml @@ -99,6 +99,26 @@ tests: - notExists: path: spec.networking + - it: should render path routing expose values + set: + cluster: + expose: + domain: "example.com" + contextPath: "/bar" + adminContextPath: "/admin" + pathRouting: true + asserts: + - isKind: + of: ClusterConfig + - equal: + path: spec.expose + value: + domain: "example.com" + adminDomain: "admin.example.com" + 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..d855108957 100644 --- a/src/pepr/uds-operator-config/values.schema.json +++ b/src/pepr/uds-operator-config/values.schema.json @@ -62,6 +62,19 @@ "adminDomain": { "type": "string", "description": "Admin domain for cluster exposure." + }, + "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..0929d60df4 100644 --- a/src/pepr/uds-operator-config/values.yaml +++ b/src/pepr/uds-operator-config/values.yaml @@ -21,6 +21,9 @@ cluster: # Domain configuration (admin defaults to `admin.UDS_DOMAIN`) domain: "###ZARF_VAR_DOMAIN###" adminDomain: "###ZARF_VAR_ADMIN_DOMAIN###" + 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..7f1e028789 100644 --- a/src/pepr/zarf.yaml +++ b/src/pepr/zarf.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 kind: ZarfPackageConfig @@ -15,6 +15,18 @@ variables: - name: ADMIN_DOMAIN description: "Domain for admin services, defaults to `admin.DOMAIN`" + - 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: "" + + - 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: ""