Skip to content

[freeradius] release 1.2.0#123

Merged
firmansyahn merged 95 commits into
mainfrom
feat/radius-260529
Jun 2, 2026
Merged

[freeradius] release 1.2.0#123
firmansyahn merged 95 commits into
mainfrom
feat/radius-260529

Conversation

@firmansyahn
Copy link
Copy Markdown
Collaborator

@firmansyahn firmansyahn commented Jun 1, 2026

Summary

Releases freeradius chart 1.2.0. Full notes in charts/freeradius/CHANGELOG.md (long form) and Chart.yaml artifacthub.io/changes (brief).

Headline deltas vs. 1.1.0:

  • Generic OIDC authentication module (modules.oidc.*) — replaces the dedicated Keycloak module (now removed). Provider-agnostic (Keycloak / Authentik / Azure AD / Auth0 / Okta / …). Per-instance backends with groupMappings / attributeMappings / require / introspect / refreshTokenCache. NAS binding via clients.<x>.oidc: <name>.
  • End-to-end PROXY v1 for RADSEC — new gateway.proxyProtocol knob renders an Envoy Gateway BackendTrafficPolicy (gateway.envoyproxy.io/v1alpha1) scoped to the RADSEC Service port and forces proxy_protocol = yes on the RADSEC listen { } block, so FreeRADIUS sets Packet-Src-IP-Address to the real client IP. v1 because FreeRADIUS 3.2.x only parses v1.
  • Routes attach to specific Gateway listeners via sectionName — UDPRoute (auth/acct/coa) and TLSRoute (radsec) parentRefs now name the listener explicitly instead of attaching to all compatible listeners on the parent Gateway.
  • TLS auto-generation is implicit (BREAKING) — cert-manager auto-detected; tls.autoGenerated / tls.certManager.create / modules.{sql.tls,eap.tlsConfig}.autoGenerated deprecated.
  • RADSEC site renamed tlsradsec (BREAKING) — values, env-var, ConfigMap, and FreeRADIUS-internal block names all migrated. Top-level tls.* (cert material) untouched.
  • Bundled Redis subchart + modules.redis.* + standalone modules.cache.*; NetworkPolicy egress narrowing; templates/gateway-api/EnvoyProxy.yaml + Envoy Gateway data-plane infra knobs; ListenerSet (with XListenerSet v1alpha1 support); values.schema.json; .helmignore.

Workload + topology:

  • kind: knob accepts Deployment / StatefulSet / DaemonSet (case-insensitive). StatefulSet wires the headless Service via serviceName: and switches persistence to per-replica volumeClaimTemplates. New freeradius.isStatefulSet + freeradius.isDaemonSet helpers; headless Service now also renders for DaemonSet (same DNS-based per-pod discovery applies).
  • Per-pod Services + Gateway fan-out for StatefulSettemplates/Service-perPod.yaml renders one Service per StatefulSet ordinal; UDPRoute / TLSRoute backendRefs fan out via freeradius.gateway.backendRefs, preserving NAS source IP end-to-end with per-pod externalTrafficPolicy: Local.
  • createDefaultInstance.{realm, homeServer, homeServerPool} — three opt-in toggles for chart-managed peer-mesh proxy primitives. realm DEFAULT + per-pod home_server <fullname>_<ord>_<auth|acct> (per-pod Service DNS) + <fullname>_<auth|acct>_pool. All three silently skip on non-StatefulSet kinds. Shared secret rendered as literal $ENV{FREERADIUS_DEFAULT_INSTANCE_SECRET}; user wires via extraEnvVarsSecret.
  • podManagementPolicy default flipped to Parallel for fresh StatefulSet installs. IMMUTABLE on live releases — k8s rejects in-place updates. See §Upgrading → podManagementPolicy in README.md for the kubectl delete sts --cascade=orphan recovery recipe and the "pin the old behavior in your overlay" alternative.

Config-as-values surface:

  • logging.* knobs — surface the FreeRADIUS log { } directive set as values (destination / colourise / file / syslog_facility / stripped_names / auth / auth_badpass / auth_goodpass / msg_denied). Default destination: stdout makes kubectl logs work out of the box.
  • radiusd.conf body moved into templates/configmaps/configuration.yaml — single source of truth. Escape hatches preserved: .Values.configurations (inline string override) and .Values.configurationsConfigMap (BYO ConfigMap). files/radiusd.conf deleted (1214-line upstream-style file with wrong paths). The previously-hardcoded -l stdout CLI override dropped — log destination is configmap-driven now.
  • Snake_case rename batch (BREAKING; same-release silent-ignore) — mirrors FreeRADIUS native directive names across:
    • modules.sql.{readGroups, readProfiles}read_groups / read_profiles
    • modules.json.encode.value.{singleValueAsArray, enumAsInteger, datesAsInteger, alwaysString} → snake_case
    • modules.cache.maxEntries (base + per-instance) → max_entries
    • modules.eap.{timerExpire, ignoreUnknownEapTypes, ciscoAccountingUsernameBug, maxSessions, defaultType} → snake_case (including default_eap_type)
    • Plus the existing homeServers[] / homeServerPools[] / realms[] / sites.radsec.clients[] / modules.sql.{groupAttribute, readClients} renames covered earlier in the branch.
  • modules.eap.tlsConfig.cipher_list string → []string (mirrors sites.radsec.tls.cipher_list shipped earlier). Joined with : via freeradius.utils.joinOrDefault; default ["DEFAULT"]; empty / nil falls through to DEFAULT.
  • Dead .Values.architecture top-level key removed (never referenced in any template; the only architecture hits in templates/ belong to the mariadb/postgresql subcharts).

Removed (BREAKING — see Upgrading section in README):

  • Dedicated Keycloak module end-to-end (top-level keycloak.* block, KC_* / FREERADIUS_KEYCLOAK_* env vars, mods-config/keycloak ConfigMaps, all freeradius.keycloak.* helpers, clients.<x>.keycloak).
  • lua mapper-script mode (rlm_lua not bundled in freeradius/freeradius-server:3.2.8).
  • keycloak.mode: rest.

