Security¶
This page covers only the security settings HAPTIC itself owns. 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 (for example, 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, haproxytemplatelibraries (haproxy-haptic.org) |
get, list, watch | The config CRD and the template libraries it references (the chart renders one per enabled library) |
haproxytemplatelibraries |
patch | Stamp an ownerReference from the config onto each library it references, so resource-tree views show the relationship. The chart can't: an ownerReference needs the owner's UID, which doesn't exist until the config is applied |
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 read-write access 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 (for example, 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 (controller.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 with fieldSelector: "metadata.namespace=<ns>" on each watched-resource entry — see Watching Resources. For label-based namespace filtering, see Performance — Resource Watching Optimization.
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 the agent 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 |
configmaps |
get, list, watch, create, update, patch, delete | Only with cache.varnish.enabled — the annotation library emits the Varnish Configuration Language (VCL) ConfigMap into the controller's namespace via Server-Side Apply |
statefulsets, deployments (apps) |
get, list, watch, create, update, patch, delete | Only with the Varnish tier or the managed rate-limit store — those templates own a Varnish StatefulSet/Deployment and a Valkey StatefulSet |
poddisruptionbudgets (policy) |
get, list, watch, create, update, patch, delete | Only when one of those auxiliary workloads emits a PDB |
horizontalpodautoscalers (autoscaling) |
get, list, watch, create, update, patch, delete | Only with cache.varnish.autoscaling.enabled — the cache tier's HPA is applied via Server-Side Apply |
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 agent 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 — the key keeps its name), 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
HAProxy pod hardening¶
The HAProxy pods the chart deploys also run restricted: runAsNonRoot: true, allowPrivilegeEscalation: false, capabilities.drop: [ALL], and seccompProfile.type: RuntimeDefault — compatible with the restricted Pod Security Standard. The chart auto-derives the UID from haproxy.enterprise.enabled and applies it identically as runAsUser, runAsGroup, and fsGroup: community images use 99 (the haproxy user), enterprise images use 1000 (the hapee-lb user).
HAProxy binds privileged ports 80 and 443
HAProxy binds the literal :80 and :443. Community images drop all capabilities and run non-root, so binding these privileged ports relies on the node permitting unprivileged low-port binds — the kernel sysctl net.ipv4.ip_unprivileged_port_start must be <= 80 (the default in kind and Docker). On a cluster that keeps the kernel default of 1024, either lower that sysctl or add CAP_NET_BIND_SERVICE to the HAProxy container's capabilities. Enterprise images run as UID 1000 and their binaries carry CAP_NET_BIND_SERVICE file capabilities, so the chart adds that capability automatically when haproxy.enterprise.enabled: true.
Network exposure¶
The controller pod exposes three HTTP ports (all chart defaults):
| Port | Endpoint | Notes |
|---|---|---|
8080 |
/healthz, /debug/vars, /debug/events, /debug/pprof/ |
controller.ports.healthz configures the process, pod, Service, probes, and policy together. /healthz is required by the probes; shield /debug/* with NetworkPolicy rather than disabling the listener |
9090 |
/metrics |
controller.ports.metrics configures the process, pod, Service, and monitors together; set it to 0 to disable metrics |
9443 |
Validating webhook | Required when the webhook is enabled |
Outbound, the controller talks to the Kubernetes API server and to the agent on each HAProxy pod (default port 5555). That traffic is plain HTTP over the pod network — the agent has no TLS server configuration. Rely on pod-network protection (NetworkPolicy, service mesh, Container Network Interface (CNI) encryption) rather than transport-level authentication for that hop.
An apply carries the rendered configuration and every auxiliary file, which includes SSL private keys. That's the same content the pod already holds on disk, but it's one more reason the hop deserves network-level protection.
The agent 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 the controller (controller.networkPolicy.enabled) and HAProxy (haproxy.networkPolicy.enabled) pods. Enabling the managed Varnish or Valkey tiers adds release-scoped default-on policies controlled by cache.varnish.networkPolicy.enabled and rateLimit.shared.managedStore.networkPolicy.enabled. Know what the defaults actually allow before relying on them:
- The controller policy restricts ingress to the exposed ports (metrics ingress only opens when
controller.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 agent/stats ports, plus a defaultcontroller.networkPolicy.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. - The Varnish policy admits only same-release HAProxy cache requests and permits egress only to DNS and the same HAProxy HTTP origin. The managed Valkey/Sentinel policy admits only same-release HAProxy/SPOA and store-internal traffic.
To tighten, replace, or debug these policies — including a copy-pastable replacement policy and its selector caveat — see Networking. 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.
Annotation input as a trust boundary¶
Most annotation values reach the config as validated or escaped data: CIDR-list annotations are parsed as CIDRs (an invalid entry fails the render), and header, cookie, SNI, and rewrite-target values are checked against a strict character set that rejects control characters, so they can't break out of their directive and inject arbitrary HAProxy config.
The *-config-snippet annotations (haproxy.org/backend-config-snippet, nginx.ingress.kubernetes.io/configuration-snippet, and the like) are the deliberate exception: their value is inserted into the rendered config verbatim. Anyone who can create or edit an Ingress in a watched namespace can therefore inject arbitrary HAProxy directives. Treat Ingress edit permission in watched namespaces as equivalent to HAProxy config access, and restrict it with RBAC accordingly.
Admission validation coverage¶
The admission webhook renders the whole configuration with the submitted object applied and runs haproxy -c, so a change that would break the fleet is rejected at kubectl apply time. It covers Ingress, HTTPRoute, GRPCRoute, Gateway, BackendTLSPolicy, TLSRoute, and TCPRoute.
GatewayClass is deliberately not admitted. The controller emits its own GatewayClass via Server-Side Apply (it's the operator's operational identity, not a routing object you author), so an admission rule on GatewayClass would intercept the controller's own write. A malformed GatewayClass from another source is still caught: the config-load gate re-validates the whole rendered configuration and fails closed, so a bad object surfaces as a rejected config load on the HAProxyTemplateConfig status rather than at admission.
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 controller.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 keepcontroller.ports.healthzenabled). - [ ] 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 + agent egress.
- [ ] Audit policy in place for
HAProxyTemplateConfigchanges. - [ ] Image signature verification (
cosign verify …) wired into your admission policy — see Releasing.
See also¶
- Networking — NetworkPolicy mechanics: default rules, hardening, replacement policies
- Monitoring — signals for auth failures, webhook drops, leader flaps
- Debugging — accessing
/debug/*safely - High Availability — leader election RBAC and lease ownership