Deep dive

Every subsystem, with its actual config

omlb bundles reverse proxy, automatic HTTPS, load balancing, health-checked failover, the edge security gate, VRRP self-HA, live config, caching, L4 proxying, self-healing managed pools, plugins, a web UI, and observability into one Rust binary on Cloudflare's Pingora. Thirteen sections below, each with the plain-English version and the exact YAML that turns it on.

Routing & rewrites

First match wins, top to bottom

A route is a match plus a destination. The match side reads host, path prefix, method, and exact headers; the first route that fits, top to bottom, takes the request. The destination is either an upstream pool or a canned respond — handy for health endpoints and redirects that have no business waking a backend. Need to reshape the request on its way through? rewrite strips or prepends a path prefix and can swap the Host header before the backend ever sees it.

One thing we deliberately left out: regex. Hosts match exactly, paths match on prefix, and that's the whole grammar. It keeps configs boring to read — but if your routing genuinely needs a regex engine, omlb will frustrate you today.

omlb.yaml — routes
routes:                                   # first match wins, top to bottom
  - { name: api, match: { host: x.com, path_prefix: /api/, methods: [GET, POST] }, upstream: api_pool }
  - name: legacy
    match: { host: x.com, path_prefix: /old/ }
    upstream: app
    rewrite: { strip_prefix: /old, add_prefix: /v2, host: internal.x.com }
  - { name: assets, match: { host: x.com, path_prefix: /assets/ }, upstream: app, encode: true, max_body: 10485760 }
  - { name: healthz, match: { host: x.com, path_prefix: /healthz }, respond: { status: 200, body: "ok" } }
  - { name: www, match: { host: www.x.com }, respond: { status: 308, headers: { location: "https://x.com" } } }
Engineering note Route selection allocates nothing: hosts land in a pre-partitioned exact-match bucket before any fallback scan, method and host comparisons are case-insensitive without copying, and a rewrite only builds a new path string when it actually fires.
Automatic HTTPS

ACME by default, bring your own when you need to

List your domains under tls.acme and certificates simply happen: issued when omlb boots, renewed on schedule, and swapped into the live SNI store mid-flight — a fresh cert starts serving without dropping a single connection. There's no renewal cron to forget and no reload to schedule. Every domain shares one HTTP-01 responder on http01_address, so port 80 stays a solved problem.

The honest asterisk: HTTP-01 is the only challenge type today, and wildcards need DNS-01 — so wildcard certs are bring-your-own under tls.certs. They'll serve fine; they just won't renew themselves. Static and ACME certs share one store, and SNI resolution tries exact, then wildcard, then the default.

omlb.yaml — tls.acme
tls:
  min_version: "tls1.2"
  acme:
    enabled: true
    directory: "https://acme-v02.api.letsencrypt.org/directory"   # swap to …/acme-staging-v02/… to test
    contacts: ["mailto:admin@example.com"]
    terms_of_service_agreed: true
    cache_dir: /var/lib/omlb/acme        # persist account + certs across restarts
    http01_address: "0.0.0.0:80"         # HTTP-01 challenge responder (port 80 must be reachable)
    renew_before_days: 30
    domains: [example.com, www.example.com, api.example.com]
listeners:
  - { name: https, address: "0.0.0.0:443", tls: { managed: true }, http2: true }
omlb.yaml — tls.certs (BYO / wildcard)
tls:
  certs:
    - { sni: ["*.example.com"], cert: /etc/ssl/wild.crt, key: /etc/ssl/wild.key }    # single-label wildcard
    - { sni: ["example.com"],   cert: /etc/ssl/apex.crt, key: /etc/ssl/apex.key }
    - { sni: [],                cert: /etc/ssl/fallback.crt, key: /etc/ssl/fallback.key }  # default/fallback
listeners:
  - { name: https, address: "0.0.0.0:443", tls: { managed: true }, http2: true }
Load balancing

Four algorithms, per pool

