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 the grammar *)
"literal" (* literal KDL 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
| access-policy-def
| error-page-def
TLS defaults
tls-options-block = "tls" { tls-option* }
tls-option = "min-version" ("1.2" | "1.3")
| "cipher" <string>
Authentication backend
auth-backend =
"auth" "pam" [ { pam-child* } ]
| "auth" "ldap" { ldap-child* }
pam-child =
"service" <string> (* default: "login" *)
| "cache-ttl" <integer> (* seconds; default: 60 *)
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 *)
| "cache-ttl" <integer> (* seconds; default: 60 *)
GeoIP, health, named access policies, error pages
geoip-block = "geoip" (<string> | { "db" <string> })
health-block = "health" (<boolean> | { "enabled" <boolean> })?
access-policy-def = "access-policy" <string> { access-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* }]
(* the optional positional string is the bind address *)
listener-child =
"bind" <string> (* if not given as positional *)
| "fd" <integer> (* mutually exclusive with bind *)
| tls-node (* at most one; valid in both modes *)
| "proxy" <string> [{ stream-proxy-opt* }] (* stream mode: upstream address *)
| access-block (* stream mode only; ip/country conditions *)
| "default-vhost" (<string> | #null) (* HTTP mode only; #null disables fallback *)
| timeouts-block (* HTTP mode only *)
(* "proxy" and "access" are mutually exclusive with "default-vhost" and "timeouts" *)
TLS
Three distinct sibling nodes select the TLS mode. Each accepts
its mode-specific values either as KDL properties (one-line form)
or as child nodes (block form), plus optional
min-version and cipher children.
tls-node =
"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>
tls-acme-child =
"domain" <string>+ (* at least one required overall; variadic args allowed *)
| "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.
Timeouts
timeouts-block = "timeouts" timeout-prop* [{ timeout-child* }]
(* properties and child nodes are equivalent; both forms accepted *)
timeout-prop = request-header=<integer> | handler=<integer> | keepalive=<integer>
timeout-child =
"request-header" <integer> (* seconds *)
| "handler" <integer> (* seconds; 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 proxy 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")
| "tls" [{ "skip-verify" }] (* re-TLS to upstream; skip-verify disables cert check *)
access block on a stream listener may only use
ip and country conditions.
user, group, and authenticated
are forbidden because stream mode has no HTTP authentication layer.
proxy and access are mutually exclusive
with default-vhost and timeouts.
vhost
vhost-child =
"alias" <string> [ regex=<boolean> ]
| location
Vhost names and aliases are literal hostnames by default; set
regex=#true on the node 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 *)
| access-block
| basic-auth-block
| request-headers-block
| response-headers-block
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"
static-child = "root" <string> (* required *)
| "strip-prefix" <boolean> (* default: #false *)
| "index-file" <string>+ (* repeatable; default: "index.html" "index.htm" *)
proxy-child = "upstream" <string> (* required; "http://host:port" *)
| "strip-prefix" <boolean> (* default: #false *)
redirect-child = "to" <string> (* required *)
| "code" <integer> (* default: 301 *)
fastcgi-child = "socket" <string> (* required; "unix:/path" or "host:port" *)
| "root" <string> (* required *)
| "index" <string>
scgi-child = "socket" <string> (* required *)
| "root" <string> (* required *)
| "index" <string>
Access control
access-block = "access" { access-statement* }
access-statement =
"allow" [ { condition+ } ]
| "pass" [ { condition+ } ]
| "deny" [ code=<integer> ] [ { condition+ } ] (* default code: 403 *)
| "redirect" to=<string> [ code=<integer> ] [ { condition+ } ] (* default code: 302 *)
| "apply" <string> (* reference a named access-policy from server block *)
condition =
"ip" <cidr-or-ip>+
| "country" <iso2>+ (* 2-letter ISO 3166-1 alpha-2, uppercased *)
| "user" <string>
| "group" <string>
| "authenticated"
<cidr-or-ip> = <string> (* e.g. "10.0.0.0/8" or "192.168.1.1" *)
<iso2> = <string> (* e.g. "US" "DE" *)
country node are OR-combined.
Statements are first-match; an access block with no
matching rule returns 403.
deny and redirect use KDL named properties
(code=403, to="/login/"), not positional
arguments and not child nodes.
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 *)