- 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
9.0 KiB
AGENTS.md
FWL (Firewall Language) is a Haskell DSL that compiles to nftables JSON. Stack: GHC 9.10.3, Cabal, Parsec 3.x, Aeson 2.x, Tasty/HUnit for tests.
Key Commands
cabal build # build everything
cabal test # run all test suites
cabal run fwlc -- check examples/simple-router.fwl # parse + type-check a source file
cabal run fwlc -- compile examples/simple-router.fwl # emit nftables JSON to stdout
cabal run fwlc -- pretty examples/simple-router.fwl # pretty-print the parsed AST
Run tests before marking any task complete. The test suite is cabal test.
Project Structure
fwl/
├── AGENTS.md
├── doc/
│ ├── proposal.md <- initial design document and exploration
│ ├── fwl_grammar.md <- authoritative grammar reference; keep in sync with Parser.hs
│ └── ref/
│ ├── ruleset.nft <- example nftables ruleset
│ └── ruleset.json <- the same example nftables ruleset in json format
├── examples/
│ ├── simple-router.fwl <- canonical simple example; must parse and compile cleanly
│ ├── simple-router.nft <- compiled nftables text output of simple-router.fwl
│ └── router.fwl <- full router example with WireGuard detection
├── src/FWL/
│ ├── AST.hs <- all data types; source of truth for the AST
│ ├── Lexer.hs <- Parsec TokenParser, reservedNames, reservedOpNames
│ ├── Parser.hs <- top-level parser, all sub-parsers
│ ├── Pretty.hs <- AST -> FWL source (round-trip printer)
│ ├── TypeCheck.hs <- effect row checker, exhaustiveness, CIDR intervals
│ ├── Interpret.hs <- evaluator + effect dispatch
│ ├── Compile.hs <- AST -> nftables JSON (Aeson Value)
│ └── Util.hs <- shared helpers
└── test/
├── Main.hs
├── ParserTests.hs
├── TypeCheckTests.hs
└── CompileTests.hs
The grammar document at doc/fwl_grammar.md must stay in sync with Parser.hs and Lexer.hs.
When changing the parser, update the grammar doc in the same commit.
Architecture
The pipeline is strictly linear with no back-edges:
source text
-> Lexer (Text.Parsec.Token)
-> Parser -> [Decl] (AST.hs)
-> TypeCheck -> TypedDecl
-> Compile -> Aeson Value (nftables JSON)
The interpreter (Interpret.hs) runs the policy against a mock packet environment
and is separate from the compiler. It uses the same typed AST.
Compiler Synthesis
These constructs are synthesised by Compile.hs and do not appear directly in the
nftables output as user-written rules:
| FWL construct | Synthesised nftables output |
|---|---|
portforward decl |
A named map, a nat hook prerouting priority dstnat chain with fib daddr type local guard and dnat ip to ... map rewrite, and a ct status dnat accept rule injected into every Forward chain in the file. |
masquerade decl |
A nat hook postrouting priority srcnat chain with ip saddr @set masquerade rule. |
| Filter hook policy | ct state { established, related } accept (stateful), iifname "lo" accept (loopback), and meta nfproto ipv6 ip6 nexthdr ipv6-icmp ip6 saddr fe80::/10 accept (NDP) are prepended automatically to every Input, Forward, and Output chain before user-written rules. |
These injections are intentional and documented in doc/fwl_grammar.md. Do not remove
them without updating the grammar document and all affected tests.
Reserved Words Rule
Only syntactic keywords belong in reservedNames in Lexer.hs.
A word is a syntactic keyword if and only if Parser.hs uses reserved "word" for it.
Semantic values — action constructors (Allow, Drop, Masquerade),
effect labels (Log, Warn, Error), result constructors (Matched, Unmatched),
and type names (Frame, FlowPattern, Action) — must NOT be in reservedNames.
They are parsed as plain identifiers so they can appear in type, pattern,
and expression positions without causing parse errors.
If you add a new keyword: add it to both reservedNames in Lexer.hs
AND use reserved "word" in Parser.hs. Never add a word to only one place.
Current reserved keywords:
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
tableis not a reserved keyword — it was removed whenpolicyDeclswitched from the verboseon { hook = ..., table = ..., priority = ... }syntax to the compacthook <Hook> [priority <P>]form.
Policy Hook Syntax
The on { hook = ..., table = ..., priority = ... } block is gone.
Policies now use:
policy name : Frame hook Input = { ... };
-- or with a non-default priority:
policy name : Frame hook Prerouting priority Mangle = { ... };
The table is inferred from the hook; the priority defaults to the canonical
value for that hook. See doc/fwl_grammar.md → Policy Declaration for the
full hook-to-table-to-priority mapping.
IP Address Representation
IP addresses are stored as plain Integer in the AST (see AST.hs):
- IPv4: 32-bit value in the low 32 bits of
Integer. - IPv6: 128-bit value. All standard notations are supported including
::compression and embedded IPv4 (e.g.::ffff:192.168.1.1). - CIDR:
(Literal, Int)— base address literal + prefix length. - Validation: host bits must be zero:
(addr .&. hostMask prefix bits) == 0.
Use ipv4Lit a b c d from AST.hs to construct IPv4 literals in tests.
Never use tuple (Word8, Word8, Word8, Word8) — that type is gone.
Priority
Priority is newtype Priority = Priority { priorityValue :: Int }.
Named constants are resolved at parse time in priorityP:
| Name | Value |
|---|---|
Raw |
-300 |
ConnTrack |
-200 |
Mangle |
-150 |
DstNat |
-100 |
Filter |
0 |
SrcNat |
100 |
The compiler emits "prio": <int> — always an integer in the nftables JSON,
never a string. Do not use the old priorityStr function (deleted).
Parser Conventions
- All blocks use explicit
{ }delimiters with trailing;on each item.endBy p semi(notsemiSep) is used wherever trailing semicolons are expected. mapLitmust be tried beforesetLitinatom— both start with{andmapLitconsumes{ expr -> expr }whichsetLitwould misparse.framePatmust be wrapped intryin thepatalternatives — it is a reserved-word-prefixed parser that can fail after consuming input.- Port literals (
:22,:8080) in record field patterns usefieldLiteral, notliteral— the baseliteralparser does not handle:Nsyntax. FrameandFlowPatternare NOT inreservedNames; they appear as type names and must be accepted byidentifier.portforwardandmasqueradeare inreservedNames; their parsers (portforwardDeclP,masqueradeDeclP) must usereservedfor these words.
Testing Conventions
- Test files use
{-# LANGUAGE OverloadedStrings #-}— required becauseA.StringexpectsData.Text.Text, notString. - IP address assertions use
LIP IPv4 n/LIP IPv6 n, not the oldLIPv4 (a,b,c,d)tuple constructors. - Priority assertions use
Priority ndirectly, e.g.Priority 0,Priority (-100). - All parse tests must compile and pass before any PR is merged.
CompileTests.hsmust include tests forportforwardandmasqueradesynthesis (synthesised chain names, injectedct status dnat accept, injected stateful/loopback/ndp rules).
Boundaries
✅ Safe to do without asking
- Read any file, list directories
- Run
cabal build,cabal test,cabal run fwlc - Edit
src/,test/,examples/,doc/ - Add new test cases to existing test files
⚠️ Ask first
- Add or remove Cabal dependencies (
fwl.cabal) - Rename or delete source modules
- Change the nftables JSON schema emitted by
Compile.hs - Modify
examples/simple-router.fwlorexamples/router.fwlin ways that change their semantics - Add new compiler-injected rules (stateful, loopback, ndp, or new ones)
🚫 Never
- Add semantic value names (
Allow,Drop,Log, etc.) toreservedNames - Add
tabletoreservedNames— it is not a keyword in the current grammar - Break the
cabal testsuite - Emit nftables
"prio"as a string — it must always be an integer - Remove the implicit stateful/loopback/ndp injections from filter-hook chain compilation without updating the grammar doc and all tests