Quickstart
Build, set an admin password, then run — three steps.
# 1) build (or use an existing release binary)
cargo build --release
# 2) create the admin password hash → paste it into the config
./target/release/lbrust hash-password
# output: $argon2id$... → put it in [admin] password_hash
# 3) validate, then run
./target/release/lbrust -c config/example.toml --test
./target/release/lbrust -c config/example.toml runhttp://127.0.0.1:9464 and sign in with that
password. Monitoring, Settings (config editor), Docs, and the Restart button live there.
| Command | Purpose |
|---|---|
lbrust run | Run the load balancer (default) |
lbrust check | Validate config then exit (exit 2 if invalid) |
lbrust hash-password | Create the argon2id hash for the admin |
-c FILE | Config path (default: lbrust.toml) |
-t / --test | Same as check |
--log-level LEVEL | Override log level: error|warn|info|debug|trace |
# signals
SIGHUP # reload config from file (no restart)
SIGINT/SIGTERM # graceful shutdown + connection drainConfig format
The config uses TOML with HAProxy-style concepts 1:1 — it is not HAProxy syntax. Each section is a table / array of tables:
[global] # process behavior: workers, maxconn, drain
[defaults] # defaults for frontends/backends
[[frontend]] # many allowed — each bind listens for traffic
[[backend]] # many allowed — server pool + balancer
[[backend.server]] # a server inside a backend
[log] # mode, level, escalation
[log.file] # THE log file: compact & full records, rotation + retention
[log.capture] # capture parameters for full mode
[monitor] # resource sampling, capacity alerts, webhook (hot-reload)
[monitor.thresholds] # CPU/mem/maxconn/disk/fd alert levels
[admin] # admin GUI/APIDurations and sizes use flexible strings:
| Type | Examples |
|---|---|
| Duration | "500ms" "5s" "2m" "1h30m" "14d" |
| Size | "512MB" "2GB" "1024" (bytes) |
lbrust --test and the Validate button in the GUI.
[global] & [defaults]
[global]
workers = 0 # 0 = automatic (use physical core count)
maxconn = 200000 # total active connection cap (admission control)
backlog = 4096 # listen queue — match it with somaxconn
drain_timeout = "30s" # time to drain active conns on shutdown/restart
[defaults]
mode = "tcp" # "tcp" | "http" (http = L7: rules, cookie sticky)
balance = "roundrobin" # roundrobin | leastconn | source
timeout_connect = "5s" # batas waktu connect ke backend
timeout_client = "60s" # idle timeout, client side
timeout_server = "60s" # idle timeout, backend side
retries = 1 # retry another server on connect failure| Key | Default | Notes |
|---|---|---|
workers | 0 (auto) | Thread-per-core, pinned, SO_REUSEPORT |
maxconn | 100.000 | Exceeding it → new connections are rejected (not queued) |
backlog | 4096 | Raise net.core.somaxconn on Linux |
retries | 0 (maks 10) | The failed server is excluded during retries |
balance | roundrobin | Smooth WRR (nginx algorithm) — even spread despite unequal weights |
Balance algorithms:
roundrobin— smooth weighted round-robin (nginx)leastconn— pick the server with the fewest active connections (good for long-lived traffic)source— rendezvous hashing (HRW) over the client IP: deterministic, stable across instances
[[frontend]]
[[frontend]]
name = "app_in"
bind = ["*:9000"] # "*:PORT" | "IP:PORT" | "[::]:PORT"
default_backend = "app"
# mode = "tcp" # override defaults.mode
# forceclose = true # force Connection: close (needed for per-request rules)
# rule = [ ... ] # see the Routing rules section
# TLS termination — see the SSL/TLS section
# [frontend.tls]
# crt = "/etc/lbrust/fullchain.pem"
# key = "/etc/lbrust/privkey.pem"| Key | Required | Description |
|---|---|---|
name | ✓ | Unique — used in logs & monitoring |
bind | ✓ | List of listen addresses; one frontend can have many binds |
default_backend | ✓ | Target backend when no rule matches |
mode | — | Inherits defaults.mode |
tls | — | Certificate block — see the SSL/TLS section |
[[backend]]
[[backend]]
name = "app"
balance = "roundrobin" # override defaults.balance
# sticky = { type = "source_ip", hash = "xxhash64" }
# sticky = { type = "cookie", name = "SRV", ttl = "30m",
# httponly = true, secure = true, path = "/" }
[[backend.server]]
addr = "10.0.0.1:8080" # IP:port atau hostname (DNS di-refresh tiap check)
weight = 2 # 1..=100 (default 1)
maxconn = 10000 # 0 = unlimited per server
# cookie = "s1" # sticky id (default: addr)
# disabled = true # present in config but disabled
[[backend.server]]
addr = "10.0.0.2:8080"
check = { type = "tcp", interval = "2s", timeout = "1s",
rise = 2, fall = 3 }
# check = { type = "http", path = "/healthz", ... }
# TLS ke backend (HAProxy: `server ... ssl ...`)
# ssl = { verify = false } # verify none — self-signed internal
# ssl = { verify = true, cafile = "/etc/ca.pem" } # verifikasi penuh + CA bundlessl = { ... } on a server and every connection to it
(proxy traffic and health checks) is wrapped in TLS. verify = false
accepts any certificate — the same semantics as HAProxy's ssl verify none,
suitable for internal services like CockroachDB. verify = true requires
cafile (PEM CA bundle) and validates the full chain.
ALPN offered is http/1.1.
addr is a hostname, resolution is refreshed automatically
every health check cycle. Resolution failure → the server is considered down until a check restores it.
Routing rules (ACL subset)
Rules pick a backend based on request conditions — they only apply in
mode = "http" (tcp mode has no concept of a request).
[[frontend]]
name = "web"
bind = ["*:80"]
default_backend = "app"
rule = [
{ if = "path_beg(\"/api\")", backend = "api" },
{ if = "path_end(\".png\")", backend = "static" },
{ if = "host(\"example.com\")", backend = "vhost" },
{ if = "method(\"POST\")", backend = "write" },
{ if = "hdr(\"x-beta\", \"1\")", backend = "beta" },
{ if = "not path_beg(\"/admin\")", backend = "public" },
]| Function | Example | Matches when… |
|---|---|---|
path_beg | path_beg("/api") | path starts with the string |
path_end | path_end(".jpg") | path ends with the string |
path | path("/health") | path equals exactly |
host | host("example.com") | Host header matches (case-insensitive) |
method | method("POST") | HTTP method (uppercased first) |
hdr | hdr("x-api-key", "abc") | header with an exact value |
not … | not path_beg("/x") | negation of a condition |
mode = "http" — first match wins, no match falls back to
default_backend. Rules on a tcp frontend are rejected
(tcp has no request to inspect).
Sticky sessions
Two modes — set them inside [[backend]]:
# 1) Cookie-based (http mode) — the LB hands the client a cookie
sticky = { type = "cookie", name = "SRV", ttl = "30m",
httponly = true, secure = true, path = "/" }
# 2) Source-IP hashing (tcp/http mode) — client = source IP
sticky = { type = "source_ip", hash = "xxhash64" }| Aspect | source_ip | cookie |
|---|---|---|
| Selection basis | Client IP (rendezvous/HRW) | Cookie inserted by the LB |
| Mode | tcp & http | http only |
| Stabilitas | Server dies → only clients on that IP move | Server dies → cookie clients are remapped |
| Across instances | ✓ consistent (deterministic hash) | ✓ (as long as the cookie algorithm matches) |
| Kelemahan | Many clients behind NAT pile onto one server | Clients without cookies (curl) do not stick |
Health checks
Active — the LB proactively tests servers every interval:
[[backend.server]]
addr = "10.0.0.1:8080"
check = { type = "tcp", interval = "2s", timeout = "1s",
rise = 2, fall = 3 }
# HTTP check — 2xx/3xx responses are considered healthy
check = { type = "http", path = "/healthz",
interval = "2s", timeout = "1s", rise = 2, fall = 3 }| Key | Default | Meaning |
|---|---|---|
type | tcp | tcp (connect only) | http (GET + status check) |
interval | 2s | Gap between checks (min 100ms) |
timeout | 2s | Time limit for one check |
rise | 2 | Consecutive successful checks → server marked UP |
fall | 3 | Consecutive failed checks → server marked DOWN |
Passive — no config needed, derived from real traffic:
- Connect failure from the proxy → the server counts as failed, then exponential backoff 5s → 10s → 20s → … max 60s (excluded from selection during backoff)
- Successful connect → failure counter resets
- The log records
backend UP/backend DOWNwhen rise/fall is reached
Logging & retention
[log]
level = "info" # error|warn|info|debug|trace
mode = "compact" # compact | full (togglable from the GUI at runtime)
escalate_on_error = true # errors are logged in FULL detail, even in compact mode
error_status_min = 500
format = "json" # json | text
[log.file] # ONE file for both compact and full records
enabled = true
path = "logs/access.log"
rotate = "daily" # hourly | daily | size
# max_size = "500MB" # used when rotate = "size"
retention_days = 14 # delete archives older than this…
retention_size = "2GB" # …AND/OR when the total exceeds this
compress = true # gzip archives
queue_capacity = 8192 # queue full → drop + counter, proxy keeps running
[log.capture] # applies when a FULL record is written
max_body_bytes = "8KB" # body capture limit
capture_request_body = true
capture_response_body = false
redact_headers = ["authorization", "proxy-authorization", "x-api-key"]
body_content_types = ["application/json",
"application/x-www-form-urlencoded", "text/*"]
sample_rate = 1.0 # 0..1 — sampling rate for full modemode = "compact" writes one
short line per request; mode = "full" writes one detailed
line (headers, cookies, bodies) for each request instead — never both.
Escalated errors always get the full record. The old [log.full]
section is still accepted for compatibility but ignored.
| Behavior | Explanation |
|---|---|
| Non-blocking | Producers use a bounded queue — full → drop + counter (see Log dropped on the dashboard). The data plane never waits on disk. |
| Rotasi | hourly/daily/size; archives are named access-YYYYMMDD-HHMMSS.log.gz |
| Retensi | retention_days AND retention_size — whichever hits first triggers deletion; swept every 10 min + at rotation |
| Escalasi | Even in compact mode, failed requests (connect failure/timeout, code ≥ error_status_min) are written with full detail (headers + payload preview) |
| Mode runtime | Toggle Compact/Full from the dashboard or the Logs page — applied instantly and saved to the config file, so it survives restart |
| Log Viewer | The Logs page tails the last 1000 lines live (from memory — works even if file.enabled = false), with filter, follow and click-to-expand raw JSON |
Resources & alerts
The Resources page samples the server (CPU, RAM, disk, network), the lbrust process (CPU, RSS, threads, open files vs ulimit) and the data plane (active/maxconn, rejected rate) — then evaluates capacity alerts and gives upgrade advice.
[monitor]
enabled = true
interval = "5s" # sampling rate (min 500ms) — also the chart resolution
debounce = 3 # consecutive samples before an alert fires/resolve
# (anti-flap: 3 × 5s = 15s sustained)
[monitor.thresholds] # all percent 0..=100 — customize freely
cpu_warn = 80 # system CPU ≥ 80% → warn, ≥ cpu_crit → critical
cpu_crit = 95
mem_warn = 85 # used memory %
mem_crit = 95
maxconn_warn = 80 # active connections as % of global.maxconn
maxconn_crit = 95
disk_warn = 10 # log volume FREE space ≤ 10% → warn (inverted!)
disk_crit = 5 # …≤ 5% → critical
fd_warn = 80 # open files as % of ulimit -n
fd_crit = 95
reject_warn = 60 # rejected connections per MINUTE (not percent)
reject_crit = 600
[monitor.webhook] # POST JSON on alert fire/resolve
enabled = false
url = "https://hooks.slack.com/services/XXX" # http:// or https://
min_interval = "5m" # cooldown per alert (escalation always sends)
timeout = "5s"SIGHUP) and they take effect within one interval,
no restart.
| Alert | Fires when… | Suggested action |
|---|---|---|
| cpu | system CPU sustained ≥ warn/crit | add vCPU or scale out to more LB nodes |
| mem | memory used ≥ warn/crit | add RAM; reduce log capture buffers |
| maxconn | active/maxconn ≥ warn/crit | raise global.maxconn (+ ulimit) or add an LB node |
| disk | log volume free ≤ warn/crit | shorten retention or move [log.file] |
| fd | open files ≥ % of ulimit | raise LimitNOFILE / ulimit -n |
| reject | rejected/min ≥ warn/crit | maxconn full — raise it or add capacity |
Webhook payload
One JSON body works for Slack (text), Discord (content)
and generic receivers:
{
"text": "[lbrust] crit maxconn: Connections CRITICAL: 96.2% of maxconn (96200/100000) — …",
"content": "…same…",
"source": "lbrust",
"event": "fire", // or "resolve"
"level": "crit", // warn | crit | ok
"alert": "maxconn",
"message": "…", "advice": "…",
"value": 96.2, "threshold": 95,
"ts": 1762500000, "host": "lb-01"
}Analytics & history
- 1 hour at full sampling resolution (fine ring buffer)
- 7 days at 1-minute resolution (coarse ring) — 24h view filters to the last day
- Charts: CPU/memory %, connections vs maxconn, bandwidth rx/tx
- Capacity advice: peaks of the last hour + headroom percentage
- History lives in memory only (lost on restart) — export via Prometheus for long-term storage
# prometheus resource metrics
lb_sys_cpu_percent lb_sys_mem_used_bytes lb_proc_rss_bytes
lb_proc_open_fds lb_proc_fd_limit lb_maxconn_utilization_percent
lb_alerts_active lb_alert{id="cpu",level="warn"} 1SSL / TLS
[frontend.tls] on an
http frontend: crt accepts a combined PEM file or a directory
of PEMs (HAProxy style), with multi-SNI selection — exact SAN match →
wildcard *.domain → first cert as default. Patterns A/B below remain
valid alternatives (external terminator, end-to-end passthrough).
Pattern A — TLS terminator in front of lbrust (recommended)
Encryption is terminated in a separate process (nginx/caddy/haproxy); lbrust stays L4 behind it. The most battle-tested option for production.
internet ──TLS──▶ [terminator :443] ──HTTP/TCP──▶ [lbrust :9000] ──▶ backend# nginx — TLS terminator in front of lbrust
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:9000; # ← lbrust bind
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# redirect HTTP → HTTPS
server {
listen 80;
server_name example.com;
return 301 https://$host$request_uri;
}# free certificates with Let's Encrypt (certbot)
sudo certbot --nginx -d example.com
# or standalone if nginx is not running yet:
sudo certbot certonly --standalone -d example.com
# auto-renewal (enabled by default): certbot renew --dry-runPattern B — end-to-end TLS (lbrust just passes bytes through)
The connection stays encrypted from client to backend; lbrust forwards bytes as-is (tcp mode). Nobody reads the payload — ideal when the backend manages its own TLS.
internet ──TLS──▶ [lbrust :443, mode tcp] ──TLS──▶ [backend TLS sendiri][[frontend]]
name = "tls_passthrough"
bind = ["*:443"]
default_backend = "secure_app" # backend must terminate TLS itselfNative TLS termination + multi-SNI
Certificate selection per connection: exact SAN → wildcard → first cert (default).
[[frontend]]
name = "web"
bind = ["*:443"]
default_backend = "app"
[frontend.tls]
crt = "/etc/lbrust/fullchain.pem" # cert chain + key (combined, HAProxy style)
# key = "/etc/lbrust/privkey.pem" # optional if the .crt file already includes the key# combine cert + key (HAProxy `crt` style)
cat fullchain.pem privkey.pem > /etc/lbrust/fullchain.pem
chmod 600 /etc/lbrust/fullchain.pemTLS security checklist
- Use TLS ≥ 1.2 (ideally 1.3 only) with modern cipher suites
- Enable HSTS once HTTPS is stable:
Strict-Transport-Security: max-age=31536000; includeSubDomains - Secure the key: root-owned, mode
600, never in a repo - Watch expiry — certbot warns 30 days before it lapses
- In lbrust: set
secure = trueon the sticky cookie once behind HTTPS
Reload vs restart
Three ways to apply changes:
| Method | Scope | When |
|---|---|---|
| Apply (reload) | No connection drops | Settings button / SIGHUP |
| Restart | Everything, including restart-only keys | Restart button in Settings (drain → re-exec) |
| Start ulang manual | Everything | systemctl restart lbrust / run the binary again |
Hot-reloadable while running
- Backends, servers, weights, balance, sticky
- Frontend rules, default_backend
- Timeout, retries, drain_timeout
- Health checks (old checkers stop, new ones start — no duplicates)
- Compact/full log mode (also written to the config file, so it survives restart)
[monitor]— thresholds, interval, debounce & webhook (re-read every sample)
Requires a restart
bind/modeon a frontend — listeners must be recreated[log.file]— path & rotation open at startup[admin]— bind & admin password[global]workers/maxconn
Admin & security
[admin]
enabled = true
bind = "127.0.0.1:9464" # localhost only — never expose it directly
session_ttl = "8h"
password_hash = "$argon2id$v=19$m=19456,t=2,p=1$..." # dari `lbrust hash-password`
login_max_attempts = 5 # failed attempts…
login_window = "60s" # …within this window → blocked| Layer | Mechanism |
|---|---|
| Password | argon2id — plaintext is never stored |
| Session | 256-bit random token in an HttpOnly + SameSite=Strict cookie, 8-hour TTL |
| CSRF | All mutating requests require an x-csrf-token header (double-checked against the session) |
| Brute-force | Per-IP rate limit: 5 failures / 60 seconds → 429 |
| Transport | Binds localhost — for remote access, tunnel over SSH/VPN |
# reach the admin UI from another machine via SSH tunnel (safe, no open port)
ssh -L 9464:127.0.0.1:9464 user@lb-server
# then open http://127.0.0.1:9464 locallyEndpoint
| Method | Path | Purpose |
|---|---|---|
| POST | /api/login | Login (publik) |
| POST | /api/logout | Logout |
| GET | /api/session | Check session + CSRF token |
| GET | /api/stats | Real-time statistics |
| GET/PUT | /api/config | Read / apply config |
| POST | /api/config/validate | Validate with no side effects |
| POST | /api/log-mode | Toggle compact/full (saved to config file) |
| GET | /api/logs | Log tail: ?after=<seq>&limit=<n> — last lines from the in-memory ring buffer |
| GET | /api/resources | Resource snapshot: server + process + active alerts + capacity advice |
| GET | /api/resources/history | Chart history: ?window=fine|coarse&max=<n> (1h full-res / 7d per-minute) |
| POST | /api/restart | Drain + re-exec the process |
| GET | /metrics | Prometheus (public — localhost bind) |
| GET | /healthz | Health probe (publik) |
Linux tuning (production)
# /etc/sysctl.d/99-lbrust.conf — lalu: sysctl --system
# listen queue — match it with global.backlog
net.core.somaxconn = 65535
net.core.netdev_max_backlog = 65535
# ephemeral ports for backend connections (the LB opens many)
net.ipv4.ip_local_port_range = 1024 65535
net.ipv4.tcp_tw_reuse = 1
# buffer TCP
net.core.rmem_max = 16777216
net.core.wmem_max = 16777216
net.ipv4.tcp_rmem = 4096 87380 16777216
net.ipv4.tcp_wmem = 4096 65536 16777216
# file descriptors (the LB holds thousands of sockets)
fs.file-max = 2097152
fs.nr_open = 2097152# per-process limit (systemd: add it to the unit file)
ulimit -n 1048576
# systemd unit:
# LimitNOFILE=1048576
# verify after start
cat /proc/$(pgrep -f 'lbrust run')/limits | grep 'open files'FAQ & troubleshooting
Admin login says "admin password is not set"
admin.password_hash is still empty. Create the hash, then paste it:
lbrust hash-password
# → $argon2id$... tempel ke [admin] password_hash di configDoes host() match subdomains (wildcard)?
No — host() is an exact match (faithful to HAProxy's
hdr(host) -i); the port is ignored, so
example.com:8443 still matches host('example.com').
For subdomains add explicit rules, e.g.
host('api.example.com') or path_beg('/api').
Wildcards do apply to TLS certificates (*.example.com SAN),
just not to routing rules.
HTTPS works but the browser warns about the certificate
Self-signed certificates are for testing only — issue a real one (certbot / Let's Encrypt) or add your internal CA to the trust store. The SSL/TLS section shows the config and checklist.
How do I connect to a backend over TLS (e.g. CockroachDB)?
Add ssl = { verify = false } to the server — every connection to it
(traffic and health checks) is then wrapped in TLS. This matches HAProxy's
server ... ssl verify none, which is the common setup for internal
self-signed services:
addr = "127.0.0.1:26250" + ssl = { verify = false }.
For public endpoints use ssl = { verify = true, cafile = "/path/ca.pem" }
to validate the certificate chain. See the [[backend]] section.
Bind fails: Address already in use
Another process owns the port. Check with: lsof -i :9000. For a
zero-downtime move from old → new port, bind both temporarily (SO_REUSEPORT
is already enabled in lbrust).
Dashboard shows Log dropped climbing
The log queue is full (slow disk or heavy traffic) — the data plane keeps
running and the newest lines are dropped. Raise queue_capacity, avoid
mode = "full", or move path to a faster disk.
All servers are DOWN even though they are alive
Check the log for backend DOWN: fall may have been reached
(verify check.path for http checks), DNS hostname resolution may be
failing, or passive backoff is active after repeated connect errors.
Apply says "requires a process restart"
The change touches bind/mode/log/admin — use the Restart button
(drains first, then re-execs); SIGHUP is not enough here.
Admin session is gone after a restart
Expected — sessions live in process memory. Sign in again with the same password (the hash comes from the config file and does not change).
How do I measure performance?
# example: 200 connections, 30 seconds, target through lbrust
wrk -t8 -c200 -d30s --latency http://127.0.0.1:9000/
# or for pure L4:
oha -c 200 -z 30s http://127.0.0.1:9000/Compare directly against HAProxy on the same machine & configuration.