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" (L7: rules, cookie sticky) | "udp" (datagram)
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 = "source_ip", xff = true } # key = first X-Forwarded-For
# # IP (behind proxy; http mode)
# 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" }
# 2b) Behind a proxy? Key on the FIRST X-Forwarded-For IP instead
# (falls back to the peer IP when the header is missing; http mode)
sticky = { type = "source_ip", xff = true }| Aspect | source_ip | cookie |
|---|---|---|
| Selection basis | Client IP (rendezvous/HRW) — or the first X-Forwarded-For IP when xff = true | Cookie inserted by the LB |
| Mode | tcp & http (xff: http only) | 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 (mitigated by xff = true) | Clients without cookies (curl) do not stick |
Sticky by X-Forwarded-For — xff = true
When lbrust sits behind a reverse proxy (nginx, another LB, CDN…), every request
arrives from the proxy's IP. Plain source_ip would then see one single client
and pin all traffic to one server. With xff = true the sticky key
becomes the first IP listed in X-Forwarded-For — the original client
behind the proxy:
[[backend]]
name = "app"
balance = "roundrobin"
# key = 1st XFF entry; header missing/invalid → peer (TCP source) IP
sticky = { type = "source_ip", xff = true }| Aspect | Behavior |
|---|---|
| Key | First entry of X-Forwarded-For — 203.0.113.7, 10.0.0.1 → 203.0.113.7 (comma list, trimmed) |
| Fallback | Header absent or not a valid IP → peer (TCP source) IP, i.e. identical to xff = false |
| Mode | http only — TCP mode has no headers; validate rejects xff = true on a backend that no http frontend uses |
| HAProxy equivalent | stick on var(txn.clientip) where txn.clientip = src, overwritten by req.hdr_ip(X-Forwarded-For) if found |
| Trust | XFF is client-supplied unless your proxy overwrites it — enable only when clients cannot reach lbrust directly (spoofing would let them pick their own server) |
| Lifetime | Stateless: affinity holds as long as the server set is unchanged (HAProxy's stick-table … expire 30m has no equivalent — nothing expires here) |
198.51.100.10 → nginx → lbrust. lbrust sees the TCP peer
10.0.0.1 (nginx), but the request carries
X-Forwarded-For: 198.51.100.10 — the sticky hash uses
198.51.100.10, so each end-user stays on the same backend regardless of how
many requests go through the proxy.
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) | udp (probe datagram — see UDP) |
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
UDP load balancing
Set mode = "udp" on a frontend to balance datagrams — DNS, game
servers, VPN, syslog, custom protocols. Any port works; lbrust forwards each
flow to a backend and returns the replies.
[defaults]
mode = "tcp"
udp_idle_timeout = "30s" # flow closed after this long with no datagram
# in EITHER direction (min: any positive duration)
[[frontend]]
name = "dns-udp"
bind = ["*:53"] # or ["*:53/udp"] host:port — mode decides the protocol
mode = "udp" # tcp | http | udp
default_backend = "dns"
[[backend]]
name = "dns"
balance = "roundrobin" # roundrobin | leastconn | source
[[backend.server]]
addr = "10.0.0.53:53"
check = { type = "udp", interval = "2s", timeout = "1s",
rise = 2, fall = 3 } # probe datagram health checkHow a flow works
- The first datagram from a client endpoint (IP:port) picks a server with the backend's balancer — same client endpoint stays on the same server while the flow lives (natural stickiness for UDP protocols)
- Replies are returned to the exact client endpoint that sent the request
- The flow is removed after
udp_idle_timeoutwith no traffic in either direction, or immediately when the server dies - Failover: ICMP port unreachable (
ECONNREFUSED) from a dead server marks it failed (exponential backoff 5s→60s) and the next datagram creates a flow on a healthy server — no restart, no lost configuration - Rules (ACL) and
[frontend.tls]are HTTP-only and rejected in UDP mode (no DTLS yet)
| Key | Default | Meaning |
|---|---|---|
mode | tcp | "udp" on the frontend (or in [defaults]) |
udp_idle_timeout | 30s | Flow lifetime without datagrams (both directions) |
balance | roundrobin | Applied per new flow — source gives IP stickiness |
check.type = "udp" | — | Probe datagram: any reply = up; ICMP unreachable = down; silent + no ICMP = port open = up |
retries | 0 | Extra servers tried when creating a flow fails |
/api/stats
(udp_flows, udp_pkts_in/out) and Prometheus
(lb_udp_flows_active, lb_udp_pkts_in_total, …).
Access records carry "proto":"udp" with byte counters and the
flow duration — visible in the Logs page like TCP/HTTP records.
client ──datagram──▶ [lbrust :53, mode udp] ──▶ server A (flow 1)
▲ server B (flow 2 …)
└──reply────────── flow dibuang setelah udp_idle_timeoutAccess control & rate limit
Protect frontends at the connection level — before any proxy work happens. Applies to tcp, http and udp frontends alike.
[[frontend]]
name = "app_in"
bind = ["*:9000"]
default_backend = "app"
# ── IP access control (checked in this order) ──
# entries: "IP", "CIDR" (all ports), "IP:port", "CIDR:port",
# "IP:from-to" (inclusive), "[2001:db8::1]:443" (IPv6 + port)
deny = ["203.0.113.9:8080", "198.51.100.0/24:443"] # 1) blacklist — always rejected
allow = ["10.0.0.0/8", "10.0.0.5:443"] # 2) whitelist — if set, ONLY these
# (empty = everyone allowed)
# ── Rate limit: token bucket per frontend + per client IP ──
ratelimit = {
rate = 5000, # new connections/sec, all clients (0 = off)
burst = 10000, # bucket capacity (default = rate)
per_ip = 100, # new connections/sec per client IP (0 = off)
per_ip_burst = 200, # per-IP bucket capacity (default = per_ip)
}| Key | Meaning |
|---|---|
deny | IP/CIDR blacklist — checked first, wins over allow. Entries may carry a port: 203.0.113.9:8080, 198.51.100.0/24:443, 10.0.0.1:8000-9000 (inclusive range); IPv6 with port is bracketed ([2001:db8::1]:443). No port = all ports |
allow | IP/CIDR whitelist — when non-empty, only listed sources pass. Same entry syntax as deny; matched against the client's source IP and the port it connected to |
ratelimit.rate / burst | Global new-connection rate for this frontend (GCRA token bucket, lock-free across workers) |
ratelimit.per_ip / per_ip_burst | Per client-IP rate (sharded table, auto-purged when idle; a full table never blocks new IPs — global limit still applies) |
What happens on rejection
- http (no TLS): client receives
403 Forbidden(ACL) or429 Too Many RequestswithRetry-After: 1(rate limit) - tcp / udp / https: the connection (or datagram) is dropped silently
— counted in
acl_denied/rate_limited - Counters:
/api/stats→acl_denied,rate_limited; Prometheus →lb_acl_denied_total,lb_rate_limited_total - Dashboard shows them under Failed / rejected when active; the frontend
list shows
allow:N/deny:N/rl:N/sbadges
Manage it from the GUI — the Access page
No need to hand-edit TOML: the Access page edits the policy
of any frontend with a form — whitelist / blacklist chips (add &
remove IP, CIDR, IP:port,
IP:from-to entries), the four rate-limit fields, and an SNI
domain → backend table (tcp frontends):
- Validate & Apply rewrites only the selected frontend's
allow/deny/ratelimit/sni_routesviaPUT /api/access— comments and the rest of the config stay byte-identical (surgicaltoml_editrewrite) - Full validation runs first: a bad CIDR, unknown backend or
sni_routeson an http frontend is rejected with no changes - Applied by hot-reload (ACL lives in
FeCtx) — no restart, no dropped connections; the change is persisted to the config file so a later restart keeps it
SNI routing — many domains, one port (TLS passthrough)
Route TLS connections by the SNI hostname from the ClientHello — without terminating TLS (backend keeps its own certificate):
[[frontend]]
name = "tls_in"
bind = ["*:443"]
mode = "tcp" # sni_routes works in tcp mode only
default_backend = "be_default" # used when no SNI / no match
sni_routes = { "api.example.com" = "be_api",
"*.example.com" = "be_web" } # wildcard = subdomains onlyexample.com) does not match *.example.com.
Non-TLS traffic on the same port falls through to default_backend.
For HTTP-mode domains use host() rules instead (with
[frontend.tls] termination); host('*.example.com')
wildcards work there too. sni_routes is applied on
Apply (reload) — the listener itself doesn't change.
Firewall (WAF-lite)
Inspect HTTP requests before routing and answer 403
when a payload matches. Byte-scan only — no regex, no body buffering,
no new dependencies; conservative patterns chosen to avoid false
positives. Requires mode = "http".
[[frontend]]
name = "app_in"
bind = ["*:9000"]
mode = "http"
default_backend = "app"
# ── WAF-lite: presets + size limits ──
waf = {
enabled = true,
presets = ["sqli", "xss", "traversal", "bad_ua", "null_byte"],
max_url = "8KB", # path + query length (0 = no limit)
max_header = "16KB", # total request header bytes
max_body = "1MB", # Content-Length (0 = no limit)
}
# ── custom rules on top of the presets (case-insensitive substring) ──
waf_deny = [
{ where = "path", contains = "/.env" },
{ where = "query", contains = "etc/passwd" },
{ where = "header", contains = "union select" },
]| Key | Meaning |
|---|---|
waf.enabled | Master switch. A block with enabled = false keeps your presets/rules stored but inspection off |
waf.presets | Any subset of sqli, xss, traversal, bad_ua, null_byte. Empty = size limits + waf_deny only |
waf.max_url / max_header / max_body | Size limits (B/KB/MB/GB, 0 = off), checked before pattern scanning |
waf_deny | Custom rules: where = "path"|"query"|"header"|"all" + contains (case-insensitive). Needs the waf block |
Presets
traversal—../,..\including encoded forms (%2e%2e%2f,..%5c)sqli—' or ',' or 1=1,union select,sleep(,benchmark(,information_schema,drop table,xp_cmdshellin path, query or header valuesxss—<script,javascript:,onerror=,document.cookie,<svgbad_ua— missing/emptyUser-Agentor scanner fingerprints (sqlmap, nikto, nmap, masscan, dirbuster, wpscan, acunetix, zgrab, gobuster)null_byte—%00or a raw NUL byte in path, query or header
+/%20 → space in the query and %XX decoded for
space, quotes, <>(), / \ . = : — so
union+select and %3Cscript%3E are caught too.
Deliberately conservative: bodies are not buffered (chunked
requests without Content-Length can't be size-limited) and
double encoding (%252e) passes. WAF-lite is a first line of
defence — pair it with the Access ACL/rate limit, not a
replacement for a full WAF (ModSecurity/Coraza).
What happens on a block
- The client receives
403 Forbidden; the request never reaches the backend (counted inhttp_4xx) - The access log records the reason in
err:waf: sqli,waf: url_too_long,waf: bad_ua,waf: custom: /.env… - Counters:
/api/stats→waf_blocked; Prometheus →lb_waf_blocked_total. A WAF block is a deliberate rejection — it does not count asfailed - Size violations fire first and are the cheapest check:
url_too_long,header_too_large,body_too_large
Manage it from the GUI — the Firewall page
- Pick a frontend — non-http frontends show a hint and disable the form (use Access for IP ACL there)
- Master switch + five preset checkboxes, three size-limit fields, and
a custom deny table (
where+contains) - Validate & Apply rewrites only
waf/waf_denyof the selected frontend viaPUT /api/firewall— comments and the rest of the config stay byte-identical; applied by hot-reload, persisted to the config file - The Blocked card shows
waf_blockedsince process start
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
stdout = false # duplicate every access record to stdout
# (containers: docker/journald collect stdout)
syslog = "" # RFC5424 target: "/dev/log" (unix datagram)
# or UDP "host:port" e.g. "127.0.0.1:514"
[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 |
| stdout | log.stdout = true duplicates every access record line to stdout — pair with [log.file] enabled = false for container-only pipelines (ELK/Loki) |
| syslog | log.syslog = "/dev/log" (unix datagram, rsyslog/journald) or "host:port" UDP. RFC5424 framing <134>1 TS host lbrust PID - - {json} — the JSON record is the MSG. Failures counted in log_errors, never break proxying |
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
- Persistence (optional): set
monitor.history_path→ a JSONL point is appended everyhistory_interval(default 60s) and reloaded on startup, so the 1h/24h/7d charts survive restarts. Old points are trimmed pasthistory_retention_days(default 7) - Without a file, history lives in memory only (lost on restart) — export via Prometheus for long-term storage
[monitor]
# ... (see Resources & alerts)
history_path = "logs/metrics-history.jsonl" # "" = disabled (memory only)
history_interval = "60s" # min 1s — one JSON point per line
history_retention_days = 7 # trimmed on startup + every 6h# 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 |
| GET | /api/access | ACL policy of every frontend (allow/deny/ratelimit/sni_routes) + backend names |
| PUT | /api/access | Rewrite one frontend's policy surgically → validate → hot-reload (422 = rejected, config untouched) |
| GET | /api/firewall | WAF-lite policy of every frontend (waf + waf_deny) |
| PUT | /api/firewall | Rewrite one frontend's WAF policy surgically → validate → hot-reload (422 = rejected, config untouched) |
| 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.