Documentation
Configuration reference
equinox is driven by a single YAML file. It is read on
start and re-read on every edit — changes hot-reload with
zero downtime. A config file is
required; there is no built-in default,
so start by copying
config.example.yaml to
config.yaml and editing it.
equinox uses Direct Server Return (DSR):
the destination IP stays as the VIP, only the L2 MAC is
rewritten. For Docker backends, equinox automatically
injects the VIP into each container's loopback. For
static/DNS backends, run
scripts/setup-vip.sh <VIP> on each
server.
IFACE=eth0) so it can intercept real external traffic at wire speed.
Overview
The file has five top-level blocks. Only
gateway and discovery are
meaningful to set — the rest have safe defaults and
can be omitted entirely.
| Block | Required | What it controls |
|---|---|---|
gateway |
optional* | Which ports to intercept and how to attach XDP. Defaults to ports 80 & 443 if omitted. |
discovery |
yes | Where the backends are — a static list, the Docker socket, or DNS. |
protection |
optional | Per-source-IP rate & malformed limiting. On by default. |
health_check |
optional | Active TCP probing of backends. On by default. |
observability |
optional |
Prometheus /metrics +
/healthz. Off unless set.
|
* discovery is the only block that must
be present for the file to parse.
Config location
By default the binary reads
config.yaml from its working directory.
In the Docker image the working directory is
/app, so the path is
/app/config.yaml. Override it with the
--config flag or the
CONFIG environment variable. If the file
you point at doesn't exist, equinox falls back to
config.yaml in the same directory; if
that's missing too it exits with an error rather than
starting on an empty config.
# bare metal
$ sudo RUST_LOG=info ./target/release/l4 --config config.yaml
# docker — mount your config at /app/config.yaml
$ docker run --rm --network host --privileged \
-v "$(pwd)/config.yaml:/app/config.yaml" typicallhavok/equinox:latest
gateway optional
Defines the public-facing ports equinox intercepts on
the host and how the XDP program attaches. Any port
not listed is passed straight to the host stack, so
SSH and unrelated traffic are never touched. If the
whole block is omitted, ports
80 and 443 are used.
| Field | Default | Description |
|---|---|---|
listen_ports |
[80, 443] | List of TCP/UDP destination ports to validate and load-balance. Everything else passes through untouched. |
vip |
— |
Virtual IP that clients connect to.
DSR: backends must have this on their
loopback. For Docker backends equinox
injects it automatically; for
static/DNS, run
scripts/setup-vip.sh.
|
xdp_mode |
auto |
How the XDP program attaches:
auto (try native, fall back
to skb), drv/native
(driver mode, fastest),
skb (generic, works
everywhere), or hw (hardware
offload).
|
gateway:
listen_ports: [80, 443]
vip: "10.0.0.100"
xdp_mode: "auto"
discovery required
Tells equinox where the backends live — it never runs them itself. There are two everyday ways to point at your backends, plus DNS:
-
Already on Docker? Use
strategy: dockerand give thenetworkyour backends' compose created — that alone routes to every container on it, scaling included, with no per-backend setup. equinox tracks the group live over the Docker socket.target_service_nameis optional, only to narrow to one service when others share the network. -
Not on Docker? Use
strategy: staticand list the backend IPs directly understatic_routes. The config is the source of truth; edits hot-reload.
Whichever you pick, backends are re-checked on an interval so crashed or scaled instances are handled automatically. The shared fields are below; the strategy-specific ones follow in each sub-section.
| Field | Default | Description |
|---|---|---|
strategy |
— |
static, docker,
or dns. Required.
|
sync_interval_ms |
3000 | How often (ms) discovery re-runs to pick up changes. |
drop_unmatched |
false |
When a validated packet hits a listen
port with no backend: drop it
(true) or pass it to the
host stack (false).
|
strategy: static
A fixed list of backends already on the same L2
segment. Each entry needs an ip; the
port and MAC are optional.
DSR setup required: each static
backend must have the VIP assigned to its loopback.
Run sudo scripts/setup-vip.sh <VIP>
on each backend server.
| Field | Default | Description |
|---|---|---|
ip |
— |
Backend IPv4, as "1.2.3.4"
or "1.2.3.4:3000". Required
per entry.
|
port |
listen port |
Destination port override. Takes
precedence over a port in
ip; omit both to keep the
original listen port.
|
mac |
ARP table | Destination MAC to rewrite to. Resolved from the host neighbour table when omitted (after the kernel has seen traffic to that host). |
discovery:
strategy: "static"
sync_interval_ms: 3000
drop_unmatched: false
static_routes:
- ip: "172.18.0.10"
port: 3000
mac: "02:42:ac:12:00:0a"
strategy: docker
Discovers running containers through
/var/run/docker.sock (mount it into the
container). Set network and equinox
routes to every container on it — that's the
whole setup. You must supply network or
target_service_name (at least one); the
rest are optional.
Automatic DSR VIP injection: when
gateway.vip is set, equinox enters each
container's network namespace and assigns the VIP to
its loopback interface. No Dockerfile changes, no
manual setup. This happens on every discovery reload,
so new containers get the VIP automatically.
| Field | Default | Description |
|---|---|---|
network |
— | Route to every container on this Docker network. On its own this is enough. |
target_service_name |
— | Optional. Narrow to one compose service when others share the network. Omit to take the whole network. |
target_port |
listen port | Backend port to route to when it differs from the listen port. |
discovery:
strategy: "docker"
network: "myapp_backends" # routes to the whole network
# target_service_name: "api" # optional: narrow to one service
# target_port: 8080 # optional: if backends aren't on the listen port
strategy: dns
Resolves the A records of a hostname and routes to them. The MAC is taken from the host ARP table, so the resolved addresses must be on the same L2 segment.
| Field | Default | Description |
|---|---|---|
target_service_name |
— | Hostname to resolve, e.g.
backend.internal. |
target_port |
listen port | Backend port to route to. |
discovery:
strategy: "dns"
target_service_name: "backend.internal"
target_port: 443
protection optional
In-kernel abuse protection, evaluated per source IP
in the data plane. A source that exceeds
rate_limit_per_sec valid requests or
malformed_limit malformed packets within
window_ms is blacklisted for
block_duration_secs — every packet from
it is dropped until the block expires. Enabled by
default with conservative limits; set
enabled: false to turn it off.
Note: if many clients share one IP (NAT or an upstream proxy), raise the limits so legitimate traffic isn't tripped.
| Field | Default | Description |
|---|---|---|
enabled |
true | Master switch for the whole block. |
rate_limit_per_sec |
5000 |
Valid requests per source IP before
blacklisting. 0 disables the
rate check.
|
malformed_limit |
20 |
Malformed packets per window per source
IP before blacklisting.
0 disables the malformed
check.
|
window_ms |
1000 | Sliding-window length in milliseconds. |
block_duration_secs |
300 | How long a tripped source stays blacklisted (5 minutes). |
protection:
enabled: true
rate_limit_per_sec: 5000
malformed_limit: 20
window_ms: 1000
block_duration_secs: 300
health_check optional
Active TCP health checking. On every reload each backend is probed with a short TCP connect; only healthy backends enter the Maglev table, so a crashed backend is evicted within a reload cycle and re-added automatically when it recovers. Enabled by default.
| Field | Default | Description |
|---|---|---|
enabled |
true | Turn active health checking on or off. |
timeout_ms |
500 | TCP connect timeout per probe, in milliseconds. |
port |
backend port | Probe this port instead of the backend's routed/listen port. |
health_check:
enabled: true
timeout_ms: 500
# port: 8080 # override the probe port
observability optional
Off unless you set metrics_addr. When
set, equinox serves a tiny, dependency-free HTTP
endpoint:
-
GET /metrics— Prometheus counters: packets routed and dropped-by-reason (validation, blocked, rate, no-backend) plus backend total/healthy gauges. -
GET /healthz— returns200when at least one backend is healthy, else503.
| Field | Default | Description |
|---|---|---|
metrics_addr |
off |
Bind address for the endpoint, e.g.
"0.0.0.0:9100". Omit the
whole block to keep it off — zero setup
required.
|
observability:
metrics_addr: "0.0.0.0:9100"