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.
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: 5432That 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.
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.