Skip to content

Configuration Reference

Every config value in OpenShield-L7, with its YAML path, type, default, and semantics. Field names are exact snake_case names from the config model (crates/osc-core/src/config.rs); defaults on this page match that source.

File layout & parser rules

OpenShield-L7 reads configuration from a project root directory (--root, default .):

text
<root>/config.yaml          -> global config (one file)
<root>/sites.d/<name>.yaml  -> site config  (one file per proxied site)
<root>/data/                -> runtime state (quota counters, snapshots)
  • Every field is optional at the parser level — defaults below fill in. A site file with only hostnames + origin.url is valid.
  • Unknown fields are ignored (forward compatibility — safe binary downgrades).
  • Validation checks semantics and collects all problems into one error list instead of failing on the first. Run openshield-l7 validate to check every file without starting the proxy.
  • Both file types hot-reload; see Hot Reload.

1. Global config (config.yaml)

Top level

YAML pathTypeDefaultSemantics
listen_httplist of ip:port["0.0.0.0:80"]Addresses that terminate cleartext HTTP. All are hostname-routed.
listen_httpslist of ip:port["0.0.0.0:443"]Addresses that terminate TLS. SNI-routed to per-site certificates.
adminmapsee belowAdmin API listener and tokens.
data_dirstring"data"Directory for runtime state (quota counters, challenge secret, snapshots). Relative to --root unless absolute.
sites_dirstring"sites.d"Directory holding one <site>.yaml per proxied site. Relative to --root unless absolute.
global_aclmapemptyIP lists evaluated before any per-site logic. See ACL semantics.
eventsmapsee belowTelemetry bus tuning.
log_levelstring"info"Log verbosity: error / warn / info / debug / trace.

When to change: listen_http/listen_https when running behind another terminator or on non-standard ports; data_dir/sites_dir to relocate state; log_level to debug when diagnosing.

Common mistakes:

  • Listen and directory changes apply to new connections / next restart — hot reload picks up the file but does not rebind sockets or move directories. Restart the process for those.
  • RUST_LOG overrides log_level — if the env var is set, the config value is ignored.
  • Privileged ports (80/443) need root or CAP_NET_BIND_SERVICE; validation passes regardless — the failure appears at bind time.
yaml
listen_http:
  - "0.0.0.0:80"
listen_https:
  - "0.0.0.0:443"
data_dir: "data"
sites_dir: "sites.d"
log_level: "info"

admin

YAML pathTypeDefaultSemantics
admin.listenip:port"127.0.0.1:9090"Bind address for the admin API. Keep it on loopback unless you know why not.
admin.tokenslist of token maps[]At least one token is required.
admin.allow_hostslist of strings[]If non-empty, the admin API additionally requires the request's Host header to be in this list (defense in depth when the API is exposed beyond loopback). Checked before token auth.

Each token entry:

YAML pathTypeDefaultSemantics
admin.tokens[].namestringFree-form label (used in logs, never the secret).
admin.tokens[].tokenstringThe bearer secret. Minimum 16 characters.
admin.tokens[].roleenumoperatoradmin = full control incl. global config/tokens; operator = manage sites/bans/reload; readonly = GET endpoints only.

When to change: add a readonly token for dashboards; set allow_hosts if you must bind the API beyond loopback (prefer an SSH tunnel instead).

Common mistakes: shipping a token under 16 chars (validation fails); exposing the API on a public interface without allow_hosts; storing tokens in browser localStorage for a dashboard (XSS-readable — keep them server-side).

yaml
admin:
  listen: "127.0.0.1:9090"
  allow_hosts: ["127.0.0.1:9090", "localhost:9090"]
  tokens:
    - name: root
      token: "9f2c1f7e-38c4-4c1e-9b2a-7d5e6a01c3f2"
      role: admin
    - name: dashboard
      token: "b74d0e55-1aa2-4f0e-8c9d-2e6f7b48d5aa"
      role: readonly

Generate tokens with openshield-l7 gen-token --name <n> — it prints the token plus a paste-ready YAML snippet. Tokens are re-read from the live config on every request, so rotation (via PUT /api/v1/global or a file edit) is instant for new requests.

global_acl

