Traffic Overview

Real-time metrics from the data plane

mode: compact 0d
Active connections
0
sessions in flight right now
Requests / second
0
inbound throughput
Accepted
0
total connections accepted
Failed / rejected
0 / 0
errors & maxconn exhausted
Bytes in / out
0 B / 0 B
total traffic both directions
Log dropped / error
0 / 0
queue & disk writer
UDP flows
0
0 datagrams in/out

Traffic — last 60 points

Req/sec Active conns

Log mode

Full captures headers, cookies & bodies. Automatic escalation on errors stays active in compact mode.

Frontends

    Backend pool

    active health checks · rise/fall
    BackendServerStatus WeightSticky ID ActiveTotal ErrorBytes in/out Check

    Log Viewer

    Live tail · last 1000 lines in memory · same file as disk log

    mode: compact 0 lines 0 dropped

    Access log

    Waiting for traffic…
    click a line to expand the raw record live

    Resources & Capacity

    Server + LB process usage · analytics · upgrade advice

    healthy — never

    Alerts & capacity advice

    0 active
      Recent events

        Server

        —
        CPU
        —%
        load avg —
        Memory
        —%
        — of —
        Disk (log volume)
        —% free
        — available
        Network I/O
        —↓ —↑
        rx / tx per second (excl. loopback)

        lbrust process

        pid —
        Process CPU
        —%
        of total system capacity
        RSS memory
        —
        resident set size
        Threads
        —
        worker + helper threads
        Open files
        —
        vs ulimit -n

        Connection capacity

        active vs maxconn
        0%
        Active0
        maxconn0
        Rejected (total)0
        Rejected / min0
        Headroom100%

        Analytics

        CPU % Memory % Connections Bandwidth
        CPU / Memory (%)
        Connections vs maxconn
        Bandwidth (B/s)
        History accumulates while lbrust runs — 1 hour at full resolution, 7 days at 1-minute resolution.

        Access Control

        IP whitelist · blacklist · rate limit · SNI routing — hot-reload, no restart

        Frontend

        —
        Edits are full-replace for allow, deny, ratelimit and sni_routes of the selected frontend — everything else in the config stays untouched (comments included). Press Validate & Apply: the ACL is rebuilt in memory (hot-reload), no restart and no dropped connections.

        Allow — whitelist

        0 entries
        When non-empty, only these sources may connect (checked after deny). Empty = everyone allowed. Syntax per entry: IP or CIDR (all ports), IP:port, CIDR:port, IP:from-to (inclusive range); IPv6 with a port must be bracketed ([2001:db8::1]:443).

        Deny — blacklist

        0 entries
        Checked first — always wins over allow. Same syntax as allow: IP/CIDR (all ports), :port, :from-to, bracketed IPv6 with port. The port is the one the client connected to on this frontend. http answers 403; tcp/udp drop silently.

        Rate limit

        off
        GCRA token bucket — new connections per second; 0 = off. Global bucket per frontend (lock-free across workers) + per client-IP bucket (auto-purged). http answers 429 with Retry-After.

        SNI routes TLS passthrough

        —
        DomainBackend
        Route by the TLS ClientHello SNI without terminating TLS (backend keeps its certificate). Exact match wins over wildcard; the apex does not match *.domain; non-TLS traffic falls to the default backend. mode = "tcp" only.

        Firewall

        WAF-lite request inspection — SQLi · XSS · traversal · bad UA · size limits

        Frontend

        —
        WAF-lite inspects every request before routing and answers 403 with the reason in the access log (err = waf: …). Byte-scan only — no regex, conservative patterns to avoid false positives. Edits are full-replace for waf and waf_deny; everything else in the config stays untouched (comments included). Hot-reload, no restart.
        WAF needs mode = "http" — this frontend runs in mode. TCP/UDP carry no HTTP payload to inspect; use Access (IP ACL + rate limit) for those.

        Presets

        —
        Matched case-insensitively after light normalization (+/%20 → space, common %XX decoded), so union+select and %3Cscript%3E are caught too. Uncheck everything to run limits + custom rules only.

        Size limits

        —
        Accepts B, KB, MB, GB (e.g. 512KB) — 0 = no limit. Checked before pattern scanning, so oversized requests never reach the backend. Chunked bodies without Content-Length are not limited.

        Custom deny rules

        0 rules
        WhereContains
        Case-insensitive substring match on the selected part of the request, checked after the presets. where = "all" scans path, query and header values. Blocked requests log waf: custom: <rule>. Requires the WAF switch above.

        Blocked

        since start
        0
        requests answered 403 by the WAF
        Prometheus counter lb_waf_blocked_total; each hit is written to the access log with its reason (waf: sqli, waf: url_too_long, waf: custom: /.env…).

        Config Editor

        Edit live · validate · apply without restart

        lbrust.toml TOML
        Tab indent Ctrl+/ comment Ctrl+Space autocompletion Ctrl+F cari Esc leave editor
        Perubahan bind/mode frontend, [log.file] dan [admin] butuh restart proses — sisanya diterapkan langsung.

        Documentation

        Complete lbrust configuration guide — from basics to SSL & tuning

        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 run
        GUI: open http://127.0.0.1:9464 and sign in with that password. Monitoring, Settings (config editor), Docs, and the Restart button live there.
        CommandPurpose
        lbrust runRun the load balancer (default)
        lbrust checkValidate config then exit (exit 2 if invalid)
        lbrust hash-passwordCreate the argon2id hash for the admin
        -c FILEConfig path (default: lbrust.toml)
        -t / --testSame as check
        --log-level LEVELOverride log level: error|warn|info|debug|trace
        # signals
        SIGHUP          # reload config from file (no restart)
        SIGINT/SIGTERM  # graceful shutdown + connection drain

        Config 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/API

        Durations and sizes use flexible strings:

        TypeExamples
        Duration"500ms" "5s" "2m" "1h30m" "14d"
        Size"512MB" "2GB" "1024" (bytes)
        Validation always shows the complete list of errors at once — both via 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
        KeyDefaultNotes
        workers0 (auto)Thread-per-core, pinned, SO_REUSEPORT
        maxconn100.000Exceeding it → new connections are rejected (not queued)
        backlog4096Raise net.core.somaxconn on Linux
        retries0 (maks 10)The failed server is excluded during retries
        balanceroundrobinSmooth 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"
        KeyRequiredDescription
        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 bundle
        Backend TLS: set ssl = { ... } 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.
        DNS: when 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" },
        ]
        FunctionExampleMatches when…
        path_begpath_beg("/api")path starts with the string
        path_endpath_end(".jpg")path ends with the string
        pathpath("/health")path equals exactly
        hosthost("example.com")Host header matches (case-insensitive)
        methodmethod("POST")HTTP method (uppercased first)
        hdrhdr("x-api-key", "abc")header with an exact value
        not …not path_beg("/x")negation of a condition
        Status: implemented. Rules are evaluated per request in 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 }
        Aspectsource_ipcookie
        Selection basisClient IP (rendezvous/HRW) — or the first X-Forwarded-For IP when xff = trueCookie inserted by the LB
        Modetcp & http (xff: http only)http only
        StabilitasServer dies → only clients on that IP moveServer dies → cookie clients are remapped
        Across instances✓ consistent (deterministic hash)✓ (as long as the cookie algorithm matches)
        KelemahanMany clients behind NAT pile onto one server (mitigated by xff = true)Clients without cookies (curl) do not stick
        HRW (Rendezvous): score = hash(server_id, client_ip); the highest-scoring server wins. When one server disappears, only the clients mapped to it move — minimal disruption.

        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 }
        AspectBehavior
        KeyFirst entry of X-Forwarded-For — 203.0.113.7, 10.0.0.1 → 203.0.113.7 (comma list, trimmed)
        FallbackHeader absent or not a valid IP → peer (TCP source) IP, i.e. identical to xff = false
        Modehttp only — TCP mode has no headers; validate rejects xff = true on a backend that no http frontend uses
        HAProxy equivalentstick on var(txn.clientip) where txn.clientip = src, overwritten by req.hdr_ip(X-Forwarded-For) if found
        TrustXFF is client-supplied unless your proxy overwrites it — enable only when clients cannot reach lbrust directly (spoofing would let them pick their own server)
        LifetimeStateless: affinity holds as long as the server set is unchanged (HAProxy's stick-table … expire 30m has no equivalent — nothing expires here)
        Example: client 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 }
        KeyDefaultMeaning
        typetcptcp (connect only) | http (GET + status check) | udp (probe datagram — see UDP)
        interval2sGap between checks (min 100ms)
        timeout2sTime limit for one check
        rise2Consecutive successful checks → server marked UP
        fall3Consecutive 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 DOWN when 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 check

        How 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_timeout with 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)
        KeyDefaultMeaning
        modetcp"udp" on the frontend (or in [defaults])
        udp_idle_timeout30sFlow lifetime without datagrams (both directions)
        balanceroundrobinApplied per new flow — source gives IP stickiness
        check.type = "udp"—Probe datagram: any reply = up; ICMP unreachable = down; silent + no ICMP = port open = up
        retries0Extra servers tried when creating a flow fails
        Stats & logs. UDP flows appear on the dashboard (UDP flows card), in /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_timeout

        Access 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)
        }
        KeyMeaning
        denyIP/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
        allowIP/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 / burstGlobal new-connection rate for this frontend (GCRA token bucket, lock-free across workers)
        ratelimit.per_ip / per_ip_burstPer 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) or 429 Too Many Requests with Retry-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/s badges

        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_routes via PUT /api/access — comments and the rest of the config stay byte-identical (surgical toml_edit rewrite)
        • Full validation runs first: a bad CIDR, unknown backend or sni_routes on 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 only
        Notes. Exact match wins over wildcard; the apex domain (example.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" },
        ]
        KeyMeaning
        waf.enabledMaster switch. A block with enabled = false keeps your presets/rules stored but inspection off
        waf.presetsAny subset of sqli, xss, traversal, bad_ua, null_byte. Empty = size limits + waf_deny only
        waf.max_url / max_header / max_bodySize limits (B/KB/MB/GB, 0 = off), checked before pattern scanning
        waf_denyCustom 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_cmdshell in path, query or header values
        • xss — <script, javascript:, onerror=, document.cookie, <svg
        • bad_ua — missing/empty User-Agent or scanner fingerprints (sqlmap, nikto, nmap, masscan, dirbuster, wpscan, acunetix, zgrab, gobuster)
        • null_byte — %00 or a raw NUL byte in path, query or header
        Notes. Matching is case-insensitive after light normalization: +/%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 in http_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 as failed
        • 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_deny of the selected frontend via PUT /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_blocked since 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 mode
        One file, two detail levels. mode = "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.
        BehaviorExplanation
        Non-blockingProducers use a bounded queue — full → drop + counter (see Log dropped on the dashboard). The data plane never waits on disk.
        Rotasihourly/daily/size; archives are named access-YYYYMMDD-HHMMSS.log.gz
        Retensiretention_days AND retention_size — whichever hits first triggers deletion; swept every 10 min + at rotation
        EscalasiEven in compact mode, failed requests (connect failure/timeout, code ≥ error_status_min) are written with full detail (headers + payload preview)
        Mode runtimeToggle Compact/Full from the dashboard or the Logs page — applied instantly and saved to the config file, so it survives restart
        Log ViewerThe 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
        stdoutlog.stdout = true duplicates every access record line to stdout — pair with [log.file] enabled = false for container-only pipelines (ELK/Loki)
        sysloglog.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"
        Everything is hot-reloadable. Thresholds, interval, debounce and the webhook are re-read from the config on every sample — apply changes from Settings (or SIGHUP) and they take effect within one interval, no restart.
        AlertFires when…Suggested action
        cpusystem CPU sustained ≥ warn/critadd vCPU or scale out to more LB nodes
        memmemory used ≥ warn/critadd RAM; reduce log capture buffers
        maxconnactive/maxconn ≥ warn/critraise global.maxconn (+ ulimit) or add an LB node
        disklog volume free ≤ warn/critshorten retention or move [log.file]
        fdopen files ≥ % of ulimitraise LimitNOFILE / ulimit -n
        rejectrejected/min ≥ warn/critmaxconn 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 every history_interval (default 60s) and reloaded on startup, so the 1h/24h/7d charts survive restarts. Old points are trimmed past history_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"} 1

        SSL / TLS

        Native TLS termination is built in. Set [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-run

        Pattern 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 itself

        Native 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.pem

        TLS 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 = true on the sticky cookie once behind HTTPS

        Reload vs restart

        Three ways to apply changes:

        MethodScopeWhen
        Apply (reload)No connection dropsSettings button / SIGHUP
        RestartEverything, including restart-only keysRestart button in Settings (drain → re-exec)
        Start ulang manualEverythingsystemctl 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 / mode on a frontend — listeners must be recreated
        • [log.file] — path & rotation open at startup
        • [admin] — bind & admin password
        • [global] workers/maxconn
        Applying a restart-only change is rejected with a clear message ("requires a process restart") — use the Restart button. Restart drains first (active connections get time to finish), then re-execs the binary with the latest config from file.

        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
        LayerMechanism
        Passwordargon2id — plaintext is never stored
        Session256-bit random token in an HttpOnly + SameSite=Strict cookie, 8-hour TTL
        CSRFAll mutating requests require an x-csrf-token header (double-checked against the session)
        Brute-forcePer-IP rate limit: 5 failures / 60 seconds → 429
        TransportBinds 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 locally

        Endpoint

        MethodPathPurpose
        POST/api/loginLogin (publik)
        POST/api/logoutLogout
        GET/api/sessionCheck session + CSRF token
        GET/api/statsReal-time statistics
        GET/PUT/api/configRead / apply config
        POST/api/config/validateValidate with no side effects
        GET/api/accessACL policy of every frontend (allow/deny/ratelimit/sni_routes) + backend names
        PUT/api/accessRewrite one frontend's policy surgically → validate → hot-reload (422 = rejected, config untouched)
        GET/api/firewallWAF-lite policy of every frontend (waf + waf_deny)
        PUT/api/firewallRewrite one frontend's WAF policy surgically → validate → hot-reload (422 = rejected, config untouched)
        POST/api/log-modeToggle compact/full (saved to config file)
        GET/api/logsLog tail: ?after=<seq>&limit=<n> — last lines from the in-memory ring buffer
        GET/api/resourcesResource snapshot: server + process + active alerts + capacity advice
        GET/api/resources/historyChart history: ?window=fine|coarse&max=<n> (1h full-res / 7d per-minute)
        POST/api/restartDrain + re-exec the process
        GET/metricsPrometheus (public — localhost bind)
        GET/healthzHealth 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'
        Benchmark note: a "10 million req/sec" claim is not realistic for a single process on 4–8 vCPUs (HAProxy on 64 cores reaches ≈ 2M HTTP RPS). lbrust strategy: single first → measure (wrk2/oha) → scale horizontally. Realistic 4–8 vCPU numbers: ~25k–60k HTTP RPS, higher for pure L4 TCP.

        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 config
        Does 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.

        Restarting lbrust…

        contacting process…

        Sending restart command
        Draining active connections
        Waiting for the new process
        Reloading console