Every pool chooses its own algorithm: weighted round_robin, random, power_of_two (pick two backends at random, keep the less-loaded one — the sensible default), or consistent Ketama hashing for when clients need to stick to a backend. Weights are per-backend, and any backend can be dialed over TLS with its own SNI.

omlb.yaml — upstreams.app
upstreams:
  app:
    algorithm: power_of_two    # round_robin | random | power_of_two (p2c/least-conn) | consistent (ketama)
    backends:
      - { addr: "10.0.0.1:8080", weight: 3 }
      - { addr: "10.0.0.2:8080" }
      - { addr: "10.0.0.3:8080", tls: true, sni: app.internal }   # TLS to the backend
Health & failover

Failover that actually fails over

Two detection layers work together. Active checks probe each backend on an interval — a TCP connect or a real HTTP request — and pull it out of rotation after enough failures. Passive outlier detection doesn't wait for the next probe: a backend that starts answering 5xx on live traffic gets ejected on the spot and re-admitted later. And when a request does land on a dying backend, the retry policy moves it to a healthy one instead of surfacing the error. Both layers ship enabled, with thresholds that behave sanely out of the box.

Timeouts are per pool — connect, read, write — and 0s disables one deliberately, which is exactly what you want in front of SSE and long-polls.

omlb.yaml — upstreams.app (continued)
    health_check: { kind: http, path: /healthz, interval: 2s, healthy_threshold: 2, unhealthy_threshold: 3 }
    outlier:  { enabled: true, consecutive_errors: 5, ejection_time: 30s }   # passive circuit-break
    retry:    { enabled: true, max_retries: 1, retry_on_connect_error: true }
    timeout:  { connect: 5s, read: 30s, write: 30s }   # 0s = disabled, e.g. for SSE/long-poll
Engineering note The bookkeeping never locks: in-flight counters for least-conn and ejection state are plain atomic reads and writes on the request path.
Edge security gate

fail2ban's job, without fail2ban

The gate sits in front of routing, so a request gets judged before omlb even decides where it would go — which means scans against hostnames you don't serve still get caught. It watches three things: request rate per IP (a token bucket; over the limit means 429), trap paths (touch /.env or /wp-login.php once and that IP eats a 403 on every host until ban_duration expires), and — optionally — repeated 404s inside a strike window. Strikes ship off: a Matrix homeserver or a REST API 404s legitimately all day, and we'd rather not ban your users.

Bans track the real client address even behind Docker, survive config reloads, and show up in metrics as omlb_abuse_*. Ranges on the allowlist are never touched — put your own infra there.

omlb.yaml — security
security:
  enabled: true
  allowlist: [127.0.0.0/8, "::1", 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16]
  rate_limit_rps: 50            # per-IP token bucket; over-limit -> 429
  rate_limit_burst: 100
  ban_duration: 1h
  max_strikes: 0                # strike-bans OFF — 404 is normal for Matrix/REST; enable only on static-only hosts
  strike_window: 1m
  strike_statuses: [404]        # never 401/403 (app auth) or 5xx (backend outage)
  ban_paths: [/.env, /.git/, /wp-login.php, /xmlrpc.php, /.aws/, /.ssh/]
Engineering note State is sharded 64 ways by client IP — a request takes one shard-local lock, and the trap-path scan never allocates. Don't take our word for it: python3 bench/bench.py security measures the gate on your hardware.
High availability

VRRP and a floating VIP, without keepalived

Run omlb on two boxes, give both the same virtual_router_id and different priorities, and they hold an election over UDP heartbeats. The winner claims the virtual_ip — the one address your DNS points at. Go silent for dead_multiplier × advert_interval and the backup takes the address over; preempt decides whether the old master gets it back when it recovers. The only privilege it needs is CAP_NET_ADMIN, for moving the IP.

Worth knowing before you rely on it: the security gate's ban table stays local to each node, so a scanner banned on the master starts fresh after a failover.

