The Kubernetes ingress controller

SSLB3 runs as a Kubernetes ingress controller: it watches the API for Ingress and L4Ingress objects and configures itself from them. HTTP routing comes from Ingress; TCP and UDP services come from a custom resource, because an Ingress cannot describe one.

How it is put together

The controller and the balancer run in one pod, and that is a deliberate choice rather than a convenience. The admin API configures one balancer instance. With the controller in a Deployment of its own it would have to discover every balancer pod and push to each, and a pod that started after the last reconcile would serve nothing until the next one. As a sidecar, each pod configures itself: scaling is a replica count, a new pod is ready within one reconcile, and the admin API binds loopback so the control plane never crosses the network.

The cost is one API server watch per replica. For the handful of replicas an ingress fleet runs, that is cheaper than solving the fan-out.

How the ingress controller worksThe controller watches Ingress, L4Ingress, Service, EndpointSlice and Secret objects in the Kubernetes API, translates them into SSLB3 listeners, serverfarms, backends and certificates, and pushes them over the admin API on loopback inside the same pod.Kubernetes APIIngressL4IngressServiceEndpointSliceSecretone podcontrollerwatches, translates, reconciles127.0.0.1:6443sslb3listeners · serverfarmsbackends · certificatesyour pods

Installing it

The chart is published as an OCI artifact, so there is no repository to add.

shell

helm install sslb3 \
  oci://docker.hacking.hu/public/sslb3-ingress \
  --namespace sslb3 --create-namespace

Or apply the manifests directly, which is the same deployment written out. Use --server-side — two ports share 443 and 8443, and a client-side apply silently drops one of them.

shell

kubectl apply --server-side -k crates/sslb3-ingress-controller/kube

Full detail, including the values worth setting, is on the installation page.

HTTP, with an Ingress

An ordinary Ingress, claimed by the sslb3 IngressClass. Hosts and paths become native routing rules in the balancer's configuration rather than a script, so the request path stays in Rust.

Three of them below, each adding one thing to the last. Start at the top: annotations are all optional, and most Ingresses need none of them.

1. The simplest one

No TLS, no annotations, nothing optional. This is a complete Ingress and it serves plain HTTP on port 80 — which is all you want while you are getting a service reachable for the first time.

ingress-plain.yaml

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: shop
  namespace: shop
spec:
  ingressClassName: sslb3
  rules:
    - host: shop.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: shop
                port: { number: 80 }

ingressClassName is the field that says this Ingress is SSLB3's. One naming no class is served here only when the sslb3 IngressClass is the cluster default — marked ingressclass.kubernetes.io/is-default-class: "true" — and otherwise it sits there looking valid and is served by nothing. An Ingress naming another controller's class is left to that controller, default or not. The older kubernetes.io/ingress.class annotation is read as a fallback for manifests that predate the field.

2. With TLS

One block added. The certificate comes from an ordinary Secret, which is what cert-manager writes, so an issuer annotation is all that stands between this and an automatically renewed certificate.

ingress-tls.yaml

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: shop
  namespace: shop
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt
spec:
  ingressClassName: sslb3
  tls:
    - hosts: [shop.example.com]
      secretName: shop-tls
  rules:
    - host: shop.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: shop
                port: { number: 80 }

The controller reads the Secret named in spec.tls, pushes the certificate to the balancer's store, and the listener presents it by server name — no restart, and a renewed certificate needs nothing more than the Secret changing.

3. With basic auth and a few settings

Now the annotations, and only because this particular service wants them: credentials in front of a staging site, plaintext redirected to HTTPS, a header set for the backend, and the farm health checked rather than trusted. Every one of these is independent — take the two you want and leave the rest out.

ingress-protected.yaml

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: staging
  namespace: shop
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt

    # Basic auth. Both of these are needed: auth-secret without auth-type
    # is reported and applied to nothing, rather than half-protecting the site.
    sslb3.ingress.hacking.hu/auth-type: basic
    sslb3.ingress.hacking.hu/auth-secret: staging-users
    sslb3.ingress.hacking.hu/auth-realm: "Staging"

    # Send plaintext callers to https instead of serving them.
    sslb3.ingress.hacking.hu/force-ssl-redirect: "true"

    # Tell the backend what it is behind.
    sslb3.ingress.hacking.hu/request-headers: |
      X-Forwarded-Host: staging.example.com

    # Probe the backends here too, rather than trusting the pod readiness alone.
    sslb3.ingress.hacking.hu/health-check: "true"
    sslb3.ingress.hacking.hu/health-check-type: http
    sslb3.ingress.hacking.hu/health-check-path: /healthz
spec:
  ingressClassName: sslb3
  tls:
    - hosts: [staging.example.com]
      secretName: staging-tls
  rules:
    - host: staging.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: staging
                port: { number: 80 }

The Secret behind auth-secret is an ordinary htpasswd file, the same shape ingress-nginx expects:

shell

