From 6fed0a7dfe28abab69310a23affe6314e6d6e560 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 18 May 2026 15:13:54 -0500 Subject: [PATCH 1/4] fix: remove namespace and paused fields from image trigger annotations The trigger controller only fired once on annotation creation rather than on subsequent ImageStream updates. Stripping namespace (same-namespace triggers don't need it) and the paused field to match the minimal format that the controller reliably watches. Bump chart to 0.3.3. --- chart/Chart.yaml | 2 +- chart/templates/deployment.yaml | 2 +- chart/templates/freeradius-deployment.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/chart/Chart.yaml b/chart/Chart.yaml index 299b101..3170a14 100644 --- a/chart/Chart.yaml +++ b/chart/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: pint description: Pouring IPA for Network Trust - CSH WiFi EAP-TLS enrollment and home RadSec management type: application -version: 0.3.2 +version: 0.3.3 appVersion: "0.1.0" diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml index 528195c..9910c27 100644 --- a/chart/templates/deployment.yaml +++ b/chart/templates/deployment.yaml @@ -8,7 +8,7 @@ metadata: {{- include "pint.labels" . | nindent 4 }} {{- if .Values.openshift.build.enabled }} annotations: - image.openshift.io/triggers: '[{"from":{"kind":"ImageStreamTag","name":"{{ .Values.openshift.build.imageStreamName }}:{{ .Values.pint.image.tag }}","namespace":"{{ .Release.Namespace }}"},"fieldPath":"spec.template.spec.containers[?(@.name==\"pint\")].image","paused":"false"}]' + image.openshift.io/triggers: '[{"from":{"kind":"ImageStreamTag","name":"{{ .Values.openshift.build.imageStreamName }}:{{ .Values.pint.image.tag }}"},"fieldPath":"spec.template.spec.containers[?(@.name==\"pint\")].image"}]' {{- end }} spec: replicas: 1 diff --git a/chart/templates/freeradius-deployment.yaml b/chart/templates/freeradius-deployment.yaml index bd9c438..ea06b56 100644 --- a/chart/templates/freeradius-deployment.yaml +++ b/chart/templates/freeradius-deployment.yaml @@ -8,7 +8,7 @@ metadata: {{- include "pint.freeradiusLabels" . | nindent 4 }} {{- if .Values.openshift.build.enabled }} annotations: - image.openshift.io/triggers: '[{"from":{"kind":"ImageStreamTag","name":"{{ .Values.openshift.build.freeradiusImageStreamName }}:{{ .Values.freeradius.image.tag }}","namespace":"{{ .Release.Namespace }}"},"fieldPath":"spec.template.spec.containers[?(@.name==\"freeradius\")].image","paused":"false"}]' + image.openshift.io/triggers: '[{"from":{"kind":"ImageStreamTag","name":"{{ .Values.openshift.build.freeradiusImageStreamName }}:{{ .Values.freeradius.image.tag }}"},"fieldPath":"spec.template.spec.containers[?(@.name==\"freeradius\")].image"}]' {{- end }} spec: replicas: 1 From 3843414b0c9db81e3045cd68896720761222bb8f Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Sun, 7 Jun 2026 13:27:12 -0500 Subject: [PATCH 2/4] feat: add radSecProxyHosts config to allow HAProxy PROXY protocol with FreeRADIUS FreeRADIUS performs client lookup by raw TCP source IP before reading the PROXY protocol header, so the HAProxy host IP must be in clients.conf for connections to be accepted. Adds PINT_RADIUS_RADSEC_PROXY_HOSTS (comma-separated IPs/CIDRs) which generates pint_proxy_N client blocks alongside regular RadSec clients, enabling the two-phase client lookup FreeRADIUS requires. --- chart/templates/deployment.yaml | 4 ++++ chart/values.schema.json | 8 ++++++++ chart/values.yaml | 1 + internal/config/config.go | 12 ++++++++++-- internal/handlers/radius.go | 2 +- internal/radius/config.go | 19 ++++++++++++++++--- internal/radius/config_test.go | 31 +++++++++++++++++++++++++++---- internal/radius/reload.go | 8 ++++---- internal/radius/reload_test.go | 2 +- 9 files changed, 72 insertions(+), 15 deletions(-) diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml index 9910c27..9b7e135 100644 --- a/chart/templates/deployment.yaml +++ b/chart/templates/deployment.yaml @@ -78,6 +78,10 @@ spec: - name: PINT_RADIUS_RADSEC_PROXY_PROTOCOL value: "true" {{- end }} + {{- if .Values.config.radSecProxyHosts }} + - name: PINT_RADIUS_RADSEC_PROXY_HOSTS + value: {{ .Values.config.radSecProxyHosts | join "," | quote }} + {{- end }} {{- if .Values.config.radSecStatusPort }} - name: PINT_RADIUS_STATUS_PORT value: {{ .Values.config.radSecStatusPort | quote }} diff --git a/chart/values.schema.json b/chart/values.schema.json index c06a3dc..4b7dd7a 100644 --- a/chart/values.schema.json +++ b/chart/values.schema.json @@ -96,6 +96,14 @@ "description": "Enable PROXY protocol on the RadSec listener. Required when HAProxy fronts FreeRADIUS.", "default": false }, + "radSecProxyHosts": { + "type": "array", + "description": "IPs/CIDRs of trusted proxy hosts (e.g. HAProxy). Required alongside radSecProxyProtocol so FreeRADIUS accepts their TCP connections before reading the PROXY header.", + "items": { + "type": "string" + }, + "default": [] + }, "radSecStatusPort": { "type": "string", "description": "Override the FreeRADIUS status server port (default: 18121)." diff --git a/chart/values.yaml b/chart/values.yaml index 2d3384d..e5800de 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -36,6 +36,7 @@ config: # RADIUS / RadSec radSecCheckCRL: true # set false to disable CRL checking in the RadSec TLS listener radSecProxyProtocol: true # set false when HAProxy is not fronting FreeRADIUS + radSecProxyHosts: [] # IPs/CIDRs of trusted proxy hosts (e.g. HAProxy); required alongside radSecProxyProtocol so FreeRADIUS accepts connections before reading the PROXY header radSecStatusPort: "" # overrides the FreeRADIUS status server port (default: 18121) radSecStatusAddr: "" # overrides the status server address (host:port); useful in dev when pod IPs are unreachable diff --git a/internal/config/config.go b/internal/config/config.go index 9a020da..7002da1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -42,8 +42,9 @@ type Config struct { // FreeRADIUS status virtual server RADIUSStatusPort string // PINT_RADIUS_STATUS_PORT: port for the FreeRADIUS status virtual server RADIUSStatusAddr string // PINT_RADIUS_STATUS_ADDR: override address (host:port) for status queries; replaces per-pod IP (useful when pod IPs are unreachable, e.g. local dev against kind) - RadSecCheckCRL bool // PINT_RADIUS_RADSEC_CHECK_CRL: enable CRL checking in the RadSec TLS listener (default true; set false for local dev) - RadSecProxyProtocol bool // PINT_RADIUS_RADSEC_PROXY_PROTOCOL: expect HAProxy PROXY protocol header on RadSec connections (default false) + RadSecCheckCRL bool // PINT_RADIUS_RADSEC_CHECK_CRL: enable CRL checking in the RadSec TLS listener (default true; set false for local dev) + RadSecProxyProtocol bool // PINT_RADIUS_RADSEC_PROXY_PROTOCOL: expect HAProxy PROXY protocol header on RadSec connections (default false) + RadSecProxyHosts []string // PINT_RADIUS_RADSEC_PROXY_HOSTS: comma-separated IPs/CIDRs of trusted proxy hosts (e.g. HAProxy); added as clients so FreeRADIUS accepts their connections before reading the PROXY header // Apple profile signing @@ -127,6 +128,13 @@ func Load() (*Config, error) { cfg.IPASkipTLSVerify = os.Getenv("PINT_IPA_SKIP_TLS_VERIFY") == "true" cfg.RadSecCheckCRL = os.Getenv("PINT_RADIUS_RADSEC_CHECK_CRL") != "false" cfg.RadSecProxyProtocol = os.Getenv("PINT_RADIUS_RADSEC_PROXY_PROTOCOL") == "true" + if v := os.Getenv("PINT_RADIUS_RADSEC_PROXY_HOSTS"); v != "" { + for _, h := range strings.Split(v, ",") { + if h = strings.TrimSpace(h); h != "" { + cfg.RadSecProxyHosts = append(cfg.RadSecProxyHosts, h) + } + } + } cfg.RootCAName = os.Getenv("PINT_IPA_ROOT_CA_NAME") if cfg.RootCAName == "" { cfg.RootCAName = "ipa" diff --git a/internal/handlers/radius.go b/internal/handlers/radius.go index 7d891f0..bd35ae4 100644 --- a/internal/handlers/radius.go +++ b/internal/handlers/radius.go @@ -195,7 +195,7 @@ func (s *Server) commitStore(c *gin.Context, store *radius.ClientStore) error { s.fail(c, http.StatusInternalServerError, "radius store save failed", err) return err } - if err := radius.WriteRadiusConfig(ctx, s.K8s, s.Cfg.Namespace, s.Cfg.ConfigSecret, s.Cfg.FreeRADIUSDeployment, store.All()); err != nil { + if err := radius.WriteRadiusConfig(ctx, s.K8s, s.Cfg.Namespace, s.Cfg.ConfigSecret, s.Cfg.FreeRADIUSDeployment, store.All(), s.Cfg.RadSecProxyHosts); err != nil { s.fail(c, http.StatusInternalServerError, "radius config write failed", err) return err } diff --git a/internal/radius/config.go b/internal/radius/config.go index 30c0ecc..99a9f67 100644 --- a/internal/radius/config.go +++ b/internal/radius/config.go @@ -39,9 +39,11 @@ func RenderRadSecTLS(checkCRL, proxyProtocol bool) string { `, proxy, crl) } -// RenderClientsConf generates the FreeRADIUS clients.conf content from the given client list. -// Each client block includes proto = tls for RadSec (RFC 6614) support. -func RenderClientsConf(clients []RadiusClient) string { +// RenderClientsConf generates the FreeRADIUS clients.conf content from the given client list +// and optional proxy host IPs/CIDRs. Each client block includes proto = tls for RadSec +// (RFC 6614) support. Proxy hosts are added as bare TLS clients so FreeRADIUS accepts their +// TCP connections before reading the HAProxy PROXY protocol header. +func RenderClientsConf(clients []RadiusClient, proxyHosts []string) string { var b strings.Builder b.WriteString("# Auto-generated by PINT - do not edit manually\n\n") @@ -61,5 +63,16 @@ func RenderClientsConf(clients []RadiusClient) string { b.WriteString(" virtual_server = radsec\n") b.WriteString("}\n\n") } + + for i, host := range proxyHosts { + fmt.Fprintf(&b, "client pint_proxy_%d {\n", i) + fmt.Fprintf(&b, " ipaddr = %s\n", host) + b.WriteString(" secret = radsec\n") + b.WriteString(" proto = tls\n") + fmt.Fprintf(&b, " shortname = pint-proxy-%d\n", i) + b.WriteString(" virtual_server = radsec\n") + b.WriteString("}\n\n") + } + return b.String() } diff --git a/internal/radius/config_test.go b/internal/radius/config_test.go index 6c68fe9..d64fd3c 100644 --- a/internal/radius/config_test.go +++ b/internal/radius/config_test.go @@ -50,7 +50,7 @@ func TestRenderClientsConf_WithIP(t *testing.T) { clients := []radius.RadiusClient{ {Username: "mbillow", IPCIDR: &ip}, } - out := radius.RenderClientsConf(clients) + out := radius.RenderClientsConf(clients, nil) if !strings.Contains(out, "client mbillow_home") { t.Error("missing client block header") @@ -76,7 +76,7 @@ func TestRenderClientsConf_NoIP(t *testing.T) { clients := []radius.RadiusClient{ {Username: "jsmith", IPCIDR: nil}, } - out := radius.RenderClientsConf(clients) + out := radius.RenderClientsConf(clients, nil) if !strings.Contains(out, "ipaddr = 0.0.0.0/0") { t.Error("missing wildcard ipaddr for nil IPCIDR") @@ -89,7 +89,7 @@ func TestRenderClientsConf_MultipleClients(t *testing.T) { {Username: "alice", IPCIDR: &ip}, {Username: "bob", IPCIDR: nil}, } - out := radius.RenderClientsConf(clients) + out := radius.RenderClientsConf(clients, nil) if !strings.Contains(out, "client alice_home") { t.Error("missing alice block") @@ -100,8 +100,31 @@ func TestRenderClientsConf_MultipleClients(t *testing.T) { } func TestRenderClientsConf_Empty(t *testing.T) { - out := radius.RenderClientsConf(nil) + out := radius.RenderClientsConf(nil, nil) if !strings.Contains(out, "Auto-generated by PINT") { t.Error("missing header comment") } } + +func TestRenderClientsConf_ProxyHosts(t *testing.T) { + out := radius.RenderClientsConf(nil, []string{"10.0.0.1", "10.0.0.2/32"}) + + if !strings.Contains(out, "client pint_proxy_0") { + t.Error("missing pint_proxy_0 block") + } + if !strings.Contains(out, "ipaddr = 10.0.0.1") { + t.Error("missing first proxy host IP") + } + if !strings.Contains(out, "client pint_proxy_1") { + t.Error("missing pint_proxy_1 block") + } + if !strings.Contains(out, "ipaddr = 10.0.0.2/32") { + t.Error("missing second proxy host IP") + } + if !strings.Contains(out, "proto = tls") { + t.Error("missing proto = tls on proxy client") + } + if !strings.Contains(out, "virtual_server = radsec") { + t.Error("missing virtual_server = radsec on proxy client") + } +} diff --git a/internal/radius/reload.go b/internal/radius/reload.go index 1a04305..6cb1bf9 100644 --- a/internal/radius/reload.go +++ b/internal/radius/reload.go @@ -22,10 +22,10 @@ const ( KeyRadSecTLS = "radsec-tls.conf" ) -// WriteRadiusConfig renders clients.conf from the given client list, patches the +// WriteRadiusConfig renders clients.conf from the given client list and proxy hosts, patches the // key in the named Kubernetes Secret, and triggers a FreeRADIUS rollout restart. -func WriteRadiusConfig(ctx context.Context, k8s kubernetes.Interface, namespace, secretName, deployment string, clients []RadiusClient) error { - if err := patchSecretKey(ctx, k8s, namespace, secretName, KeyClientsConf, []byte(RenderClientsConf(clients))); err != nil { +func WriteRadiusConfig(ctx context.Context, k8s kubernetes.Interface, namespace, secretName, deployment string, clients []RadiusClient, proxyHosts []string) error { + if err := patchSecretKey(ctx, k8s, namespace, secretName, KeyClientsConf, []byte(RenderClientsConf(clients, proxyHosts))); err != nil { return err } return Reload(ctx, k8s, namespace, deployment) @@ -89,7 +89,7 @@ func EnsureConfigSecret(ctx context.Context, k8s kubernetes.Interface, namespace ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: namespace}, Data: map[string][]byte{ KeyClientsJSON: []byte("[]"), - KeyClientsConf: []byte(RenderClientsConf(nil)), + KeyClientsConf: []byte(RenderClientsConf(nil, nil)), KeyStatusSecret: []byte(""), KeyStatus: []byte(""), KeyRadSecTLS: []byte(RenderRadSecTLS(true, false)), diff --git a/internal/radius/reload_test.go b/internal/radius/reload_test.go index f662a59..e1b6342 100644 --- a/internal/radius/reload_test.go +++ b/internal/radius/reload_test.go @@ -34,7 +34,7 @@ func TestWriteRadiusConfig(t *testing.T) { {Username: "mbillow", IPCIDR: nil}, } - if err := radius.WriteRadiusConfig(ctx, k8s, "default", "pint-config", "", clients); err != nil { + if err := radius.WriteRadiusConfig(ctx, k8s, "default", "pint-config", "", clients, nil); err != nil { t.Fatalf("WriteRadiusConfig() error: %v", err) } From c492b2990f91de65771b49d2211a0e1984ed6ff5 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Sun, 7 Jun 2026 13:29:15 -0500 Subject: [PATCH 3/4] chore: bump chart version to 0.4.0 --- chart/Chart.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chart/Chart.yaml b/chart/Chart.yaml index 3170a14..0ceeb09 100644 --- a/chart/Chart.yaml +++ b/chart/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: pint description: Pouring IPA for Network Trust - CSH WiFi EAP-TLS enrollment and home RadSec management type: application -version: 0.3.3 +version: 0.4.0 appVersion: "0.1.0" From ae84ebea3015391199055224af3b5a4649d31950 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Sun, 7 Jun 2026 13:33:44 -0500 Subject: [PATCH 4/4] fix: write clients.conf with proxy hosts at startup --- cmd/pint/main.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cmd/pint/main.go b/cmd/pint/main.go index 5293633..63f3a63 100644 --- a/cmd/pint/main.go +++ b/cmd/pint/main.go @@ -96,6 +96,13 @@ func main() { if err := radius.WriteRadSecTLS(ctx, k8sClient, cfg.Namespace, cfg.ConfigSecret, cfg.FreeRADIUSDeployment, cfg.RadSecCheckCRL, cfg.RadSecProxyProtocol); err != nil { log.Fatal("write radsec-tls.conf failed", zap.Error(err)) } + clientStore := radius.NewClientStore(k8sClient, cfg.Namespace, cfg.ConfigSecret) + if err := clientStore.Load(ctx); err != nil { + log.Fatal("load radius client store failed", zap.Error(err)) + } + if err := radius.WriteRadiusConfig(ctx, k8sClient, cfg.Namespace, cfg.ConfigSecret, cfg.FreeRADIUSDeployment, clientStore.All(), cfg.RadSecProxyHosts); err != nil { + log.Fatal("write clients.conf failed", zap.Error(err)) + } // Fetch CA certs in parallel. var (