Fixed:

  • Cert-manager Certificate / Issuer templates no longer emit apiVersion: false when the API is absent.
  • templates/configmaps/clients.yaml skips non-map scalar keys (includeFile, existingConfigMapName).
  • image.debug actually gates -f vs -fxx.
  • Writable emptyDir at /var/run/radiusd for readOnlyRootFilesystem: true.
  • OIDC dispatch chain now also rendered into sites/inner-tunnel (was only emitted into sites/default). EAP-TTLS / PEAP tunnelled auth bound via clients.<x>.oidc: <instance> now reaches oidc_<name>_authorize.
  • OIDC dispatch-arm whitespace bug (the ipv6 arm's -}} was eating the newline + leading indent, gluing the if (...) { directive onto the trailing comment line). Affected both sites/default and the newly-wired sites/inner-tunnel.
  • OIDC dispatch arms guard each Packet-Src-IP[v6]-Address comparison on attribute existence (&Attr && &Attr <= "...") — without it, IPv4-only requests bail with Failed casting lhs operand when the OR falls through to the IPv6 leg.
  • OIDC dispatch arms use the <= IP-in-prefix operator instead of == for the clients.<x>.{ipv4addr,ipv6addr} match — == is exact-string equality, so any CIDR value silently missed.
  • OIDC role/group mappings guard the [*] multi-value iterator on attribute existence so a missing roles/groups claim doesn't bail with Failed retrieving values required to evaluate condition.

Docs + tests:

  • README §Breaking Changes 1.2.0 inventory + §Upgrading entries (TLS deprecation, Keycloak → OIDC migration, podManagementPolicy default flip).
  • README §"OIDC authentication" narrative + §Parameters "OIDC integration parameters" table rewritten around modules.oidc.* (was Keycloak-shaped). Obsolete FREERADIUS_KEYCLOAK_* env-var rows + bullets replaced with FREERADIUS_OIDC[_<NAME>]_CLIENT_SECRET.
  • README §Parameters extended with missing rows: logging.*, extraStartupArgs, kind, podManagementPolicy, createDefaultInstance.{realm, homeServer, homeServerPool}.
  • Chart.yaml artifacthub.io/changes extended end-to-end.
  • Schema tests +7 (cipher_list array-shape positive + scalar-string negative on both surfaces; bool guards on snake_case eap/sql keys; integer guard on modules.cache.max_entries).

Test plan

  • helm unittest charts/freeradius105/105 pass locally (10 suites: Application, GatewayAPI, HPA, Istio, Metrics, NetworkPolicy, OIDC, PersistentVolumeClaim, Service, schema).
  • helm lint charts/freeradius — clean.
  • CHANGELOG.md, Chart.yaml artifacthub.io/changes, and README.md §Upgrading (to-1.2.0 subsection) describe the 1.1.0 → 1.2.0 deltas.
  • CI runs ct lint + helm-unittest on this PR.
  • Manual smoke: helm template with a representative values set (gateway.enabled=true, tls.enabled=true, modules.oidc.enabled=true with at least one instance) renders cleanly.

- Add bundled Bitnami redis subchart (condition: redis.enabled) and the
  rlm_redis module (modules.redis); host/password auto-wired to the bundled
  instance via freeradius.redis.* helpers.
- Add standalone rlm_cache module (modules.cache) with a driver selector
  (rbtree/redis/memcached); the redis driver reuses the modules.redis connection.
- cert-manager: auto-detect the API and bootstrap a self-signed CA chain
  (templates/Issuer.yaml) when no issuerRef is given; fix the truthy
  "apiVersion: false" capability gate; tls.certManager.create now defaults true.
- Fix clients.yaml render: skip non-map keys (includeFile/existingConfigMapName)
  via kindIs instead of a hand-maintained omit list.
- Wire image.debug to toggle radiusd -f/-fxx; add a writable /var/run/radiusd
  emptyDir so the pidfile works under readOnlyRootFilesystem.
- Docs: CHANGELOG Unreleased section + README upgrade notes.
- policies.includeDir: include the policy { } block from the image's real
  policy.d (relative, resolves under /etc/freeradius) instead of the
  non-existent /opt/startechnica/freeradius/policy.d. Fixes the startup error
  "Failed reading directory .../policy.d/: No such file or directory" and
  restores the bundled default policies (filter_username, eap, ...).
- README: add a "Redis-backed cache" section with the minimum values to enable
  the rlm_cache redis driver against the bundled Redis subchart.
ArgoCD runs `helm dependency build`, which falls back to range-resolution
when no Chart.lock is present — that queries Docker Hub's tags/list endpoint
for oci://registry-1.docker.io/bitnamicharts, now deprecated and returning
404. Stop gitignoring Chart.lock and pin mariadb/postgresql/redis to their
exact latest versions so builds pull pinned OCI tags (manifest fetch, no
tags/list call).
The cache module rendered `cache { }` with no `update { }` subsection, so
rlm_cache aborted at load: "Must have an 'update' section in order to cache
anything". Render the required section from a new `modules.cache.update`
(default `&reply: += &reply:`), and hard-fail validation when cache is enabled
with an empty update so the misconfig is caught at render instead of crashing
FreeRADIUS at startup.
Override the Bitnami Redis subchart so the cache backend runs as an ephemeral
Deployment (master.kind=Deployment, persistence disabled) — appropriate for a
non-durable attribute cache.
…workPolicy

- Replace the env-vars ConfigMap (templates/configmap/envvars.yaml) with a
  Secret (templates/secret/envvars.yaml, keeping the <fullname>-envvars name).
  Drop the unused FREERADIUS_ENABLE_TLS / FREERADIUS_SITES_NAMESPACE keys; the
  only remaining content is the conditional FREERADIUS_MODS_REST_PASSWORD
  fallback, now in a Secret instead of a ConfigMap. The Deployment envFrom uses
  secretRef (the existingConfigmap BYO path still uses configMapRef).
- Add NetworkPolicy egress to the Redis backend on modules.redis.port when
  rlm_redis or the redis-driver cache is enabled.
Expose sites.coa.listen.{type,ipaddr,virtual_server} and render them in
sites/coa.yaml instead of hardcoding the listen address and virtual-server
name. Set modules.cache.driver=redis in values-test.yaml for local testing.
… vars

- extract secret-backed container env into freeradius.secretEnvVars helper
- gate inner-tunnel files/sql/ldap and wire keycloak REST Auth-Type
- add modules.files and modules.ldap toggles
- set response format=json on keycloak rest module
- document keycloak.scope example
…EAP cert-manager support

- move RADSEC/gateway certs into templates/certificates/ alongside new eap-tls cert
- issue EAP tls-config server cert via cert-manager when useCertManager is on
- rename RADSEC chart-managed Secret default <fullname>-tls -> <fullname>-radsec-tls
  for parity with eap-tls; BYO Secret names unaffected
- gate self-signed eap-tls Secret on (not useCertManager) so it doesn't
  double-render alongside the cert-manager Certificate
…swords; drop dead sql privkey knob

- rename templates/configmap -> templates/configmaps, templates/secret -> templates/secrets
  (matches the certificates/ precedent); fix path references in Deployment.yaml
  checksum includes, helper/values comments
- gate sites/EAP private_key_password emission, env var injection, and credentials
  Secret key on the corresponding privateKeyPassword value being set; removes
  three-way dead weight when no passphrase is configured
- remove modules.sql.tls.privateKeyPassword entirely: rlm_sql drivers
  (libmysqlclient tls{}, libpq) don't accept an encrypted leaf key
…lm_cache instances; merge cache configmaps into one mods-enabled/cache file

- sites.tlsCache: new virtual server (cache load/save/clear/refresh, FR 3.2.x convention) backing EAP TLS session resumption via a Redis-backed cache_tls rlm_cache instance; auto-wires modules.eap.tlsConfig.cache when enabled
- keycloak.cache: cache_keycloak rlm_cache instance wired into keycloak_authorize, short-circuits the ROPC + introspection roundtrip on User-Name hits
- modules.cache.instances: user-defined named rlm_cache instances rendered alongside the chart-managed ones
- single mods-cache ConfigMap with one data.cache key holding every "cache <name> { }" block concatenated; Deployment collapses to one volume + mount + checksum
- _validate.tpl: tlsCache / cacheInstances / keycloakCache validators (hard-fail without Redis where required)
…ak ConfigMap for module+policy, separate lua-mapper script

- keycloak.yaml (new): one ConfigMap rendered for any mode; `keycloak` data key holds the active rlm_lua OR rlm_rest instance (mode-conditional); `keycloak-policy` data key holds the unlang wrapper + role mapping (lua mode only)
- keycloak-mapper-lua.yaml (renamed from configmap-lua.yaml): lua-mode-only ConfigMap holding the keycloak-mapper.lua script
- configmap-lua.yaml / configmap-rest.yaml / configmap-policy.yaml: removed
- Deployment: keycloak.enabled now mounts one `keycloak` volume at mods-enabled/keycloak (always) and policy.d/keycloak (lua only); lua mode adds a `keycloak-mapper-lua` volume at scripts/keycloak-mapper.lua. Two checksums collapse to one (or two in lua mode)
- includes the unrelated values.yaml comment tweak (/path/to/openssl -> /usr/bin/openssl)
…olicy.yaml); flip default keycloak.mode to rest

- keycloak-policy.yaml (new): standalone <fullname>-keycloak-policy ConfigMap holding the policy.d/keycloak unlang body (lua mode only). FreeRADIUS only parses policy { } blocks when included from policy.d/, so the policy can't share a ConfigMap+volume with the mods-enabled/ module file
- keycloak.yaml: drop the `keycloak-policy` data key; now renders only the `keycloak` module-config key
- Deployment: separate keycloak-policy volume + checksum (lua mode), mounted at /etc/freeradius/policy.d/keycloak; mods-enabled/keycloak mount unchanged
- values.yaml: default mode changes lua -> rest (REST mode is the simpler path; users opting into Lua role mapping set mode: lua explicitly)
… default-site call to keycloak_authorize for both modes

- keycloak-policy.yaml: gate on .keycloak.enabled (was lua-only); mode-conditional body wraps keycloak_lua (lua) or keycloak_rest (rest) with cache-aside; rest branch uses (ok || updated) rcode since rlm_rest returns updated when it parses reply attrs; role mapping (keycloak_roles) stays lua-only — chart's rest module only hits the token endpoint and can't populate role attrs
- Deployment: keycloak-policy ConfigMap/volume/mount/checksum now render unconditionally when keycloak.enabled, not just in lua mode
- _validate.tpl: drop the "cache.enabled requires mode: lua" rule; the cache wrapper exists for both modes now
- sites/default.yaml: collapse the lua/rest branches into a single keycloak_authorize call; the policy handles Auth-Type accept per mode
- inner-tunnel.yaml keycloak_rest reference unchanged (rest module as Auth-Type REST backend, separate from authorize-phase policy)
…cy' keyword from inner blocks

FreeRADIUS error at policy.d/keycloak[2]:
  Failed to find "policy" as a module or policy.