htpasswd -nbB alice 'correct horse battery staple' > auth
kubectl -n shop create secret generic staging-users --from-file=auth

Every annotation, with the values it accepts and what it does when unset, is in the annotation reference.

TCP and UDP, with an L4Ingress

An Ingress routes on hosts and paths, and neither exists in a protocol nobody has parsed. So layer 4 services come from a custom resource, declared beside the service it exposes:

l4ingress.yaml

apiVersion: sslb3.hacking.hu/v1alpha1
kind: L4Ingress
metadata:
  name: postgres
  namespace: shop
spec:
  ingressClassName: sslb3
  protocol: TCP          # or UDP
  listenPort: 15432
  service:
    name: postgres
    port: 5432

That is the whole resource — four fields and nothing optional. Source ranges are available if you want them and empty if you do not, which admits anything that can reach the port:

l4ingress-restricted.yaml

spec:
  # ... as above, plus:
  allowSourceRange: ["10.0.0.0/8"]
  denySourceRange:  ["10.1.2.3"]

Every field, which are required and which are not, the status the controller writes back and how two objects claiming one port resolve, are in the L4Ingress reference.

listenPort must be 1024 or above and the schema enforces it: these listeners are opened while the balancer is running, after it has dropped the privileges a lower port needs. Publish the port clients actually use on the Service in front, the way 80 and 443 are published onto 8080 and 8443. The L4Ingress says what to serve; the Service is what makes it reachable, and both are needed.

A port is one object across the cluster while these are namespaced, so two namespaces can claim the same one. The older wins — stable across reconciles, and a service already working is never displaced by one created later. The same number on TCP and UDP is two listeners, not a conflict.

These are the services the fast path moves

An L4Ingress produces a listener pinned to its farm with nothing parsing the traffic, which is exactly the shape the kernel fast path exists for. Where the host can forward it, the kernel does, and the source filter above travels with it as an nftables rule. Where it cannot, SSLB3 serves it and says why.

Annotations

Per-Ingress behaviour, all prefixed sslb3.ingress.hacking.hu/, and all optional. The ones reached for most often are below; the annotation reference has all 44 with the values each accepts, the defaults, and the settings that deliberately do not exist.

AnnotationWhat it does

allowlist-source-range

Admit only these addresses or CIDR ranges. Also spelled whitelist-source-range, which is what ingress-nginx calls it.

denylist-source-range

Refuse these. Resolved against the allow list by specificity, with an exact tie going to deny.

force-ssl-redirect

Answer plaintext requests with a 308 to the same URL over https.

auth-type / auth-secret

Basic authentication from an htpasswd Secret. Both are needed; one without the other is reported and applied to nothing.

load-balance

How a backend is chosen for this farm: round-robin, weighted, least-connections or lua.

health-check / health-check-*

Have SSLB3 probe the backends itself, with the type, path, accepted status codes, port, interval and fail tolerance each set per service — the settings the AWS controller calls healthcheck-path and success-codes.

affinity

Cookie-based session affinity, with session-cookie-name and session-cookie-max-age alongside it.

threat-detection

What the intrusion detector does about this service: off, detect or block. This is what makes an IPS rollout possible one service at a time.

request-headers / response-headers

Set headers on the way to the backend or on the way back, one Name: value per line. Each has a -remove counterpart.

enable-cors

Answer preflights here and add the CORS headers to every response, with six cors- settings to tune what they say.

Unknown annotations under the prefix are reported rather than ignored, so a typo does not silently do nothing.

Operating it

Status on the object

An L4Ingress carries whether it was accepted, and the reason when it was not — a port already claimed by an older object, most often. Read it with kubectl rather than the controller log.

Reconciliation is level triggered

The whole desired state is pushed each pass, so a missed event heals on the next one. There is no queue to get stuck and no incremental state to go wrong.

Metrics and a statistics page

Both off by default and both worth turning on: every listener, farm and backend with its health, plus which services the fast path moved and why the rest stayed.

The service account is narrow

It reads Secrets cluster-wide because it must, to serve the certificates Ingresses name — so its token is projected only into the controller container, never into the one running Lua.

Publishing it

The chart creates a Service in front. What type it should be is the one decision the chart cannot make for you:

LoadBalancer

The obvious answer where a cloud provider will give you an address.

ClusterIP, advertised

On bare metal with kube-router or MetalLB, the ClusterIP is what gets advertised. Pin it with service.clusterIP — deleting and recreating the Service releases the address, and anything routing to it follows.

On AWS, HTTP/3 needs a second one

A network load balancer will not carry a QUIC listener if it is dualstack or has a security group, and most are both — so HTTP/3 ends up on a second, IPv4-only load balancer in front of the same pods, with Alt-Svc pointing clients at it. HTTP/3 behind an AWS network load balancer is that layout end to end: the per-pod server IDs the load balancer routes on, the ports that must not collide, and the classic BPF filter that keeps a client which changed network on the worker holding its connection.