Config Grammar (EBNF)
Formal grammar for aloha.kdl
Notation legend
aloha uses KDL v2 as its
configuration language. The grammar below uses a pseudo-EBNF
adapted to KDL’s structure. KDL distinguishes three kinds
of values on a node: positional arguments (bare values), named
properties (key=value), and child blocks
({ … }). Each is shown explicitly in the
rules below.
(* Notation used throughout this document *)
<name> (* non-terminal — defined elsewhere in this grammar *)
"literal" (* literal KDL node name or string value *)
<string> (* any KDL string *)
<integer> (* KDL integer value *)
<boolean> (* #true or #false *)
[ x ] (* x is optional *)
x | y (* either x or y *)
x* (* zero or more repetitions of x *)
x+ (* one or more repetitions of x *)
{ … } (* KDL child block *)
prop=<value> (* named KDL property (not a positional argument) *)
(* comment *) (* explanatory note, not part of the syntax *)
Top-level
A config file contains at most one server block,
one or more listener blocks, and zero or more
vhost blocks. Any other top-level node is a hard
error.
config = server? listener+ vhost*
server = "server" { server-child* }
listener = "listener" [<string>] { listener-child* }
vhost = "vhost" <string> [ regex=<boolean> ] { vhost-child* }
vhost blocks are required when at least one HTTP
listener is present. A config consisting only of stream
listeners (with a proxy child) needs no vhosts.
server
Global server settings. All children are optional.
server-child =
"state-dir" <string>
| "user" <string>
| "group" <string>
| "inherit-supplementary-groups" <boolean>
| tls-options-block
| auth-backend
| geoip-block
| health-block
| policy-def
| error-page-def
TLS defaults
tls-options-block = "tls" { tls-option* }
tls-option = "min-version" ("1.2" | "1.3")
| "cipher" <string>
Authentication backends
Exactly one auth node may appear inside
server. The second positional argument names the
backend type.
auth-backend =
"auth" "pam" [ { pam-child* } ]
| "auth" "ldap" { ldap-child* }
| "auth" "subrequest" ( <string> | { subrequest-child* } )
| "auth" "jwt" [ { jwt-child* } ]
pam-child =
"service" <string> (* default: "login" *)
ldap-child =
"url" <string> (* required; ldap:// ldaps:// ldapi:// *)
| "bind-dn" <string> (* required; must contain {user} *)
| "base-dn" <string> (* required *)
| "group-filter" <string> (* default: "(memberUid={user})" *)
| "group-attr" <string> (* default: "cn" *)
| "starttls" <boolean> (* default: #false *)
| "timeout" <integer> (* seconds; default: 5 *)
subrequest-child =
"url" <string> (* required; http:// scheme *)
| "forward-header" <string>+ (* repeatable; header names to pass through *)
| "user-header" <string> (* response header carrying the username *)
| "groups-header" <string> (* response header carrying group list *)
| "timeout" <integer> (* seconds; default: 5 *)
jwt-child =
"cookie-name" <string> (* default: "aloha_session" *)
| "validity" <integer> (* seconds; default: 300 *)
| "wrap" <string> (* optional inner backend type: "pam" | "ldap" | "subrequest" *)
(* same child syntax as the corresponding auth-backend *)
auth jwt runs as a standalone token validator
when no wrap child is present (it validates
incoming cookies and bearer tokens but never issues new
ones). With wrap, it wraps an inner credential
backend: successful credential logins receive a session
cookie, and subsequent requests are validated by JWT alone.
auth subrequest short form
auth subrequest "http://…" passes the
URL as a positional argument and is equivalent to the block
form auth subrequest { url "http://…" }.
GeoIP, health, named policies, error pages
geoip-block = "geoip" (<string> | { "db" <string> })
health-block = "health" (<boolean> | { "enabled" <boolean> })?
policy-def = "policy" <string> { policy-statement* }
error-page-def =
"error-page" <integer> path=<string> (* path to an HTML file *)
| "error-page" <integer> html=<string> (* inline HTML string *)
listener
listener = "listener" [<string>] [{ listener-child* }]
(* optional positional string is the bind address *)
listener-child =
"bind" <string> (* if not given as positional *)
| "accept-proxy-protocol" ("v1" | "v2") (* HAProxy PROXY protocol on incoming connections *)
| tls-node (* at most one; valid in both HTTP and stream mode *)
| "proxy" <string> [{ stream-proxy-opt* }] (* stream mode: upstream address *)
| policy-block (* stream mode only *)
| "default-vhost" (<string> | #null) (* HTTP mode only; #null disables fallback *)
| timeouts-block (* HTTP mode only *)
| "max-connections" <integer> (* HTTP + stream mode; defer connections at limit *)
| "max-request-body" <integer> (* bytes; HTTP mode only; returns 413 on excess *)
(* "proxy" is mutually exclusive with "default-vhost" and "timeouts" *)
TLS
Four mutually exclusive sibling nodes select the TLS mode. The
inline forms (tls-file, tls-self-signed,
tls-acme) carry their source inside the listener;
tls instead references a named
top-level certificate so
multiple listeners can share one acceptor (and, for ACME, one
renewal loop). All four forms accept optional
min-version and cipher children
(inherited from server › tls defaults).
tls-node =
"tls" ( <string> | cert=<string> )
[{ tls-option* }] (* reference a top-level certificate *)
| "tls-file"
[ cert=<string> ] [ key=<string> ]
[{ tls-file-child* tls-option* }]
| "tls-self-signed"
[{ tls-option* }]
| "tls-acme"
[ domain=<string> ] [ email=<string> ]
[{ tls-acme-child+ tls-option* }]
tls-file-child = "cert" <string> | "key" <string> | mtls-block
mtls-block =
"mtls"
{ "ca" <string>+ (* at least one trust anchor PEM *)
[ "mode" <string> ] (* "required" (default) | "optional" *)
[ "revocation" <string>+ ] (* CRL PEM, repeatable *)
[ "refresh" <integer> ] (* CRL reload seconds; 0 disables *)
}
tls-acme-child =
"domain" <string>+ (* at least one required overall; variadic *)
| "name" <string> (* storage name; default: first domain *)
| "email" <string>
| "staging" <boolean> (* default: #false *)
| "server" <string> (* ACME directory URL override *)
| "retry-interval" <integer> (* seconds; default: 3600 *)
tls-file requires both cert and
key. tls-acme requires at least
one domain and a state-dir on the
server block. Two listeners may not carry inline
tls-acme blocks that resolve to the same on-disk
slot (same explicit name or same first domain);
define a top-level certificate and reference it
from both.
Top-level certificate
A named certificate definition. Any number of listeners can
reference it via tls cert="name"; for ACME
sources they share a single renewal loop and on-disk cert
directory. The body holds exactly one source child whose
contents match the corresponding inline form.
certificate = "certificate" <string>
{ cert-source } (* positional string is the certificate's name *)
cert-source =
"acme" { tls-acme-child+ }
| "files" ( cert=<string> key=<string> | { tls-file-child+ } )
| "self-signed"
Timeouts
timeouts-block = "timeouts" timeout-prop* [{ timeout-child* }]
(* properties and child nodes are equivalent; both forms accepted *)
timeout-prop = request-header=<integer> (* default 30; 0 = unlimited *)
| handler=<integer>
| keepalive=<integer>
timeout-child =
"request-header" <integer> (* seconds; default 30; 0 = unlimited *)
| "handler" <integer> (* seconds; returns 408 on expiry *)
| "keepalive" <integer> (* seconds; 0 disables keep-alive *)
Stream mode (proxy child)
A proxy child activates stream mode: raw bytes are
forwarded to the upstream over TCP or a Unix domain socket; HTTP
routing does not apply. The positional argument is the upstream
address (required). TLS termination on the incoming connection
uses the same tls-* nodes as HTTP listeners. A
tls child inside proxy re-encrypts the
upstream connection.
stream-proxy-opt =
"proxy-protocol" ("v1" | "v2") (* prepend PROXY header to upstream *)
| "tls" [{ "skip-verify" }] (* re-TLS to upstream; skip-verify disables cert check *)
policy block on a stream listener may only
use address and country predicates.
user, group, and
authenticated are forbidden because stream mode
has no HTTP authentication layer.
vhost
vhost-child =
"alias" <string> [ regex=<boolean> ]
| location
Vhost names and aliases are literal hostnames by default; set
regex=#true to treat the value as an anchored
regex matched against the request Host. Matching order: exact
literal lookup (O(1)), then regex patterns in config declaration
order, then the listener’s default-vhost
fallback.
location
location = "location" <string> { location-child* }
location-child =
handler (* exactly one required *)
| policy-block
| basic-auth-block
| request-headers-block
| response-headers-block
| "max-request-body" <integer> (* bytes; per-location override of listener cap *)
| rate-limit-block* (* repeatable; stacked AND *)
| match-block (* optional; predicates AND-evaluated *)
| rewrite-directive (* optional; URL rewrite + re-route *)
rate-limit-block = "rate-limit" {
"rate" <integer> [per=("second" | "minute" | "hour")]
["burst" <integer>] (* default: rate *)
"key" ("client-ip" | "user" | "header" <string>)
}
rewrite-directive = "rewrite" (from=<string> to=<string>)
| "rewrite" { "from" <string>; "to" <string> }
match-block = "match" { match-predicate+ } (* at least one predicate; ANDed *)
match-predicate =
"method" <string>+ (* OR within the list *)
| "header" <string> <string>+ (* name then value(s); ~prefix = regex *)
| "header-absent" <string> (* match when header missing *)
| "query" <string> <string>+ (* parameter name then accepted value(s) *)
| "path" <string>+ (* regex(es) against URI path *)
| "not" { match-predicate+ } (* inner AND, result negated *)
Locations within a vhost are matched by longest-prefix. The path argument is a literal prefix, not a regex.
Handlers
Exactly one handler node must appear inside each
location block.
(* Each handler accepts its primary field as a positional arg, a property,
or a child node. Modifiers (strip-prefix, code) are property/child. *)
handler =
"static" [<string>] [strip-prefix=<boolean>] [{ static-child* }]
| "proxy" [<string>] [strip-prefix=<boolean>] [{ proxy-child* }]
| "redirect" [<string>] [code=<integer>] [{ redirect-child* }]
| "fastcgi" [socket=<string> root=<string> index=<string>] [{ fastcgi-child* }]
| "scgi" [socket=<string> root=<string> index=<string>] [{ scgi-child* }]
| "cgi" (<string> | root=<string> | { "root" <string> })
| "status" (* no arguments *)
| "auth-request" (* no arguments *)
static-child = "root" <string> (* required *)
| "strip-prefix" <boolean> (* default: #false *)
| "index-file" <string>+ (* repeatable; default: "index.html" "index.htm" *)
| "try-files" <string>+ (* repeatable; ordered candidates; `{path}` substitutes the request path *)
proxy-child = "upstream" <string> [weight=<integer>] (* repeatable; "http://host:port" or "unix-stream:/path"; weight default 1 *)
| "proxy-protocol" ("v1" | "v2") (* send HAProxy PROXY header to upstream *)
| "strip-prefix" <boolean> (* default: #false *)
| "scheme" ("auto" | "h3") (* h3 needs https upstream; default auto *)
| "pool-idle-timeout" <integer> (* seconds; 0 disables; default 90 *)
| "pool-max-idle" <integer> (* h1/h2 only *)
| "connect-timeout" <integer> (* seconds *)
| "tls" { "skip-verify" } (* https upstreams only *)
| "lb-policy" ("round-robin" | "least-conn" | "random" | "ip-hash" | "header-hash") [header=<string>]
(* header= required iff header-hash *)
| "active-health" {
["path" <string>] (* default "/" *)
["interval" <integer>] (* seconds; 0 disables *)
["timeout" <integer>] (* seconds; default 2 *)
["expect-status" <integer>] (* default 200 *)
["unhealthy-after" <integer>] (* default 2 *)
["healthy-after" <integer>] (* default 1 *)
}
| "passive-health" {
["eject-after" <integer>] (* default: never *)
["eject-for" <integer>] (* seconds; default 30 *)
}
| "retry" {
["max" <integer>] (* additional attempts; default 0 *)
["on-status" <integer>+] (* required iff max>0 *)
}
redirect-child = "to" <string> (* required *)
| "code" <integer> (* default: 301 *)
fastcgi-child = "socket" <string> (* required; "unix-stream:/path" or "host:port" *)
| "root" <string> (* required *)
| "index" <string>
scgi-child = "socket" <string> (* required *)
| "root" <string> (* required *)
| "index" <string>
auth-request exposes the current request’s
resolved identity as response headers
(X-Auth-User, X-Auth-Groups). It
is used as the upstream endpoint referenced by an
auth subrequest backend on another vhost.
Access enforcement happens before the handler runs, so the
handler only fires when access is allowed.
Access control
(* Inline policy in a location or stream listener,
or named policy defined in a server block: *)
policy-block = "policy" [<string>] { policy-statement* }
(* no name = inline; with name = named policy in server block *)
policy-statement =
"allow" [ predicate ]
| "deny" [ code=<integer> ] [ predicate ] (* default code: 403 *)
| "redirect" to=<string> [ code=<integer> ] [ predicate ] (* default code: 302 *)
| "apply" <string> (* inline rules from a named policy *)
(* A predicate is either inline (args on the statement node) or
a child block (AND across multiple pred-node entries). *)
(* Inline: *) predicate = pred-type <string>*
| "not" pred-type <string>*
(* Block (AND): *) | { pred-node+ }
pred-type = "address" | "country" | "user" | "group" | "authenticated"
(* Inside a child block, each predicate is a node: *)
pred-node =
"address" <cidr-or-ip>+ (* OR across values *)
| "country" <iso2>+ (* OR across values; ISO 3166-1 alpha-2 *)
| "user" <string>+ (* OR across values *)
| "group" <string>+ (* OR across values *)
| "authenticated"
| "not" pred-type <string>* (* negate *)
<cidr-or-ip> = <string> (* e.g. "10.0.0.0/8" or "::1" *)
<iso2> = <string> (* e.g. "US" "DE" *)
policy block with
no matching rule returns 403. Multiple values on one
predicate are OR-combined
(country "US" "CA" matches either).
Multiple predicates in a child block are AND-combined,
evaluated left-to-right with short-circuit.
not inverts a predicate; when it suppresses an
auth challenge (e.g. not authenticated for
an anonymous user) no 401 is issued. Auth predicates
(authenticated, user,
group) automatically return 401 when the user
is anonymous, without requiring an explicit
deny code=401.
apply "name" splices a named policy’s
rules at that point — first-match semantics continue
across the flattened list. Circular references are detected
at startup.
Header operations
basic-auth-block =
"basic-auth" (* realm default: "Restricted" *)
| "basic-auth" realm=<string> (* property form *)
| "basic-auth" { [ "realm" <string> ] } (* block form *)
request-headers-block = "request-headers" { header-op* }
response-headers-block = "response-headers" { header-op* }
header-op =
"set" <string> <string> (* header-name value *)
| "add" <string> <string> (* header-name value *)
| "remove" <string> (* header-name *)