Files in policy.d/ are $INCLUDEd inside radiusd.conf's outer policy { } block,
so each named policy is declared as bare `<name> { }`. The chart's template
emitted `policy <name> { }`, which the parser treats as a call to a module
named "policy" — fails authorize-section load and refuses to start.

Also restore the (ok) parens in lua mode for consistency with the rest-mode
(ok || updated) rendering.
…ting

The official freeradius/freeradius-server image strips client tools
(radclient/radtest), so running an Access-Request from inside the cluster
requires a separate pod. This adds an opt-in Deployment that:

- runs a small Debian pod (default `debian:bookworm-slim`)
- apt-installs freeradius-utils on first start (cached after that)
- runs `sleep infinity` so it's `kubectl exec`-able
- pre-populates RADIUS_HOST / RADIUS_AUTH_PORT / RADIUS_SECRET env vars
  pointing at the chart's own Service, so the test command is short:
    radtest <user> <pass> $RADIUS_HOST $RADIUS_NAS_PORT $RADIUS_SECRET

Disabled by default (`radtest.enabled: false`). Intended for short-lived
debugging of Keycloak/EAP/SQL flows — not a production traffic generator.
…n securityContext

- image default `debian:bookworm-slim` -> `2stacks/radtest:latest` (Alpine,
  ~8 MB, ships radtest/radclient preinstalled — no apt-install dance)
- drop the apt-get path from the container command; just print a hint and
  sleep infinity
- containerSecurityContext now PSS-restricted by default: runAsUser/Group
  65534, runAsNonRoot true, readOnlyRootFilesystem true, drop ALL caps.
  Safe because radtest only needs a UDP socket + stdin/stdout — no
  on-disk state, no privileged ops
…o the pod

radiusd.conf has `$INCLUDE clients.conf` at top level, and the chart already
renders templates/configmaps/clients.yaml from .Values.clients — but
Deployment.yaml never mounted the resulting ConfigMap. The container ended
up using whatever clients.conf shipped in the image, so any chart-level
`clients.<name>` config (including the documented localhost shared secret)
silently did nothing.

Adds:
- volumeMount at /etc/freeradius/clients.conf, subPath clients.conf
- volume `freeradius-clients` backed by `<fullname>-clients` (or
  `.Values.clients.existingConfigMapName` when BYO)
- checksum/configmap-clients pod annotation so values changes trigger a
  rollout
Default `clients.localhost.coa_server: coa` made FreeRADIUS reject the
clients.conf at startup:

  /etc/freeradius/clients.conf[1]: No such home_server or home_server_pool "coa"

The `coa_server` directive in a `client { }` block points at a
`home_server` (or `home_server_pool`) used for *outbound* CoA-Request
proxying — NOT at the inbound CoA virtual server. This chart renders
no `home_server` blocks anywhere, so the default value resolved to
nothing.

Inbound CoA (the actual `sites.coa.enabled` feature) is wired through
the `listen { virtual_server = coa }` block in templates/sites/coa.yaml
and is independent of any client.coa_server setting.

- Default to empty string so the template's `if $client.coa_server`
  guard skips emitting the directive.
- Doc-clarify what coa_server is actually for, and that the chart does
  not yet expose home_server blocks.
… JSON into Session-Timeout / Class; tune rest connection pool; expose require_message_authenticator

- keycloak-policy.yaml rest-mode branch: after a successful keycloak_rest call,
  `map json "%{reply:REST-HTTP-Body}"` extracts:
    * `/expires_in`   -> &reply:Session-Timeout (NAS terminates the session
                          when the OAuth token would expire, forcing re-auth
                          against Keycloak)
    * `/access_token` -> &reply:Class           (opaque pass-through; NAS
                          round-trips it in accounting for token correlation)
  Then strips REST-HTTP-Status-Code / REST-HTTP-Body from the reply so they
  don't leak to the NAS. Applies in both cache-enabled (inside the cache-miss
  store path) and cache-disabled branches.
- cache_keycloak update {} now also caches &reply:Session-Timeout and
  &reply:Class so cache hits in rest mode restore the same attribute set
  the fresh path would have produced. Lua mode is unaffected (those reply
  attrs are empty there, so caching them is a no-op).
- keycloak.yaml: add pool { start=1 min=1 max=5 spare=1 ttl=3600 } to the
  rlm_rest instance — silences the "you probably need to lower min" log spam
  when traffic is bursty (default pool was over-provisioned for typical RADIUS
  workloads).
- clients.yaml + values.yaml: expose `clients.<name>.require_message_authenticator`,
  rendered as `require_message_authenticator = yes` when true. Mitigates the
  BlastRADIUS attack (CVE-2024-3596). Default false to preserve back-compat.

Requires `modules.json.enabled: true` for rest mode (the `map json` xlat
comes from rlm_json). FR will fail policy load if json isn't loaded.
…token-body parsing

Adds `json json_keycloak { }` to mods-enabled/keycloak (rest mode only) and
switches the policy from `map json` to `map json_keycloak`. Self-contains
the Keycloak feature — the rest-mode JSON extraction no longer depends on
the chart-wide `modules.json.enabled` toggle; the dedicated instance loads
rlm_json on its own.
…ycloak rest; suppress status-site auth logs

keycloak rest JSON mapping:
- FR 3.2.x parses `map <name>` against built-in types only; rlm_json's map
  proc isn't registered by instance name, so the previous `map json_keycloak`
  failed at policy load: "Expecting section start brace '{' after 'map
  json_keycloak'". Revert both occurrences to `map json`.
- Drop the now-useless `json json_keycloak {}` block from
  mods-config/keycloak/keycloak.yaml (it would have loaded but couldn't be
  referenced).
- Add `freeradius.validate.keycloakRest`: hard-fail when keycloak.mode=rest
  is set without modules.json.enabled=true, with the exact error message
  users would otherwise see at FR startup.
- Fix /access_token vs /session_state inconsistency in the no-cache branch
  (the earlier "swap to session_state" only touched the cache branch).

keycloak-policy.yaml mode dispatch:
- Convert remaining implicit `not $isLua` / `else` rest-mode branches to
  explicit `$isRest` / `else if $isRest` so adding a third mode later is a
  clean addition rather than a silent fall-through.
- Replace the two `ternary "..." "..." $isLua` variable assignments at the
  top with an explicit `if $isLua / else if $isRest` block setting $mod
  and $okRcode symmetrically.

sites/status.yaml:
- Add per-virtual-server `log { auth = no; auth_badpass = no; auth_goodpass
  = no; stripped_names = no }` to silence the "Auth: Login OK" line emitted
  on every Status-Server poll from the metrics exporter. Global log config
  is unaffected; other virtual servers keep their default verbosity.
- Rename inline status-only client `admin` -> `probe` (cosmetic; clients
  match by IP, not by name).
…s no map_proc, map json doesn't parse

FR 3.2.x's rlm_json module is encode-only — there's no map_proc_register
anywhere in the rlm_json source tree (verified rlm_json.c and json.c on
v3.2.x), no jpath xlat, no json_decode. Loading the module enables
%{json_encode:...} (attrs -> JSON string), nothing in the reverse direction.

The `map json` syntax was added in FR 4.0-alpha; using it on 3.2.x produces:

  /etc/freeradius/policy.d/keycloak[13]: Expecting section start brace '{'
  after "map json"
  Errors reading or parsing /etc/freeradius/radiusd.conf

Reverting the JSON-mapping feature accordingly:

- keycloak-policy.yaml: drop the rest-mode `map json ... { Session-Timeout
  ... Class ... }` blocks from both the cache-miss and no-cache branches,
  drop the REST-HTTP-Body/REST-HTTP-Status-Code strip blocks that paired
  with them. Rest mode now just calls keycloak_rest and accepts on
  (ok || updated), same as the working baseline before the feature.
- modules/cache.yaml: drop &reply:Session-Timeout / &reply:Class from
  cache_keycloak's update {}. Caches only &control:<roleAttribute> again
  (populated only in lua mode; rest mode still benefits from the ROPC
  short-circuit, just with an empty cached attr set).
- _validate.tpl: remove `freeradius.validate.keycloakRest` — no longer
  needed since the policy doesn't reference rlm_json. Rest mode works
  without modules.json.enabled.

If/when we move to FR 4.x, the mapping can come back in one commit.
… introspection in script modes; rename policies; tighter HTTP-status dispatch

Python backend mode (new):
- keycloak-mapper-python.yaml: rlm_python3 script doing ROPC + JWT-decode +
  role extraction (mirrors keycloak-mapper.lua functionally, stdlib-only —
  urllib + json + base64 + ssl, no third-party deps)
