Skip to content

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 default controller.networkPolicy.egress.additionalRules entry allowing every in-cluster pod (so template helpers like http.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):

apiVersion: haproxy-haptic.org/v1alpha1
kind: HAProxyTemplateConfig
metadata:
  name: secret-userlist-demo
spec:
  watchedResources:
    secrets:
      apiVersion: v1
      resources: secrets
      indexBy:
        - metadata.namespace
        - metadata.name
  haproxyConfig:
    template: |
      global
        log stdout format raw local0
      defaults
        mode http
        timeout connect 5s
        timeout client 30s
        timeout server 30s
      {%- var secret = resources.secrets.GetSingle("auth", "basic-auth") %}
      {%- if secret != nil %}
      userlist authenticated_users
          user admin password {{ secret.data.password_hash | b64decode() }}
      {%- end %}
apiVersion: v1
kind: List
items:
  - apiVersion: v1
    kind: Secret
    metadata:
      name: basic-auth
      namespace: auth
    data:
      password_hash: JDJ5JDA1JFp0MENrMXFZdzhwMXNRbTltUjNuUWVKOHlxM3ZKaEY1eEo4ZFEwb1YyYk43Y1gxa0w5bVNl

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 keep controller.ports.healthz enabled).
  • [ ] 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 HAProxyTemplateConfig changes.
  • [ ] 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
Found a problem on this page? Report it or edit the page with the pencil icon above the title.