aloha
Config Grammar (EBNF) ← Home

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 *)
i See the Configuration Reference for prose explanations, defaults, and annotated examples for every node.

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* }
i 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 *)
i The 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" *)
i Condition semantics: multiple condition nodes inside the same block are AND-combined across types. Multiple values on a single country node are OR-combined. Statements are first-match; an access block with no matching rule returns 403.
i 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 *)