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.

ingress.yaml

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: shop
  namespace: shop
  annotations:
    sslb3.ingress.hacking.hu/allowlist-source-range: "10.0.0.0/8"
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 }

TLS comes from the Secret named in spec.tls. The controller reads it, 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.

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
  # Optional. The access control available in front of a protocol nobody parses.
  allowSourceRange: ["10.0.0.0/8"]
  denySourceRange:  ["10.1.2.3"]

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/. The common ones:

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 a tie going to deny.

ssl-redirect

Answer plaintext requests with a redirect to HTTPS.

auth-type / auth-secret

Basic authentication from an htpasswd Secret.

balancer

The balancing algorithm for this Ingress’s farm: round-robin, weighted, least-connections or source-hash.

health-check

Turn health checking on for this farm, and choose its type.

affinity

Cookie-based session affinity, with the cookie name and lifetime.

proxy-body-size

The largest request body accepted before it is refused.

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.