- policyDecl: replace verbose on{hook,table,priority} block with
compact `hook <Hook> [priority <P>]` syntax; table is inferred
from hook, priority defaults to canonical value for that hook
- Add portforwardDecl and masqueradeDecl top-level declarations
- Add implicit injection rules for stateful/loopback/ndp to
compiler behaviour section (MVP; importable builtins deferred)
- Remove nat_prerouting / nat_postrouting from canonical policy
example (replaced by portforward/masquerade declarations)
- Update reserved keywords: add portforward, masquerade, hook (was
already reserved), priority (was already reserved); remove table
as a reserved word since it no longer appears in policyDecl
- AGENTS.md: update architecture notes, reserved-words rule, and
boundaries to reflect new declarations and compiler synthesis
17 KiB
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.mdand 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 —
portforwardandmasqueradecompile 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
program ::= { decl }
decl ::= interfaceDecl
| zoneDecl
| importDecl
| letDecl
| patternDecl
| flowDecl
| ruleDecl
| portforwardDecl
| masqueradeDecl
| policyDecl
Declarations
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:
- A named map set from the inline
Map<...>literal. - A
nat hook prerouting priority dstnatchain withfib daddr type localguard anddnat ip torewrite. - A
ct status dnat acceptrule injected into everyForwardpolicy in the same file.
portforwardDecl ::= "portforward" ident
"on" ident
"via" type "=" mapLit ";"
Example:
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.
masqueradeDecl ::= "masquerade" ident
"on" ident
"src" ident ";"
Example:
masquerade wan_snat
on wan
src rfc1918;
The src field must name a Set<IPv4> 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.
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:
-- 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;
};
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
type ::= simpleType
| simpleType "->" type -- function type
| "<" effectList ">" type -- effectful function type
simpleType ::= ident -- type name (Frame, Action, IP, etc.)
| ident "<" typeList ">" -- generic: Map<K,V>, 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
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
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
armBlock ::= "{" { arm } "}"
arm ::= "|" pat ( "if" expr )? "->" expr ";"
Literals
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:
tableis no longer a reserved keyword — it was only used inside the oldon { 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
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
let rfc1918 : Set<IPv4> = { 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
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
pattern WGInitiation : (UDPHeader, Bytes<{}>) =
(udp { length = 156 }, [0x01 _*]);
flow WireGuardHandshake : FlowPattern =
WGInitiation . WGResponse within 5s;
Rule with effects
rule blockOutboundWG : Frame -> <FlowMatch, Log> 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)
-- 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.