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 diff --git a/packages/system/keycloak/templates/httproute.yaml b/packages/system/keycloak/templates/httproute.yaml index d6d9bb581..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 @@ -16,7 +18,50 @@ spec: hostnames: - {{ $ingressHost | 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 8f0a57369..035bebdf6 100644 --- a/packages/system/keycloak/templates/ingress.yaml +++ b/packages/system/keycloak/templates/ingress.yaml @@ -3,8 +3,16 @@ {{- $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 }} +{{- /* 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 @@ -35,6 +43,64 @@ spec: {{- 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: + service: + name: keycloak-http + port: + name: http + {{- end }} +{{- if $adminHost }} +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: keycloak-admin-ingress + {{- with .Values.ingress.annotations }} + annotations: + {{- /* 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 }} + 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 }} + {{- /* 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: + - host: {{ $adminHost }} http: paths: - path: / @@ -45,3 +111,4 @@ spec: port: name: http {{- end }} +{{- end }} diff --git a/packages/system/keycloak/templates/sts.yaml b/packages/system/keycloak/templates/sts.yaml index c33da8d53..a764d3918 100644 --- a/packages/system/keycloak/templates/sts.yaml +++ b/packages/system/keycloak/templates/sts.yaml @@ -182,6 +182,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/tests/admin_hostname_test.yaml b/packages/system/keycloak/tests/admin_hostname_test.yaml new file mode 100644 index 000000000..9376c54fe --- /dev/null +++ b/packages/system/keycloak/tests/admin_hostname_test.yaml @@ -0,0 +1,272 @@ +suite: admin hostname split (ingress.adminHost) + +release: + name: keycloak + namespace: cozy-keycloak + +# 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) + 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: + - contains: + path: spec.template.spec.containers[0].env + content: + name: KC_HOSTNAME_ADMIN + value: https://keycloak-admin.example.com + + # --- Ingress (gateway disabled) --- + - 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 + - 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: narrows the login Ingress to OIDC paths and adds an admin Ingress + template: templates/ingress.yaml + set: + _cluster: + root-host: example.com + ingress: + adminHost: keycloak-admin.example.com + asserts: + - hasDocuments: + count: 2 + # 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 + - 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 + + - 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 + set: + _cluster: + root-host: example.com + gateway-enabled: "true" + asserts: + - hasDocuments: + count: 1 + - isKind: + of: HTTPRoute + - lengthEqual: + path: spec.hostnames + count: 1 + - equal: + path: spec.hostnames[0] + value: keycloak.example.com + # no path matches -> all paths routed + - isNull: + path: spec.rules[0].matches + + - it: narrows the login HTTPRoute to OIDC paths and adds an admin HTTPRoute + template: templates/httproute.yaml + set: + _cluster: + root-host: example.com + gateway-enabled: "true" + ingress: + adminHost: keycloak-admin.example.com + asserts: + - hasDocuments: + count: 2 + # 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 bbd3d2311..73ab066df 100644 --- a/packages/system/keycloak/values.yaml +++ b/packages/system/keycloak/values.yaml @@ -5,6 +5,22 @@ 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"): + # - 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: nginx.ingress.kubernetes.io/affinity: "cookie" nginx.ingress.kubernetes.io/session-cookie-expires: "86400"