- keycloak.yaml: `python3 keycloak_python { }` module block (mode-conditional)
- Deployment: subPath mount for /etc/freeradius/scripts/keycloak_mapper.py,
  configmap volume, checksum annotation; env vars injected for both
  script-driven modes (lua, python). rlm_python3.so is bundled in the
  default freeradius/freeradius-server:3.2.8 image — no image rebuild
  needed (lua mode still needs a custom image with freeradius-lua).
- keycloak-policy.yaml: $mod/$okRcode dispatch grows a python branch
  ($okRcode = (ok), matching lua semantics; $hasRoles = isLua OR isPython)
- _validate.tpl: keycloak.mode in {lua, python, rest} hard-fail validator

Drop introspection in lua/python — saves one HTTPS round-trip per auth.
ROPC success already means Keycloak validated the credentials; the access
token's JWT payload (base64url-decoded) carries the role data. The
previous /introspect call added latency without security gain.

Specific HTTP-status handling (lua/python):
  None      network/TLS error                  -> FAIL   (transient)
  400, 401  credentials rejected by Keycloak   -> REJECT (final)
  200       token issued                       -> proceed
  other     unexpected (5xx, misconfig, etc.)  -> FAIL
Previously any non-200 collapsed to REJECT, masking operational issues as
auth failures.

`keycloak.roleMapper` (new) — choose role source:
  client (default)  resource_access[clientId].roles  (per-client roles)
  realm             realm_access.roles               (realm-wide roles)
Both script-driven modes honor it via FREERADIUS_KEYCLOAK_ROLE_MAPPER env;
rest mode ignores (rlm_rest doesn't extract roles).

Keycloak TLS — `keycloak.tls.*` (new):
- caCert: inline PEM bundle -> chart renders <fullname>-keycloak-ca Secret
- existingSecret + existingSecretCaKey: BYO Secret name + key (default
  ca.crt — standard K8s convention used by cert-manager / kube-root-ca)
- insecure: skip TLS verification (dev only)
All three modes consume the CA consistently:
  - rest: rlm_rest tls{} ca_file = ...; check_cert = no when insecure
  - python: ssl.create_default_context().load_verify_locations(cafile=...);
    ssl._create_unverified_context() when insecure
  - lua: per-https.request{} cafile / verify params via TLS_PARAMS
Validator hard-fails when caCert AND existingSecret are both set.

Policy rename for naming-convention consistency (verb_subject):
  keycloak_authorize -> authorize_keycloak
  keycloak_roles     -> roles_keycloak
Call sites in default.yaml and inline comments updated. CHANGELOG
historical entries left as-is (they document the original released names).

CHANGELOG entry for the prior KC_* -> FREERADIUS_KEYCLOAK_* env-var
rename is bundled in this commit (predates the python work but belongs
in the same release).
…Keycloak)

The 1.2.0 release notes were written when the dedicated Keycloak module
was still the headline feature. Commit 3542d35 then ripped that module
out and replaced it with the generic modules.oidc.* module, but the
release notes were never updated. Result: CHANGELOG, Chart.yaml's
artifacthub.io/changes, and README's "Upgrading -> Unreleased" section
all described keycloak.instances.<name>, freeradius.keycloak.* helpers,
KC_*/FREERADIUS_KEYCLOAK_* env vars and clients.<x>.keycloak bindings
that no longer exist anywhere in the chart.

Rewrite all three to reflect what shipped vs. 1.1.0:

- ### Added (CHANGELOG + artifacthub):
  - Headline: generic OIDC module replacing the dedicated Keycloak
    module. Provider-agnostic (Keycloak/Authentik/Azure AD/Auth0/Okta).
  - Per-instance feature list (roleMappings/groupMappings,
    attributeMappings, require, introspect, refreshTokenCache).
  - Per-instance K8s resources (module/policy ConfigMaps,
    client-secret + TLS CA Secrets, env wiring).
  - Shared oidc.py library + per-instance wrappers (single shared
    <fullname>-oidc-python ConfigMap holds N keys; the consolidation
    detail from earlier in the cycle is folded into this entry rather
    than presented as a 1.1.0->1.2.0 "change", since 1.1.0 had no OIDC).
  - cache oidc[_<name>]_cache integration.
  - freeradius.oidc.* helpers + freeradius.validate.oidc* validators.