omlb.yaml — ha (node A)
ha:
  enabled: true
  virtual_router_id: 51           # MUST match on both nodes
  priority: 150                   # A = 150, B = 100
  interface: eth0
  virtual_ip: "10.0.0.100/24"     # the floating address clients use
  bind: "10.0.0.11:1821"          # this node's heartbeat address
  peers: ["10.0.0.12:1821"]       # the other node's heartbeat address
  advert_interval: 1s
  dead_multiplier: 3              # promote after ~3s of master silence
  preempt: true                   # reclaim MASTER when this node returns
Engineering note The HA role itself is a single atomic read on the request path; the election, heartbeats, and VIP moves all run in background tasks, off the hot path entirely.
Live configuration

Hot reload that doesn't lie about what's live

One YAML document, three layers: compiled-in defaults underneath, your file on top of them, and OMLB_* environment variables above both for the knobs that differ per machine. The schema rejects unknown keys outright — omlb --check fails on a typo'd key instead of quietly ignoring it, which is the difference between a config language and a suggestion box. While running, a file watcher applies edits atomically: routes, plugin chains, rewrites, cache policy, and retry/timeout budgets all swap without dropping a connection.

Structural edits — listeners, backend sets, algorithms, health checks, TLS, HA — don't hot-apply, on purpose. omlb refuses the reload and keeps serving the last good config, because half of a new topology is worse than all of the old one.

shell
omlb --check -c config.yaml                      # parse + cross-validate; non-zero exit on any error
omlb -c config.yaml                              # run
OMLB_LOG_LEVEL=debug OMLB_THREADS=8 omlb -c config.yaml   # OMLB_* overrides scalar knobs
# hot reload: just edit config.yaml — the config-watcher swaps routes/plugins/rewrites/cache/timeouts
# atomically, with zero dropped connections (structural changes are rejected, not half-applied)
Engineering note Everything reloadable — config, filter chains, TLS certs — sits behind an atomic pointer (ArcSwap), so applying an edit is one pointer swap. A request in flight keeps whichever config generation it started under; there's no moment of half-old, half-new.
Response caching

Skip the upstream entirely on a hit

Add cache: { ttl, max_body } to a route and cacheable GET responses start being answered straight from memory — no upstream round-trip, no filter chain, just bytes out. "Cacheable" is strict: a 200 with no Authorization on the way in and no Set-Cookie or no-store on the way out. The store is a bounded LRU sized by server.cache_max_entries, so it can't eat the host.

omlb.yaml — cache
server: { cache_max_entries: 10000 }      # bounded in-memory LRU; 0 disables
routes:
  - { name: static, match: { host: x.com, path_prefix: /assets/ }, upstream: app, cache: { ttl: 5m, max_body: 1048576 } }
L4 proxying

Plain TCP, with optional TLS termination

Not everything at the edge speaks HTTP. l4 entries forward raw TCP — an IRC daemon, a Postgres, anything — with two optional TLS twists: tls: true terminates client TLS using the same certificate store as the HTTPS listeners, and backend_tls: true dials onward to the backend over TLS.

omlb.yaml — l4
l4:
  - { name: irc-tls, address: "0.0.0.0:6697", backend: "ergo:6667", tls: true }         # terminate TLS -> plaintext backend
  - { name: pg,      address: "0.0.0.0:5432", backend: "db.internal:5432", timeout: 300s }
Managed pools

Self-healing backends, between compose and Kubernetes

For backends that are containers on the same host, a pool can manage them instead of merely balancing across them. Label the containers, point the pool's selector at the label, and omlb adopts them, holds the pool at replicas healthy instances, and replaces any container its own health checks give up on — including the hung-but-alive kind that a Docker restart: policy will happily leave running forever. Your compose file creates the containers exactly once (with restart: "no"); replacements are cloned from an adopted container's config, so there is no second copy of the image spec to keep in sync.

