This guide documents the current RELIW behavior implemented in:
src/reliw/reliw.lua
src/reliw/reliw/*.lua
src/http/http/server.lua
It is intended for both operators (deployment and troubleshooting) and developers (schema, routing, and failure semantics).
RELIW uses a coroutine-per-connection model built on the LEV async
I/O runtime. The manager process forks listener processes (IPv4, IPv6,
metrics), and each listener runs a lev.run() event loop that accepts
connections and spawns a coroutine per client. Redis access uses
redis (async Redis client) injected into the request handler
context. TLS with SNI is handled by LEV's LITLS integration.
Legacy config field names fork_limit and requests_per_fork are
accepted as aliases for connection_limit and requests_per_connection.
The standalone RELIW app entry point is defined in buildgen/apps/reliw.lua and runs:
local reliw = require("reliw")
math.randomseed(os.time())
local reliw_srv, err = reliw.new()
if not reliw_srv then
print("failed to init RELIW: " .. tostring(err))
os.exit(-1)
end
reliw_srv:run()
RELIW reads config JSON from:
RELIW_CONFIG_FILE environment variable, if set
/etc/reliw/config.json otherwise
{
"ip": "127.0.0.1",
"port": 8080
}
Example below serves / for example.com from data_dir/example.com/index.md.
redis-cli -n 13 SET RLW:API:example.com '[["/","home",true]]'
redis-cli -n 13 SET RLW:API:example.com:home '{"methods":{"GET":true,"HEAD":true},"index":"index.md"}'
Create content file:
mkdir -p /www/example.com
printf '# Hello\n' > /www/example.com/index.md
Request:
curl -H 'Host: example.com' http://127.0.0.1:8080/
The reliw.new() manager object exposes read-only inspection helpers:
has_child_pid(pid) -> boolean
list_child_pids() -> map of pid -> process_name
get_process_pids() -> { ipv4 = pid|nil, ipv6 = pid|nil, metrics = pid|nil }
These are intended for runtime introspection and tests, replacing direct reads of internal state tables.
RELIW combines defaults from src/reliw/reliw.lua and src/http/http/server.lua.
| Key | Default | Notes |
|---|---|---|
ip | 127.0.0.1 | IPv4 bind address |
port | 8080 | Bind port |
ipv6 | unset | If set, manager spawns IPv6 server process |
data_dir | /www | Static/dynamic content root |
cache_max_size | 5242880 | Max bytes for cached file content in Redis |
backlog | 256 | Listen backlog |
connection_limit | 64 | Max concurrent connections per listener process |
requests_per_connection | 512 | Requests handled on one connection before close |
max_body_size | 5242880 | Request body cap for content-length and chunked uploads |
request_line_limit | 8192 | Max request line or header line bytes |
keepalive_idle_timeout | 15 | Keep-alive idle timeout (seconds) |
request_header_timeout | 10 | Header read timeout (seconds) |
request_body_timeout | 30 | Body read timeout (seconds) |
tls_handshake_timeout | 10 | Server-side TLS handshake timeout (seconds) |
log_level | access | Logger level passed to std.logger |
log_headers | ["referer","x-real-ip","user-agent"] | Request headers copied into access logs |
compression | enabled | Response compression policy (gzip preferred, deflate fallback) |
redis | object | Redis connection and namespace config |
metrics | object | Metrics listener + SCAN tuning |
ssl | unset | Enable HTTPS listener (see TLS section) |
Compression defaults:
compression.enabled = true
compression.min_size = 4096
compression.types includes text/html, text/plain, text/css, text/javascript, image/svg+xml, application/json, application/rss+xml
Encoding selection: prefers gzip when client advertises it, falls back to deflate
Responses include Vary: Accept-Encoding when compressed
redis)| Key | Default | Notes |
|---|---|---|
host | 127.0.0.1 | Redis host |
port | 6379 | Redis port |
db | 13 | Selected DB |
prefix | RLW | Key namespace prefix |
timeout | 5 | Socket timeout (seconds) |
ssl | unset | Optional Redis TLS mode |
auth | unset | Optional auth object for Redis AUTH |
metrics)| Key | Default | Notes |
|---|---|---|
ip | 127.0.0.1 | Metrics listener bind IP |
port | 9101 | Metrics listener bind port |
disabled | false | If true, metrics process is not spawned |
scan_count | 100 | Redis SCAN count hint; clamped to 1..1000 |
scan_limit | 2000 | Max keys inspected per scrape; clamped to 1..10000 |
Metrics process behavior:
spawned by manager as a dedicated process
uses reliw.metrics.show
forces ssl = nil and log_level = 100
reuses manager-loaded config in memory (no second config-file read during spawn)
ssl)Server-side TLS config shape:
{
"ssl": {
"default": { "cert": "/path/default.crt", "key": "/path/default.key" },
"hosts": {
"*.example.org": { "cert": "/path/_.example.org.crt", "key": "/path/_.example.org.key" },
"other-site.com": { "cert": "/path/other-site.com.crt", "key": "/path/other-site.com.key" }
}
}
}
Notes:
ssl.default is required when TLS is enabled.
ssl.hosts adds SNI contexts for additional hostnames.
ssl.hosts keys support the *.example.org wildcard syntax to match any single-label subdomain (e.g. api.example.org). Exact hostname entries take priority over wildcard entries when both match.
RELIW validates that configured cert/key files exist before startup.
All keys are prefixed with redis.prefix (default RLW).
${PREFIX}:API:<host>
JSON array of route entries: [pattern, entry_id, exact_match?]
exact_match (true) means strict equality; otherwise query:match(pattern) is used
${PREFIX}:API:<host>:<entry_id>
JSON object containing entry metadata
Common metadata fields:
Required:
methods map, for example { "GET": true, "POST": true }
Optional:
file: explicit file path
index: appended when query ends with /
try_extensions: try .lua, .dj, .md if file is missing
gsub: { "pattern": "...", "replacement": "..." } query remap
title, css_file, favicon_file
cache_control (for example max-age=3600)
path_cache: if true and file is set, RELIW checks ${PREFIX}:FILES:<host>:<query> for a pre-computed response before loading the handler. On a cache hit the handler is skipped entirely. The handler is responsible for writing that key (fields: content, hash, size, mime, title) and calling EXPIRE. Cache invalidation is performed by DEL-ing the key.
auth: see auth section
rate_limit: see rate-limiting section
error: status-specific image/html override map
${PREFIX}:FILES:<host>:<filename> (hash)
fields: content, hash, size, mime, title
cache TTL: 3600 seconds
${PREFIX}:FILES:<host>:<query> (hash, same fields)
written by Lua handlers that use path_cache: true
key uses the request URL path (e.g. /std/fs) rather than the handler filename
TTL is set by the handler; recommended to match cache_control
${PREFIX}:TITLES:<host> (hash)
optional per-file title override
${PREFIX}:DATA:<host>:<name> (string)
user data; fallback key: ${PREFIX}:DATA:__:<name>
template.lua is used as page template override if present
ACME HTTP-01 challenge payloads can be provisioned at ${PREFIX}:DATA:<host>:.well-known/acme-challenge/<token>
${PREFIX}:USERS:<host> (hash)
field: username
value: JSON { "pass": "<hex_hmac>", "salt": "<salt>" }
${PREFIX}:SESSIONS:<host>:<token> (string with TTL)
value: username
${PREFIX}:PROXY:<host> (JSON object)
target (required): upstream host
scheme (optional): http (default) or https
port (optional): defaults to 80/443 by scheme
tls_cafile, tls_capath, tls_handshake_timeout (optional)
tls_insecure, tls_no_verify, no_verify_mode (optional bools; any true enables no-verify mode)
${PREFIX}:WAF (hash)
field __: global rule set JSON
field <host>: per-host rule set JSON
${PREFIX}:WAFFERS (Pub/Sub channel)
receives blocked IP value from WAF branch
${PREFIX}:CTL (Pub/Sub channel)
generic control messages from store:send_ctl_msg
${PREFIX}:METRICS:<host>:total (hash: status_code -> count)
${PREFIX}:METRICS:<host>:by_method (hash: method -> count)
${PREFIX}:METRICS:<host>:by_request (hash: query -> count; internal, 24h TTL)
${PREFIX}:METRICS:<host>:timing (hash: <name>_sum / <name>_count pairs)
request_sum / request_count — total handler duration
proxy_sum / proxy_count — upstream proxy round-trip (proxied vhosts only)
content_sum / content_count — content fetch + rendering
${PREFIX}:METRICS:<host>:waf_blocks (hash: rule_source -> count)
"global" — blocks from the global WAF rule set (__)
"<hostname>" — blocks from a per-host WAF rule set
${PREFIX}:METRICS:misdirected (string counter — total 421 responses for non-configured vhosts)
${PREFIX}:LIMITS:<host>:<method>:<query>:<ip> (string counter with TTL)
Rule document format (global or per-host):
{
"ip_header": "x-forwarded-for",
"query": ["^/admin", "drop%stable"],
"headers": {
"user-agent": ["badbot", "sqlmap"],
"x-custom": ["evil"]
}
}
Semantics:
Matching uses Lua pattern matching (string.match), not PCRE.
Evaluation order:
global query rules
global header rules
per-host query rules
per-host header rules
Default blocked-IP header source is x-forwarded-for if ip_header is missing.
On match:
publishes IP to ${PREFIX}:WAFFERS
logs blocked event with rule and host
increments http_waf_blocks_total counter with rule="global" or rule="<host>"
returns 301 to http://127.0.0.1/Fuck_Off
Main handler: src/reliw/reliw/handle.lua.
Order of operations:
Initialize Redis-backed store for request.
Normalize/validate host and query.
Verify host is a configured vhost (421 if not).
Evaluate WAF.
Check host-level proxy config.
Resolve route metadata.
Apply auth, method checks, and rate limits.
Load/render content.
Apply ETag/cache semantics and update metrics.
Client IP normalization at request ingress:
x-client-ip is always set from socket peer IP.
x-real-ip is only auto-filled from peer IP when request does not provide it.
Downstream RELIW logic prefers x-client-ip.
Host validation:
accepts DNS name, localhost, IPv4, bracketed IPv6
rejects malformed ports, comma-separated host lists, control chars, unbracketed IPv6
Query validation:
requires leading /
rejects control chars and backslashes
rejects encoded traversal separators (%2e, %2f, %5c)
percent-decodes and rejects .. segments
metadata.auth supports three paths:
login endpoint mode (metadata.auth.login == true)
GET: returns login form
POST: parses body form fields login and password
successful auth: sets rlw_session_token=<token>; secure; HttpOnly and 303 redirect
failed/malformed auth body: deterministic 401
logout endpoint mode (metadata.auth.logout == true)
clears rlw_session_token and rlw_redirect, returns 303 to /
allowlist mode (metadata.auth is a username list)
unauthenticated request: 302 to /login and sets rlw_redirect=<query>
If ${PREFIX}:PROXY:<host> exists, RELIW proxies request and skips local content flow.
Current proxy behavior:
upstream connect over TCP; TLS wrap+handshake when scheme == "https"
rewrites:
Host -> upstream host
Origin/Referer -> upstream origin
adds X-Forwarded-Host, X-Forwarded-Proto, X-Forwarded-For
response handling:
supports chunked responses with chunk extensions
normalizes content length
rewrites CORS allow-origin to original origin/host
ensures proxied Set-Cookie includes Secure
For content responses, ETag is generated from SHA-256 of content.
Conditional behavior:
GET + matching If-None-Match -> 304 with empty body
HEAD -> 200 with empty body, includes ETag/content-length
non-GET/HEAD does not use ETag short-circuit
Markdown and Djot (text/markdown, text/djot):
Content is rendered to HTML by the bundled markdown module.
If the rendered HTML begins with an <h1> element, its text is automatically extracted and used as the page title (overriding any metadata.title or RLW:TITLES value). The <h1> is then stripped from the body so it is not duplicated alongside the <header> section injected by the page template.
If no <h1> is present, the title falls back to metadata.title, then to the RLW:TITLES:<host> Redis hash, then to empty string.
When the request Accept header includes text/markdown, the raw source is returned as-is with the original MIME type (no rendering or title extraction).
Lua handlers (application/lua):
The .lua file is loaded once, cached as bytecode in RLW:FILES:<host>:<filename>, and executed on every request as handler(method, query, args, headers, body).
The handler may return (content, status) — RELIW wraps content with the page template — or (content, status, headers_table) — RELIW uses headers_table directly and skips template wrapping.
When path_cache: true is set in entry metadata, RELIW checks RLW:FILES:<host>:<query> before invoking the handler. If a cached response is present it is served immediately without handler execution.
Common status outcomes:
| Status | Trigger |
|---|---|
400 | Invalid host header or invalid query |
421 | Request to non-configured vhost |
401 | Login failure (wrong creds or malformed body) |
404 | Route/content missing; non-/metrics on metrics listener |
405 | Method not allowed by entry metadata |
429 | Rate limit exceeded |
500 | Metadata/content/Lua content execution failures |
502 | Upstream proxy failures |
503 | Store initialization failure (main handler or metrics handler) |
Additional behavior:
WAF block returns 301 with redirect to local sink URL.
Unauthorized protected content returns 302 to /login.
Logout returns 303 to /.
Main listener and metrics process both emit structured logs via std.logger.
Access-style request logs include:
vhost, method, query, status, process, size, time
client_ip (always present; socket peer address)
plus configured log_headers if present in request
optional forwarded context when present:
forwarded_for (from request x-forwarded-for)
forwarded_real_ip (from request x-real-ip when different from client_ip)
Important explicit log events:
store init failed
invalid host header
invalid query
blocked by WAF
proxy startup/errors and metrics store init failures
Metrics listener:
GET /metrics -> Prometheus text format
any other path -> 404
Exported families:
http_requests_total{host="<host>",code="<status>"} <count>
http_requests_by_method{host="<host>",method="<method>"} <count>
http_waf_blocks_total{host="<host>",rule="<source>"} <count>
rule is "global" for global WAF rules, or the domain name for per-host rules
http_misdirected_total <count> (requests to non-configured vhosts)
http_request_duration_seconds_sum{host="<host>"} <seconds> (summary)
http_request_duration_seconds_count{host="<host>"} <count>
http_proxy_duration_seconds_sum{host="<host>"} <seconds> (summary, proxied vhosts only)
http_proxy_duration_seconds_count{host="<host>"} <count>
http_content_duration_seconds_sum{host="<host>"} <seconds> (summary)
http_content_duration_seconds_count{host="<host>"} <count>
All metrics commands per request are batched into a single Redis pipeline.
RELIW regression tests are not yet implemented.