- ### Removed (BREAKING — new section in CHANGELOG, mirrored as kind: removed in artifacthub):
  - Dedicated Keycloak module end-to-end (top-level keycloak.* block,
    KC_*/FREERADIUS_KEYCLOAK_* env vars,
    templates/modules/mods-config/keycloak/* ConfigMaps, all
    freeradius.keycloak.* helpers and validators, clients.<x>.keycloak,
    Auth-Type REST wiring).
  - lua mapper-script mode (rlm_lua not bundled in 3.2.8 image).
  - keycloak.mode: rest (strict subset of python).

- ### Changed: dropped the three Keycloak-specific entries (KC_* env
  var rename, mapper-script filename rename, rest-mode removal) — all
  three describe an intermediate-state Keycloak that no longer exists.
- ### Deprecated: dropped the keycloak.{mode,url,realm,...} top-level
  deprecation entry — those keys are now removed, not deprecated.
- ### Fixed: kept; fixed templates/configmap/clients.yaml path
  reference to the post-1.1.0 templates/configmaps/clients.yaml.

- README "Upgrading -> Unreleased":
  - Replaced the "Keycloak is now multi-instance" + "Keycloak script
    env vars renamed" subsections with one "Keycloak module removed;
    migrate to modules.oidc.*" subsection, including a values
    before/after diff and a per-instance resource table.
  - Dropped the "OIDC python wrappers consolidated" subsection — for a
    1.1.0 user there's no consolidation to migrate, since 1.1.0 had no
    OIDC module to begin with.

92/92 helm-unittest tests pass; chart lints clean.
…ectionName on route parentRefs

Adds a single `gateway.proxyProtocol: false` knob that wires PROXY
protocol v1 from the Envoy data plane through to FreeRADIUS in one shot.
When true (and the gateway-api + envoy + tls.enabled preconditions are
met) the chart:

  - Renders templates/gateway-api/BackendTrafficPolicy.yaml — an Envoy
    Gateway BackendTrafficPolicy (gateway.envoyproxy.io/v1alpha1) scoped
    to the FreeRADIUS Service's `tls-radsec` port via
    targetRefs[].sectionName, with proxyProtocol.version: V1. Envoy
    prepends a PROXY v1 header on every TCP connection to the pod.
  - Forces proxy_protocol = yes on the RADSEC listen { } block via an
    `or` with the existing sites.radsec.listen.proxy_protocol knob, so
    FreeRADIUS sets Packet-Src-IP-Address to the real client IP.

v1 is pinned because FreeRADIUS 3.2.x parses v1 only on its TCP
listeners — v2 is undocumented in the receive path. UDP auth/acct/coa is
unaffected (PROXY protocol is TCP-only and FreeRADIUS UDP sockets can't
parse it regardless). The standalone sites.radsec.listen.proxy_protocol
knob is still there for non-Envoy front-ends (HAProxy, NLB direct-to-pod).

freeradius.validate.gatewayProxyProtocol hard-fails the three
non-functional combinations: gateway.implementation: istio (no BTP
equivalent), gateway.infrastructure: "" (BTP is Envoy Gateway-specific),
and tls.enabled: false (no RADSEC TCP listener to wire into). Caught at
helm install / helm template / helm lint, not at apply.

Side change: chart-rendered UDPRoute (auth/acct/coa) and TLSRoute
(radsec) now emit spec.parentRefs[].sectionName matching the listener
name in the chart's Gateway, so each route attaches to one specific
listener rather than all compatible listeners on the parent. Threaded
through freeradius.gateway.routeParentRefs as a new optional sectionName
argument; routes whose parentRefs are overridden via
gateway.{udpRoute,tlsRoute}.parentRefs are passed through verbatim, and
the ListenerSet-attached branch is unchanged (user controls listener
names there).

Tests added: BTP absent by default, BTP renders with V1 + sectionName
scoping, gateway.proxyProtocol=true flips proxy_protocol=yes on the
RADSEC listen, three validation rejections (tls off / implementation
istio / infrastructure not envoy). Existing UDPRoute and TLSRoute tests
gained spec.parentRefs[0].sectionName assertions. Istio cross-isolation
test updated (BTP replaces CTP; gateway.proxyProtocol removed since the
new validator would reject it under istio). 98/98 pass.

Cleanup: removed a duplicate freeradius.validate.oidcClientBindings
registration in the validation aggregator while in there.

Docs: CHANGELOG (1.2.0 Added + Changed) and artifacthub.io/changes
updated for the new knob, the validator, and the sectionName change.
…; enable EAP-MD5

OIDC dispatch chain was missing from sites/inner-tunnel and rendered
malformed in sites/default whenever a clients.<x>.oidc binding was set:

- Inner-tunnel had zero OIDC wiring despite the 1.2.0 Added-note promising
  the dispatch chain into both sites. EAP-TTLS / PEAP tunnelled auth bound
  via clients.<x>.oidc fell straight through to pap. Ported the same block
  from sites/default, between logintime and pap. Packet-Src-IP[v6]-Address
  still reflects the outer NAS inside the tunnel (RFC 5281 §11.2), so the
  same NAS-binding logic works unchanged.
- The ipv6 dispatch-arm directive ended in `-}}` which ate the newline +
  indent before the `if (...)` action, gluing `if (Packet-Src-IP-Address …) {`
  onto the trailing comment line. Result: one big comment line plus an
  unmatched closing `}` — radiusd -C would fail. Changed to `}}` (no
  forward trim) in both sites. Bug pre-existed in default.yaml since 1.2.0
  but only fired with at least one clients.<x>.oidc binding set.

Also enables modules.eap.methods.md5 so the EAP submodule loads, matching
supplicants that request EAP-MD5 as the inner method (note: EAP-MD5 needs
&control:Cleartext-Password — only useful where plaintext password storage
is acceptable).
…e existence

Without the guard, unlang treats the bare-word `Packet-Src-IPv6-Address`
in the OIDC dispatch arm as an xlat (`%{Packet-Src-IPv6-Address}`), and
when the packet arrived over IPv4 the absent attribute expanded to `""`,
which the IPv6 cast then failed to resolve:

    EXPAND Packet-Src-IPv6-Address
    -->
    ERROR: Failed casting lhs operand: Failed resolving "" to IPv6 address
    ERROR: Failed retrieving values required to evaluate condition

The cast error stamped Module-Failure-Message and the OR-arm silently
evaluated false, so dispatch missed the matching instance and fell
through to pap. Symmetric risk for IPv6-only requests on the IPv4 leg.

Fix: render each comparison as `(&Attr && Attr == "<addr>")` — `&Attr`
is the attribute-reference form (no xlat) and the existence check short-
circuits the comparison when the attribute isn't present. Applied to
both sites/default and sites/inner-tunnel.
… as a CIDR prefix, not an exact string

The dispatch arm rendered `Packet-Src-IP-Address == "<addr>"`, which is
exact-string equality in unlang. Any CIDR value silently missed:

    if ((&Packet-Src-IP-Address && Packet-Src-IP-Address == "0.0.0.0/0")) {
    EXPAND Packet-Src-IP-Address
    --> 172.18.135.80
    if ((&Packet-Src-IP-Address && Packet-Src-IP-Address == "0.0.0.0/0")) -> FALSE

The semantic mismatch was double-sided: the underlying `clients{}` block
has always accepted CIDR for shared-secret matching (it parses prefixes
natively), so anyone wiring an OIDC instance via clients.<x>.oidc with
the same CIDR they used for ipaddr hit a silent dispatch miss and fell
through to pap -> "No Auth-Type found".

Switch the operator to `<=`, which FR3 unlang(5) §CONDITIONS documents
as "checking that an IP address is contained within a network". Single
hosts (`172.18.0.1`) are treated as /32 prefixes containing only
themselves, so the same render works for both CIDR and bare hosts —
no template branching needed. Existence guard (`&Attr && ...`) is kept
so absent attributes still short-circuit cleanly.

Applied to both sites/default and sites/inner-tunnel.
…bute existence

When the IdP returned no roles at the configured `rolesClaim` (or no
groups at `groupsClaim`), the policy hit:

    policy oidc_keycloak_roles {
    if (&control:Class[*] == "basecamp-wifi") {
    ERROR: Failed retrieving values required to evaluate condition
    } # policy oidc_keycloak_roles = noop

The `[*]` iterator on an absent attribute is a hard error in unlang, not
a no-match. The rest of the mapping chain is then skipped — auth still
completes (Auth-Type := Accept was already set by oidc_<name>_authorize)
but every "user has no role/group" case stamps a noisy error.

Add the same `&Attr && ...` existence guard the dispatch arms use:

    if (&control:Class && &control:Class[*] == "basecamp-wifi") { ... }

Symmetric fix for the groups policy. Same pattern as the IP-cast and the
IP-prefix containment fixes — `[*]` on a missing attribute is the third
"missing-attribute footgun" we've shipped in 1.2.0; the dispatch already
demonstrates the right pattern.
Two themes, no contract change for runtime callers.

Diagnostics — opaque failures become actionable log lines:

- _post_form returns (status, body, err) where err is repr(URLError|
  OSError) on network/TLS failure. authorize() and validate() surface
  it into radlog instead of swallowing — DNS, TCP, TLS, timeout each
  produce a distinct log line. Same treatment for introspect.
- L_DBG around each HTTP call: `ROPC POST <url> (user=...)` /
  `ROPC -> HTTP <status>`, `introspect POST <url>`, `refresh_token
  POST <url>` / `refresh_token -> HTTP <status>`. Visible under
  `radiusd -X` — confirm the rendered tokenUrl/introspectUrl without
  exec-into-pod + curl.
- L_AUTH: HTTP body on ROPC 4xx/5xx (RFC 6749 error body —
  `{"error":"invalid_grant","error_description":"..."}`) so the
  IdP-side rejection reason is visible.
- L_DBG after JWT decode / introspect: sorted top-level claim keys,
  plus the RESOLVED value at each configured claim path (required[],
  rolesClaim, groupsClaim). Top-level values not logged — keys only,
  since values may carry PII.
- "no roles" warning walks the configured rolesClaim path one segment
  at a time and names the missing one, listing siblings at the parent
  level. Keycloak's `realm_access.roles` vs
  `resource_access.<client>.roles` confusion now points at the first
  missing segment instead of "no roles at <full path>".
- L_WARN when local JWT decode returns no claims (malformed/truncated
  token) — previously every downstream check silently saw an empty
  dict.
- L_DBG extracted role/group lists logged as one line each.
- L_DBG token-response extras (Keycloak's `not-before-policy`,
  Authentik's `id_token_expires`, Azure's `ext_expires_in`, …) and
  `session_state` when present — without baking provider knowledge.

id_token decode (OIDC Core §3.1.3.3):

- The token endpoint returns id_token alongside access_token whenever
  the request carries the `openid` scope. id_token is the
  authoritative identity bearer (sub, email, name, preferred_username,
  …) — claims access_token typically lacks.
- The module now decodes both and uses the union for required[],
  rolesClaim, groupsClaim, attributeMappings. id_token wins on
  conflicts for shared registered claims; access-token-only fields
  (realm_access.roles, resource_access.*) are untouched. Pure OAuth
  2.0 providers without id_token degrade silently. Introspection path
  unchanged — RFC 7662 stays the single source of truth.
- L_DBG: id_token-only vs access-token-only key sets so you can see
  which bearer carried which claim path.
…exporter-secret + 64-char status/exporter secrets

Three independent changes bundled into one release-prep commit.

1. realmPool — auto-generated peer home_server pool for StatefulSet
   deployments.

   New opt-in top-level key `realmPool.{enabled,name,type,port,proto,secret,
   virtualServer}`. When kind=StatefulSet AND replicaCount>1 AND
   realmPool.enabled=true, the chart renders one home_server entry per
   replica pointing at <fullname>-<ord>.<headless-svc>.<ns>.svc.cluster.
   local — the stable per-pod DNS the headless Service already provides —
   plus a wrapping home_server_pool listing all of them.

   Additive: rendered AFTER user-supplied .Values.homeServers[] /
   homeServerPools[], and any same-named user entry wins (mirrors the
   existing radsec chart-managed-with-override pattern). Defaults:
   type=load-balance, proto=udp, port falls back to containerPorts.auth
   (1812). Secret is required and must match the clients{} block that
   accepts the peer-pod source IPs (typically the existing
   clients.localhost wildcard with ipv4addr: 0.0.0.0/0 already covers it).

   New validator freeradius.validate.realmPool hard-fails at helm template
   time when:
   - realmPool.enabled=true with kind!=StatefulSet (per-pod DNS unavailable)
   - realmPool.enabled=true with replicaCount<2 (single-member pool is a no-op)
   - realmPool.enabled=true with empty realmPool.secret

   Also rides along: new freeradius.utils.joinOrDefault helper that joins
   list-shaped cipher_list values into the colon-separated scalar
   FreeRADIUS expects, replacing the previous `default` form in
   home-servers.yaml + sites/radsec.yaml which would mis-render a list.

2. status virtual server — accept the metrics exporter Deployment.

   Previously sites/status had only `client probe { ipaddr = 127.0.0.1 }`,
   which (when present) makes FreeRADIUS ignore global clients.conf
   entries for this virtual server — so the metrics exporter (different
   pod, different IP) was silently rejected at the listener. Add a
   `client exporter { ipaddr = 0.0.0.0/0 }` block gated on
   metrics.enabled; NetworkPolicy already restricts the status port to
   pods labelled component=metrics (templates/NetworkPolicy.yaml), and
   the shared secret is the second layer.

3. exporter-secret — dedicated secret end-to-end, separate from sites-status-secret.

   - New metrics.secret values knob, auto-generated into the chart
     credentials Secret under key `exporter-secret` when empty.
   - sites/status `client exporter` uses $ENV{FREERADIUS_EXPORTER_SECRET};
     _env.tpl emits the env sourced from `exporter-secret` (gated on
     metrics.enabled), with the existingSecretPerPassword.metricsSecret
     BYO path honoured.
   - metrics/Deployment.yaml RADIUS_PASSWORD switched from
     sites-status-secret to exporter-secret, same BYO path honoured.
   - Independent rotation, independent blast radius: probe (radclient
     loopback) and exporter (metrics pod) now authenticate against
     separate Status-Server credentials.
   - Bumped both sites-status-secret AND exporter-secret auto-gen length
     to 64 chars (previously 10). Existing installs preserve their old
     values via the `lookup` path; only fresh installs get the longer
     default.

CHANGELOG entries under 1.2.0 §Added and §Changed.
…h probes invalid

K8s rejected the metrics Deployment with

  Deployment.apps "freeradius-metrics" is invalid:
  spec.template.spec.containers[0].livenessProbe.httpGet:
      Forbidden: may not specify more than 1 handler type
  spec.template.spec.containers[0].readinessProbe.httpGet:
      Forbidden: may not specify more than 1 handler type

Cause: values.yaml `metrics.livenessProbe` / `metrics.readinessProbe`
each carried an `exec: { command: [sh, /health/healthcheck.sh] }`
block AND the chart template appended its own `httpGet: { path:
/metrics, port: metrics }` underneath. Rendered probes had two
handler types — invalid per the K8s probe schema.

The exec config was dead — `/health/healthcheck.sh` doesn't exist in
`bvantagelimited/freeradius_exporter:1.4.4` and isn't mounted from
anywhere, so even if the probe had accepted it, it would have failed
every check. The httpGet against `/metrics:metrics` is the real
working probe (the exporter serves Prometheus metrics on that port
and path by default).

Drop the exec blocks from both probe defaults. Timing fields
(initialDelaySeconds, periodSeconds, …) and `enabled: true` retained;
the template's `httpGet` becomes the sole handler.
… StatefulSet

Add DaemonSet to the kind switch in Application.yaml alongside Deployment
and StatefulSet, plus the related downstream gates.

- Application.yaml: $kindMap accepts `daemonset` -> `DaemonSet`; apiVersion
  routes through the existing st-common.capabilities.daemonset.apiVersion
  helper. For DaemonSet pods: skip `replicas`, `serviceName`,
  `podManagementPolicy` (none of them have semantics for a per-node
  workload); emit `updateStrategy:` (DaemonSet shares the field name with
  StatefulSet — rollingUpdate vs OnDelete — distinct from Deployment's
  `strategy:`). The StatefulSet-only volumeClaimTemplates path is
  untouched; DaemonSet falls back to the standalone PVC, which means RWX
  storage if you want to share across node pods (or BYO existingClaim /
  set persistence.enabled=false for an emptyDir).

- HorizontalPodAutoscaler.yaml: HPA targets workloads that expose the
  `scale` subresource — Deployment and StatefulSet qualify, DaemonSet
  does not (one pod per node is the whole point). When kind=DaemonSet,
  skip the HPA render entirely instead of emitting an HPA that targets a
  non-existent scale endpoint. Also fix a latent pre-existing bug: the
  HPA's scaleTargetRef.{apiVersion,kind} was hardcoded to Deployment
  even when the actual workload was a StatefulSet — anyone running
  kind=StatefulSet with the HPA enabled had a non-functional HPA. Now
  the scaleTargetRef tracks the rendered kind.

- helpers/_validate.tpl: realmPool validator message updated to mention
  both Deployment and DaemonSet as kinds without per-pod stable DNS
  (was previously only naming Deployment). Functional behavior unchanged
  — realmPool.enabled=true still hard-fails on anything other than
  StatefulSet.

- values.yaml: `@param kind` docstring extended to cover DaemonSet
  semantics — replicaCount ignored, HPA silently skipped, shared PVC
  requires RWX or BYO, realmPool.enabled still requires StatefulSet.

Service-headless.yaml and PersistentVolumeClaim.yaml needed no changes
— their existing `eq ... statefulset` / `ne ... statefulset` gates
already produce the correct behavior for DaemonSet (no headless Service,
shared PVC rendered).
…act OIDC helpers

Three changes bundled.

1. Per-pod Services for StatefulSet (templates/Service-perPod.yaml, new).

   When kind: StatefulSet, render one Service per replica named
   `<fullname>-<ord>` alongside the main load-balanced Service and the
   headless Service. Each per-pod Service targets exactly one pod via
   the `statefulset.kubernetes.io/pod-name` label (kube-controller-
   manager adds it to every StatefulSet pod automatically). All knobs
   inherit from .Values.service.* — no new values keys. Two intentional
   differences from the main Service:

   - spec.externalTrafficPolicy is hardcoded to Local. Each per-pod
     Service has a single backing pod, so the cross-node traffic drop
     that Local causes is exactly correct, and the client source IP is
     preserved end-to-end (no kube-proxy SNAT). NAS IPs land in
     Packet-Src-IP-Address for clients.conf matching.
   - spec.ports[].nodePort is unset on all per-pod Services. K8s auto-
     allocates a unique NodePort per pod — the main Service keeps the
     fixed service.nodePorts.* knob; per-pod NodePorts would otherwise
     collide cluster-wide.

   Gated purely on kind: StatefulSet. No render under Deployment or
   DaemonSet (their pods don't carry the pod-name label).

2. Gateway API routes fan out per-pod for StatefulSet.

   When kind: StatefulSet AND gateway.implementation: gateway-api,
   each chart-managed route's backendRefs now lists one entry per
   replica targeting that replica's per-pod Service (equal weight).
   UDPRoute auth/acct/coa and TLSRoute radsec all fan out symmetrically.
   Deployment and DaemonSet keep a single backendRef pointing at the
   main load-balanced Service. Combined with per-pod
   externalTrafficPolicy: Local, this preserves the NAS source IP
   end-to-end through the Envoy data plane.

3. Helpers refactor.

   - New freeradius.service.portsList in _helpers.tpl: shared port-list
     emitter used by both Service.yaml and Service-perPod.yaml. Pass
     `suppressNodePorts: true` from the per-pod path to force nodePort:
     null regardless of service.type / service.nodePorts.*. Single
     source of truth for the port list; drift on port additions is now
     impossible. Service.yaml goes from ~60 inline lines to one
     `include`.
   - New freeradius.gateway.backendRefs in _helpers.tpl: centralises
     the Deployment-vs-StatefulSet fan-out logic so future routes pick
     it up automatically.
   - Extract all 20 freeradius.oidc.* helpers from _helpers.tpl into
     a dedicated _oidc.tpl. Pure relocation (no behavioural change):
     resolveInstances, envVarPrefix, moduleName / validateModuleName,
     policyName / rolesPolicyName / groupsPolicyName, cacheName,
     cacheKey, modKey / policyKey / scriptKey, clientSecretName,
     tlsVolumeName, tls.{enabled, createSecret, secretName, caKey,
     caFilePath}, dispatchArms. _helpers.tpl shrinks from ~671 to ~475
     lines; the OIDC surface now lives next to its usage in
     templates/modules/oidc/.

Test fixtures: tests/Application_test.yaml — the "rejects an invalid
workload kind" case used `kind: DaemonSet` as the invalid value, but
DaemonSet is now a valid kind (commit 0c5b8ce). Switched to `kind: Job`
as the invalid placeholder and updated the expected error message to
mention DaemonSet.

All 98 helm-unittest cases pass.
…ase names; EAP-TTLS supplicant notes; proxy.conf tabs->spaces

Three changes bundled.

1. realmPool auth/acct split (same-release breaking shape change).

   Previously: when kind=StatefulSet AND realmPool.enabled, the chart emitted
   one `home_server <name>-<ord>` (type=auth) per replica plus one
   `home_server_pool <name>` listing them.

   Now: TWO `home_server` entries per replica — `<name>_<ord>_auth` (type=auth,
   port=containerPorts.auth) and `<name>_<ord>_acct` (type=acct,
   port=containerPorts.acct) — plus TWO matching pools `<name>_auth` and
   `<name>_acct`. Both home_servers per ordinal target the same per-pod DNS
   name; only type and port differ.

   Naming switched to snake_case so the rendered identifiers parse cleanly in
   FreeRADIUS proxy.conf (the upstream convention). Any hyphens in
   realmPool.name are auto-converted to underscores via a new
   `freeradius.utils.snakeCase` helper — so user-supplied "my-cluster" still
   renders to legal "my_cluster_0_auth" / "my_cluster_auth" identifiers.

   Pool type stays as realmPool.type (default load-balance). Per-entry
   conflict avoidance still applies — user-supplied homeServers / pools
   with matching names win and skip the auto-gen for that name.

   This is a same-release shape change (realmPool only shipped in this
   1.2.0 release); anyone already configured needs to update their realm
   references from `<name>` to `<name>_auth` / `<name>_acct`. Per the
   established rule, no README §Upgrading entry is added for in-release
   breaking renames.

2. EAP-TTLS / PEAP supplicant notes in README.

   New `### EAP-TTLS / PEAP supplicant notes` subsection between the
   Keycloak/OIDC and Gateway routing sections. Explains the "Outer and
   inner identities are the same. User privacy is compromised." warning,
   the recommended supplicant config (Identity / Anonymous identity /
   Password), the realm-routing caveat, and per-OS rollout paths
   (Android, iOS/macOS, Windows, MDM). Chart can't fix this — it's a
   supplicant-side setting on the end-user device.

3. files/proxy.conf: convert tabs to 4 spaces.

   The bundled proxy.conf (extracted from FreeRADIUS 3.2.8 upstream) used
   tab indentation. Converted to 4 spaces for visual consistency with the
   rest of the chart's config files. 655 lines re-indented; semantics
   unchanged. Future re-extracts from upstream will show as a big diff —
   the chart's copy is intentionally space-indented; run `expand -t 4`
   when bumping the bundled version.

All 98 helm-unittest cases pass.
… checks across templates

Centralises the case-insensitive `kind=StatefulSet` check that was
duplicated across 7 call sites (each rendering its own
`lower (toString (.Values.kind | default "Deployment"))` expression and
local $kind/$rpKind variables). The helper returns the literal string
"true" when StatefulSet — truthy in template `if` — and the empty
string otherwise; negate with `not (include ...)` for the
"render only on Deployment/DaemonSet" gates.

Call sites updated:

- templates/Service-headless.yaml
- templates/Service-perPod.yaml             (dropped now-unused $kind local)
- templates/PersistentVolumeClaim.yaml
- templates/configmaps/home-server-pools.yaml (dropped $rpKind local)
- templates/configmaps/home-servers.yaml      (dropped $rpKind local)
- templates/helpers/_helpers.tpl::gateway.backendRefs
- templates/helpers/_validate.tpl::validate.realmPool

Pure refactor — no behavioural change. All 98 helm-unittest cases pass
unchanged.
…US_REALMPOOL_SECRET}; declare redis.metrics.extraArgs map shape

credentials.yaml synthesizes a 32-char `realmpool-secret` via st-common.secrets.passwords.manage when realmPool.enabled, honouring a user-supplied realmPool.secret as the providedValues override; _env.tpl wires FREERADIUS_REALMPOOL_SECRET from that Secret into the pod; home-servers.yaml swaps the per-pod auto-gen home_server entries from a literal `secret = "<value>"` to `secret = $ENV{FREERADIUS_REALMPOOL_SECRET}` (FreeRADIUS expands at runtime). The matching clients{} entry uses the same env-var indirection. validate.realmPool drops the empty-secret hard-fail (auto-gen handles it) but keeps the kind:StatefulSet + replicaCount>=2 checks. values.yaml redis.metrics.extraArgs: {} re-states the Bitnami subchart's map shape so a list-typed override is an obvious contract violation rather than a coalesce.go warning surfacing only at template time.
Same-release rip-out of the realmPool auto-gen primitive added across this session. Deleted: values.yaml realmPool block + §Auto-gen narrative + the kind: @param sentence + realmpool-secret/realmpoolSecret mentions in auth docstrings; values.schema.json realmPool property; the $rpEnabled-gated auto-gen sections in home-servers.yaml / home-server-pools.yaml; freeradius.validate.realmPool function + its dispatch line in _validate.tpl; FREERADIUS_REALMPOOL_SECRET env entry in _env.tpl; realmpool-secret row in credentials.yaml; freeradius.utils.snakeCase helper (realmPool was its only consumer). freeradius.headlessServiceName + freeradius.isStatefulSet kept — both still used by Service-headless.yaml / Application.yaml / other validators. Validator removed too, so old values files setting realmPool.enabled: true now silently render without it instead of failing loudly.
…oved in 8263dbd)

The realmPool §Added bullet + freeradius.utils.snakeCase §Added bullet + freeradius.validate.realmPool §Added bullet all referenced a feature that no longer exists in the 1.2.0 release surface. Clean cancellation: 1.2.0 ships as if realmPool was never added in the first place.
…ol} toggles for StatefulSet peer mesh

Three independent opt-in toggles, each emits one chart-managed config piece named after <fullname> snake-cased: realm DEFAULT { auth_pool = <fullname>_auth_pool; acct_pool = <fullname>_acct_pool }; one home_server <fullname>_<ord>_<auth|acct> per StatefulSet replica with ipaddr pointing at the per-pod headless DNS, secret rendered as literal $ENV{FREERADIUS_DEFAULT_INSTANCE_SECRET}; and two home_server_pool <fullname>_<auth|acct>_pool blocks load-balancing across the per-pod entries. All three silently skip on non-StatefulSet kinds. No cross-toggle validation — enabling one without the others dangles refs and FreeRADIUS fails loudly at startup. No conflict-detection vs the user-facing homeServers[] / homeServerPools[] / realms[] arrays — the createDefaultInstance block is rendered AFTER those loops in its own self-contained section per template (home-servers.yaml, home-server-pools.yaml, realms.yaml). User wires FREERADIUS_DEFAULT_INSTANCE_SECRET via extraEnvVarsSecret and uses the same $ENV{...} expansion in the matching clients{} entry — chart stays out of the credentials path. freeradius.utils.snakeCase helper re-added (one-liner, replace - with _) as the only sole consumer.
… headless DNS

Pivot the rendered ipaddr from <pod>-<ord>.<headless-svc>.<ns>.svc.cluster.local (StatefulSet headless A-record) to <pod>-<ord>.<ns>.svc (per-pod ClusterIP Service rendered by Service-perPod.yaml for kind: StatefulSet). The cluster-domain suffix (.cluster.local etc.) is appended by the resolver search path so the short form stays portable. Drops the $cdiHeadless let-binding.
…log{} as logging.*

Chart-managed radiusd.conf body moves out of .Values.configurations (89-line inline string) and out of files/radiusd.conf (1214-line upstream copy with wrong paths) and into templates/configmaps/configuration.yaml itself — single source of truth. .Values.configurations stays as an inline-string escape hatch (user-supplied verbatim radiusd.conf body, evaluated through tplvalues.render); .Values.configurationsConfigMap stays as the BYO ConfigMap escape hatch; the chart-managed body fires only when neither is set. Application.yaml mount + volume gates dropped (always present now). New logging.* block (destination, colourise, file, syslog_facility, stripped_names, auth, auth_badpass, auth_goodpass, msg_denied) surfaces the log{} body as values knobs — booleans render via ternary "yes" "no" to match FreeRADIUS-native truthy syntax; msg_denied gets | quote. Defaults render byte-identical to the prior inline body. Conditional $INCLUDE lines for home-servers.conf / home-server-pools.conf now mirror the createDefaultInstance gates so the include only fires when the underlying configmap renders.
RADSEC is TCP+TLS, not HTTP — %REQ(:AUTHORITY)%, %REQ(X-ENVOY-ORIGINAL-METHOD)%, %REQ(X-ENVOY-ORIGINAL-PATH)%, %REQ(X-PROXY-USER)%, %REQ(REMOTE-USER)%, %REQ(X-REQUEST-ID)%, %RESPONSE_CODE%, and %RESPONSE_FLAGS% all evaluate to empty over a raw TCP listener, plus the basic_auth_user commented placeholder. Keep only transport-layer fields that mean something on TCP: bytes_received / bytes_sent / client_ip / duration / start_time / protocol / upstream_host.
StatefulSet pods now start in parallel instead of OrderedReady. Faster rollouts and faster initial install; the FreeRADIUS workload doesn't depend on per-pod startup ordering (each pod is independent — peer-mesh state is built at the proxy layer via the realms/pools, not via ordered pod readiness).
…ield; breaks sync on existing StatefulSets)

The b45af1a change made every existing release fail ArgoCD sync — Kubernetes forbids updates to podManagementPolicy on a live StatefulSet (it's in the same immutable-spec class as serviceName / selector / volumeClaimTemplates). Revert the default to "" (empty → k8s applies its built-in OrderedReady) so existing installs keep working untouched. New installs that want Parallel start opt in explicitly. Docstring now calls out the immutability + the kubectl delete sts --cascade=orphan recovery recipe.
…t; strip vendor OIDC label

Multiple intra-release cleanups bundled:

- logging.destination default flips files -> stdout, and Application.yaml drops the `-l stdout` CLI override that was forcing stdout regardless of the log{} block. The configmap is now the single source of truth for log destination — set logging.destination: files (+ logging.file: <path>) to write to disk, leave at stdout to keep kubectl-logs-native behavior. Pair: chart-side log control finally lives in one place.
- podManagementPolicy default flips back to Parallel after the 0451299 revert. Fresh installs get parallel pod start; the immutability caveat is documented in the @param. Operators upgrading a live release that took the empty default need `kubectl delete sts --cascade=orphan` then resync — same recipe noted in the values.yaml docstring.
- values-test.yaml persistence.enabled flips true -> false so the test overlay runs on ephemeral storage.
- app.firmansyah.id/oidc-instance label stripped from the four OIDC resources (oidc.yaml, oidc-policy.yaml, secret.yaml, secret-tls.yaml). Pure metadata, no in-chart consumer.
…ams (logging.*, createDefaultInstance.*, etc.)

The 1.2.0 release removed the dedicated Keycloak module end-to-end (replaced by modules.oidc.*), but the README still carried the old Keycloak narrative + parameter table + env-var rows + breaking-changes bullets in places that weren't part of the §Upgrading migration doc. Rewrite each obsolete location with the OIDC equivalent: §"OIDC authentication" narrative (was §"Keycloak (OIDC) authentication" — now configured via modules.oidc.instances.<name>, with rolesClaim required, no mode knob, rlm_python3-only); §Parameters "OIDC integration parameters" table (was "Keycloak integration parameters" — 12 keycloak.* rows replaced with modules.oidc.* schema reference); §Chart-managed environment variables table rows + bullets (FREERADIUS_KEYCLOAK_* -> FREERADIUS_OIDC_*); §Breaking Changes 1.2.0 four Keycloak bullets collapsed into one "module removed -> migrate" bullet linking to existing §Upgrading entry. §TODO swept of dead refs (keycloak in sibling-modules list, keycloak.instances[].existingSecret example), with the "OIDC introspect decoupled from Keycloak" item dropped (shipped in 1.2.0). §Parameters typo `configuration` -> `configurations` + description rewritten to match actual escape-hatch behavior; `tls.autoGenerated` row marked DEPRECATED; dead `tls.autoGenerator.certmanager.enabled` row removed.

Same pass also adds missing parameter rows that were never documented: architecture; logging.{destination,colourise,file,syslog_facility,stripped_names,auth,auth_badpass,auth_goodpass,msg_denied}; extraStartupArgs; kind; podManagementPolicy (with the kubectl delete sts --cascade=orphan recovery recipe inline); createDefaultInstance.{realm,homeServer,homeServerPool} in §Proxy/realm.
…ce for DaemonSet too

isDaemonSet mirrors isStatefulSet (case-insensitive kind match, returns "true" / empty). Service-headless.yaml gate changes from `if isStatefulSet` to `if or isStatefulSet isDaemonSet` — DaemonSet pods benefit from the same DNS-based pod discovery (one A record per pod IP) that StatefulSet uses; StatefulSet additionally needs it for `serviceName:` per-pod stable DNS. Deployment still skips (workload-wide load-balanced ClusterIP suffices).
…alues.merge call

Application.yaml + Service.yaml metadata.annotations rendering went `{{- if or A B }} annotations: {{- if A }}…{{- end }} {{- if B }}…{{- end }} {{- end }}` — the inner conditional was redundant since the outer `if or` already gated non-empty inputs. Replace with `tplvalues.merge` over the source list + a single `tplvalues.render`. Same output, less template noise; matches the pattern already used in Service-headless.yaml.
…, dead-key removal, docs

Code:
- Snake_case rename batch (BREAKING; same-release silent-ignore): modules.sql.{readGroups, readProfiles} -> read_groups / read_profiles; modules.json.encode.value.{singleValueAsArray, enumAsInteger, datesAsInteger, alwaysString} -> snake_case; modules.cache.maxEntries (and per-instance modules.cache.instances.<name>.maxEntries) -> max_entries; modules.eap.{timerExpire, ignoreUnknownEapTypes, ciscoAccountingUsernameBug, maxSessions, defaultType} -> timer_expire / ignore_unknown_eap_types / cisco_accounting_username_bug / max_sessions / default_eap_type. Validator + error-message text updated alongside the default_eap_type rename. values.schema.json reflects the new shape.
- modules.eap.tlsConfig.cipher_list: string -> []string. Chart joins entries with ":" via freeradius.utils.joinOrDefault; default ["DEFAULT"]; empty list and nil both fall through to DEFAULT. Mirrors the same change already shipped for sites.radsec.tls.cipher_list. Schema rejects the old scalar string form.
- Drop the top-level .Values.architecture key: never referenced anywhere in the chart (the only `architecture` hits in templates/ are .Values.mariadb.architecture and .Values.postgresql.architecture, Bitnami subchart knobs). Removed from values.yaml + values.schema.json + README parameters table.
- sites/default.yaml: cosmetic comment-block indent cleanup + new sites.default.authorize hook for inline rendering (was the bundled-in-tree edit prior to this commit).

Tests:
- tests/schema_test.yaml: +7 new cases — sites.radsec.tls.cipher_list and modules.eap.tlsConfig.cipher_list array-shape positive + scalar-string negative; modules.eap.ignore_unknown_eap_types bool guard; modules.sql.read_groups bool guard; modules.cache.max_entries integer guard.
- tests/NetworkPolicy_test.yaml: first "narrows egress to DNS only" test now pins modules.redis.enabled: false and modules.cache.driver: rbtree explicitly — preserves the test intent under the new module-enabled defaults (cache.driver default flipped to redis upstream, which would otherwise add a second egress rule).

Docs:
- Chart.yaml artifacthub.io/changes: +14 entries covering createDefaultInstance, logging.*, isDaemonSet helper + headless Service for DaemonSet, radiusd.conf body move, log destination via configmap, podManagementPolicy default flip, snake_case batch, cipher_list array shape on both surfaces, EnvoyProxy access-log trim, firmansyah.id label strip, annotation rendering refactor, redis.metrics.extraArgs defensive shape; stale "defaultType" reference on the existing validate.eap entry corrected to "default_eap_type".
- CHANGELOG.md 1.2.0 §Added: createDefaultInstance, logging.*, isDaemonSet helper. §Changed: radiusd.conf body move (with escape hatches retained), log destination via configmap, podManagementPolicy default flip (with the IMMUTABLE-field caveat called out), snake_case batch, both cipher_list shape changes, EnvoyProxy access-log trim, firmansyah.id label strip, headless Service for DaemonSet, annotation rendering refactor, redis.metrics.extraArgs defensive shape. Same stale "defaultType" reference on line 358 fixed to "default_eap_type".
- README.md §Breaking Changes 1.2.0: extended inventory with remaining snake_case renames, modules.eap.tlsConfig.cipher_list shape, radiusd.conf body move, podManagementPolicy default flip; new §Upgrading entry "podManagementPolicy default is now Parallel (StatefulSet only)" with the kubectl delete sts --cascade=orphan recovery recipe and the "pin the old behavior in your overlay" alternative.
@firmansyahn firmansyahn merged commit bc6314c into main Jun 2, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant