Files
fwl/doc/fwl_grammar.md

19 KiB

FWL Grammar Specification (MVP)

Overview

FWL is a typed, functional DSL that compiles to nftables JSON. Programs are sequences of top-level declarations. The grammar uses explicit braces and semicolons throughout — no indentation sensitivity. Types are mandatory on all top-level declarations for MVP; inference is deferred to a later version.

The target nftables table is a single table named fwl by default (configurable via a top-level config declaration). Both filter and NAT policies compile into this one table.


Notation

::=   production
|     alternative
{ x } zero or more repetitions of x
[ x ] optional x

String terminals are written in "double quotes". Regex-like character classes use [a-z], etc.


Top-Level Structure

program     ::= { config } { decl }

config      ::= "config" "{" { configProp ";" } "}"

configProp  ::= "table" "=" stringLit

Every non-config declaration is terminated by ";".


Declarations

decl        ::= interfaceDecl
              | zoneDecl
              | importDecl
              | letDecl
              | patternDecl
              | flowDecl
              | ruleDecl
              | policyDecl

Interface

interfaceDecl ::= "interface" ident ":" ifaceKind "{" { ifaceProp ";" } "}" ";"

ifaceKind     ::= "WAN" | "LAN" | "WireGuard" | ident

ifaceProp     ::= "dynamic"
                | "cidr4" "=" cidrSet
                | "cidr6" "=" cidrSet

cidrSet       ::= "{" cidrLit { "," cidrLit } "}"

Zone

zoneDecl    ::= "zone" ident "=" "{" ident { "," ident } "}" ";"

Import

importDecl  ::= "import" ident ":" type "from" stringLit ";"

Let

letDecl     ::= "let" ident ":" type "=" expr ";"

Pattern

Named patterns are first-class; they may appear anywhere a structural pattern appears, including nested inside constructor patterns, Frame(...), and other named patterns.

patternDecl ::= "pattern" ident ":" type "=" packetPat ";"

Named patterns are resolved during type-checking, not by macro-expanding at parse time. Recursive named patterns are a type error.

Flow

flowDecl    ::= "flow" ident ":" "FlowPattern" "=" flowExpr ";"

flowExpr    ::= seqExpr

seqExpr     ::= flowAtom
              | flowAtom "." seqExpr
              | flowAtom "." seqExpr "within" duration

A within clause applies to the entire sequence to its left and binds most tightly to the innermost .. For MVP, within is only valid at the top level of a flowExpr.

flowAtom    ::= ident

Rule

Rules are reusable, named packet-processing functions. They receive a Frame and return an Action (possibly via effects).

ruleDecl    ::= "rule" ident ":" type "=" lambdaExpr ";"

lambdaExpr  ::= "\" ident "->" expr

A rule body must be a lambda at the top level for MVP.

Policy

Policies are the entry points tied to nftables hooks. A policy body is a bare arm-block (no case ... of wrapper; the matched value is always the bound Frame-like parameter of the policy).

policyDecl  ::= "policy" ident ":" type
                "on" "{" hookSpec "}"
                "=" armBlock ";"

hookSpec    ::= hookProp "," hookProp "," hookProp
              | hookProp "," hookProp "," hookProp ","   -- trailing comma OK

hookProp    ::= "hook"     "=" hook
              | "table"    "=" tableName
              | "priority" "=" priority

hook        ::= "Input" | "Forward" | "Output" | "Prerouting" | "Postrouting"
tableName   ::= "Filter" | "NAT"
priority    ::= "Filter" | "DstNat" | "SrcNat" | "Mangle" | intLit

Arm Blocks

Used uniformly inside rule bodies (via case) and policy bodies.

armBlock    ::= "{" { arm } "}"

arm         ::= "|" pat guardOpt "->" expr ";"

guardOpt    ::= ε
              | "if" expr

Patterns

Patterns describe packet structure and bind names. All membership/comparison predicates are guards (see § Expressions), not patterns — except for field constraints inside record patterns, which are written as field predicates.

pat         ::= "_"                          -- wildcard
              | ident                        -- variable binding
              | namedPat                     -- first-class named pattern
              | ctorPat                      -- e.g., IPv4(ip, ...)
              | recordPat                    -- e.g., tcp { dport = :22 }
              | tuplePat                     -- e.g., (udp, payload)
              | framePat                     -- Frame(path, inner)
              | bytePat                      -- e.g., [0x01 _*]

