# FWL Grammar Specification > **Version:** MVP > **Last updated:** May 2026 > This document is the authoritative grammar reference for the Firewall Language (FWL). > It supersedes the syntax examples in `proposal.md` and reflects the current parser implementation. --- ## Design Principles - **Explicit delimiters everywhere** — all blocks use `{` `}` with trailing `;` on each item. No layout/indentation sensitivity. - **Syntactic keywords are reserved** — only words that structurally delimit declarations or expressions are in `reservedNames`. Semantic values (action names, effect labels, constructors) are plain identifiers. - **Types are explicit** — top-level declarations carry full type annotations in the MVP. - **Patterns vs. guards are strictly separated** — structural decomposition happens in patterns; boolean predicates over bound names happen in guards. - **IP addresses are integers** — IPv4 is a 32-bit value; IPv6 is a 128-bit value. Named priority constants (`Filter`, `SrcNat`, etc.) lower to their canonical integer values at parse time. - **High-level NAT declarations hide nftables mechanics** — `portforward` and `masquerade` compile to their respective prerouting/postrouting chains automatically. Users never write NAT hook policies directly for these common patterns. - **Common filter boilerplate is compiler-injected** — stateful (established/related accept), loopback accept, and link-local NDP accept are automatically prepended to all filter-hook policies by the compiler. Future work: make these importable builtins that can be overridden. --- ## Top-Level Program ```ebnf program ::= { decl } decl ::= interfaceDecl | zoneDecl | importDecl | letDecl | patternDecl | flowDecl | ruleDecl | portforwardDecl | masqueradeDecl | policyDecl ``` --- ## Declarations ```ebnf interfaceDecl ::= "interface" ident ":" ifaceKind "{" { ifaceProp ";" } "}" ";" ifaceKind ::= "WAN" | "LAN" | "WireGuard" | ident ifaceProp ::= "dynamic" | "cidr4" "=" cidrSet | "cidr6" "=" cidrSet cidrSet ::= "{" cidrLit { "," cidrLit } "}" zoneDecl ::= "zone" ident "=" "{" ident { "," ident } "}" ";" importDecl ::= "import" ident ":" type "from" stringLit ";" letDecl ::= "let" ident ":" type "=" expr ";" patternDecl ::= "pattern" ident ":" type "=" pat ";" flowDecl ::= "flow" ident ":" "FlowPattern" "=" flowExpr ";" flowExpr ::= ident | ident "." ident "within" duration ruleDecl ::= "rule" ident ":" type "=" lambdaExpr ";" ``` ### Port-Forward Declaration `portforward` declares an IPv4 DNAT rule. The compiler synthesises: 1. A named map set from the inline `Map<...>` literal. 2. A `nat hook prerouting priority dstnat` chain with `fib daddr type local` guard and `dnat ip to` rewrite. 3. A `ct status dnat accept` rule injected into every `Forward` policy in the same file. ```ebnf portforwardDecl ::= "portforward" ident "on" ident "via" type "=" mapLit ";" ``` **Example:** ```fwl portforward wan_forwards on wan via Map<(Protocol, Port), (IPv4, Port)> = { (tcp, :8080) -> (10.0.0.10, :80) }; ``` ### Masquerade Declaration `masquerade` declares source NAT (masquerade) for outbound traffic. The compiler synthesises a `nat hook postrouting priority srcnat` chain. ```ebnf masqueradeDecl ::= "masquerade" ident "on" ident "src" ident ";" ``` **Example:** ```fwl masquerade wan_snat on wan src rfc1918; ``` The `src` field must name a `Set` bound with `let`. ### Policy Declaration The `on` block is replaced by a compact `hook` clause. The table is inferred from the hook; the priority defaults to the canonical value for that hook and may be overridden. ```ebnf policyDecl ::= "policy" ident ":" type "hook" hook ( "priority" priority )? "=" armBlock ";" ``` | Hook | Inferred table | Default priority | |---------------|---------------|------------------| | `Input` | `filter` | `Filter` (0) | | `Forward` | `filter` | `Filter` (0) | | `Output` | `filter` | `Filter` (0) | | `Prerouting` | `nat` | `DstNat` (-100) | | `Postrouting` | `nat` | `SrcNat` (100) | **Implicit compiler injections for filter-hook policies:** The compiler automatically prepends the following rules to every `Input`, `Forward`, and `Output` policy, before the user-written arms. These do not appear in the FWL source. | Rule | nftables equivalent | Suppressed by | |--------------|-------------------------------------------------------------|---------------| | `stateful` | `ct state { established, related } accept` | *(future: `no-stateful` annotation)* | | `loopback` | `iifname "lo" accept` | *(future: `no-loopback` annotation)* | | `ndp` | `meta nfproto ipv6 ip6 nexthdr ipv6-icmp ip6 saddr fe80::/10 accept` | *(future: `no-ndp` annotation)* | The intended full design is for these to be importable builtins (see `proposal.md`); compiler injection is an MVP simplification. The `ct status dnat accept` rule is also injected into every `Forward` policy when at least one `portforward` declaration exists in the file. **Note:** Because `portforward` and `masquerade` synthesise the NAT chains, explicit `Prerouting` and `Postrouting` policies are not needed for these common patterns. A user-written `Prerouting` or `Postrouting` policy is still valid for advanced NAT cases not covered by the declarative forms. **Example:** ```fwl -- No priority override needed; defaults to Filter (0) policy input : Frame hook Input = { | Frame(_, IPv4(_, TCP(tcp, _))) if tcp.dport in open_ports -> Allow; | _ -> Drop; }; -- Non-default priority example policy mangle_pre : Frame hook Prerouting priority Mangle = { | _ -> Continue; }; ``` ```ebnf hook ::= "Input" | "Forward" | "Output" | "Prerouting" | "Postrouting" -- Priority is always an integer in nftables JSON. -- Named constants are resolved at parse time: -- Raw = -300, ConnTrack = -200, Mangle = -150, -- DstNat = -100, Filter = 0, SrcNat = 100 priority ::= "Filter" | "DstNat" | "SrcNat" | "Mangle" | "Raw" | "ConnTrack" | [ "-" ] nat ``` --- ## Types ```ebnf type ::= simpleType | simpleType "->" type -- function type | "<" effectList ">" type -- effectful function type simpleType ::= ident -- type name (Frame, Action, IP, etc.) | ident "<" typeList ">" -- generic: Map, Bytes<{}> | "(" type { "," type } ")" -- tuple type typeList ::= type { "," type } effectList ::= ident { "," ident } ``` > **Note:** `Frame`, `FlowPattern`, and all action/effect type names (`Action`, `CIDRSet`, etc.) > are plain identifiers in the type parser — they are **not** reserved keywords. --- ## Expressions ```ebnf lambdaExpr ::= "\" ident "->" expr | expr expr ::= ifExpr | doExpr | infixExpr ifExpr ::= "if" expr "then" expr "else" expr doExpr ::= "do" "{" stmt { ";" stmt } "}" stmt ::= "let" ident "=" expr | ident "<-" expr | expr infixExpr ::= prefixExpr { infixOp prefixExpr } infixOp ::= "&&" | "||" | "==" | "!=" | "<" | "<=" | ">" | ">=" | "++" | ">>" | ">>=" | "\u2208" | "in" prefixExpr ::= "!" prefixExpr | appExpr appExpr ::= atom { atom } atom ::= performExpr | mapLit -- { expr -> expr, ... } tried before setLit | setLit -- { expr, ... } | tupleLit -- ( expr, expr, ... ) requires >= 2 | "(" expr ")" | literal | portLit -- :22 :8080 | qualName -- foo foo.bar foo.bar.baz performExpr ::= "perform" qualName "(" argList? ")" argList ::= expr { "," expr } mapLit ::= "{" mapEntry { "," mapEntry } "}" mapEntry ::= expr "->" expr setLit ::= "{" expr { "," expr } "}" tupleLit ::= "(" expr "," expr { "," expr } ")" qualName ::= ident { "." ident } ``` --- ## Patterns ```ebnf pat ::= wildcardPat -- _ | framePat -- Frame(...) | tuplePat -- (p, p, ...) requires >= 2 | bytesPat -- [ byteElem* ] | recordPat -- Ctor { field = lit, ... } | namedOrCtorPat -- Ctor(p,...) or bare identifier | pat "|" pat -- Or-pattern wildcardPat ::= "_" framePat ::= "Frame" "(" frameArgs ")" frameArgs ::= pathPat "," pat -- with explicit path | pat -- path inferred pathPat ::= endpointPat? ( "->" endpointPat? )? endpointPat ::= "_" | ident "in" ident -- iif in lan_zone | ident "\u2208" ident | ident tuplePat ::= "(" pat "," pat { "," pat } ")" bytesPat ::= "[" byteElem* "]" byteElem ::= hexByte -- 0xff | "_" -- any byte | "_" "*" -- zero or more bytes recordPat ::= ident "{" fieldPat { "," fieldPat } "}" fieldPat ::= ident "=" fieldLit -- exact match | ident "in" expr -- membership | ident "\u2208" expr | ident "as" ident -- bind with alias | ident -- bind to same name -- fieldLit extends literal with port syntax fieldLit ::= ":" nat | literal namedOrCtorPat ::= ident "(" pat { "," pat } ")" -- constructor with args | ident -- variable or nullary ctor ``` --- ## Case Arms ```ebnf armBlock ::= "{" { arm } "}" arm ::= "|" pat ( "if" expr )? "->" expr ";" ``` --- ## Literals ```ebnf literal ::= ipOrCidrLit | hexByte -- 0xff | "true" | "false" | stringLit -- "..." | nat -- decimal integer portLit ::= ":" nat -- :22, :8080, :51944 ipOrCidrLit ::= ipLit ( "/" nat )? -- optional prefix -> CIDR ipLit ::= ipv6Lit | ipv4Lit -- IPv4: four decimal octets 0-255 ipv4Lit ::= octet "." octet "." octet "." octet octet ::= nat -- 0..255 -- IPv6: full or compressed notation, optional embedded IPv4 -- All standard forms are supported: -- full: 2001:0db8:85a3:0000:0000:8a2e:0370:7334 -- compressed: 2001:db8::8a2e:370:7334 -- loopback: ::1 -- any: :: -- link-local: fe80::1 -- IPv4-mapped: ::ffff:192.168.1.1 ipv6Lit ::= ipv6Groups ipv6Groups ::= "::" ipv6RightGroups? -- starts with :: | ipv6LeftGroups ( "::" ipv6RightGroups? )? ipv6LeftGroups ::= hex16 { ":" hex16 } -- stops before :: ipv6RightGroups ::= ipv4EmbeddedGroups | ipv6LeftGroups ipv4EmbeddedGroups ::= { hex16 ":" } octet "." octet "." octet "." octet hex16 ::= hexDigit+ -- 1-4 hex digits, value 0x0000..0xffff cidrLit ::= ipLit "/" nat -- must be a CIDR (prefix required) hexByte ::= "0x" hexDigit hexDigit duration ::= nat timeUnit timeUnit ::= "s" | "ms" | "m" | "h" ``` ### Internal IP Representation IP addresses are stored as plain `Integer` values, not tuples or byte arrays: | Type | Storage | Range | |-------|----------|------------------| | IPv4 | 32-bit `Integer` | `0x00000000`..`0xFFFFFFFF` | | IPv6 | 128-bit `Integer` | `0x0`..`0xFFFF...FFFF` | CIDR host-bit validation: `(addr .&. hostMask) == 0` where `hostMask = (1 << (bits - prefix)) - 1`. --- ## Reserved Keywords Only these words are reserved (i.e. `identifier` will reject them): ``` config interface zone import from let in pattern flow rule policy on case of if then else do perform within as dynamic cidr4 cidr6 hook priority portforward masquerade WAN LAN WireGuard Input Forward Output Prerouting Postrouting Filter NAT Mangle DstNat SrcNat Raw ConnTrack true false ``` > **Note:** `table` is no longer a reserved keyword — it was only used inside the old > `on { hook = ..., table = ..., priority = ... }` block, which is removed. The following are **not** reserved and parse as plain identifiers in all positions (type names, constructors, action values, effect labels): ``` Frame FlowPattern Allow Drop Continue Masquerade DNAT DNATMap Log Info Warn Error Matched Unmatched Action Packet IP IPv4 IPv6 Port Protocol CIDRSet Map Bytes ``` --- ## Priority Constants Named priorities resolve to integers at parse time: | Name | Integer value | |-------------|---------------| | `Raw` | -300 | | `ConnTrack` | -200 | | `Mangle` | -150 | | `DstNat` | -100 | | `Filter` | 0 | | `SrcNat` | 100 | Arbitrary integers (including negative, e.g. `-150`) are also accepted. --- ## Operator Precedence From lowest to highest binding: | Level | Operators | Associativity | |-------|------------------------|---------------| | 1 | `if ... then ... else` | — | | 2 | `\|\|` | left | | 3 | `&&` | left | | 4 | `==` `!=` | none | | 5 | `<` `<=` `>` `>=` | none | | 6 | `∈` `in` | none | | 7 | `++` `>>` `>>=` | left | | 8 | `!` (prefix) | — | | 9 | function application | left | --- ## Canonical Examples ### Interface and zone declarations ```fwl interface wan : WAN { dynamic; }; interface lan : LAN { cidr4 = { 10.17.1.0/24 }; }; interface wg0 : WireGuard {}; zone lan_zone = { lan, wg0 }; ``` ### Port-forward and masquerade declarations ```fwl let rfc1918 : Set = { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 }; portforward wan_forwards on wan via Map<(Protocol, Port), (IPv4, Port)> = { (tcp, :8080) -> (10.0.0.10, :80), (tcp, :2222) -> (10.0.0.11, :22) }; masquerade wan_snat on wan src rfc1918; ``` ### Map literal ```fwl let forwards : Map<(Protocol, Port), (IP, Port)> = { (tcp, :8080) -> (10.17.1.10, :80), (tcp, :2222) -> (10.17.1.11, :22) }; ``` ### Named patterns and flows ```fwl pattern WGInitiation : (UDPHeader, Bytes<{}>) = (udp { length = 156 }, [0x01 _*]); flow WireGuardHandshake : FlowPattern = WGInitiation . WGResponse within 5s; ``` ### Rule with effects ```fwl rule blockOutboundWG : Frame -> Action = \frame -> case frame of { | Frame(iif in lan_zone -> wan, IPv4(ip, UDP(udp, payload))) if matches(WGInitiation, (udp, payload)) -> case perform FlowMatch.check(flowOf(ip, wg), WireGuardHandshake) of { | Matched -> do { perform Log.emit(Warn, "WG blocked"); Drop }; | _ -> Continue; }; | _ -> Continue; }; ``` ### Policy (new compact hook syntax) ```fwl -- stateful, loopback, and ndp are injected automatically by the compiler. -- No need to write them in the arm list. policy input : Frame hook Input = { | Frame(_, IPv4(_, TCP(tcp, _))) if tcp.dport in open_ports -> Allow; | Frame(_, IPv4(_, UDP(udp, _))) if udp.dport == :51944 -> Allow; | _ -> Drop; }; policy forward : Frame hook Forward = { -- ct status dnat accept is injected automatically when portforward decls exist. | Frame(iif in lan_zone -> wan, _) -> Allow; | Frame(wan -> iif in lan_zone, IPv6(ip6, TCP(th, _) | UDP(th, _))) if (ip6.protocol, ip6.dst, th.dport) in forwards_v6 -> Allow; | _ -> Drop; }; ``` ### Simple router (full example) See `examples/simple-router.fwl` for the complete canonical simple router example.