YAML pathTypeDefaultSemantics
global_acl.blacklistlist of IP/CIDR[]Dropped before any site logic runs.
global_acl.whitelistlist of IP/CIDR[]If non-empty, only these ranges may pass. Wins over blacklist.

Entries parse as CIDR ("10.0.0.0/8") or plain IP (becomes /32 or /128). IPv4 and IPv6 both work. See ACL semantics.

When to change: permanent infrastructure blocks (known-bad ranges), or a whitelist-only stance for an internal-only proxy.

Common mistake: setting a whitelist and locking yourself out — a non-empty whitelist denies everything else, including your own admin traffic through the proxy ports.

yaml
global_acl:
  blacklist:
    - "203.0.113.0/24"
    - "198.51.100.7"
  whitelist: []          # e.g. ["10.0.0.0/8"] for an internal-only proxy

events

YAML pathTypeDefaultSemantics
events.channel_capacityint65536Broadcast channel capacity for the telemetry bus. Minimum 64. Under congestion events are dropped (and counted) rather than blocking the data plane.
events.recent_bufferint10000Per-site ring buffer of recent request events, backing /api/v1/events/recent and analytics.
events.aggregate_retention_hoursint168 (7 days)Retention of the per-minute aggregate series (rps / bandwidth). Older data is gone — export to a TSDB if you need more.

When to change: raise recent_buffer on busy sites whose analytics need deeper lookback; lower on RAM-tight boxes. Raise channel_capacity if you see dropped-event counts on a loaded SSE consumer.

Common mistake: treating SSE/events as an audit log — the bus is lossy under congestion by design.

yaml
events:
  channel_capacity: 65536
  recent_buffer: 10000
  aggregate_retention_hours: 168

2. Site config (sites.d/<name>.yaml)

One file per proxied site. A site is a hostname set pointing at one destination web server — the proxy never hosts content itself.

Top level

YAML pathTypeDefaultSemantics
idstring""Stable identifier used in API paths and metrics. Must match [A-Za-z0-9._-]{1,64} when set (it lands in file names and API paths). Leave empty in a new file: the loader assigns one and rewrites the file with it.
enabledbooltrueDisabled sites keep their config file but do not proxy — requests get a 503 page (unknown hosts get 421). Toggle via API without deleting the file.
hostnameslist of strings[]Required, at least one. See hostname semantics.
originmapDestination web server.
tlsmapdisabledHTTPS for this site.
client_ipmapsee belowHow the real client IP is resolved and conveyed.
protectionmapsee belowACL, limits, WAF, challenge, bot, auto-mitigation.
bandwidthmapsee belowMonthly quota + speed caps.

origin

YAML pathTypeDefaultSemantics
origin.urlstring""Absolute URL of the destination, e.g. "http://10.0.0.5:8080". Scheme must be http or https; no path and no userinfo (user:pass@) allowed. Required.
origin.host_headerstring or nullnullHost header sent to the origin. Default: the original client Host. Set e.g. "localhost" when the origin vhosts differently.
origin.connect_timeout_msint (ms)5000Upstream TCP connect timeout.
origin.read_timeout_msint (ms)30000Upstream read timeout.
origin.keepalive_secsint (s)75Idle keep-alive lifetime for pooled upstream connections.
origin.max_idle_per_hostint32Max pooled idle upstream connections per site.

Origin failures produce 502 with backoff; the proxy never panics on upstream garbage.

When to change: host_header for origins that vhost on a different name; raise read_timeout_ms for slow backends (long reports, uploads); max_idle_per_host for high-RPS sites to deepen the keep-alive pool.

Common mistakes: a path in origin.url (http://host:8080/app is rejected — the proxy forwards the client's path as-is); userinfo in the URL (rejected); forgetting to lock down the origin's bind so attackers can bypass the proxy entirely.

yaml
origin:
  url: "http://10.0.0.5:8080"
  host_header: null
  connect_timeout_ms: 5000
  read_timeout_ms: 30000
  keepalive_secs: 75
  max_idle_per_host: 32

tls

YAML pathTypeDefaultSemantics
tls.enabledboolfalseServe HTTPS for this site's hostnames using these certs (SNI-routed). Hosts without TLS config never get a certificate.
tls.cert_pathstring""PEM certificate chain. Must exist at load time when enabled.
tls.key_pathstring""PEM private key. Must exist at load time when enabled.
tls.redirect_http_to_httpsbooltrueCleartext requests for this site get 308https://. The Location keeps an explicit non-default port from the request's Host header (host:8443https://host:8443/...); default ports (80/443) are dropped.
tls.hsts_max_ageint or nullnullIf set, emit Strict-Transport-Security: max-age=<n> on HTTPS responses.

When to change: enable per site as certs become available; set hsts_max_age (e.g. 15552000 = 180 days) only after HTTPS is proven working — HSTS is sticky.

Common mistakes: enabling with missing/typo'd cert paths (that site's validation fails; it never serves); setting redirect_http_to_https: false and wondering why cleartext works; unknown SNI aborts the handshake by design — there is no default cert.

