Security¶
HAPTIC exposes a focused set of security-relevant knobs. Anything that isn't HAPTIC-specific (how to issue certs with cert-manager, how to wire External Secrets Operator (ESO), etc.) is left to the upstream project's docs.
What the Controller Needs¶
RBAC¶
The Helm chart provisions a ServiceAccount, a ClusterRole, and a namespace-scoped Role (names derive from the Helm release fullname). The ClusterRole grants:
| Resource | Verbs | Why |
|---|---|---|
pods, namespaces |
get, list, watch | Discover HAProxy pods, target namespaces |
<each watched resource> |
get, list, watch | Generated per watchedResources entry — Ingress, Service, EndpointSlice, Secret, etc. depending on the enabled libraries |
<watched resource>/status |
patch | Generated for watched resources with statusPatch: true (e.g. Ingress LoadBalancer status, Gateway / HTTPRoute conditions) |
leases (coordination.k8s.io) |
get, create, update | Leader election |
customresourcedefinitions (apiextensions.k8s.io) |
get, list, watch | Fetch watched-resource OpenAPI schemas from their CRDs so typed template access stays full-fidelity (degrades to the public OpenAPI endpoint otherwise) |
haproxytemplateconfigs.haproxy-haptic.org |
get, list, watch | Primary config CRD |
haproxytemplateconfigs/status |
update, patch | Report validation status back onto the CRD |
haproxycfgs, haproxygeneralfiles, haproxycrtlistfiles, haproxymapfiles (.haproxy-haptic.org) |
get, list, watch, create, update, patch, delete | Publish rendered config + auxiliary files as observable CRDs (full CRUD because the controller owns these resources and prunes stale entries) |
<above CRDs>/status |
update, patch | Report deployment status on the published artifacts |
services |
get, list, watch, create, update, patch, delete | Gateway library only — cluster-wide Service writes for Gateway-API templates that emit owned Services into a Gateway's own namespace (e.g. the per-Gateway infrastructure-propagation marker Service) |
gatewayclasses (gateway.networking.k8s.io) |
create, update, patch, delete | Gateway library only — the GatewayClass is applied at runtime via Server-Side Apply, not by Helm (read verbs come from the watched-resource rules) |
events (core) |
create, update, patch, delete | Ingress library only — Warning Events on Ingresses whose backend Service is missing |
Anything else referenced from watchedResources needs matching RBAC. The Helm chart auto-generates the watched-resource rules from controller.config.watchedResources and the enabled libraries; if you manage RBAC yourself (rbac.create: false), keep it in sync. The full template is charts/haptic/templates/clusterrole.yaml.
Narrow the cluster-wide watch to a single namespace by pinning namespace: on each watched-resource entry — see Watching Resources. For multi-namespace filtering by labels, fall back to per-namespace Role/RoleBinding instead of a ClusterRole, or filter inside the template against a watched namespaces resource.
A namespace-scoped Role (bound only in the controller's own namespace) additionally grants the writes the controller performs locally — kept off the ClusterRole to tighten the blast radius:
| Resource | Verbs | Why |
|---|---|---|
secrets |
get, list, watch, create, update, patch, delete | Read Dataplane API credentials; read/write SSL certificate Secrets |
haproxycfgs, haproxymapfiles |
get, list, watch, create, update, patch, delete | Publish rendered config + map files as observable CRDs in the controller's own namespace |
haproxycfgs/status, haproxymapfiles/status |
get, update, patch | Status on the published artifacts |
services |
get, list, watch, create, update, patch, delete | Namespace-scoped counterpart to the gateway Service grant above — Gateway StaticAddresses LoadBalancer Services emitted into the controller's own namespace |
The full template is charts/haptic/templates/role.yaml.
Credentials¶
The CRD references a Secret via spec.credentialsSecretRef. It must contain two keys:
apiVersion: v1
kind: Secret
metadata:
name: haproxy-credentials
type: Opaque
stringData:
dataplane_username: admin
dataplane_password: <random>
The controller watches the Secret and picks up rotations live — no pod restart needed. Use whatever secret-management tool you already run (ESO, Vault agent, SOPS, …); the controller just reads the Secret.
Set the Dataplane password explicitly under GitOps
If you install via the Helm chart and leave credentials.dataplane.password empty, the chart generates a random 32-char password and preserves it across upgrades by reading the existing Secret via lookup. GitOps tools that render without cluster access (ArgoCD/Flux) can't lookup, so an empty value regenerates on every sync and churns the credential — set credentials.dataplane.password explicitly (SealedSecret / external secret) for those deployments.
Debug endpoints expose credential metadata only (version, has_dataplane_creds: true), never passwords — pkg/controller/debug/setup.go enforces that. See Debugging for access control if you run with the debug port enabled.
Pod Hardening¶
The chart ships with a restrictive default pod spec. The relevant securityContext (container-level) / controller.podSpec.podSecurityContext (pod-level) defaults:
| Setting | Default |
|---|---|
runAsNonRoot |
true |
runAsUser / runAsGroup / fsGroup |
65532 (nonroot) |
readOnlyRootFilesystem |
true |
allowPrivilegeEscalation |
false |
capabilities.drop |
[ALL] |
seccompProfile.type |
RuntimeDefault |
The controller writes temporary files (for haproxy -c validation) to /tmp, which is mounted as an emptyDir. Everything else is read-only.
The chart is compatible with the "restricted" Pod Security Standard out of the box:
apiVersion: v1
kind: Namespace
metadata:
name: haptic
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/warn: restricted
Network Exposure¶
The controller pod exposes three HTTP ports (all chart defaults):
| Port | Endpoint | Notes |
|---|---|---|
8080 |
/healthz, /debug/vars, /debug/events, /debug/pprof/ |
/healthz and /debug/* share the same listener; setting controller.debugPort: 0 disables both and breaks the liveness/readiness probes. To shield /debug/* in production, restrict access with a NetworkPolicy (example below) instead of disabling the port |
9090 |
/metrics |
Disable by setting the METRICS_PORT=0 env var on the controller container (e.g. extraEnv in Helm); the controller.config.controller.metricsPort Helm value is display-only — the chart strips it before serializing |
9443 |
Validating webhook | Required when the webhook is enabled |
Outbound, the controller talks to the Kubernetes API server and to each HAProxy pod's Dataplane API (default port 5555). Dataplane API traffic is plain HTTP over the pod network — the controller has no TLS client configuration for the Dataplane API. Rely on pod-network protection (NetworkPolicy, service mesh, CNI encryption) rather than transport-level authentication for that hop.
The Dataplane API is authenticated with a basic-auth password stored in the <release>-haptic-credentials Secret (the release fullname, which collapses to <release>-credentials only when the release name already contains haptic). Password generation and the GitOps caveat are covered in the warning box above.
The chart already ships default-on NetworkPolicy resources for both the controller (networkPolicy.enabled) and HAProxy (haproxy.networkPolicy.enabled) pods — both default true. Know what the defaults actually allow before relying on them:
- The controller policy restricts ingress to the exposed ports (metrics ingress only opens when
networkPolicy.ingress.monitoring.enabled: true— it's off by default, so enable it for Prometheus). Egress covers DNS, the Kubernetes API server, and the HAProxy Dataplane/stats ports, plus a defaultnetworkPolicy.egress.additionalRulesentry allowing every in-cluster pod (so template helpers likehttp.Fetch()work) — set it to[]to lock egress down (see Networking). - The HAProxy policy defaults to
allowExternal: true, which renders a permissive all-port ingress rule — deliberate, because Gateway listeners bind dynamic ports.
Set the relevant flag to false to manage your own. The example below is a narrowed, controller-only variant — the shipped policy's selector matches every release pod (name + instance labels, no component discriminator) and therefore also carries the Dataplane port 5555 ingress allowance for the HAProxy pods; if you replace it, cover the HAProxy pods separately:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: haptic-controller
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: haptic
app.kubernetes.io/component: controller
policyTypes: [Ingress, Egress]
ingress:
- ports:
- port: 8080 # /healthz, /debug/*
- port: 9090 # /metrics
- port: 9443 # webhook
egress:
- to:
- namespaceSelector: {} # kube-apiserver is in every cluster, tighten if you know the selector
ports:
- port: 443
- to:
- podSelector:
matchLabels:
app.kubernetes.io/component: loadbalancer
ports:
- port: 5555 # Dataplane API
If you keep the debug port enabled, pair it with a NetworkPolicy that restricts ingress to your observability namespace.
Secrets in Templates¶
Templates read watched Secrets like any other resource. Decode with b64decode (values in .data are base64-encoded by Kubernetes):
Store hashes, not plaintext. For HAProxy basic auth:
htpasswd -nbB admin mypassword | cut -d: -f2
kubectl create secret generic basic-auth -n auth \
--from-literal=password_hash='$2y$05$...'
Bcrypt is slow to verify on every request; for large userbases use htpasswd -n -5 (SHA-512 crypt) and see Performance for the trade-off.
Audit Trail¶
A minimal audit policy that records who touched HAProxyTemplateConfig and which Secrets the controller reads:
apiVersion: audit.k8s.io/v1
kind: Policy
rules:
- level: RequestResponse
resources:
- group: haproxy-haptic.org
resources: ["haproxytemplateconfigs"]
- level: Metadata
users: ["system:serviceaccount:<namespace>:<release>-haptic"]
resources:
- group: ""
resources: ["secrets"]
Replace <namespace>/<release> with your Helm release. The SA name is the release fullname <release>-haptic (collapsing to <release> only when the release name already contains haptic) unless you overrode serviceAccount.name — get the exact value with kubectl -n <namespace> get sa. A rule keyed on the wrong SA name silently never matches, so the controller's Secret reads go unaudited.
Checklist¶
Before exposing a HAPTIC deployment to production traffic:
- [ ] Random, rotated passwords in
credentialsSecretRef. - [ ] NetworkPolicy that pins
/debug/*ingress to trusted namespaces (the port also serves/healthz, so don't setcontroller.debugPort: 0). - [ ] Watched-resource selectors scoped to the namespaces you intend to serve.
- [ ] Release namespace labelled with
pod-security.kubernetes.io/enforce=restricted. - [ ] NetworkPolicy allowing only kube-apiserver + Dataplane-API egress.
- [ ] Audit policy in place for
HAProxyTemplateConfigchanges. - [ ] Image signature verification (
cosign verify …) wired into your admission policy — see Releasing.
See Also¶
- Monitoring — signals for auth failures, webhook drops, leader flaps
- Debugging — accessing
/debug/*safely - High Availability — leader election RBAC and lease ownership