omlb.yaml — upstreams.app.managed
upstreams:
  app:
    algorithm: round_robin        # p2c is rejected for managed pools (see below)
    health_check: { kind: http, path: /healthz, host: app, interval: 2s }   # REQUIRED
    managed:
      provider: docker            # only value in v1
      selector: "omlb.pool=app"   # adopt containers with this Docker label
      port: 8080                  # container port to route to
      network: app_net            # which network's container IP to use
      replicas: 3
      recreate_grace: 30s         # MUST exceed a backend's startup+health time
      restart_backoff: 5s         # min gap between lifecycle actions
      max_actions_per_min: 6      # circuit breaker: stops a crash-loop storm
      min_healthy: 1
Honest caveats
  • Single host, adopt-only — no cross-node scheduling, no rollouts, no image/spec management.
  • Needs the Docker socket, which is root-equivalent — scope it with a socket proxy in production.
  • recreate_grace must exceed a backend's real startup+health time, or a slow-starting container gets churned.
  • power_of_two is rejected for managed pools (in-flight weighting needs static backends), and passive outlier detection doesn't apply — active health checks and the reconciler cover them instead.
Plugins

Native filters first, WASM when you need it

Six filters ship in-process, written in Rust against zero-copy accessors: rate limiting, basic auth, IP ACLs, CORS, header injection, request IDs. This is the always-on logic an edge actually runs, and at roughly 86% of unfiltered baseline throughput it's cheap enough to leave enabled everywhere it matters.

When you need custom logic, sandboxed WASM modules load and unload at runtime through the admin API against a stable, versioned ABI. You pick the isolation per plugin: per_request spins up a completely separate instance for every call, so the worst a broken plugin can do is fail its own request — while pooled recycles warm instances through a lock-free queue and gets sandboxed code surprisingly close to native speed (a pooled header-inject plugin holds ~61% of baseline versus ~24% per-request). The catch with pooling: a guest's heap survives between requests, so save it for stateless filters you trust.

It works, and the engineering is real — but it isn't the reason to run omlb today. Treat WASM as a serious experimental extension point; for anything always-on, the native filters are the answer.

Engineering note Under the hood it's wasmtime with a pooling allocator and pre-instantiated modules; epoch interruption reins in a plugin that won't return, at the cost of only its own request. Plugins declare which phases they hook, so an unused phase costs nothing, and config parses once per instance rather than once per request.
Web UI

A dashboard that costs nothing on the data plane

Flip webui.enabled and a dashboard comes up on its own listener — no second process to babysit, no npm build, no CDN assets phoning home. It reads the same atomic counters and control-plane snapshots the proxy already maintains, so watching it costs the data plane nothing; the only knob with a real per-request price is opt-in log capture.

What it shows: live SVG charts (req/s, response classes, active requests, cache hit rate, bans enforced) plus stat cards; a services view of pools → backends with ready/ejected/inflight/weight state and a routes → upstream → plugin resolutions view; an opt-in live log tail; and the effective merged config with forms to add a domain or a service.

It binds 127.0.0.1 deliberately — this thing can edit your running config. SSH-tunnel to it, or publish it through omlb itself behind the built-in basic_auth filter. Its edits respect the same boundary as hot reload: a new route onto an existing upstream applies immediately, while a new upstream is staged to an overlay file and waits for a graceful restart — and the UI tells you which of the two just happened.

omlb.yaml — webui
webui:
  enabled: true
  address: "127.0.0.1:9095"   # bind localhost; tunnel in or front via the proxy (see below)
  capture_logs: false         # opt-in live log tail — small per-request cost when on
Observability

Metrics and logs, not a black box

Metrics are Prometheus on a dedicated listener (127.0.0.1:9091 by default): requests and responses by class, upstream errors, backend ejections, cache hits and misses, plus the omlb_abuse_* family for the gate — bans applied, bans enforced, rate-limited requests, active bans. Logs come out pretty for humans, JSON for shippers, or native journald — pick per deployment.

The admin REST API (127.0.0.1:9090 by default) is the control plane: read status, drain or undrain a backend, trigger a reload, load or unload a plugin. The web UI is a client of this same API — anything it does, you can curl.