Cert renewal: write the new files, then touch the site file (the watcher re-reads it) or POST /api/v1/reload. New TLS connections pick up the new cert — no restart. The SNI cert map is also rebuilt on an hourly timer, so out-of-band rotation goes live within an hour even with no reload at all.

yaml
tls:
  enabled: true
  cert_path: "/etc/openshield-l7/certs/example.com/fullchain.pem"
  key_path: "/etc/openshield-l7/certs/example.com/privkey.pem"
  redirect_http_to_https: true
  hsts_max_age: 15552000

For local testing, openshield-l7 gen-cert --host example.com --out ./certs/example.com writes a self-signed pair — never for production.

client_ip

Two halves: how the proxy resolves the real client IP (trusted_proxies), and how the origin learns it (forward_mode / custom_header / transparent / proxy_protocol).

YAML pathTypeDefaultSemantics
client_ip.trusted_proxieslist of IP/CIDR[]Peers whose X-Forwarded-For we trust (Cloudflare ranges, your CDN). Direct connections from untrusted peers have their inbound XFF ignored entirely — it is attacker-controlled.
client_ip.forward_modeenumx_forwarded_forHow to inform the origin about the client IP: none, x_forwarded_for, x_real_ip, forwarded, custom. See the forward modes table.
client_ip.custom_headerstring or nullnullHeader name used when forward_mode: custom. Required (non-empty) in that mode — validation fails otherwise.
client_ip.transparentboolfalseLinux IP_TRANSPARENT: source the upstream connection from the resolved client IP. The origin sees the client directly — no headers, no PROXY protocol, zero origin config. Requires root/CAP_NET_ADMIN + policy routing; degrades gracefully to normal egress with a loud warning if privileges are missing. IPv4-only (IPv6 clients use normal egress). Full guide: Transparent Client IP.
client_ip.proxy_protocolenumoffPrepend an HAProxy PROXY protocol header to each upstream connection: off, v1 (text), or v2 (binary). The origin must be configured to expect it. The advertised source is the trusted-proxy-resolved client IP (port 0 = unknown when the peer is a trusted proxy).

Resolution algorithm (trusted-proxy aware): if the direct TCP peer is not in trusted_proxies, the client IP is the peer, full stop. If the peer is trusted, the XFF chain is walked right-to-left, skipping trusted hops; the first untrusted address is the client. If the whole chain is trusted, the rightmost parseable XFF entry wins (the least forgeable hop — appended by the trusted proxy closest to us; the leftmost entry is client-supplied verbatim), then X-Real-Ip, then the peer.

The resolved client IP — not the peer — is what ACLs, rate limits, bans, telemetry, transparent mode, and the PROXY-protocol source address use.

Forward modes: what the origin sees

Assume the resolved client IP is 203.0.113.9, the client sent X-Forwarded-For: 198.51.100.1 inbound, and the origin is nginx.

forward_modeHeaders sent to originWhat nginx sees
noneX-Forwarded-For, X-Real-Ip, and Forwarded are stripped entirely$remote_addr = the proxy's IP; no client-identifying headers at all.
x_forwarded_for (default)X-Forwarded-For: <inbound chain>, 203.0.113.9 (client IP appended; header created if absent)Standard chain; nginx $remote_addr = proxy IP, realip module / logs pick the client from XFF.
x_real_ipX-Real-Ip: 203.0.113.9nginx set_real_ip_from + real_ip_header X-Real-Ip; restores the client IP.
forwardedForwarded: for=203.0.113.9RFC 7239 header; origins parsing Forwarded see the client.
custom<custom_header>: 203.0.113.9Whatever your origin expects, e.g. CF-Connecting-IP: 203.0.113.9.

