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.

Production Deployment: The docker-compose file in the repository is a test environment that attaches to a local bridge (`equinox_test0`). In production, you must attach Equinox to your physical uplink NIC (e.g. 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.

terminal
# 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).
config.yaml
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: docker and give the network your 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_name is optional, only to narrow to one service when others share the network.
  • Not on Docker? Use strategy: static and list the backend IPs directly under static_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).
config.yaml
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.
config.yaml
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.
config.yaml
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).
config.yaml
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.
config.yaml
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 — returns 200 when at least one backend is healthy, else 503.
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.
config.yaml
observability:
  metrics_addr: "0.0.0.0:9100"