L4Ingress reference

The custom resource for TCP and UDP services: every field, which ones you must set, and what happens to the ones you leave out.

An Ingress routes on hosts and paths, and neither exists in a protocol nobody has parsed. So a plain TCP or UDP service gets a resource of its own, declared in the same namespace as the service it exposes.

API group and version

sslb3.hacking.hu/v1alpha1

Kind

L4Ingress

Plural, short name

l4ingresses, l4ing

Scope

Namespaced

The smallest one that works

Four fields. No access control, no TLS, nothing optional — this is a complete, valid L4Ingress and it is what most of them look like.

postgres.yaml

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

Fields

Everything under spec. The API server enforces the types and the ranges, so a malformed object is refused at kubectl apply rather than accepted and then logged about.

FieldTypeRequiredMeaning

ingressClassName

string

In practice

Which controller is responsible, matching an IngressClass exactly as an Ingress does. Nullable in the schema, but an object without it is skipped rather than claimed — see the note below.

protocol

TCP | UDP

Yes

Which of the two this is. No default; the API server refuses anything else.

listenPort

integer, 1024–65535

Yes

The port the load balancer accepts on. Below 1024 is refused by the schema.

service

object

Yes

The service to send it to, in this object's own namespace. Cross-namespace targets are not expressible, deliberately.

service.name

string

Yes

The Service name.

service.port

integer, 1–65535

Yes

The service's own port, which is not usually the one above.

allowSourceRange

string[]

No

Addresses admitted, as CIDR ranges or bare addresses. Defaults to empty, which admits anything not denied.

denySourceRange

string[]

No

Addresses refused, in the same form. Defaults to empty. Applied together with allowSourceRange and resolved by specificity.

The two optional fields

allowSourceRange and denySourceRange are the whole of the optional surface, and both default to empty. Empty means admit anything not denied — the minimal example above serves every client that can reach the port, which is what you want for a database already behind a private network.

They are schema fields rather than annotations on purpose. An annotation is the right shape for something layered onto a resource somebody else defined; this is a kind I own, so a control this important belongs where the API server validates it and kubectl explain describes it.

postgres-restricted.yaml

spec:
  ingressClassName: sslb3
  protocol: TCP
  listenPort: 15432
  service:
    name: postgres
    port: 5432
  # Both optional, both empty by default.
  allowSourceRange:
    - 10.0.0.0/8
    - 192.168.4.0/24
  denySourceRange:
    - 10.1.2.3

The two lists are resolved together by specificity, not by order or by which list a rule came from: 10.1.2.3 above is denied even though 10.0.0.0/8 admits it, because a rule about one host is more specific than a rule about its network. An exact tie goes to deny. Where the kernel fast path takes the service, the same rules travel with it as nftables rules with that ordering preserved.

Ports

listenPort must be 1024 or above and the schema enforces it. These listeners are opened while the balancer is already running, after it has dropped the privileges a lower port would need. 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. Both are needed, and neither implies the other.

Two objects, one port

A port is one thing across the whole cluster while these resources are namespaced, so two namespaces can ask for the same number. The oldest wins, with the namespace and name breaking an exact tie so the outcome is the same on every reconcile. A service already working is never displaced by one created later; the loser is not served and says so in its own status.

The same number on TCP and on UDP is two listeners rather than a conflict — they are separate address spaces.

Status

The controller writes back what it made of the object, which is worth reading because the answer is not always yes.

FieldMeaning

status.accepted

True when this is being served, False when something stopped it.

status.reason

Why, when the answer is no. A port already claimed by an older object, most often.

status.listener

The listener serving it, so the object and the balancer's own API can be matched up.

The resource ships print columns, so the common question is answered without reaching for a JSON path:

shell

kubectl get l4ing -A

NAMESPACE   NAME       PROTOCOL   PORT    SERVICE    ACCEPTED   AGE
shop        postgres   TCP        15432   postgres   True       6d
shop        syslog     UDP        15140   syslog     True       6d

UDP

The same kind with one word changed. There is no second resource for it because everything about the two is identical except which protocol goes in that field.

syslog.yaml

apiVersion: sslb3.hacking.hu/v1alpha1
kind: L4Ingress
metadata:
  name: syslog
  namespace: shop
spec:
  ingressClassName: sslb3
  protocol: UDP
  listenPort: 15140
  service:
    name: syslog
    port: 514

An L4Ingress produces a listener with nothing parsing the traffic, which is the exact shape the kernel fast path exists for. Where the host can forward it, the kernel does and SSLB3 supervises instead. HTTP services are configured with an ordinary Ingress and its annotations.