When to change: match your origin's existing realip config; use none for maximum hygiene on origins that must not trust headers; transparent when you control the network path and want socket-level truth.

Common mistakes: trusting XFF without populating trusted_proxies (the inbound chain is ignored — correct — but operators then misread telemetry); enabling proxy_protocol against an origin that doesn't expect it (every request fails — the preamble precedes all request bytes); enabling transparent without the routing setup (site serves 502s — there is no auto-detection for a blackholed return path).

yaml
client_ip:
  trusted_proxies:
    - "173.245.48.0/20"     # Cloudflare ...
    - "103.21.244.0/22"
  forward_mode: x_forwarded_for
  transparent: false
  proxy_protocol: off

Notes:

  • transparent: true makes forward_mode and proxy_protocol redundant for IP visibility (the origin's socket table itself shows the client); combine it with forward_mode: none for the cleanest wire. They don't conflict if left on — just redundant.
  • proxy_protocol applies on every upstream connect, before any request bytes.

protection

Inspectors run in a fixed order per request — ban check + IP ACL → connection limits → rate limits → WAF/bot rules — cheap stateless drops first; first non-Allow verdict wins.

yaml
protection:
  acl: {}              # per-site blacklist/whitelist
  rate_limit: {}       # request windows + ban escalation
  connections: {}      # concurrent in-flight caps
  request_limits: {}   # request shape enforcement
  waf: {}              # content/bot rules + custom_rules
  challenge: {}        # JS proof-of-work
  bot: {}              # scoring bands
  auto_mitigation: {}  # RPS-triggered tightening

protection.acl

Same shape as global_acl, evaluated after it (after the ban check):

YAML pathTypeDefaultSemantics
protection.acl.blacklistlist of IP/CIDR[]Blocked on this site.
protection.acl.whitelistlist of IP/CIDR[]If non-empty, only these ranges may use this site. Wins over blacklist.

When to change: per-site lockdown (an admin panel host behind an office CIDR whitelist) without affecting other sites.

protection.rate_limit

Sliding-window request limits. Both windows are enforced; either tripping counts as a violation.

YAML pathTypeDefaultSemantics
protection.rate_limit.enabledbooltrueMaster toggle.
protection.rate_limit.per_ip.requestsint300Requests allowed per client IP per window.
protection.rate_limit.per_ip.window_secsint60Per-IP window length. Must be > 0.
protection.rate_limit.per_site.requestsint5000Requests allowed for the whole site per window.
protection.rate_limit.per_site.window_secsint10Per-site window length. Must be > 0.
protection.rate_limit.actionenumblockblock = 429 (403 for repeat hits); challenge = JS proof-of-work page instead (only honored when challenge.mode is not off; otherwise downgraded to 429).
protection.rate_limit.ban_after_violationsint0After this many violations inside one window, temp-ban the IP. 0 = never ban. Repeat offenders double the ban duration, capped at 24h.
protection.rate_limit.ban_duration_secsint600Base ban length.

When to change: tighten per_ip for login/API endpoints; enable ban_after_violations (e.g. 5) once you're confident limits don't false-trip legit bursty users; use action: challenge to sift browsers from scripts instead of hard-blocking.

Common mistakes: setting action: challenge while challenge.mode: off (silently downgrades to 429); forgetting that the per-site window default is only 10s (5000/10s ≈ 500 rps sustained); window of 0 fails validation.

yaml
protection:
  rate_limit:
    enabled: true
    per_ip:   { requests: 300,  window_secs: 60 }
    per_site: { requests: 5000, window_secs: 10 }
    action: block
    ban_after_violations: 5
    ban_duration_secs: 600

protection.connections

Concurrent in-flight request caps (not per-window).

YAML pathTypeDefaultSemantics
protection.connections.per_ipint0Max concurrent in-flight requests per client IP. 0 = unlimited.
protection.connections.per_siteint0Max concurrent in-flight requests for the site. 0 = unlimited.

When to change: set per_ip (e.g. 20) to blunt connection-holding attacks and download managers hogging workers; per_site as a hard ceiling for a fragile origin.

Common mistake: confusing these with rate windows — a client under its req/s limit can still trip the concurrent cap with slow parallel requests. Over-limit verdicts carry rule id conn.per_ip / conn.per_site.

protection.request_limits

Request shape enforcement, before any content rules.

YAML pathTypeDefaultSemantics
protection.request_limits.max_uri_bytesint8192Longer request targets are rejected.
protection.request_limits.max_header_bytesint32768Total header section cap.
protection.request_limits.max_body_bytesint16777216 (16 MiB)Larger request bodies are rejected (413). Chunked bodies are cut off mid-stream once the cap streams past.
protection.request_limits.body_inspect_bytesint65536How much of a request body the WAF buffers for inspection at most. Bodies are only buffered when waf.inspect_body is on and the content type is text-like.
protection.request_limits.allowed_methodslist of strings["GET","HEAD","POST","PUT","PATCH","DELETE","OPTIONS"]Uppercase method names. Anything else gets 405.
protection.request_limits.blocked_path_prefixeslist of strings[]Path prefixes that are always blocked, e.g. "/internal".

When to change: shrink all caps for a paranoia profile; raise max_body_bytes for upload endpoints; add /.git, /wp-admin style prefixes on sites that never serve them.

Common mistake: lowering max_body_bytes below what a legit form/upload posts — rejections are early 413s, visible in telemetry as such.

yaml
protection:
  request_limits:
    max_uri_bytes: 8192
    max_header_bytes: 32768
    max_body_bytes: 16777216
    body_inspect_bytes: 65536
    allowed_methods: ["GET", "HEAD", "POST"]
    blocked_path_prefixes: ["/internal", "/debug"]

protection.waf

Content and bot inspection. Each toggle is one rule family; hits are recorded in telemetry by rule id (sqli, xss, path_traversal, rce, scanner, bot, request.method, request.uri_len, request.path_blocked, or your custom rule id).

YAML pathTypeDefaultSemantics
protection.waf.sqlibooltrueSQL-injection pattern set over URI, query, and inspected body.
protection.waf.xssbooltrueCross-site scripting pattern set.
protection.waf.path_traversalbooltrue../ / encoded traversal pattern set.
protection.waf.rcebooltrueCommand-injection / RCE pattern set.
protection.waf.scanner_blockbooltrueBlock known scanner/bad-bot user agents (sqlmap, nikto, masscan, ...). Rule id scanner.
protection.waf.bad_botbooltrueHeuristic bot scoring (header coherence, automation markers). Bands in protection.bot.
protection.waf.inspect_bodybooltrueAlso inspect size-capped urlencoded/text bodies, not just the URI.
protection.waf.custom_ruleslist of rule maps[]Your own regex rules. See below.

All pattern matching is case-insensitive, applied to percent-decoded input (decoded once), and uses the linear-time regex crate — no catastrophic backtracking on adversarial input. Known gap: double-encoded SQLi/XSS payloads evade the content rules (the decode-once design; see Testing).

Each custom rule:

YAML pathTypeDefaultSemantics
…custom_rules[].idstringRule id; appears in rule_hits telemetry. Required.
…custom_rules[].namestringHuman label. Required.
…custom_rules[].enabledbooltrueDisabled rules are kept but skipped.
…custom_rules[].targetenumWhat to match: uri, query, body, user_agent, method, or !header "<name>" for a named header's value. Required.
…custom_rules[].patternstringRegular expression, regex crate syntax (no backrefs/lookaround). Compiled once at load; an invalid regex fails validation of the whole file. Required.
…custom_rules[].actionenumblockblock = deny; challenge = PoW page (when challenge mode allows); log = allow the request but record the hit in telemetry.

Target syntax

Unit targets are plain scalars (target: uri), but a header target carries a value, and YAML represents that as a tag: target: !header "x-api-version". The JSON API uses the equivalent object form — "target": {"header": "x-api-version"} (exactly what GET /sites/{id} returns). Writing the map form (target: {header: ...}) in YAML is rejected by the parser.

When to change: turn off a family that false-positives on your app (watch rule_hits first); add log-action custom rules to observe before you block.

Common mistakes: regex with backrefs/lookaround (fails validation — RE2-style syntax only); forgetting custom rules match decoded input; a bad regex anywhere in the file invalidates the whole file's reload (last good config keeps serving).

yaml
protection:
  waf:
    sqli: true
    xss: true
    path_traversal: true
    rce: true
    scanner_block: true
    bad_bot: true
    inspect_body: true
    custom_rules:
      - id: no-curl
        name: Block curl user agents
        target: user_agent
        pattern: "(?i)^curl/"
        action: block
      - id: wp-login-foreign
        name: Watch wp-login POSTs
        target: uri
        pattern: "^/wp-login\\.php"
        action: log
      - id: api-version-gate
        name: Only API v2 on this host
        target: !header "x-api-version"
        pattern: "^1\\."
        action: block

protection.challenge

JS proof-of-work challenges with HMAC-signed clearance cookies. The server keeps no per-client state — everything is in the cookie.

Flow on a challenge verdict: the proxy serves a self-contained page with a fresh self-authenticating seed — 64 lowercase hex chars encoding timestamp || random || HMAC tag; the browser brute-forces a nonce such that sha256(seed + nonce) has difficulty leading zero hex nibbles, then POSTs {seed, nonce} to /__osc_challenge/verify. The verify endpoint rejects seeds the server never issued (HMAC mismatch) and seeds older than 10 minutes, so an offline solve can never mint clearance for an attacker-chosen seed. On success the client receives the clearance cookie osc_clear (HMAC-bound to site + client IP + expiry) and reloads; later requests skip challenges until expiry — including later challenge-action rule/limit verdicts.

YAML pathTypeDefaultSemantics
protection.challenge.modeenumoffoff = never challenge (challenge verdicts degrade to blocks/429); auto = honor challenge verdicts from rules and limits (rate-limit action, bot score in the challenge band); on = challenge every request without a valid clearance cookie (the verify path excepted).
protection.challenge.difficultyint4Leading zero hex nibbles required by the PoW. Each +1 multiplies expected work by 16. Effectively capped at 16.
protection.challenge.ttl_secsint1800Clearance cookie lifetime.

When to change: auto for most public sites; on for under-attack or extremely sensitive hosts; difficulty 56 during a flood (each step costs the client 16× more, you nothing).

Common mistakes: mode: on in front of APIs consumed by non-browser clients (they can't solve JS — they'll get 403s); expecting challenges while mode: off; very high difficulty locking out mobile devices.

The HMAC secret persists at data/challenge.secret (mode 0600), so clearance cookies survive restarts. Delete it and every outstanding clearance is invalidated.

protection.bot

Heuristic bot scoring — additive weak signals (header presence/coherence, automation markers), so no single forged header clears or trips the detector. Runs when protection.waf.bad_bot is on.

YAML pathTypeDefaultSemantics
protection.bot.enabledbooltrueMaster toggle for scoring.
protection.bot.challenge_scoreint (0–100)50Score ≥ this → JS challenge (when challenge.mode allows).
protection.bot.block_scoreint (0–100)80Score ≥ this → hard block.
protection.bot.allow_good_botsbooltrueLet verified-good crawler UAs (googlebot, bingbot) skip bot rules. UA-match only — spoofable. Pair with trusted_proxies + external PTR verification if you rely on it.

When to change: widen the gap (challenge_score: 40, block_score: 85) to challenge more and block less; disable allow_good_bots on sites that should have no crawler traffic.

Common mistake: setting challenge_score ≥ block_score — the challenge band vanishes and everything suspicious eats a hard block.

protection.auto_mitigation

When a site's RPS crosses the threshold, all of that site's rate limits are multiplied by factor (i.e. tightened) for duration_secs, renewed while the flood continues. mitigation events fire on enter/exit, and SiteStats.mitigation_active reflects the state.

YAML pathTypeDefaultSemantics
protection.auto_mitigation.enabledbooltrueMaster toggle.
protection.auto_mitigation.threshold_rpsfloat800.0Site requests/sec that triggers mitigation.
protection.auto_mitigation.duration_secsint300How long tightened limits stay on once triggered (renewed while hot).
protection.auto_mitigation.factorfloat0.25Multiplier applied to rate limits while mitigating. 0.25 = limits at 25% of normal.

When to change: lower threshold_rps on small origins; lower factor (e.g. 0.1) when floods must be strangled harder.

Common mistake: a threshold_rps below your legit traffic peaks — the site will live in permanent mitigation. Watch mitigation events after tuning.

bandwidth

YAML pathTypeDefaultSemantics
bandwidth.monthly_quota_bytesint or nullnullTotal bytes (up + down) the site may move per calendar month. null = unlimited. Counters persist under data_dir/quotas/ (atomic tmp+rename) and reset on quota_reset_day.
bandwidth.quota_actionenumerror_pageWhat over-quota traffic gets: error_page = status 509 "Bandwidth Limit Exceeded"; close = the connection is closed without a response.
bandwidth.quota_reset_dayint or nullnull1Day of month the quota window resets. Valid range 1–28 (safe in every month); other values fail validation.
bandwidth.max_site_bpsint or nullnullSustained send-rate cap for the whole site, bytes/sec (token bucket shared by all connections). null = off.
bandwidth.max_ip_bpsint or nullnullSustained send-rate cap per client IP, bytes/sec. null = off.

Crossing the quota fires a quota_exceeded event once per window and sets quota_used_bytes / quota_limit_bytes / quota_reset_at in the site's stats. Speed caps throttle the response stream; they don't reject requests.

When to change: quotas for hosting-style "X GB/month" plans; max_ip_bps so one client can't saturate a download host.

Common mistakes: forgetting quotas count both directions; quota_reset_day: 31 fails validation; expecting speed caps to affect request rate — they shape bytes, not rps.

yaml
bandwidth:
  monthly_quota_bytes: 1099511627776    # 1 TiB
  quota_action: error_page
  quota_reset_day: 1
  max_site_bps: 52428800                # 50 MiB/s site-wide
  max_ip_bps: 2097152                   # 2 MiB/s per client

websockets

WebSocket (RFC 6455) and other HTTP upgrades are proxied as a raw bidirectional byte splice once the origin answers 101. The proxy never parses frames: text/binary frames, fragmented messages, keepalive ping/pong, and extensions like permessage-deflate pass untouched — a game-panel console (Pterodactyl-style) works out of the box and stays connected for hours.

YAML pathTypeDefaultSemantics
websockets.enabledbooltrueWhen false, upgrade requests are rejected with 403 (rule websocket.disabled) before the origin is contacted. Plain HTTP requests are unaffected.
websockets.idle_timeout_secsint3600Close a tunnel that moves no bytes in either direction for this long. Keepalive pings count as activity. 0 = never (not recommended: dead tunnels hold a socket and an active_connections slot forever).
websockets.max_lifetime_secsint0Hard cap on tunnel age, enforced even while traffic flows. 0 = unlimited (panels legitimately run for hours).

Notes:

  • The upgrade handshake is a normal HTTP request: routing, ACLs, bans, rate limits, conn limits, WAF, and challenges all apply to it as usual.
  • A live tunnel holds one active_connections slot (site and global) from the 101 until it closes — 10k idle WebSockets show up as 10k connections, not zero.
  • Tunnel bytes are metered into the site's byte counters/quota when the tunnel ends. The RequestEvent publishes when the 101 completes (its byte counts cover the handshake only).
yaml
websockets:
  enabled: true
  idle_timeout_secs: 3600
  max_lifetime_secs: 0

3. Hostname semantics

  • Case-insensitive; a port suffix (example.com:8080) and a trailing dot are stripped before matching. IPv6 literals are not routed.
  • Exact hostnames match first.
  • *.example.com is a suffix wildcard: it matches a.example.com and a.b.example.com (any depth). The bare domain example.com is not matched — list it explicitly.
  • Overlapping wildcards: the longest suffix wins, so *.a.example.com beats *.example.com.
  • No hostname matches at all → 421 (no site context — bare-IP scanners land here).
  • Two enabled sites claiming the same hostname are rejected: the first file (sorted order) wins, the second is logged and skipped; through the API the write fails with a conflict naming the other site. A disabled site's hostnames don't claim anything.

4. ACL semantics

Applies identically to global_acl and protection.acl:

  1. The resolved client IP is tested (trusted-proxy aware, see client_ip), never the raw peer.
  2. Whitelist wins over blacklist: an IP present in both is allowed.
  3. If the whitelist is non-empty, only whitelisted ranges are allowed — everything else is denied.
  4. global_acl runs before any per-site logic; protection.acl runs per site, after the ban check.

5. Full example site files

Minimal — sites.d/minimal.yaml

yaml
hostnames:
  - minimal.example.com
origin:
  url: "http://127.0.0.1:8080"

Everything else defaults: WAF on, per-IP 300 req/60s, per-site 5000 req/10s, x_forwarded_for to the origin, no TLS, no quotas.

Typical — sites.d/typical.yaml

TLS, tighter rate limits with auto-ban, Cloudflare in front.

yaml
hostnames:
  - www.example.com
  - example.com
origin:
  url: "http://10.0.0.5:8080"
  connect_timeout_ms: 4000
  read_timeout_ms: 20000
tls:
  enabled: true
  cert_path: "/etc/openshield-l7/certs/example.com/fullchain.pem"
  key_path: "/etc/openshield-l7/certs/example.com/privkey.pem"
  redirect_http_to_https: true
  hsts_max_age: 15552000
client_ip:
  trusted_proxies:
    - "173.245.48.0/20"
    - "103.21.244.0/22"
    - "103.22.200.0/22"
    - "103.31.4.0/22"
  forward_mode: x_forwarded_for
protection:
  acl:
    blacklist:
      - "203.0.113.0/24"
  rate_limit:
    enabled: true
    per_ip:   { requests: 200,  window_secs: 60 }
    per_site: { requests: 4000, window_secs: 10 }
    action: block
    ban_after_violations: 5
    ban_duration_secs: 900
  connections:
    per_ip: 20
    per_site: 0
  waf:
    sqli: true
    xss: true
    path_traversal: true
    rce: true
    scanner_block: true
    bad_bot: true
    inspect_body: true
  challenge:
    mode: auto
    difficulty: 4
    ttl_secs: 1800
  bot:
    enabled: true
    challenge_score: 50
    block_score: 85
    allow_good_bots: true
  auto_mitigation:
    enabled: true
    threshold_rps: 600
    duration_secs: 300
    factor: 0.25

Paranoia — sites.d/paranoia.yaml

Challenge everything, strict shapes, small windows, monthly quota with speed caps.

yaml
hostnames:
  - internal.example.com
origin:
  url: "http://127.0.0.1:9000"
  host_header: "localhost"
  connect_timeout_ms: 2000
  read_timeout_ms: 10000
tls:
  enabled: true
  cert_path: "/etc/openshield-l7/certs/internal/fullchain.pem"
  key_path: "/etc/openshield-l7/certs/internal/privkey.pem"
  redirect_http_to_https: true
  hsts_max_age: 31536000
client_ip:
  trusted_proxies: []
  forward_mode: none
protection:
  rate_limit:
    enabled: true
    per_ip:   { requests: 60,   window_secs: 60 }
    per_site: { requests: 1000, window_secs: 10 }
    action: challenge
    ban_after_violations: 3
    ban_duration_secs: 1800
  connections:
    per_ip: 5
    per_site: 500
  request_limits:
    max_uri_bytes: 2048
    max_header_bytes: 8192
    max_body_bytes: 1048576
    body_inspect_bytes: 16384
    allowed_methods: ["GET", "HEAD", "POST"]
    blocked_path_prefixes: ["/internal", "/.git", "/wp-admin"]
  waf:
    sqli: true
    xss: true
    path_traversal: true
    rce: true
    scanner_block: true
    bad_bot: true
    inspect_body: true
    custom_rules:
      - id: no-empty-ua
        name: Block empty user agents
        target: user_agent
        pattern: "^$"
        action: block
  challenge:
    mode: on
    difficulty: 5
    ttl_secs: 900
  bot:
    enabled: true
    challenge_score: 40
    block_score: 70
    allow_good_bots: false
  auto_mitigation:
    enabled: true
    threshold_rps: 200
    duration_secs: 600
    factor: 0.1
bandwidth:
  monthly_quota_bytes: 1099511627776    # 1 TiB
  quota_action: error_page
  quota_reset_day: 1
  max_site_bps: 52428800                # 50 MiB/s site-wide
  max_ip_bps: 2097152                   # 2 MiB/s per client

See also

  • Hot Reload — how file edits and API writes apply, and how failures are isolated.
  • Admin API — the same config model as JSON, endpoint by endpoint.
  • Transparent Client IPclient_ip.transparent requirements and routing setups.