From 1df0e2d9030e37f92319cfa85978d1a5e993b4c6 Mon Sep 17 00:00:00 2001 From: Kirill Ilin Date: Fri, 19 Jun 2026 17:10:07 +0500 Subject: [PATCH 1/5] feat(keycloak): add opt-in separate admin console hostname Add ingress.adminHost option to serve the Keycloak admin console and admin REST API on a dedicated hostname, separate from the login/realm hostname. When set, KC_HOSTNAME_ADMIN is configured and an additional Ingress rule / HTTPRoute hostname routes the admin domain to the same Keycloak service. Empty by default, keeping current behavior. Assisted-By: Claude AI Signed-off-by: Kirill Ilin --- .../system/keycloak/templates/httproute.yaml | 3 +++ .../system/keycloak/templates/ingress.yaml | 19 +++++++++++++++++++ packages/system/keycloak/templates/sts.yaml | 4 ++++ packages/system/keycloak/values.yaml | 8 ++++++++ 4 files changed, 34 insertions(+) diff --git a/packages/system/keycloak/templates/httproute.yaml b/packages/system/keycloak/templates/httproute.yaml index d6d9bb581..0cd5c1b57 100644 --- a/packages/system/keycloak/templates/httproute.yaml +++ b/packages/system/keycloak/templates/httproute.yaml @@ -15,6 +15,9 @@ spec: namespace: {{ $exposeIngress }} hostnames: - {{ $ingressHost | quote }} + {{- with .Values.ingress.adminHost }} + - {{ . | quote }} + {{- end }} rules: - backendRefs: - name: keycloak-http diff --git a/packages/system/keycloak/templates/ingress.yaml b/packages/system/keycloak/templates/ingress.yaml index 8f0a57369..73069eda3 100644 --- a/packages/system/keycloak/templates/ingress.yaml +++ b/packages/system/keycloak/templates/ingress.yaml @@ -33,6 +33,13 @@ spec: {{- if not $wildcardSecret }} secretName: web-tls {{- end }} + {{- with .Values.ingress.adminHost }} + - hosts: + - {{ . }} + {{- if not $wildcardSecret }} + secretName: keycloak-admin-tls + {{- end }} + {{- end }} rules: - host: {{ $ingressHost }} http: @@ -44,4 +51,16 @@ spec: name: keycloak-http port: name: http + {{- with .Values.ingress.adminHost }} + - host: {{ . }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: keycloak-http + port: + name: http + {{- end }} {{- end }} diff --git a/packages/system/keycloak/templates/sts.yaml b/packages/system/keycloak/templates/sts.yaml index e07fe3e62..c20ee45e8 100644 --- a/packages/system/keycloak/templates/sts.yaml +++ b/packages/system/keycloak/templates/sts.yaml @@ -170,6 +170,10 @@ spec: value: "docker" - name: KC_HOSTNAME value: https://{{ $ingressHost }} + {{- with .Values.ingress.adminHost }} + - name: KC_HOSTNAME_ADMIN + value: https://{{ . }} + {{- end }} - name: JAVA_OPTS_APPEND value: "-Djgroups.dns.query=keycloak-headless.cozy-keycloak.svc.{{ $clusterDomain }}" {{- with .Values.extraEnv }} diff --git a/packages/system/keycloak/values.yaml b/packages/system/keycloak/values.yaml index b593011af..12b83febd 100644 --- a/packages/system/keycloak/values.yaml +++ b/packages/system/keycloak/values.yaml @@ -5,6 +5,14 @@ ingress: # If set, this value will be used as the Ingress hostname (e.g., "auth.example.com"). # If left empty, defaults to "keycloak." based on the cluster root-host setting. host: "" + # Optional separate hostname for the Keycloak admin console (opt-in). + # When set (e.g., "keycloak-admin.example.com"), the admin console and admin + # REST API are served only on this hostname (Keycloak KC_HOSTNAME_ADMIN), and + # the admin endpoints become unavailable on the main `host` (login/realms + # only). An additional Ingress rule / HTTPRoute hostname is created so the + # admin hostname routes to the same Keycloak service. + # If left empty, the admin console stays on the main hostname (default). + adminHost: "" annotations: nginx.ingress.kubernetes.io/affinity: "cookie" nginx.ingress.kubernetes.io/session-cookie-expires: "86400" From 08dca137debdbe9943bc78da67c3d5ca7ebbf62b Mon Sep 17 00:00:00 2001 From: Kirill Ilin Date: Fri, 19 Jun 2026 17:10:14 +0500 Subject: [PATCH 2/5] test(keycloak): cover admin console hostname split Add helm-unittest suite asserting KC_HOSTNAME_ADMIN env, the extra Ingress rule/TLS entry, and the appended HTTPRoute hostname are emitted only when ingress.adminHost is set. Wire up the chart 'test' target. Assisted-By: Claude AI Signed-off-by: Kirill Ilin --- .../keycloak/tests/admin_hostname_test.yaml | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 packages/system/keycloak/tests/admin_hostname_test.yaml diff --git a/packages/system/keycloak/tests/admin_hostname_test.yaml b/packages/system/keycloak/tests/admin_hostname_test.yaml new file mode 100644 index 000000000..9e8e5b34e --- /dev/null +++ b/packages/system/keycloak/tests/admin_hostname_test.yaml @@ -0,0 +1,133 @@ +suite: admin hostname split (ingress.adminHost) + +release: + name: keycloak + namespace: cozy-keycloak + +# ingress.adminHost is an opt-in option that serves the Keycloak admin +# console / admin REST API on a dedicated hostname (KC_HOSTNAME_ADMIN) and +# routes it through an additional Ingress rule / HTTPRoute hostname to the +# same keycloak-http service. Empty (default) keeps the admin console on the +# main hostname and emits no admin-specific wiring. +tests: + # --- StatefulSet env: KC_HOSTNAME_ADMIN --- + - it: does not set KC_HOSTNAME_ADMIN when adminHost is empty (default) + template: templates/sts.yaml + documentIndex: 1 + set: + _cluster: + root-host: example.com + asserts: + - isKind: + of: StatefulSet + - contains: + path: spec.template.spec.containers[0].env + content: + name: KC_HOSTNAME + value: https://keycloak.example.com + - notContains: + path: spec.template.spec.containers[0].env + content: + name: KC_HOSTNAME_ADMIN + value: https://keycloak-admin.example.com + + - it: sets KC_HOSTNAME_ADMIN when adminHost is set + template: templates/sts.yaml + documentIndex: 1 + set: + _cluster: + root-host: example.com + ingress: + adminHost: keycloak-admin.example.com + asserts: + - isKind: + of: StatefulSet + - contains: + path: spec.template.spec.containers[0].env + content: + name: KC_HOSTNAME + value: https://keycloak.example.com + - contains: + path: spec.template.spec.containers[0].env + content: + name: KC_HOSTNAME_ADMIN + value: https://keycloak-admin.example.com + + # --- Ingress (gateway disabled) --- + - it: renders a single rule and single TLS entry when adminHost is empty + template: templates/ingress.yaml + set: + _cluster: + root-host: example.com + asserts: + - isKind: + of: Ingress + - lengthEqual: + path: spec.rules + count: 1 + - equal: + path: spec.rules[0].host + value: keycloak.example.com + - lengthEqual: + path: spec.tls + count: 1 + + - it: renders a second rule and TLS entry for the admin hostname + template: templates/ingress.yaml + set: + _cluster: + root-host: example.com + ingress: + adminHost: keycloak-admin.example.com + asserts: + - lengthEqual: + path: spec.rules + count: 2 + - equal: + path: spec.rules[1].host + value: keycloak-admin.example.com + - equal: + path: spec.rules[1].http.paths[0].backend.service.name + value: keycloak-http + - lengthEqual: + path: spec.tls + count: 2 + - equal: + path: spec.tls[1].hosts[0] + value: keycloak-admin.example.com + - equal: + path: spec.tls[1].secretName + value: keycloak-admin-tls + + # --- HTTPRoute (gateway enabled) --- + - it: renders a single hostname when adminHost is empty + template: templates/httproute.yaml + set: + _cluster: + root-host: example.com + gateway-enabled: "true" + asserts: + - isKind: + of: HTTPRoute + - lengthEqual: + path: spec.hostnames + count: 1 + - equal: + path: spec.hostnames[0] + value: keycloak.example.com + + - it: appends the admin hostname when adminHost is set + template: templates/httproute.yaml + set: + _cluster: + root-host: example.com + gateway-enabled: "true" + ingress: + adminHost: keycloak-admin.example.com + asserts: + - lengthEqual: + path: spec.hostnames + count: 2 + - contains: + path: spec.hostnames + content: keycloak-admin.example.com From caeb2c466f1c6809d318500377fea8c87a7e4f27 Mon Sep 17 00:00:00 2001 From: Kirill Ilin Date: Fri, 19 Jun 2026 17:35:48 +0500 Subject: [PATCH 3/5] feat(platform): add publishing.ingressNameAdmin for a separate admin gateway Expose a new opt-in publishing.ingressNameAdmin that is propagated to _cluster.expose-ingress-admin. System components (currently Keycloak) use it to attach their admin routes to a separate Gateway/ingressClass, e.g. one bound to a private IP and unreachable from the public internet. Empty by default, so admin endpoints keep sharing expose-ingress. The private Gateway itself is provisioned out of band; this only selects where the admin route attaches. Assisted-By: Claude AI Signed-off-by: Kirill Ilin --- packages/core/platform/templates/apps.yaml | 7 ++++++ .../tests/apps_admin_ingress_wiring_test.yaml | 25 +++++++++++++++++++ packages/core/platform/values.yaml | 7 ++++++ 3 files changed, 39 insertions(+) create mode 100644 packages/core/platform/tests/apps_admin_ingress_wiring_test.yaml diff --git a/packages/core/platform/templates/apps.yaml b/packages/core/platform/templates/apps.yaml index 91cab8deb..ff01a8908 100644 --- a/packages/core/platform/templates/apps.yaml +++ b/packages/core/platform/templates/apps.yaml @@ -108,6 +108,13 @@ stringData: key. Documented here as a known limitation. */}} expose-ingress: {{ .Values.publishing.ingressName | quote }} + {{- /* Optional namespace of a SEPARATE Gateway/ingressClass that + system components (currently Keycloak) attach their admin + routes to — e.g. a private-IP Gateway unreachable from the + public internet. Empty -> admin shares expose-ingress. The + Gateway itself is provisioned out of band; this only selects + where the admin route attaches. */}} + expose-ingress-admin: {{ .Values.publishing.ingressNameAdmin | default "" | quote }} expose-external-ips: {{ .Values.publishing.externalIPs | join "," | quote }} {{- /* Host PROXY-protocol switch. extra/ingress reads this and applies use-proxy-protocol to the external-facing ingress (the namespace diff --git a/packages/core/platform/tests/apps_admin_ingress_wiring_test.yaml b/packages/core/platform/tests/apps_admin_ingress_wiring_test.yaml new file mode 100644 index 000000000..447a950b3 --- /dev/null +++ b/packages/core/platform/tests/apps_admin_ingress_wiring_test.yaml @@ -0,0 +1,25 @@ +suite: apps.yaml admin-ingress platform → tenant value wiring +templates: + - templates/apps.yaml + +release: + name: cozy-platform + namespace: cozy-system + +# Asserts that publishing.ingressNameAdmin lands in _cluster.expose-ingress-admin, +# the key read by system charts (currently keycloak) to attach admin routes to a +# separate Gateway/ingressClass. Empty by default so admin shares expose-ingress. +tests: + - it: writes an empty expose-ingress-admin by default + asserts: + - matchRegex: + path: stringData["values.yaml"] + pattern: 'expose-ingress-admin:\s*""' + + - it: writes the configured admin ingress namespace + set: + publishing.ingressNameAdmin: cozy-private + asserts: + - matchRegex: + path: stringData["values.yaml"] + pattern: 'expose-ingress-admin:\s*"cozy-private"' diff --git a/packages/core/platform/values.yaml b/packages/core/platform/values.yaml index 60af4191c..64d1aff4b 100644 --- a/packages/core/platform/values.yaml +++ b/packages/core/platform/values.yaml @@ -49,6 +49,13 @@ networking: publishing: host: "example.org" ingressName: tenant-root + # Optional namespace of a SEPARATE Gateway / ingressClass that system + # components attach their admin routes to (currently Keycloak) — e.g. one + # bound to a private IP, unreachable from the public internet. Empty means + # admin endpoints share the same ingress as everything else (ingressName). + # The private Gateway itself is NOT created by cozystack; provision it out + # of band, then point this at its namespace to move admin traffic onto it. + ingressNameAdmin: "" exposedServices: - api - dashboard From 1c8f0243cfabdb246604fa55f6b79501d7609d73 Mon Sep 17 00:00:00 2001 From: Kirill Ilin Date: Fri, 19 Jun 2026 17:35:55 +0500 Subject: [PATCH 4/5] feat(keycloak): isolate admin endpoints to a dedicated route When ingress.adminHost is set, narrow the public (login) route to the OIDC paths only (/realms, /resources, /.well-known) so the admin console and Administration REST API are no longer served on the login hostname, and serve them from a dedicated admin route instead. The admin route attaches to the Gateway/ingressClass selected by _cluster.expose-ingress-admin (defaults to expose-ingress), so it can be moved onto a separate, e.g. private, Gateway. KC_HOSTNAME_ADMIN alone is not a security boundary; the path and network separation here is what restricts access. Assisted-By: Claude AI Signed-off-by: Kirill Ilin --- .../system/keycloak/templates/httproute.yaml | 46 ++++- .../system/keycloak/templates/ingress.yaml | 62 ++++++- .../keycloak/tests/admin_hostname_test.yaml | 173 ++++++++++++++---- packages/system/keycloak/values.yaml | 18 +- 4 files changed, 243 insertions(+), 56 deletions(-) diff --git a/packages/system/keycloak/templates/httproute.yaml b/packages/system/keycloak/templates/httproute.yaml index 0cd5c1b57..8f17a51e8 100644 --- a/packages/system/keycloak/templates/httproute.yaml +++ b/packages/system/keycloak/templates/httproute.yaml @@ -1,7 +1,9 @@ {{- $host := index .Values._cluster "root-host" }} {{- $ingressHost := .Values.ingress.host | default (printf "keycloak.%s" $host) }} {{- $exposeIngress := (index .Values._cluster "expose-ingress") | default "tenant-root" }} +{{- $adminExposeIngress := (index .Values._cluster "expose-ingress-admin") | default $exposeIngress }} {{- $gatewayEnabled := (index .Values._cluster "gateway-enabled") | default "false" }} +{{- $adminHost := .Values.ingress.adminHost }} {{- if eq $gatewayEnabled "true" }} apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute @@ -15,11 +17,51 @@ spec: namespace: {{ $exposeIngress }} hostnames: - {{ $ingressHost | quote }} - {{- with .Values.ingress.adminHost }} - - {{ . | quote }} + rules: + {{- if $adminHost }} + # adminHost is set: the public (login) hostname exposes only the + # OIDC/login paths. The admin console and Administration REST API are + # served exclusively on the dedicated admin route below, so /admin is + # not reachable here. See the Keycloak reverse proxy guide, + # "Exposed path recommendations". + - matches: + - path: + type: PathPrefix + value: /realms + - path: + type: PathPrefix + value: /resources + - path: + type: PathPrefix + value: /.well-known + backendRefs: + - name: keycloak-http + port: 80 + {{- else }} + - backendRefs: + - name: keycloak-http + port: 80 {{- end }} +{{- if $adminHost }} +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: keycloak-admin +spec: + # Attaches to the Gateway named cozystack in the admin namespace. When + # expose-ingress-admin points at a separate (e.g. private-IP) Gateway, + # admin traffic leaves the public Gateway entirely. + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: cozystack + namespace: {{ $adminExposeIngress }} + hostnames: + - {{ $adminHost | quote }} rules: - backendRefs: - name: keycloak-http port: 80 {{- end }} +{{- end }} diff --git a/packages/system/keycloak/templates/ingress.yaml b/packages/system/keycloak/templates/ingress.yaml index 73069eda3..a2f0e848c 100644 --- a/packages/system/keycloak/templates/ingress.yaml +++ b/packages/system/keycloak/templates/ingress.yaml @@ -3,8 +3,10 @@ {{- $solver := (index .Values._cluster "solver") | default "http01" }} {{- $clusterIssuer := (index .Values._cluster "issuer-name") | default "letsencrypt-prod" }} {{- $exposeIngress := (index .Values._cluster "expose-ingress") | default "tenant-root" }} +{{- $adminExposeIngress := (index .Values._cluster "expose-ingress-admin") | default $exposeIngress }} {{- $gatewayEnabled := (index .Values._cluster "gateway-enabled") | default "false" }} {{- $wildcardSecret := (index .Values._cluster "wildcard-secret-name") | default "" }} +{{- $adminHost := .Values.ingress.adminHost }} {{- if ne $gatewayEnabled "true" }} apiVersion: networking.k8s.io/v1 @@ -33,17 +35,25 @@ spec: {{- if not $wildcardSecret }} secretName: web-tls {{- end }} - {{- with .Values.ingress.adminHost }} - - hosts: - - {{ . }} - {{- if not $wildcardSecret }} - secretName: keycloak-admin-tls - {{- end }} - {{- end }} rules: - host: {{ $ingressHost }} http: paths: + {{- if $adminHost }} + # adminHost is set: expose only the OIDC/login paths here. The admin + # console and Administration REST API live on the admin Ingress below, + # so /admin is not reachable on the login hostname. See the Keycloak + # reverse proxy guide, "Exposed path recommendations". + {{- range $path := list "/realms" "/resources" "/.well-known" }} + - path: {{ $path }} + pathType: Prefix + backend: + service: + name: keycloak-http + port: + name: http + {{- end }} + {{- else }} - path: / pathType: Prefix backend: @@ -51,8 +61,40 @@ spec: name: keycloak-http port: name: http - {{- with .Values.ingress.adminHost }} - - host: {{ . }} + {{- end }} +{{- if $adminHost }} +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: keycloak-admin-ingress + {{- with .Values.ingress.annotations }} + annotations: + {{- /* Operator wildcard mode: nginx serves the default SSL cert, so skip per-host ACME. */}} + {{- if not $wildcardSecret }} + {{- if eq $solver "http01" }} + acme.cert-manager.io/http01-ingress-ingressclassname: {{ $adminExposeIngress }} + {{- end }} + cert-manager.io/cluster-issuer: {{ $clusterIssuer }} + {{- end }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + # Uses the admin ingressClass. When expose-ingress-admin points at a + # separate (e.g. private) ingress controller, admin traffic leaves the + # public ingress entirely. + ingressClassName: {{ $adminExposeIngress }} + tls: + - hosts: + - {{ $adminHost }} + {{- /* Operator wildcard mode: no per-host secret — ingress-nginx falls + back to the default SSL certificate while the tls section keeps + the HTTP-to-HTTPS redirect enforced. */}} + {{- if not $wildcardSecret }} + secretName: keycloak-admin-tls + {{- end }} + rules: + - host: {{ $adminHost }} http: paths: - path: / @@ -62,5 +104,5 @@ spec: name: keycloak-http port: name: http - {{- end }} +{{- end }} {{- end }} diff --git a/packages/system/keycloak/tests/admin_hostname_test.yaml b/packages/system/keycloak/tests/admin_hostname_test.yaml index 9e8e5b34e..5bda09d54 100644 --- a/packages/system/keycloak/tests/admin_hostname_test.yaml +++ b/packages/system/keycloak/tests/admin_hostname_test.yaml @@ -4,11 +4,12 @@ release: name: keycloak namespace: cozy-keycloak -# ingress.adminHost is an opt-in option that serves the Keycloak admin -# console / admin REST API on a dedicated hostname (KC_HOSTNAME_ADMIN) and -# routes it through an additional Ingress rule / HTTPRoute hostname to the -# same keycloak-http service. Empty (default) keeps the admin console on the -# main hostname and emits no admin-specific wiring. +# ingress.adminHost is an opt-in option. When set, Keycloak's admin console +# and Administration REST API are moved onto a dedicated hostname/route, and +# the public (login) route is narrowed to the OIDC paths only so /admin is no +# longer served there. The admin route attaches to the Gateway/ingressClass +# selected by _cluster.expose-ingress-admin (defaults to expose-ingress), +# allowing it to be pointed at a separate, e.g. private, Gateway. tests: # --- StatefulSet env: KC_HOSTNAME_ADMIN --- - it: does not set KC_HOSTNAME_ADMIN when adminHost is empty (default) @@ -40,13 +41,6 @@ tests: ingress: adminHost: keycloak-admin.example.com asserts: - - isKind: - of: StatefulSet - - contains: - path: spec.template.spec.containers[0].env - content: - name: KC_HOSTNAME - value: https://keycloak.example.com - contains: path: spec.template.spec.containers[0].env content: @@ -54,25 +48,30 @@ tests: value: https://keycloak-admin.example.com # --- Ingress (gateway disabled) --- - - it: renders a single rule and single TLS entry when adminHost is empty + - it: renders one Ingress serving all paths when adminHost is empty template: templates/ingress.yaml set: _cluster: root-host: example.com asserts: + - hasDocuments: + count: 1 - isKind: of: Ingress - - lengthEqual: - path: spec.rules - count: 1 - equal: path: spec.rules[0].host value: keycloak.example.com + - lengthEqual: + path: spec.rules[0].http.paths + count: 1 + - equal: + path: spec.rules[0].http.paths[0].path + value: / - lengthEqual: path: spec.tls count: 1 - - it: renders a second rule and TLS entry for the admin hostname + - it: narrows the login Ingress to OIDC paths and adds an admin Ingress template: templates/ingress.yaml set: _cluster: @@ -80,33 +79,82 @@ tests: ingress: adminHost: keycloak-admin.example.com asserts: - - lengthEqual: - path: spec.rules + - hasDocuments: count: 2 - - equal: - path: spec.rules[1].host + # doc 0: public login Ingress — only OIDC paths, never /admin or / + - documentIndex: 0 + equal: + path: metadata.name + value: keycloak-ingress + - documentIndex: 0 + equal: + path: spec.rules[0].host + value: keycloak.example.com + - documentIndex: 0 + lengthEqual: + path: spec.rules[0].http.paths + count: 3 + - documentIndex: 0 + equal: + path: spec.rules[0].http.paths[0].path + value: /realms + - documentIndex: 0 + equal: + path: spec.rules[0].http.paths[1].path + value: /resources + - documentIndex: 0 + equal: + path: spec.rules[0].http.paths[2].path + value: /.well-known + # doc 1: admin Ingress — full path, dedicated host/secret/class + - documentIndex: 1 + equal: + path: metadata.name + value: keycloak-admin-ingress + - documentIndex: 1 + equal: + path: spec.rules[0].host value: keycloak-admin.example.com - - equal: - path: spec.rules[1].http.paths[0].backend.service.name - value: keycloak-http - - lengthEqual: - path: spec.tls - count: 2 - - equal: - path: spec.tls[1].hosts[0] - value: keycloak-admin.example.com - - equal: - path: spec.tls[1].secretName + - documentIndex: 1 + equal: + path: spec.rules[0].http.paths[0].path + value: / + - documentIndex: 1 + equal: + path: spec.tls[0].secretName value: keycloak-admin-tls + - documentIndex: 1 + equal: + path: spec.ingressClassName + value: tenant-root + + - it: admin Ingress uses expose-ingress-admin ingressClass when set + template: templates/ingress.yaml + documentIndex: 1 + set: + _cluster: + root-host: example.com + expose-ingress-admin: cozy-private + ingress: + adminHost: keycloak-admin.example.com + asserts: + - equal: + path: metadata.name + value: keycloak-admin-ingress + - equal: + path: spec.ingressClassName + value: cozy-private # --- HTTPRoute (gateway enabled) --- - - it: renders a single hostname when adminHost is empty + - it: renders one HTTPRoute serving all paths when adminHost is empty template: templates/httproute.yaml set: _cluster: root-host: example.com gateway-enabled: "true" asserts: + - hasDocuments: + count: 1 - isKind: of: HTTPRoute - lengthEqual: @@ -115,8 +163,11 @@ tests: - equal: path: spec.hostnames[0] value: keycloak.example.com + # no path matches -> all paths routed + - isNull: + path: spec.rules[0].matches - - it: appends the admin hostname when adminHost is set + - it: narrows the login HTTPRoute to OIDC paths and adds an admin HTTPRoute template: templates/httproute.yaml set: _cluster: @@ -125,9 +176,53 @@ tests: ingress: adminHost: keycloak-admin.example.com asserts: - - lengthEqual: - path: spec.hostnames + - hasDocuments: count: 2 - - contains: - path: spec.hostnames - content: keycloak-admin.example.com + # doc 0: public login route — only OIDC path prefixes + - documentIndex: 0 + equal: + path: metadata.name + value: keycloak + - documentIndex: 0 + equal: + path: spec.hostnames[0] + value: keycloak.example.com + - documentIndex: 0 + lengthEqual: + path: spec.rules[0].matches + count: 3 + - documentIndex: 0 + equal: + path: spec.rules[0].matches[0].path.value + value: /realms + # doc 1: admin route — dedicated hostname, default admin namespace + - documentIndex: 1 + equal: + path: metadata.name + value: keycloak-admin + - documentIndex: 1 + equal: + path: spec.hostnames[0] + value: keycloak-admin.example.com + - documentIndex: 1 + equal: + path: spec.parentRefs[0].namespace + value: tenant-root + + - it: admin HTTPRoute attaches to expose-ingress-admin namespace when set + template: templates/httproute.yaml + documentIndex: 1 + set: + _cluster: + root-host: example.com + gateway-enabled: "true" + expose-ingress-admin: cozy-private + ingress: + adminHost: keycloak-admin.example.com + asserts: + - equal: + path: metadata.name + value: keycloak-admin + - equal: + path: spec.parentRefs[0].namespace + value: cozy-private diff --git a/packages/system/keycloak/values.yaml b/packages/system/keycloak/values.yaml index 12b83febd..0ba5f029b 100644 --- a/packages/system/keycloak/values.yaml +++ b/packages/system/keycloak/values.yaml @@ -6,11 +6,19 @@ ingress: # If left empty, defaults to "keycloak." based on the cluster root-host setting. host: "" # Optional separate hostname for the Keycloak admin console (opt-in). - # When set (e.g., "keycloak-admin.example.com"), the admin console and admin - # REST API are served only on this hostname (Keycloak KC_HOSTNAME_ADMIN), and - # the admin endpoints become unavailable on the main `host` (login/realms - # only). An additional Ingress rule / HTTPRoute hostname is created so the - # admin hostname routes to the same Keycloak service. + # When set (e.g., "keycloak-admin.example.com"): + # - KC_HOSTNAME_ADMIN is configured so the admin console generates its + # URLs on this hostname; + # - the main `host` (login) route is narrowed to the public OIDC paths + # (/realms, /resources, /.well-known) only, so the admin console and + # Administration REST API (/admin) are NOT served on the login hostname; + # - a dedicated Ingress/HTTPRoute for this hostname routes / to Keycloak. + # The admin route attaches to the Gateway/ingressClass selected by the + # cluster-level expose-ingress-admin (defaults to expose-ingress). Point + # that at a separate, e.g. private-IP, Gateway to keep the admin console off + # the public internet. Note: KC_HOSTNAME_ADMIN alone is NOT a security + # boundary (admin REST stays reachable on any hostname routed to Keycloak); + # the network/path separation here is what restricts access. # If left empty, the admin console stays on the main hostname (default). adminHost: "" annotations: From a411da4aa8db377eab5a272f6a6a4b2b9814e595 Mon Sep 17 00:00:00 2001 From: Kirill Ilin Date: Mon, 22 Jun 2026 09:49:47 +0500 Subject: [PATCH 5/5] fix(keycloak): honor wildcard cert mode on the admin Ingress The admin Ingress always requested a per-host ACME certificate, ignoring the operator wildcard mode that the login Ingress already respects. When the admin route shares the public ingressClass, the operator default SSL cert covers it too, so skip per-host ACME there. A separate admin ingress keeps its own certificate, since that controller has no wildcard default. Assisted-By: Claude AI Signed-off-by: Kirill Ilin --- .../system/keycloak/templates/ingress.yaml | 18 +++++--- .../keycloak/tests/admin_hostname_test.yaml | 44 +++++++++++++++++++ 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/packages/system/keycloak/templates/ingress.yaml b/packages/system/keycloak/templates/ingress.yaml index a2f0e848c..035bebdf6 100644 --- a/packages/system/keycloak/templates/ingress.yaml +++ b/packages/system/keycloak/templates/ingress.yaml @@ -7,6 +7,12 @@ {{- $gatewayEnabled := (index .Values._cluster "gateway-enabled") | default "false" }} {{- $wildcardSecret := (index .Values._cluster "wildcard-secret-name") | default "" }} {{- $adminHost := .Values.ingress.adminHost }} +{{- /* Wildcard mode applies to the admin Ingress only when it shares the + public ingressClass: the operator's default SSL cert covers it too, so + skip per-host ACME like the login Ingress. When admin is moved onto a + separate (e.g. private) ingress, that controller has no wildcard default + cert, so keep its own per-host certificate regardless of wildcard mode. */}} +{{- $adminWildcard := and $wildcardSecret (eq $adminExposeIngress $exposeIngress) }} {{- if ne $gatewayEnabled "true" }} apiVersion: networking.k8s.io/v1 @@ -70,8 +76,9 @@ metadata: name: keycloak-admin-ingress {{- with .Values.ingress.annotations }} annotations: - {{- /* Operator wildcard mode: nginx serves the default SSL cert, so skip per-host ACME. */}} - {{- if not $wildcardSecret }} + {{- /* Operator wildcard mode (shared public ingress): nginx serves the + default SSL cert, so skip per-host ACME. */}} + {{- if not $adminWildcard }} {{- if eq $solver "http01" }} acme.cert-manager.io/http01-ingress-ingressclassname: {{ $adminExposeIngress }} {{- end }} @@ -87,10 +94,9 @@ spec: tls: - hosts: - {{ $adminHost }} - {{- /* Operator wildcard mode: no per-host secret — ingress-nginx falls - back to the default SSL certificate while the tls section keeps - the HTTP-to-HTTPS redirect enforced. */}} - {{- if not $wildcardSecret }} + {{- /* Wildcard mode on the shared public ingress: rely on the default SSL + cert, no per-host secret. A separate admin ingress keeps its own. */}} + {{- if not $adminWildcard }} secretName: keycloak-admin-tls {{- end }} rules: diff --git a/packages/system/keycloak/tests/admin_hostname_test.yaml b/packages/system/keycloak/tests/admin_hostname_test.yaml index 5bda09d54..9376c54fe 100644 --- a/packages/system/keycloak/tests/admin_hostname_test.yaml +++ b/packages/system/keycloak/tests/admin_hostname_test.yaml @@ -145,6 +145,50 @@ tests: path: spec.ingressClassName value: cozy-private + - it: admin Ingress drops per-host TLS in wildcard mode on the shared ingress + template: templates/ingress.yaml + documentIndex: 1 + set: + _cluster: + root-host: example.com + wildcard-secret-name: cozystack-wildcard + ingress: + adminHost: keycloak-admin.example.com + asserts: + - equal: + path: metadata.name + value: keycloak-admin-ingress + # no per-host secret — relies on the operator default SSL cert + - notExists: + path: spec.tls[0].secretName + # no ACME / cluster-issuer annotations in wildcard mode + - notExists: + path: metadata.annotations["cert-manager.io/cluster-issuer"] + - notExists: + path: metadata.annotations["acme.cert-manager.io/http01-ingress-ingressclassname"] + + - it: admin Ingress keeps its own cert on a separate ingress even in wildcard mode + template: templates/ingress.yaml + documentIndex: 1 + set: + _cluster: + root-host: example.com + wildcard-secret-name: cozystack-wildcard + expose-ingress-admin: cozy-private + ingress: + adminHost: keycloak-admin.example.com + asserts: + - equal: + path: metadata.name + value: keycloak-admin-ingress + # separate ingress has no wildcard default cert -> per-host secret stays + - equal: + path: spec.tls[0].secretName + value: keycloak-admin-tls + - equal: + path: metadata.annotations["cert-manager.io/cluster-issuer"] + value: letsencrypt-prod + # --- HTTPRoute (gateway enabled) --- - it: renders one HTTPRoute serving all paths when adminHost is empty template: templates/httproute.yaml