-- A named pattern reference; resolved at type-check time.
-- Binds NO additional names itself (names are bound in the pattern's definition).
namedPat    ::= ident   -- must refer to a declared pattern

ctorPat     ::= ident "(" patList ")"
patList     ::= pat { "," pat }

recordPat   ::= ident "{" [ fieldPatList ] "}"
fieldPatList::= fieldPat { "," fieldPat }
fieldPat    ::= ident "=" literal            -- equality constraint
              | ident                        -- bind field to same-named variable
              | ident "as" ident             -- bind field to fresh variable

tuplePat    ::= "(" pat { "," pat } ")"

-- Frame pattern: optional interface-path specifier, then inner packet pattern.
framePat    ::= "Frame" "(" [ pathPat "," ] pat ")"

-- Interface-path: source, destination, or both.
pathPat     ::= endpointPat
              | endpointPat "->" endpointPat

endpointPat ::= "_"
              | ident                        -- exact interface name
              | ident "in" ident             -- interface is member of zone

Note on : the parser accepts both the Unicode and the ASCII keyword in as synonyms in all positions. The AST stores a single MemberOf constructor.

Byte Patterns

Used in pattern declarations for payload matching.

bytePat     ::= "[" { byteElem } "]"
byteElem    ::= hexByte                      -- e.g., 0x01
              | "_"                          -- any single byte
              | "_*"                         -- zero or more bytes

Expressions

expr        ::= letExpr
              | ifExpr
              | doExpr
              | caseExpr
              | infixExpr

letExpr     ::= "let" ident "=" expr "in" expr

ifExpr      ::= "if" expr "then" expr "else" expr

doExpr      ::= "do" "{" doStmt { ";" doStmt } "}"
doStmt      ::= ident "<-" expr              -- effectful bind
              | expr                         -- effectful sequence

caseExpr    ::= "case" expr "of" armBlock

infixExpr   ::= prefixExpr { infixOp prefixExpr }

prefixExpr  ::= appExpr
              | "!" prefixExpr

appExpr     ::= atom { atom }                -- function application

atom        ::= ident
              | qualName
              | literal
              | tupleLit
              | setLit
              | mapLit
              | performExpr
              | "(" expr ")"

performExpr ::= "perform" qualName "(" [ argList ] ")"

tupleLit    ::= "(" expr "," expr { "," expr } ")"
setLit      ::= "{" [ expr { "," expr } ] "}"
mapLit      ::= "{" mapEntry { "," mapEntry } "}"
mapEntry    ::= expr "->" expr

argList     ::= expr { "," expr }

qualName    ::= ident { "." ident }

infixOp     ::= "&&" | "||"
              | "==" | "!=" | "<" | "<=" | ">" | ">="
              | "in" | "∈"                   -- set/zone membership
              | "++"                         -- string/list concat
              | ">>"                         -- effect sequencing
              | ">>="                        -- monadic bind

Operator precedence (high to low):

Level Operators Assoc
7 application left
6 == != < <= > >= in none
5 && right
4 || right
3 ++ right
2 >>= left
1 >> left

Types

type        ::= funType

funType     ::= effectType
              | effectType "->" funType

effectType  ::= "<" [ ident { "," ident } ] ">" simpleType
              | simpleType

simpleType  ::= ident [ "<" typeList ">" ]   -- parameterised type
              | "(" type { "," type } ")"    -- tuple type
              | "(" type ")"                 -- grouped

typeList    ::= type { "," type }

Effect rows use angle brackets: <FlowMatch, Log> Action.

For MVP, effect annotations are required on rule declarations that contain perform expressions and are optional on policy declarations.


Actions

Action is a built-in type. Its constructors are:

action      ::= "Allow"
              | "Drop"
              | "Continue"
              | "Masquerade"
              | "DNAT" "(" expr ")"
              | "DNATMap" "(" expr ")"
              | "Log" "(" logLevel "," expr ")"

logLevel    ::= "Info" | "Warn" | "Error"

Continue is a legal action value and compiles to nothing (a no-op pass- through). It is used to make exhaustive arm blocks typecheck when earlier arms handle all interesting cases. A policy arm that returns Continue as the last arm is a type error (unreachable or missing terminator); a rule arm may return Continue to signal "pass control back to the caller."


Effects

The built-in effects available for MVP are:

Effect Operations
FlowMatch FlowMatch.check(flowId, pattern) : MatchResult
Log Log.emit(level, msg) : ()
FIB FIB.daddrLocal(ip) : Bool

MatchResult constructors: Matched, Unmatched.

Additional effects may be declared by the user in a future version.


Literals

literal     ::= intLit
              | stringLit
              | boolLit
              | ipv4Lit
              | ipv6Lit
              | cidrLit
              | portLit
              | durationLit
              | hexByte

intLit      ::= ["-"] digit+
stringLit   ::= '"' { strChar } '"'
boolLit     ::= "true" | "false"
ipv4Lit     ::= octet "." octet "." octet "." octet
ipv6Lit     ::= -- standard IPv6 notation including "::" compression
cidrLit     ::= (ipv4Lit | ipv6Lit) "/" digit+
portLit     ::= ":" digit+             -- e.g., :22, :8080
durationLit ::= digit+ timeUnit
timeUnit    ::= "s" | "ms" | "m" | "h"
hexByte     ::= "0x" hexDigit hexDigit
octet       ::= digit+   -- 0-255

Lexical Rules

ident       ::= letter { letter | digit | "_" }
              -- must not be a reserved word

reserved    ::= "config" | "interface" | "zone" | "import" | "let" | "in"
              | "pattern" | "flow" | "rule" | "policy" | "on"
              | "case" | "of" | "if" | "then" | "else" | "do"
              | "perform" | "within" | "as"
              | "WAN" | "LAN" | "WireGuard"
              | "Input" | "Forward" | "Output" | "Prerouting" | "Postrouting"
              | "Filter" | "NAT" | "Mangle" | "DstNat" | "SrcNat"
              | "Allow" | "Drop" | "Continue" | "Masquerade" | "DNAT"
              | "DNATMap" | "Log" | "Info" | "Warn" | "Error"
              | "Matched" | "Unmatched"
              | "dynamic" | "cidr4" | "cidr6" | "table" | "hook" | "priority"
              | "true" | "false"
              | "FlowPattern" | "Frame"

comment     ::= "--" { any char except newline }
              | "{-" { any char } "-}"

whitespace  ::= space | tab | newline | comment

Identifiers beginning with an uppercase letter are treated as constructor names by convention; those beginning with lowercase are variables. The lexer does not enforce this — it is a naming convention only, checked during type-checking.


Resolved Inconsistencies from Proposal

The following decisions were made to normalize the proposal's syntax:

Topic Proposal state MVP decision
Interface body Multiline, no delimiters Braced block with ; separators
Policy body where with indented arms = followed by arm-block
Rule body \frame -> case frame of | ... \ident -> expr; case is a normal expression
Policy vs rule Distinct surface syntax Policies use a bare arm-block; rules use case explicitly
Frame<{}> Unclear <{}> parameter Parsed but ignored for MVP; written as Frame in practice
Named patterns in sub-positions Unclear First-class everywhere; resolved at type-check time
operator Unicode only Both and in accepted everywhere
Continue Unclear semantics Legal Action constructor; compiles to nothing; type error if last arm of a policy block
Single nftables table Not specified Default table name fwl; configurable via config { table = "name"; }
handle syntax Mentioned but unspecified Deferred; MVP only has perform
Effect annotations Inconsistent (<> vs {}) Angle brackets <Eff1, Eff2> everywhere
Guard vs pattern membership Mixed Structural matching in patterns only; in/ in guards only (except fieldPat)

Canonical Examples

The following examples must all parse under the grammar above.

1. Interface and Zone

interface wan : WAN { dynamic; };
interface lan : LAN {
  cidr4 = { 10.17.1.0/24 };
  cidr6 = { fe80::/10, fd12:3456::/48 };
};
interface wg0 : WireGuard {};

zone lan_zone = { lan, wg0 };

2. Import and Let

import rfc1918 : CIDRSet from "builtin:rfc1918";

let forwards : Map<(Protocol, Port), (IP, Port)> = {
  (tcp, :8080) -> (10.17.1.10, :80),
  (tcp, :2222) -> (10.17.1.11, :22)
};

3. Pattern and Flow

pattern WGInitiation : (UDPHeader, Bytes) =
  (udp { length = 156 }, [0x01 _*]);

pattern WGResponse : (UDPHeader, Bytes) =
  (udp { length = 100 }, [0x02 _*]);

flow WireGuardHandshake : FlowPattern =
  WGInitiation . WGResponse within 5s;

4. 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: " ++ show(ip.src));
                    Drop
                  };
              | _ -> Continue;
            };
      | _ -> Continue;
    };

5. Filter Policy

policy input : Frame
  on { hook = Input, table = Filter, priority = Filter }
  = {
    | _ if ct.state in { Established, Related } -> Allow;
    | Frame(lo, _) -> Allow;
    | Frame(_, IPv6(ip6, ICMPv6(_, _)))
        if ip6.src in fe80::/10 -> Allow;
    | Frame(_, IPv4(_, TCP(tcp, _)))
        if tcp.dport == :22 -> Allow;
    | Frame(_, IPv4(_, UDP(udp, _)))
        if udp.dport == :51944 -> Allow;
    | _ -> Drop;
  };

6. NAT Policy

policy nat_prerouting : Frame
  on { hook = Prerouting, table = NAT, priority = DstNat }
  = {
    | Frame(_, IPv4(ip, _)) ->
        if perform FIB.daddrLocal(ip.dst)
        then DNATMap(forwards)
        else Allow;
    | _ -> Allow;
  };

policy nat_postrouting : Frame
  on { hook = Postrouting, table = NAT, priority = SrcNat }
  = {
    | Frame(_ -> wan, IPv4(ip, _)) if ip.src in rfc1918 -> Masquerade;
    | _ -> Allow;
  };

7. Forward Policy calling a Rule

policy forward : Frame
  on { hook = Forward, table = Filter, priority = Filter }
  = {
    | _ if ct.state in { Established, Related }          -> Allow;
    | frame if iif in lan_zone && oif == wan             -> blockOutboundWG(frame);
    | _ if ct.status == DNAT                             -> Allow;
    | Frame(iif in lan_zone -> wan, _)                   -> Allow;
    | Frame(iif in lan_zone -> lan_zone, _)              -> Allow;
    | Frame(wan -> lan_zone, IPv4(ip, TCP(tcp, _)))
        if (ip.dst, tcp.dport) in forwards               -> Allow;
    | _                                                  -> Drop;
  };

Haskell AST Sketch

The following gives the direct mapping from grammar to Haskell types.

-- src/FWL/AST.hs

data Program = Program [Config] [Decl]

data Config = Config { configTable :: Maybe String }

data Decl
  = DInterface  Name IfaceKind [IfaceProp]
  | DZone       Name [Name]
  | DImport     Name Type String
  | DLet        Name Type Expr
  | DPattern    Name Type PacketPat
  | DFlow       Name FlowExpr
  | DRule       Name Type Expr        -- expr must be LamExpr
  | DPolicy     Name Type PolicyMeta ArmBlock

data PolicyMeta = PolicyMeta
  { pmHook     :: Hook
  , pmTable    :: TableName
  , pmPriority :: Priority }

data Hook     = Input | Forward | Output | Prerouting | Postrouting
data TableName= Filter | NAT
data Priority = PFilter | PDstNat | PSrcNat | PMangle | PInt Int

data IfaceKind = WAN | LAN | WireGuard | IKUser Name
data IfaceProp = IPDynamic | IPCidr4 [CIDR] | IPCidr6 [CIDR]

-- Patterns

data Pat
  = PWild
  | PVar  Name
  | PNamed Name               -- named pattern reference (first-class)
  | PCtor Name [Pat]
  | PRecord Name [FieldPat]
  | PTuple [Pat]
  | PFrame (Maybe PathPat) Pat
  | PBytes [ByteElem]

data FieldPat
  = FPEq  Name Literal
  | FPBind Name              -- bind field to same-named var
  | FPAs  Name Name          -- bind field to fresh var

data PathPat  = PathPat (Maybe EndpointPat) (Maybe EndpointPat)
data EndpointPat
  = EPWild
  | EPName Name
  | EPMember Name Name       -- iif `in` zoneName

data ByteElem = BEHex Word8 | BEWild | BEWildStar

-- Flow

data FlowExpr
  = FAtom Name
  | FSeq  FlowExpr FlowExpr (Maybe Duration)

type Duration = (Int, TimeUnit)
data TimeUnit = Seconds | Millis | Minutes | Hours

-- Types

data Type
  = TName Name [Type]
  | TTuple [Type]
  | TFun   Type Type
  | TEffect [Name] Type

-- Expressions

data Expr
  = EVar  Name
  | EQual [Name]
  | ELit  Literal
  | ELam  Name Expr
  | EApp  Expr Expr
  | ECase Expr ArmBlock
  | EIf   Expr Expr Expr
  | EDo   [DoStmt]
  | ELet  Name Expr Expr
  | ETuple [Expr]
  | ESet   [Expr]
  | EMap   [(Expr, Expr)]
  | EPerform [Name] [Expr]  -- perform QualName(args)
  | EInfix InfixOp Expr Expr
  | EPrefix PrefixOp Expr

data InfixOp
  = OpAnd | OpOr | OpEq | OpNeq | OpLt | OpLte | OpGt | OpGte
  | OpIn | OpConcat | OpThen | OpBind

data PrefixOp = OpNot

data DoStmt
  = DSBind Name Expr
  | DSExpr Expr

type ArmBlock = [Arm]
data Arm = Arm Pat (Maybe Expr) Expr   -- pattern, guard, body

-- Actions (constructors of the Action type; parsed as Expr constructors)
-- Allow | Drop | Continue | Masquerade
-- | DNAT Expr | DNATMap Expr | Log LogLevel Expr

data LogLevel = LInfo | LWarn | LError

-- Literals

data Literal
  = LInt    Int
  | LString String
  | LBool   Bool
  | LIPv4   (Word8,Word8,Word8,Word8)
  | LIPv6   [Word16]
  | LCIDR   Literal Int
  | LPort   Int
  | LDuration Int TimeUnit
  | LHex    Word8

type Name = String
type CIDR = (Literal, Int)