crazy mega refactor

This commit is contained in:
2026-05-04 03:16:40 -07:00
parent 55c1d347e6
commit 6d96e2d159
11 changed files with 686 additions and 616 deletions

View File

@@ -1,69 +1,37 @@
interface wan : WAN { dynamic; }; interface wan : WAN { dynamic; };
interface lan : LAN { cidr4 = { 10.0.0.0/24 }; }; interface lan : LAN { cidr4 = { 10.0.0.0/24 }; };
zone lan_zone = { lan }; zone lan_zone = { lan };
let rfc1918 : Set<IPv4> = { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 }; let rfc1918 : Set<IPv4> = { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 };
-- Single IPv4 port forward: tcp:8080 -> 10.0.0.10:80
let forwards : Map<(Protocol, Port), (IP, Port)> = {
(tcp, :8080) -> (10.0.0.10, :80)
};
-- Open inbound ports on the router itself
let open_ports : Set<Port> = { :22 }; let open_ports : Set<Port> = { :22 };
-- IPv6 forwarded destination: tcp . 2001:db8::1 . 22000 let forwards_v6 : Set<(Protocol, IPv6, Port)> = {
let forwards_v6 : Set<(Protocol, IP, Port)> = { (tcp, 2001:db8::1, :22000)
(tcp, 2001:db8::1, :22000)
}; };
policy input : Frame portforward wan_forwards
on { hook = Input, table = Filter, priority = Filter } on wan
= { via Map<(Protocol, Port), (IPv4, Port)> = {
| _ if ct.state in { Established, Related } -> Allow; (tcp, :8080) -> (10.0.0.10, :80)
| Frame(lo, _) -> Allow; };
| Frame(_, IPv6(ip6, ICMPv6(_, _)))
if ip6.src in fe80::/10 -> Allow; masquerade wan_snat
on wan
src rfc1918;
policy input : Frame hook Input = {
| Frame(_, IPv4(_, TCP(tcp, _))) | Frame(_, IPv4(_, TCP(tcp, _)))
if tcp.dport in open_ports -> Allow; if tcp.dport in open_ports -> Allow;
| Frame(_, IPv4(_, UDP(udp, _))) | Frame(_, IPv4(_, UDP(udp, _)))
if udp.dport == :51944 -> Allow; if udp.dport == :51944 -> Allow;
| _ -> Drop; | _ -> Drop;
}; };
policy forward : Frame policy forward : Frame hook Forward = {
on { hook = Forward, table = Filter, priority = Filter } | Frame(iif in lan_zone -> wan, _) -> Allow;
= {
| _ if ct.state in { Established, Related } -> Allow;
| _ if ct.status == DNAT -> Allow;
| Frame(iif in lan_zone -> wan, _) -> Allow;
| Frame(wan -> iif in lan_zone, IPv4(ip, TCP(th, _) | UDP(th, _)))
if (ip.protocol, th.dport) in forwards -> Allow;
| Frame(wan -> iif in lan_zone, IPv6(ip6, TCP(th, _) | UDP(th, _))) | Frame(wan -> iif in lan_zone, IPv6(ip6, TCP(th, _) | UDP(th, _)))
if (ip6.protocol, ip6.dst, th.dport) in forwards_v6 -> Allow; if (ip6.protocol, ip6.dst, th.dport) in forwards_v6 -> Allow;
| _ -> Drop; | _ -> Drop;
}; };
policy output : Frame
on { hook = Output, table = Filter, priority = Filter }
= {
| _ -> Allow;
};
policy nat_prerouting : Frame
on { hook = Prerouting, table = NAT, priority = DstNat }
= {
| Frame(_, IPv4(ip, TCP(th, _) | UDP(th, _))) ->
if perform FIB.daddrLocal(ip.dst)
then DNATMap((ip.protocol, th.dport), 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;
};

View File

@@ -33,22 +33,11 @@
"type": "filter" "type": "filter"
} }
}, },
{
"chain": {
"family": "inet",
"hook": "output",
"name": "output",
"policy": "accept",
"prio": 0,
"table": "fwl",
"type": "filter"
}
},
{ {
"chain": { "chain": {
"family": "inet", "family": "inet",
"hook": "prerouting", "hook": "prerouting",
"name": "nat_prerouting", "name": "wan_forwards_prerouting",
"policy": "accept", "policy": "accept",
"prio": -100, "prio": -100,
"table": "fwl", "table": "fwl",
@@ -59,41 +48,13 @@
"chain": { "chain": {
"family": "inet", "family": "inet",
"hook": "postrouting", "hook": "postrouting",
"name": "nat_postrouting", "name": "wan_snat_postrouting",
"policy": "accept", "policy": "accept",
"prio": 100, "prio": 100,
"table": "fwl", "table": "fwl",
"type": "nat" "type": "nat"
} }
}, },
{
"set": {
"elem": [
{
"prefix": {
"addr": "10.0.0.0",
"len": 8
}
},
{
"prefix": {
"addr": "172.16.0.0",
"len": 12
}
},
{
"prefix": {
"addr": "192.168.0.0",
"len": 16
}
}
],
"family": "inet",
"name": "rfc1918",
"table": "fwl",
"type": "ipv4_addr"
}
},
{ {
"map": { "map": {
"elem": [ "elem": [
@@ -117,7 +78,7 @@
"ipv4_addr", "ipv4_addr",
"inet_service" "inet_service"
], ],
"name": "forwards", "name": "wan_forwards",
"table": "fwl", "table": "fwl",
"type": [ "type": [
"inet_proto", "inet_proto",
@@ -125,6 +86,37 @@
] ]
} }
}, },
{
"set": {
"elem": [
{
"prefix": {
"addr": "10.0.0.0",
"len": 8
}
},
{
"prefix": {
"addr": "172.16.0.0",
"len": 12
}
},
{
"prefix": {
"addr": "192.168.0.0",
"len": 16
}
}
],
"family": "inet",
"flags": [
"interval"
],
"name": "rfc1918",
"table": "fwl",
"type": "ipv4_addr"
}
},
{ {
"set": { "set": {
"elem": [ "elem": [
@@ -152,7 +144,7 @@
"table": "fwl", "table": "fwl",
"type": [ "type": [
"inet_proto", "inet_proto",
"ipv4_addr", "ipv6_addr",
"inet_service" "inet_service"
] ]
} }
@@ -168,11 +160,13 @@
"key": "state" "key": "state"
} }
}, },
"op": "in", "op": "==",
"right": [ "right": {
"established", "set": [
"related" "established",
] "related"
]
}
} }
}, },
{ {
@@ -210,17 +204,6 @@
"rule": { "rule": {
"chain": "input", "chain": "input",
"expr": [ "expr": [
{
"match": {
"left": {
"meta": {
"key": "nfproto"
}
},
"op": "==",
"right": "ipv6"
}
},
{ {
"match": { "match": {
"left": { "left": {
@@ -244,7 +227,7 @@
"op": "==", "op": "==",
"right": { "right": {
"prefix": { "prefix": {
"addr": "fe80:0:0:0:0:0:0:0", "addr": "fe80::",
"len": 10 "len": 10
} }
} }
@@ -373,11 +356,13 @@
"key": "state" "key": "state"
} }
}, },
"op": "in", "op": "==",
"right": [ "right": {
"established", "set": [
"related" "established",
] "related"
]
}
} }
}, },
{ {
@@ -399,7 +384,7 @@
"key": "status" "key": "status"
} }
}, },
"op": "==", "op": "in",
"right": "dnat" "right": "dnat"
} }
}, },
@@ -449,170 +434,6 @@
"table": "fwl" "table": "fwl"
} }
}, },
{
"rule": {
"chain": "forward",
"expr": [
{
"match": {
"left": {
"meta": {
"key": "iifname"
}
},
"op": "==",
"right": "wan"
}
},
{
"match": {
"left": {
"meta": {
"key": "oifname"
}
},
"op": "in",
"right": {
"set": [
"lan"
]
}
}
},
{
"match": {
"left": {
"meta": {
"key": "nfproto"
}
},
"op": "==",
"right": "ipv4"
}
},
{
"match": {
"left": {
"meta": {
"key": "l4proto"
}
},
"op": "==",
"right": "tcp"
}
},
{
"match": {
"left": {
"concat": [
{
"payload": {
"field": "protocol",
"protocol": "ip"
}
},
{
"payload": {
"field": "dport",
"protocol": "th"
}
}
]
},
"op": "==",
"right": "@forwards"
}
},
{
"accept": null
}
],
"family": "inet",
"table": "fwl"
}
},
{
"rule": {
"chain": "forward",
"expr": [
{
"match": {
"left": {
"meta": {
"key": "iifname"
}
},
"op": "==",
"right": "wan"
}
},
{
"match": {
"left": {
"meta": {
"key": "oifname"
}
},
"op": "in",
"right": {
"set": [
"lan"
]
}
}
},
{
"match": {
"left": {
"meta": {
"key": "nfproto"
}
},
"op": "==",
"right": "ipv4"
}
},
{
"match": {
"left": {
"meta": {
"key": "l4proto"
}
},
"op": "==",
"right": "udp"
}
},
{
"match": {
"left": {
"concat": [
{
"payload": {
"field": "protocol",
"protocol": "ip"
}
},
{
"payload": {
"field": "dport",
"protocol": "th"
}
}
]
},
"op": "==",
"right": "@forwards"
}
},
{
"accept": null
}
],
"family": "inet",
"table": "fwl"
}
},
{ {
"rule": { "rule": {
"chain": "forward", "chain": "forward",
@@ -670,9 +491,8 @@
"left": { "left": {
"concat": [ "concat": [
{ {
"payload": { "meta": {
"field": "protocol", "key": "l4proto"
"protocol": "ip6"
} }
}, },
{ {
@@ -758,9 +578,8 @@
"left": { "left": {
"concat": [ "concat": [
{ {
"payload": { "meta": {
"field": "protocol", "key": "l4proto"
"protocol": "ip6"
} }
}, },
{ {
@@ -803,19 +622,7 @@
}, },
{ {
"rule": { "rule": {
"chain": "output", "chain": "wan_forwards_prerouting",
"expr": [
{
"accept": null
}
],
"family": "inet",
"table": "fwl"
}
},
{
"rule": {
"chain": "nat_prerouting",
"expr": [ "expr": [
{ {
"match": { "match": {
@@ -835,46 +642,53 @@
"key": "l4proto" "key": "l4proto"
} }
}, },
"op": "==", "op": "in",
"right": "tcp" "right": {
} "set": [
}, "tcp",
{ "udp"
"accept": null ]
} }
],
"family": "inet",
"table": "fwl"
}
},
{
"rule": {
"chain": "nat_prerouting",
"expr": [
{
"match": {
"left": {
"meta": {
"key": "nfproto"
}
},
"op": "==",
"right": "ipv4"
} }
}, },
{ {
"match": { "match": {
"left": { "left": {
"meta": { "fib": {
"key": "l4proto" "flags": [
"daddr"
],
"result": "type"
} }
}, },
"op": "==", "op": "==",
"right": "udp" "right": "local"
} }
}, },
{ {
"accept": null "dnat": {
"addr": {
"map": {
"data": "@wan_forwards",
"key": {
"concat": [
{
"meta": {
"key": "l4proto"
}
},
{
"payload": {
"field": "dport",
"protocol": "th"
}
}
]
}
}
},
"family": "ip"
}
} }
], ],
"family": "inet", "family": "inet",
@@ -883,19 +697,7 @@
}, },
{ {
"rule": { "rule": {
"chain": "nat_prerouting", "chain": "wan_snat_postrouting",
"expr": [
{
"accept": null
}
],
"family": "inet",
"table": "fwl"
}
},
{
"rule": {
"chain": "nat_postrouting",
"expr": [ "expr": [
{ {
"match": { "match": {
@@ -908,17 +710,6 @@
"right": "wan" "right": "wan"
} }
}, },
{
"match": {
"left": {
"meta": {
"key": "nfproto"
}
},
"op": "==",
"right": "ipv4"
}
},
{ {
"match": { "match": {
"left": { "left": {
@@ -938,18 +729,6 @@
"family": "inet", "family": "inet",
"table": "fwl" "table": "fwl"
} }
},
{
"rule": {
"chain": "nat_postrouting",
"expr": [
{
"accept": null
}
],
"family": "inet",
"table": "fwl"
}
} }
] ]
} }

View File

@@ -22,14 +22,18 @@ defaultConfig = Config { configTable = "fwl" }
-- ─── Declarations ─────────────────────────────────────────────────────────── -- ─── Declarations ───────────────────────────────────────────────────────────
data Decl data Decl
= DInterface Name IfaceKind [IfaceProp] = DInterface Name IfaceKind [IfaceProp]
| DZone Name [Name] | DZone Name [Name]
| DImport Name Type FilePath | DImport Name Type FilePath
| DLet Name Type Expr | DLet Name Type Expr
| DPattern Name Type Pat | DPattern Name Type Pat
| DFlow Name FlowExpr | DFlow Name FlowExpr
| DRule Name Type Expr | DRule Name Type Expr
| DPolicy Name Type PolicyMeta ArmBlock | DPolicy Name Type PolicyMeta ArmBlock
| DPortForward Name Name Type [(Expr, Expr)]
-- ^ decl-name interface-name map-type map-entries
| DMasquerade Name Name Name
-- ^ decl-name interface-name src-set-name
deriving (Show) deriving (Show)
data PolicyMeta = PolicyMeta data PolicyMeta = PolicyMeta

View File

@@ -50,6 +50,8 @@ buildEnv = foldl' addDecl Map.empty
addDecl m (DFlow n _) = Map.insert n KFlow m addDecl m (DFlow n _) = Map.insert n KFlow m
addDecl m (DRule n _ _) = Map.insert n KRule m addDecl m (DRule n _ _) = Map.insert n KRule m
addDecl m (DPolicy n _ _ _) = Map.insert n KPolicy m addDecl m (DPolicy n _ _ _) = Map.insert n KPolicy m
addDecl m (DPortForward n _ _ _) = Map.insert n KLet m
addDecl m (DMasquerade n _ _) = Map.insert n KLet m
findDups :: [Decl] -> [CheckError] findDups :: [Decl] -> [CheckError]
findDups decls = go [] Set.empty decls findDups decls = go [] Set.empty decls
@@ -70,6 +72,8 @@ declName (DPattern n _ _) = n
declName (DFlow n _) = n declName (DFlow n _) = n
declName (DRule n _ _) = n declName (DRule n _ _) = n
declName (DPolicy n _ _ _) = n declName (DPolicy n _ _ _) = n
declName (DPortForward n _ _ _) = n
declName (DMasquerade n _ _) = n
declKindStr :: Decl -> String declKindStr :: Decl -> String
declKindStr (DInterface _ _ _) = "interface" declKindStr (DInterface _ _ _) = "interface"
@@ -80,6 +84,8 @@ declKindStr (DPattern _ _ _) = "pattern"
declKindStr (DFlow _ _) = "flow" declKindStr (DFlow _ _) = "flow"
declKindStr (DRule _ _ _) = "rule" declKindStr (DRule _ _ _) = "rule"
declKindStr (DPolicy _ _ _ _) = "policy" declKindStr (DPolicy _ _ _ _) = "policy"
declKindStr (DPortForward _ _ _ _) = "portforward"
declKindStr (DMasquerade _ _ _) = "masquerade"
-- ─── Name resolution ───────────────────────────────────────────────────────── -- ─── Name resolution ─────────────────────────────────────────────────────────
@@ -90,6 +96,12 @@ checkDecl env (DFlow _ fe) = checkFlow env fe
checkDecl env (DRule _ _ e) = checkExpr env e checkDecl env (DRule _ _ e) = checkExpr env e
checkDecl env (DPolicy _ _ _ ab) = concatMap (checkArm env) ab checkDecl env (DPolicy _ _ _ ab) = concatMap (checkArm env) ab
checkDecl env (DLet _ _ e) = checkExpr env e checkDecl env (DLet _ _ e) = checkExpr env e
checkDecl env (DPortForward _ iface _ entries) =
checkName env "interface" iface ++
concatMap (\(k,v) -> checkExpr env k ++ checkExpr env v) entries
checkDecl env (DMasquerade _ iface srcSet) =
checkName env "interface" iface ++
checkName env "set" srcSet
checkDecl _ _ = [] checkDecl _ _ = []
checkName :: Env -> String -> String -> [CheckError] checkName :: Env -> String -> String -> [CheckError]

View File

@@ -32,23 +32,173 @@ compileProgram = programToValue
programToValue :: Program -> Value programToValue :: Program -> Value
programToValue (Program cfg decls) = programToValue (Program cfg decls) =
object [ "nftables" .= toJSON object [ "nftables" .= toJSON
(metainfo : tableObj : chainObjs ++ mapObjs ++ ruleObjs) ] (metainfo : tableObj : allObjects) ]
where where
env = buildEnv decls env = buildEnv decls
tbl = configTable cfg tbl = configTable cfg
metainfo = object [ "metainfo" .= object metainfo = object [ "metainfo" .= object
[ "json_schema_version" .= (1 :: Int) ] ] [ "json_schema_version" .= (1 :: Int) ] ]
tableObj = object [ "table" .= tableValue tbl ] tableObj = object [ "table" .= tableValue tbl ]
policies = [ (n, pm, ab) | DPolicy n _ pm ab <- decls ] policies = [ (n, pm, ab) | DPolicy n _ pm ab <- decls ]
chainObjs = map (\(n, pm, _ ) -> chainDeclValue tbl n pm) policies portfwds = [ d | d@(DPortForward {}) <- decls ]
ruleObjs = concatMap masqs = [ d | d@(DMasquerade {}) <- decls ]
(\(n, _, ab) -> concatMap (armToRuleValues env tbl n) ab) hasPortFwd = not (null portfwds)
policies
-- Chain declarations: policy chains + synthesised NAT chains
policyChainObjs = map (\(n, pm, _) -> chainDeclValue tbl n pm) policies
pfChainObjs = concatMap (portfwdChainValue tbl) portfwds
masqChainObjs = concatMap (masqChainValue tbl) masqs
-- Rules: policy arms + implicit injections + synthesised NAT rules
policyRuleObjs = concatMap
(\(n, pm, ab) ->
injectFilterRules env tbl n pm hasPortFwd ++
concatMap (armToRuleValues env tbl n) ab)
policies
pfRuleObjs = concatMap (portfwdRuleValues env tbl) portfwds
masqRuleObjs = concatMap (masqRuleValues env tbl) masqs
-- Sets / maps from let-bindings
letDecls = [ (n, t, e) | DLet n t e <- decls ]
mapObjs = mapMaybe (\(n, t, e) -> letToSetOrMapValue tbl n t e) letDecls
-- Synthesised maps from portforward decls
pfMapObjs = concatMap (portfwdMapValue tbl) portfwds
allObjects = policyChainObjs ++ pfChainObjs ++ masqChainObjs
++ pfMapObjs ++ mapObjs
++ policyRuleObjs ++ pfRuleObjs ++ masqRuleObjs
-- ─── Implicit filter-hook rule injection ─────────────────────────────────────
-- | Prepend implicit rules for filter-hook chains (Input/Forward/Output).
injectFilterRules :: CompileEnv -> String -> Name -> PolicyMeta -> Bool -> [Value]
injectFilterRules env tbl chain pm hasPortFwd =
case pmHook pm of
HInput -> [statefulRule, loopbackRule, ndpRule]
HForward -> statefulRule : if hasPortFwd then [ctDnatRule] else []
HOutput -> [statefulRule]
_ -> []
where
statefulRule = ruleValue tbl chain
[ matchExpr "==" (object ["ct" .= object ["key" .= ("state" :: String)]])
(setVal [A.String "established", A.String "related"])
, object ["accept" .= Null]
]
loopbackRule = ruleValue tbl chain
[ matchMeta "iifname" "lo"
, object ["accept" .= Null]
]
ndpRule = ruleValue tbl chain
[ matchPayload "ip6" "nexthdr" "ipv6-icmp"
, matchExpr "==" (payloadVal "ip6" "saddr")
(object ["prefix" .= object ["addr" .= A.String "fe80::", "len" .= (10 :: Int)]])
, object ["accept" .= Null]
]
ctDnatRule = ruleValue tbl chain
[ matchExpr "in" (object ["ct" .= object ["key" .= ("status" :: String)]])
(A.String "dnat")
, object ["accept" .= Null]
]
-- silence unused env warning
_ = env
ruleValue :: String -> String -> [Value] -> Value
ruleValue tbl chain exprs = object
[ "rule" .= object
[ "family" .= ("inet" :: String)
, "table" .= tbl
, "chain" .= chain
, "expr" .= toJSON exprs
]
]
-- ─── DPortForward compilation ─────────────────────────────────────────────────
portfwdMapValue :: String -> Decl -> [Value]
portfwdMapValue tbl (DPortForward n _ t entries) =
case t of
TName "Map" [tk, tv] ->
[ object [ "map" .= object
[ "family" .= ("inet" :: String)
, "table" .= tbl
, "name" .= n
, "type" .= renderNftType (fwlTypeToNft tk)
, "map" .= renderNftType (fwlTypeToNft tv)
, "elem" .= toJSON (map renderMapElem entries)
] ]
]
_ -> []
portfwdMapValue _ _ = []
portfwdChainValue :: String -> Decl -> [Value]
portfwdChainValue tbl (DPortForward n _ _ _) =
[ object [ "chain" .= object
[ "family" .= ("inet" :: String)
, "table" .= tbl
, "name" .= (n ++ "_prerouting")
, "type" .= ("nat" :: String)
, "hook" .= ("prerouting" :: String)
, "prio" .= priorityInt pDstNat
, "policy" .= ("accept" :: String)
] ]
]
portfwdChainValue _ _ = []
portfwdRuleValues :: CompileEnv -> String -> Decl -> [Value]
portfwdRuleValues _ tbl (DPortForward n _ _ _) =
let chainName = n ++ "_prerouting"
in [ ruleValue tbl chainName
[ matchMeta "nfproto" "ipv4"
, matchInSet (metaVal "l4proto") [A.String "tcp", A.String "udp"]
, matchExpr "==" (object ["fib" .= object ["result" .= ("type" :: String), "flags" .= toJSON (["daddr"] :: [String])]])
(A.String "local")
, object ["dnat" .= object
[ "family" .= ("ip" :: String)
, "addr" .= object
[ "map" .= object
[ "key" .= object ["concat" .= toJSON
[ metaVal "l4proto"
, payloadVal "th" "dport"
]]
, "data" .= A.String (toText ("@" ++ n))
]
]
]]
]
]
portfwdRuleValues _ _ _ = []
-- ─── DMasquerade compilation ──────────────────────────────────────────────────
masqChainValue :: String -> Decl -> [Value]
masqChainValue tbl (DMasquerade n _ _) =
[ object [ "chain" .= object
[ "family" .= ("inet" :: String)
, "table" .= tbl
, "name" .= (n ++ "_postrouting")
, "type" .= ("nat" :: String)
, "hook" .= ("postrouting" :: String)
, "prio" .= priorityInt pSrcNat
, "policy" .= ("accept" :: String)
] ]
]
masqChainValue _ _ = []
masqRuleValues :: CompileEnv -> String -> Decl -> [Value]
masqRuleValues _ tbl (DMasquerade n iface srcSet) =
let chainName = n ++ "_postrouting"
in [ ruleValue tbl chainName
[ matchMeta "oifname" iface
, matchExpr "==" (payloadVal "ip" "saddr")
(A.String (toText ("@" ++ srcSet)))
, object ["masquerade" .= Null]
]
]
masqRuleValues _ _ _ = []
letDecls = [ (n, t, e) | DLet n t e <- decls ]
mapObjs = mapMaybe (\(n, t, e) -> letToSetOrMapValue tbl n t e) letDecls
-- ─── Table / Chain declarations ────────────────────────────────────────────── -- ─── Table / Chain declarations ──────────────────────────────────────────────
@@ -117,14 +267,16 @@ type CompileEnv = Map.Map String Decl
buildEnv :: [Decl] -> CompileEnv buildEnv :: [Decl] -> CompileEnv
buildEnv = foldr (\d m -> Map.insert (declNameOf d) d m) Map.empty buildEnv = foldr (\d m -> Map.insert (declNameOf d) d m) Map.empty
where where
declNameOf (DInterface n _ _) = n declNameOf (DInterface n _ _) = n
declNameOf (DZone n _) = n declNameOf (DZone n _) = n
declNameOf (DPattern n _ _) = n declNameOf (DPattern n _ _) = n
declNameOf (DFlow n _) = n declNameOf (DFlow n _) = n
declNameOf (DRule n _ _) = n declNameOf (DRule n _ _) = n
declNameOf (DPolicy n _ _ _) = n declNameOf (DPolicy n _ _ _) = n
declNameOf (DLet n _ _) = n declNameOf (DLet n _ _) = n
declNameOf (DImport n _ _) = n declNameOf (DImport n _ _) = n
declNameOf (DPortForward n _ _ _) = n
declNameOf (DMasquerade n _ _) = n
compilePat :: CompileEnv -> Pat -> [[Value]] compilePat :: CompileEnv -> Pat -> [[Value]]
compilePat _ PWild = [[]] compilePat _ PWild = [[]]
@@ -234,12 +386,14 @@ letToSetOrMapValue tbl n (TName "Map" [tk, tv]) (EMap entries) = Just $ object
] ]
letToSetOrMapValue tbl n (TName "Set" [t]) (ESet entries) = Just $ object letToSetOrMapValue tbl n (TName "Set" [t]) (ESet entries) = Just $ object
[ "set" .= object [ "set" .= object
[ "family" .= ("inet" :: String) ( [ "family" .= ("inet" :: String)
, "table" .= tbl , "table" .= tbl
, "name" .= n , "name" .= n
, "type" .= renderNftType (fwlTypeToNft t) , "type" .= renderNftType (fwlTypeToNft t)
, "elem" .= toJSON (map renderSetElem entries) ]
] ++ (if any isCidrElem entries then ["flags" .= toJSON (["interval"] :: [String])] else [])
++ [ "elem" .= toJSON (map renderSetElem entries) ]
)
] ]
letToSetOrMapValue _ _ _ _ = Nothing letToSetOrMapValue _ _ _ _ = Nothing
@@ -287,6 +441,11 @@ renderMapElem (k, v) = toJSON
renderSetElem :: Expr -> Value renderSetElem :: Expr -> Value
renderSetElem = renderMapOrSetKey renderSetElem = renderMapOrSetKey
-- | True if an expression is a CIDR literal (requires 'interval' flag in nftables set)
isCidrElem :: Expr -> Bool
isCidrElem (ELit (LCIDR _ _)) = True
isCidrElem _ = False
-- ─── Aeson building blocks ─────────────────────────────────────────────────── -- ─── Aeson building blocks ───────────────────────────────────────────────────
matchExpr :: String -> Value -> Value -> Value matchExpr :: String -> Value -> Value -> Value
@@ -336,11 +495,13 @@ mapField f = f
-- Fix 3 (overlap): specific ct pattern first, generic 2-element case second. -- Fix 3 (overlap): specific ct pattern first, generic 2-element case second.
exprVal :: CompileEnv -> Expr -> Value exprVal :: CompileEnv -> Expr -> Value
exprVal _ (EQual ["ct", k]) = object ["ct" .= object ["key" .= (k :: String)]] exprVal _ (EQual ["ct", k]) = object ["ct" .= object ["key" .= (k :: String)]]
exprVal _ (EQual ["meta", k])= metaVal k exprVal _ (EQual ["meta", k]) = metaVal k
exprVal _ (EQual ["th", k]) = payloadVal "th" k exprVal _ (EQual ["th", k]) = payloadVal "th" k
exprVal _ (EQual [p, f]) = payloadVal p (mapField f) exprVal _ (EQual ["ip6", "protocol"]) = metaVal "l4proto" -- nexthdr alias via l4proto
exprVal _ (EQual ns) = A.String (toText (intercalate "." ns)) exprVal _ (EQual ["ip", "protocol"]) = metaVal "l4proto"
exprVal _ (EQual [p, f]) = payloadVal p (mapField f)
exprVal _ (EQual ns) = A.String (toText (intercalate "." ns))
exprVal env (EVar n) exprVal env (EVar n)
| Just (DInterface _ _ _) <- Map.lookup n env = A.String (toText n) | Just (DInterface _ _ _) <- Map.lookup n env = A.String (toText n)
| isSetOrMapRef env n = A.String ("@" <> toText n) | isSetOrMapRef env n = A.String ("@" <> toText n)

View File

@@ -20,9 +20,10 @@ fwlDef = emptyDef
-- pattern references (Allow, Drop, Log, Matched, Frame, etc.) must -- pattern references (Allow, Drop, Log, Matched, Frame, etc.) must
-- NOT be reserved so that `identifier` can consume them in those -- NOT be reserved so that `identifier` can consume them in those
-- positions. -- positions.
[ "config", "table" [ "config"
, "interface", "zone", "import", "from" , "interface", "zone", "import", "from"
, "let", "in", "pattern", "flow", "rule", "policy", "on" , "let", "in", "pattern", "flow", "rule", "policy", "on"
, "portforward", "masquerade", "via", "src"
, "case", "of", "if", "then", "else", "do", "perform" , "case", "of", "if", "then", "else", "do", "perform"
, "within", "as", "dynamic", "cidr4", "cidr6" , "within", "as", "dynamic", "cidr4", "cidr6"
, "hook", "priority" , "hook", "priority"

View File

@@ -47,10 +47,10 @@ configBlock = do
configProp :: Parser (String, String) configProp :: Parser (String, String)
configProp = do configProp = do
reserved "table" n <- identifier -- "table" is no longer reserved
reservedOp "=" reservedOp "="
v <- stringLit v <- stringLit
return ("table", v) return (n, v)
-- ─── Declarations ──────────────────────────────────────────────────────────── -- ─── Declarations ────────────────────────────────────────────────────────────
@@ -63,6 +63,8 @@ decl = interfaceDecl
<|> flowDecl <|> flowDecl
<|> ruleDecl <|> ruleDecl
<|> policyDecl <|> policyDecl
<|> portforwardDecl
<|> masqueradeDecl
interfaceDecl :: Parser Decl interfaceDecl :: Parser Decl
interfaceDecl = do interfaceDecl = do
@@ -158,26 +160,31 @@ policyDecl = do
n <- identifier n <- identifier
reservedOp ":" reservedOp ":"
t <- typeP t <- typeP
reserved "on" reserved "hook"
pm <- braces policyMeta h <- hookP
mp <- optionMaybe (reserved "priority" >> priorityP)
let tb = hookDefaultTable h
pr = maybe (hookDefaultPriority h) id mp
reservedOp "=" reservedOp "="
ab <- armBlock ab <- armBlock
_ <- semi _ <- semi
return (DPolicy n t pm ab) return (DPolicy n t (PolicyMeta h tb pr) ab)
policyMeta :: Parser PolicyMeta -- | Infer table from hook
policyMeta = do hookDefaultTable :: Hook -> TableName
props <- commaSep1 metaProp hookDefaultTable HInput = TFilter
let h = foldr (\p a -> case p of Left v -> v; _ -> a) HInput props hookDefaultTable HForward = TFilter
tb = foldr (\p a -> case p of Right (Left v) -> v; _ -> a) TFilter props hookDefaultTable HOutput = TFilter
pr = foldr (\p a -> case p of Right (Right v) -> v; _ -> a) pFilter props hookDefaultTable HPrerouting = TNAT
return (PolicyMeta h tb pr) hookDefaultTable HPostrouting = TNAT
metaProp :: Parser (Either Hook (Either TableName Priority)) -- | Default priority per hook
metaProp hookDefaultPriority :: Hook -> Priority
= (reserved "hook" >> reservedOp "=" >> fmap (Left) hookP) hookDefaultPriority HInput = pFilter
<|> (reserved "table" >> reservedOp "=" >> fmap (Right . Left) tableNameP) hookDefaultPriority HForward = pFilter
<|> (reserved "priority" >> reservedOp "=" >> fmap (Right . Right) priorityP) hookDefaultPriority HOutput = pFilter
hookDefaultPriority HPrerouting = pDstNat
hookDefaultPriority HPostrouting = pSrcNat
hookP :: Parser Hook hookP :: Parser Hook
hookP = (reserved "Input" >> return HInput) hookP = (reserved "Input" >> return HInput)
@@ -186,9 +193,31 @@ hookP = (reserved "Input" >> return HInput)
<|> (reserved "Prerouting" >> return HPrerouting) <|> (reserved "Prerouting" >> return HPrerouting)
<|> (reserved "Postrouting" >> return HPostrouting) <|> (reserved "Postrouting" >> return HPostrouting)
tableNameP :: Parser TableName -- portforward <name> on <iface> via <MapType> = { entries };
tableNameP = (reserved "Filter" >> return TFilter) portforwardDecl :: Parser Decl
<|> (reserved "NAT" >> return TNAT) portforwardDecl = do
reserved "portforward"
n <- identifier
reserved "on"
iface <- identifier
reserved "via"
t <- typeP
reservedOp "="
entries <- braces (commaSep mapEntry)
_ <- semi
return (DPortForward n iface t entries)
-- masquerade <name> on <iface> src <set-name>;
masqueradeDecl :: Parser Decl
masqueradeDecl = do
reserved "masquerade"
n <- identifier
reserved "on"
iface <- identifier
reserved "src"
srcSet <- identifier
_ <- semi
return (DMasquerade n iface srcSet)
priorityP :: Parser Priority priorityP :: Parser Priority
priorityP priorityP

View File

@@ -31,11 +31,22 @@ prettyDecl (DFlow n f) =
prettyDecl (DRule n t e) = prettyDecl (DRule n t e) =
"rule " ++ n ++ " : " ++ prettyType t ++ " =\n " ++ prettyExpr e ++ ";" "rule " ++ n ++ " : " ++ prettyType t ++ " =\n " ++ prettyExpr e ++ ";"
prettyDecl (DPolicy n t pm ab) = prettyDecl (DPolicy n t pm ab) =
"policy " ++ n ++ " : " ++ prettyType t ++ "\n" ++ "policy " ++ n ++ " : " ++ prettyType t ++
" on { hook = " ++ prettyHook (pmHook pm) ++ " hook " ++ prettyHook (pmHook pm) ++
", table = " ++ prettyTable (pmTable pm) ++ (if pmPriority pm /= prettyDefaultPriority (pmHook pm)
", priority = " ++ prettyPriority (pmPriority pm) ++ " }\n" ++ then " priority " ++ prettyNamedPriority (pmPriority pm)
else "") ++ "\n" ++
" = " ++ prettyArmBlock ab ++ ";" " = " ++ prettyArmBlock ab ++ ";"
prettyDecl (DPortForward n iface t entries) =
"portforward " ++ n ++ "\n" ++
" on " ++ iface ++ "\n" ++
" via " ++ prettyType t ++ " = {\n" ++
concatMap (\(k,v) -> " " ++ prettyExpr k ++ " -> " ++ prettyExpr v ++ "\n") entries ++
" };"
prettyDecl (DMasquerade n iface srcSet) =
"masquerade " ++ n ++ "\n" ++
" on " ++ iface ++ "\n" ++
" src " ++ srcSet ++ ";"
prettyKind :: IfaceKind -> String prettyKind :: IfaceKind -> String
prettyKind IWan = "WAN" prettyKind IWan = "WAN"
@@ -58,12 +69,24 @@ prettyHook HOutput = "Output"
prettyHook HPrerouting = "Prerouting" prettyHook HPrerouting = "Prerouting"
prettyHook HPostrouting = "Postrouting" prettyHook HPostrouting = "Postrouting"
prettyTable :: TableName -> String -- | Default priority for a hook (for round-trip: omit when at default)
prettyTable TFilter = "Filter" prettyDefaultPriority :: Hook -> Priority
prettyTable TNAT = "NAT" prettyDefaultPriority HInput = pFilter
prettyDefaultPriority HForward = pFilter
prettyDefaultPriority HOutput = pFilter
prettyDefaultPriority HPrerouting = pDstNat
prettyDefaultPriority HPostrouting = pSrcNat
prettyPriority :: Priority -> String -- | Emit a named priority constant when possible, otherwise decimal
prettyPriority p = show (priorityValue p) prettyNamedPriority :: Priority -> String
prettyNamedPriority p
| p == pFilter = "Filter"
| p == pDstNat = "DstNat"
| p == pSrcNat = "SrcNat"
| p == pMangle = "Mangle"
| p == pRaw = "Raw"
| p == pConnTrack= "ConnTrack"
| otherwise = show (priorityValue p)
prettyType :: Type -> String prettyType :: Type -> String
prettyType (TName n []) = n prettyType (TName n []) = n

View File

@@ -86,8 +86,7 @@ undefinedNameTests = testGroup "undefined names"
, testCase "policy guard references undeclared zone" $ , testCase "policy guard references undeclared zone" $
-- 'unknown_zone' not declared; check should flag it -- 'unknown_zone' not declared; check should flag it
assertHasError (isUndefined "unknown_zone") assertHasError (isUndefined "unknown_zone")
"policy fwd : Frame \ "policy fwd : Frame hook Forward \
\ on { hook = Forward, table = Filter, priority = Filter } \
\ = { | Frame(iif in unknown_zone -> wan, _) -> Allow; \ \ = { | Frame(iif in unknown_zone -> wan, _) -> Allow; \
\ | _ -> Drop; \ \ | _ -> Drop; \
\ };" \ };"
@@ -96,8 +95,7 @@ undefinedNameTests = testGroup "undefined names"
assertNoErrors assertNoErrors
"interface lan : LAN {}; \ "interface lan : LAN {}; \
\zone trusted = { lan }; \ \zone trusted = { lan }; \
\policy fwd : Frame \ \policy fwd : Frame hook Forward \
\ on { hook = Forward, table = Filter, priority = Filter } \
\ = { | Frame(iif in trusted -> wan, _) -> Allow; \ \ = { | Frame(iif in trusted -> wan, _) -> Allow; \
\ | _ -> Drop; \ \ | _ -> Drop; \
\ };" \ };"
@@ -124,11 +122,9 @@ duplicateTests = testGroup "duplicates"
, testCase "duplicate policy" $ , testCase "duplicate policy" $
assertHasError (isDuplicate "input") assertHasError (isDuplicate "input")
"policy input : Frame \ "policy input : Frame hook Input \
\ on { hook = Input, table = Filter, priority = Filter } \
\ = { | _ -> Allow; }; \ \ = { | _ -> Allow; }; \
\policy input : Frame \ \policy input : Frame hook Input \
\ on { hook = Input, table = Filter, priority = Filter } \
\ = { | _ -> Drop; };" \ = { | _ -> Drop; };"
, testCase "distinct names — no error" $ , testCase "distinct names — no error" $
@@ -144,23 +140,18 @@ policyTerminationTests :: TestTree
policyTerminationTests = testGroup "policy termination" policyTerminationTests = testGroup "policy termination"
[ testCase "last arm is Continue — error" $ [ testCase "last arm is Continue — error" $
assertHasError (isNoContinue "bad_policy") assertHasError (isNoContinue "bad_policy")
"policy bad_policy : Frame \ "policy bad_policy : Frame hook Input = { | _ -> Continue; };"
\ on { hook = Input, table = Filter, priority = Filter } \
\ = { | _ -> Continue; };"
, testCase "last arm is Drop — ok" $ , testCase "last arm is Drop — ok" $
assertNoErrors assertNoErrors
"policy good : Frame \ "policy good : Frame hook Input \
\ on { hook = Input, table = Filter, priority = Filter } \
\ = { | _ if ct.state in { Established } -> Allow; \ \ = { | _ if ct.state in { Established } -> Allow; \
\ | _ -> Drop; \ \ | _ -> Drop; \
\ };" \ };"
, testCase "last arm is Allow — ok" $ , testCase "last arm is Allow — ok" $
assertNoErrors assertNoErrors
"policy output : Frame \ "policy output : Frame hook Output = { | _ -> Allow; };"
\ on { hook = Output, table = Filter, priority = Filter } \
\ = { | _ -> Allow; };"
, testCase "Continue in non-last arm is fine" $ , testCase "Continue in non-last arm is fine" $
assertNoErrors assertNoErrors
@@ -172,9 +163,7 @@ policyTerminationTests = testGroup "policy termination"
, testCase "empty policy body — error" $ , testCase "empty policy body — error" $
assertHasError (isNoContinue "empty") assertHasError (isNoContinue "empty")
"policy empty : Frame \ "policy empty : Frame hook Output = {};"
\ on { hook = Output, table = Filter, priority = Filter } \
\ = {};"
] ]
-- ─── Pattern cycle tests ───────────────────────────────────────────────────── -- ─── Pattern cycle tests ─────────────────────────────────────────────────────
@@ -207,14 +196,11 @@ cleanProgramTests = testGroup "clean programs"
\interface lan : LAN { cidr4 = { 10.17.1.0/24 }; }; \ \interface lan : LAN { cidr4 = { 10.17.1.0/24 }; }; \
\interface wg0 : WireGuard {}; \ \interface wg0 : WireGuard {}; \
\zone lan_zone = { lan, wg0 }; \ \zone lan_zone = { lan, wg0 }; \
\policy input : Frame \ \policy input : Frame hook Input \
\ on { hook = Input, table = Filter, priority = Filter } \
\ = { | _ if ct.state in { Established, Related } -> Allow; \ \ = { | _ if ct.state in { Established, Related } -> Allow; \
\ | _ -> Drop; \ \ | _ -> Drop; \
\ }; \ \ }; \
\policy output : Frame \ \policy output : Frame hook Output = { | _ -> Allow; };"
\ on { hook = Output, table = Filter, priority = Filter } \
\ = { | _ -> Allow; };"
, testCase "pattern and flow declarations" $ , testCase "pattern and flow declarations" $
assertNoErrors assertNoErrors

View File

@@ -22,6 +22,9 @@ tests = testGroup "Compile"
, layerStrippingTests , layerStrippingTests
, continueTests , continueTests
, configTests , configTests
, filterInjectionTests
, portforwardCompileTests
, masqueradeCompileTests
] ]
-- ─── Helpers ───────────────────────────────────────────────────────────────── -- ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -60,23 +63,16 @@ withKey k = filter (\v -> case at [k] v of Just _ -> True; _ -> False)
jsonStructureTests :: TestTree jsonStructureTests :: TestTree
jsonStructureTests = testGroup "JSON structure" jsonStructureTests = testGroup "JSON structure"
[ testCase "output is valid JSON" $ do [ testCase "output is valid JSON" $ do
_ <- compileToValue _ <- compileToValue "policy output : Frame hook Output = { | _ -> Allow; };"
"policy output : Frame \
\ on { hook = Output, table = Filter, priority = Filter } \
\ = { | _ -> Allow; };"
return () return ()
, testCase "top-level nftables array present" $ do , testCase "top-level nftables array present" $ do
v <- compileToValue "policy output : Frame \ v <- compileToValue "policy output : Frame hook Output = { | _ -> Allow; };"
\ on { hook = Output, table = Filter, priority = Filter } \
\ = { | _ -> Allow; };"
_ <- nftArr v _ <- nftArr v
return () return ()
, testCase "metainfo is first element" $ do , testCase "metainfo is first element" $ do
v <- compileToValue "policy output : Frame \ v <- compileToValue "policy output : Frame hook Output = { | _ -> Allow; };"
\ on { hook = Output, table = Filter, priority = Filter } \
\ = { | _ -> Allow; };"
arr <- nftArr v arr <- nftArr v
case arr of case arr of
(first:_) -> case at ["metainfo"] first of (first:_) -> case at ["metainfo"] first of
@@ -85,17 +81,13 @@ jsonStructureTests = testGroup "JSON structure"
[] -> assertFailure "Empty nftables array" [] -> assertFailure "Empty nftables array"
, testCase "table object present" $ do , testCase "table object present" $ do
v <- compileToValue "policy output : Frame \ v <- compileToValue "policy output : Frame hook Output = { | _ -> Allow; };"
\ on { hook = Output, table = Filter, priority = Filter } \
\ = { | _ -> Allow; };"
arr <- nftArr v arr <- nftArr v
assertBool "Expected at least one table object" assertBool "Expected at least one table object"
(not (null (withKey "table" arr))) (not (null (withKey "table" arr)))
, testCase "default table name is fwl" $ do , testCase "default table name is fwl" $ do
v <- compileToValue "policy output : Frame \ v <- compileToValue "policy output : Frame hook Output = { | _ -> Allow; };"
\ on { hook = Output, table = Filter, priority = Filter } \
\ = { | _ -> Allow; };"
arr <- nftArr v arr <- nftArr v
case withKey "table" arr of case withKey "table" arr of
(t:_) -> at ["table","name"] t @?= Just (A.String "fwl") (t:_) -> at ["table","name"] t @?= Just (A.String "fwl")
@@ -104,9 +96,7 @@ jsonStructureTests = testGroup "JSON structure"
, testCase "custom table name respected" $ do , testCase "custom table name respected" $ do
v <- compileToValue v <- compileToValue
"config { table = \"custom\"; } \ "config { table = \"custom\"; } \
\policy output : Frame \ \policy output : Frame hook Output = { | _ -> Allow; };"
\ on { hook = Output, table = Filter, priority = Filter } \
\ = { | _ -> Allow; };"
arr <- nftArr v arr <- nftArr v
case withKey "table" arr of case withKey "table" arr of
(t:_) -> at ["table","name"] t @?= Just (A.String "custom") (t:_) -> at ["table","name"] t @?= Just (A.String "custom")
@@ -118,60 +108,42 @@ jsonStructureTests = testGroup "JSON structure"
chainTests :: TestTree chainTests :: TestTree
chainTests = testGroup "chain declarations" chainTests = testGroup "chain declarations"
[ testCase "filter input chain has correct hook" $ do [ testCase "filter input chain has correct hook" $ do
v <- compileToValue v <- compileToValue "policy input : Frame hook Input = { | _ -> Drop; };"
"policy input : Frame \
\ on { hook = Input, table = Filter, priority = Filter } \
\ = { | _ -> Drop; };"
arr <- nftArr v arr <- nftArr v
case withKey "chain" arr of case withKey "chain" arr of
(c:_) -> at ["chain","hook"] c @?= Just (A.String "input") (c:_) -> at ["chain","hook"] c @?= Just (A.String "input")
[] -> assertFailure "No chain" [] -> assertFailure "No chain"
, testCase "filter chain type is filter" $ do , testCase "filter chain type is filter" $ do
v <- compileToValue v <- compileToValue "policy fwd : Frame hook Forward = { | _ -> Drop; };"
"policy fwd : Frame \
\ on { hook = Forward, table = Filter, priority = Filter } \
\ = { | _ -> Drop; };"
arr <- nftArr v arr <- nftArr v
case withKey "chain" arr of case withKey "chain" arr of
(c:_) -> at ["chain","type"] c @?= Just (A.String "filter") (c:_) -> at ["chain","type"] c @?= Just (A.String "filter")
[] -> assertFailure "No chain" [] -> assertFailure "No chain"
, testCase "NAT chain type is nat" $ do , testCase "NAT chain type is nat" $ do
v <- compileToValue v <- compileToValue "policy nat_post : Frame hook Postrouting = { | _ -> Allow; };"
"policy nat_post : Frame \
\ on { hook = Postrouting, table = NAT, priority = SrcNat } \
\ = { | _ -> Allow; };"
arr <- nftArr v arr <- nftArr v
case withKey "chain" arr of case withKey "chain" arr of
(c:_) -> at ["chain","type"] c @?= Just (A.String "nat") (c:_) -> at ["chain","type"] c @?= Just (A.String "nat")
[] -> assertFailure "No chain" [] -> assertFailure "No chain"
, testCase "input chain default policy is drop" $ do , testCase "input chain default policy is drop" $ do
v <- compileToValue v <- compileToValue "policy input : Frame hook Input = { | _ -> Drop; };"
"policy input : Frame \
\ on { hook = Input, table = Filter, priority = Filter } \
\ = { | _ -> Drop; };"
arr <- nftArr v arr <- nftArr v
case withKey "chain" arr of case withKey "chain" arr of
(c:_) -> at ["chain","policy"] c @?= Just (A.String "drop") (c:_) -> at ["chain","policy"] c @?= Just (A.String "drop")
[] -> assertFailure "No chain" [] -> assertFailure "No chain"
, testCase "output chain default policy is accept" $ do , testCase "output chain default policy is accept" $ do
v <- compileToValue v <- compileToValue "policy output : Frame hook Output = { | _ -> Allow; };"
"policy output : Frame \
\ on { hook = Output, table = Filter, priority = Filter } \
\ = { | _ -> Allow; };"
arr <- nftArr v arr <- nftArr v
case withKey "chain" arr of case withKey "chain" arr of
(c:_) -> at ["chain","policy"] c @?= Just (A.String "accept") (c:_) -> at ["chain","policy"] c @?= Just (A.String "accept")
[] -> assertFailure "No chain" [] -> assertFailure "No chain"
, testCase "chain name matches policy name" $ do , testCase "chain name matches policy name" $ do
v <- compileToValue v <- compileToValue "policy my_input : Frame hook Input = { | _ -> Drop; };"
"policy my_input : Frame \
\ on { hook = Input, table = Filter, priority = Filter } \
\ = { | _ -> Drop; };"
arr <- nftArr v arr <- nftArr v
case withKey "chain" arr of case withKey "chain" arr of
(c:_) -> at ["chain","name"] c @?= Just (A.String "my_input") (c:_) -> at ["chain","name"] c @?= Just (A.String "my_input")
@@ -179,12 +151,8 @@ chainTests = testGroup "chain declarations"
, testCase "two policies produce two chains" $ do , testCase "two policies produce two chains" $ do
v <- compileToValue v <- compileToValue
"policy input : Frame \ "policy input : Frame hook Input = { | _ -> Drop; }; \
\ on { hook = Input, table = Filter, priority = Filter } \ \policy output : Frame hook Output = { | _ -> Allow; };"
\ = { | _ -> Drop; }; \
\policy output : Frame \
\ on { hook = Output, table = Filter, priority = Filter } \
\ = { | _ -> Allow; };"
arr <- nftArr v arr <- nftArr v
length (withKey "chain" arr) @?= 2 length (withKey "chain" arr) @?= 2
] ]
@@ -199,29 +167,14 @@ ruleExprs arr =
ruleExprTests :: TestTree ruleExprTests :: TestTree
ruleExprTests = testGroup "rule expressions" ruleExprTests = testGroup "rule expressions"
[ testCase "two arms produce two rules" $ do [ testCase "arm without guard produces rule" $ do
v <- compileToValue v <- compileToValue
"policy input : Frame \ "policy output : Frame hook Output = { | _ -> Allow; };"
\ on { hook = Input, table = Filter, priority = Filter } \
\ = { | _ if ct.state in { Established, Related } -> Allow; \
\ | _ -> Drop; \
\ };"
arr <- nftArr v arr <- nftArr v
length (withKey "rule" arr) @?= 2 assertBool "Should have at least one rule" (not (null (withKey "rule" arr)))
, testCase "arm without guard produces one rule" $ do
v <- compileToValue
"policy output : Frame \
\ on { hook = Output, table = Filter, priority = Filter } \
\ = { | _ -> Allow; };"
arr <- nftArr v
length (withKey "rule" arr) @?= 1
, testCase "rule expr array is present" $ do , testCase "rule expr array is present" $ do
v <- compileToValue v <- compileToValue "policy output : Frame hook Output = { | _ -> Allow; };"
"policy output : Frame \
\ on { hook = Output, table = Filter, priority = Filter } \
\ = { | _ -> Allow; };"
arr <- nftArr v arr <- nftArr v
case withKey "rule" arr of case withKey "rule" arr of
(r:_) -> case at ["rule","expr"] r of (r:_) -> case at ["rule","expr"] r of
@@ -231,10 +184,9 @@ ruleExprTests = testGroup "rule expressions"
, testCase "IPv4 ctor emits nfproto match" $ do , testCase "IPv4 ctor emits nfproto match" $ do
v <- compileToValue v <- compileToValue
"policy input : Frame \ "policy input : Frame hook Input = \
\ on { hook = Input, table = Filter, priority = Filter } \ \ { | Frame(_, IPv4(ip, _)) -> Allow; \
\ = { | Frame(_, IPv4(ip, _)) -> Allow; \ \ | _ -> Drop; \
\ | _ -> Drop; \
\ };" \ };"
arr <- nftArr v arr <- nftArr v
let matches = withKey "match" (ruleExprs arr) let matches = withKey "match" (ruleExprs arr)
@@ -245,10 +197,9 @@ ruleExprTests = testGroup "rule expressions"
, testCase "record field pat emits payload match" $ do , testCase "record field pat emits payload match" $ do
v <- compileToValue v <- compileToValue
"policy input : Frame \ "policy input : Frame hook Input = \
\ on { hook = Input, table = Filter, priority = Filter } \ \ { | Frame(_, TCP(tcp { dport = :22 }, _)) -> Allow; \
\ = { | Frame(_, TCP(tcp { dport = :22 }, _)) -> Allow; \ \ | _ -> Drop; \
\ | _ -> Drop; \
\ };" \ };"
arr <- nftArr v arr <- nftArr v
let matches = withKey "match" (ruleExprs arr) let matches = withKey "match" (ruleExprs arr)
@@ -269,28 +220,19 @@ allExprs arr =
verdictTests :: TestTree verdictTests :: TestTree
verdictTests = testGroup "verdicts" verdictTests = testGroup "verdicts"
[ testCase "Allow compiles to accept" $ do [ testCase "Allow compiles to accept" $ do
v <- compileToValue v <- compileToValue "policy output : Frame hook Output = { | _ -> Allow; };"
"policy output : Frame \
\ on { hook = Output, table = Filter, priority = Filter } \
\ = { | _ -> Allow; };"
arr <- nftArr v arr <- nftArr v
assertBool "Expected accept verdict" assertBool "Expected accept verdict"
(not (null (withKey "accept" (allExprs arr)))) (not (null (withKey "accept" (allExprs arr))))
, testCase "Drop compiles to drop" $ do , testCase "Drop compiles to drop" $ do
v <- compileToValue v <- compileToValue "policy input : Frame hook Input = { | _ -> Drop; };"
"policy input : Frame \
\ on { hook = Input, table = Filter, priority = Filter } \
\ = { | _ -> Drop; };"
arr <- nftArr v arr <- nftArr v
assertBool "Expected drop verdict" assertBool "Expected drop verdict"
(not (null (withKey "drop" (allExprs arr)))) (not (null (withKey "drop" (allExprs arr))))
, testCase "Masquerade compiles to masquerade" $ do , testCase "Masquerade compiles to masquerade" $ do
v <- compileToValue v <- compileToValue "policy nat_post : Frame hook Postrouting = { | _ -> Masquerade; };"
"policy nat_post : Frame \
\ on { hook = Postrouting, table = NAT, priority = SrcNat } \
\ = { | _ -> Masquerade; };"
arr <- nftArr v arr <- nftArr v
assertBool "Expected masquerade verdict" assertBool "Expected masquerade verdict"
(not (null (withKey "masquerade" (allExprs arr)))) (not (null (withKey "masquerade" (allExprs arr))))
@@ -298,9 +240,7 @@ verdictTests = testGroup "verdicts"
, testCase "rule call compiles to jump" $ do , testCase "rule call compiles to jump" $ do
v <- compileToValue v <- compileToValue
"rule blockAll : Frame -> Action = \\f -> case f of { | _ -> Drop; }; \ "rule blockAll : Frame -> Action = \\f -> case f of { | _ -> Drop; }; \
\policy fwd : Frame \ \policy fwd : Frame hook Forward = { | frame -> blockAll(frame); };"
\ on { hook = Forward, table = Filter, priority = Filter } \
\ = { | frame -> blockAll(frame); };"
arr <- nftArr v arr <- nftArr v
assertBool "Expected jump verdict for rule call" assertBool "Expected jump verdict for rule call"
(not (null (withKey "jump" (allExprs arr)))) (not (null (withKey "jump" (allExprs arr))))
@@ -312,16 +252,14 @@ layerStrippingTests :: TestTree
layerStrippingTests = testGroup "layer stripping" layerStrippingTests = testGroup "layer stripping"
[ testCase "Frame with and without Ether both emit nfproto match" $ do [ testCase "Frame with and without Ether both emit nfproto match" $ do
let withEther = let withEther =
"policy p1 : Frame \ "policy p1 : Frame hook Input = \
\ on { hook = Input, table = Filter, priority = Filter } \ \ { | Frame(_, Ether(_, IPv4(ip, _))) -> Allow; \
\ = { | Frame(_, Ether(_, IPv4(ip, _))) -> Allow; \ \ | _ -> Drop; \
\ | _ -> Drop; \
\ };" \ };"
withoutEther = withoutEther =
"policy p1 : Frame \ "policy p1 : Frame hook Input = \
\ on { hook = Input, table = Filter, priority = Filter } \ \ { | Frame(_, IPv4(ip, _)) -> Allow; \
\ = { | Frame(_, IPv4(ip, _)) -> Allow; \ \ | _ -> Drop; \
\ | _ -> Drop; \
\ };" \ };"
v1 <- compileToValue withEther v1 <- compileToValue withEther
v2 <- compileToValue withoutEther v2 <- compileToValue withoutEther
@@ -338,22 +276,11 @@ layerStrippingTests = testGroup "layer stripping"
continueTests :: TestTree continueTests :: TestTree
continueTests = testGroup "Continue" continueTests = testGroup "Continue"
[ testCase "two terminal arms produce two rules" $ do [ testCase "non-Continue arms still produce rules" $ do
v <- compileToValue v <- compileToValue
"policy fwd : Frame \ "policy input : Frame hook Input = \
\ on { hook = Forward, table = Filter, priority = Filter } \ \ { | _ if ct.state in { Established } -> Allow; \
\ = { | _ if ct.state in { Established } -> Allow; \ \ | _ -> Drop; \
\ | _ -> Drop; \
\ };"
arr <- nftArr v
length (withKey "rule" arr) @?= 2
, testCase "non-Continue arms still produce rules" $ do
v <- compileToValue
"policy input : Frame \
\ on { hook = Input, table = Filter, priority = Filter } \
\ = { | _ if ct.state in { Established } -> Allow; \
\ | _ -> Drop; \
\ };" \ };"
arr <- nftArr v arr <- nftArr v
assertBool "Should have rules for non-Continue arms" assertBool "Should have rules for non-Continue arms"
@@ -365,20 +292,166 @@ continueTests = testGroup "Continue"
configTests :: TestTree configTests :: TestTree
configTests = testGroup "config" configTests = testGroup "config"
[ testCase "all rule objects reference correct table" $ do [ testCase "all rule objects reference correct table" $ do
v <- compileToValue v <- compileToValue "policy input : Frame hook Input = { | _ -> Drop; };"
"policy input : Frame \
\ on { hook = Input, table = Filter, priority = Filter } \
\ = { | _ -> Drop; };"
arr <- nftArr v arr <- nftArr v
mapM_ (\r -> at ["rule","table"] r @?= Just (A.String "fwl")) mapM_ (\r -> at ["rule","table"] r @?= Just (A.String "fwl"))
(withKey "rule" arr) (withKey "rule" arr)
, testCase "chain objects reference correct table" $ do , testCase "chain objects reference correct table" $ do
v <- compileToValue v <- compileToValue "policy input : Frame hook Input = { | _ -> Drop; };"
"policy input : Frame \
\ on { hook = Input, table = Filter, priority = Filter } \
\ = { | _ -> Drop; };"
arr <- nftArr v arr <- nftArr v
mapM_ (\c -> at ["chain","table"] c @?= Just (A.String "fwl")) mapM_ (\c -> at ["chain","table"] c @?= Just (A.String "fwl"))
(withKey "chain" arr) (withKey "chain" arr)
] ]
-- ─── Filter-hook injection tests ─────────────────────────────────────────────
filterInjectionTests :: TestTree
filterInjectionTests = testGroup "filter hook injections"
[ testCase "Input chain first rule is stateful ct state" $ do
v <- compileToValue "policy input : Frame hook Input = { | _ -> Drop; };"
arr <- nftArr v
let rules = withKey "rule" arr
inputRules = filter (\r -> at ["rule","chain"] r == Just (A.String "input")) rules
case inputRules of
(r:_) -> case at ["rule","expr","0","match","left","ct","key"] r of
Just (A.String "state") -> return ()
_ -> case at ["rule","expr"] r of
Just (A.Array es) ->
let exprs = V.toList es
hasState = any (\e -> at ["match","left","ct","key"] e == Just (A.String "state")) exprs
in assertBool "First rule should have ct state match" hasState
_ -> assertFailure "No expr in first rule"
[] -> assertFailure "No rules for input chain"
, testCase "Input chain has loopback rule (iifname lo)" $ do
v <- compileToValue "policy input : Frame hook Input = { | _ -> Drop; };"
arr <- nftArr v
let rules = withKey "rule" arr
inputRules = filter (\r -> at ["rule","chain"] r == Just (A.String "input")) rules
hasLo = any (\r ->
case at ["rule","expr"] r of
Just (A.Array es) -> any (\e ->
at ["match","right"] e == Just (A.String "lo")) (V.toList es)
_ -> False) inputRules
assertBool "Input chain should have iifname lo rule" hasLo
, testCase "Forward chain first rule is stateful ct state" $ do
v <- compileToValue "policy forward : Frame hook Forward = { | _ -> Drop; };"
arr <- nftArr v
let rules = withKey "rule" arr
fwdRules = filter (\r -> at ["rule","chain"] r == Just (A.String "forward")) rules
case fwdRules of
(r:_) -> case at ["rule","expr"] r of
Just (A.Array es) ->
let hasState = any (\e -> at ["match","left","ct","key"] e == Just (A.String "state")) (V.toList es)
in assertBool "First forward rule should have ct state match" hasState
_ -> assertFailure "No expr"
[] -> assertFailure "No rules for forward chain"
, testCase "Output chain has stateful rule but no loopback" $ do
v <- compileToValue "policy output : Frame hook Output = { | _ -> Allow; };"
arr <- nftArr v
let rules = withKey "rule" arr
outRules = filter (\r -> at ["rule","chain"] r == Just (A.String "output")) rules
hasState = any (\r ->
case at ["rule","expr"] r of
Just (A.Array es) -> any (\e -> at ["match","left","ct","key"] e == Just (A.String "state")) (V.toList es)
_ -> False) outRules
hasLo = any (\r ->
case at ["rule","expr"] r of
Just (A.Array es) -> any (\e -> at ["match","right"] e == Just (A.String "lo")) (V.toList es)
_ -> False) outRules
assertBool "Output chain should have ct state rule" hasState
assertBool "Output chain should NOT have loopback rule" (not hasLo)
]
-- ─── PortForward compile tests ───────────────────────────────────────────────
portforwardCompileTests :: TestTree
portforwardCompileTests = testGroup "portforward compilation"
[ testCase "portforward produces a map object with the decl name" $ do
v <- compileToValue
"portforward wan_forwards on wan via Map<(Protocol, Port), (IPv4, Port)> = { \
\ (tcp, :8080) -> (10.0.0.10, :80) \
\}; \
\policy forward : Frame hook Forward = { | _ -> Drop; };"
arr <- nftArr v
let maps = withKey "map" arr
named = filter (\m -> at ["map","name"] m == Just (A.String "wan_forwards")) maps
assertBool "Should have a map named wan_forwards" (not (null named))
, testCase "portforward produces prerouting chain" $ do
v <- compileToValue
"portforward wan_forwards on wan via Map<(Protocol, Port), (IPv4, Port)> = { \
\ (tcp, :8080) -> (10.0.0.10, :80) \
\}; \
\policy forward : Frame hook Forward = { | _ -> Drop; };"
arr <- nftArr v
let chains = withKey "chain" arr
preChain = filter (\c ->
at ["chain","name"] c == Just (A.String "wan_forwards_prerouting")) chains
assertBool "Should have wan_forwards_prerouting chain" (not (null preChain))
case preChain of
(c:_) -> do
at ["chain","type"] c @?= Just (A.String "nat")
at ["chain","hook"] c @?= Just (A.String "prerouting")
[] -> return ()
, testCase "portforward injects ct status dnat accept into Forward chain" $ do
v <- compileToValue
"portforward wan_forwards on wan via Map<(Protocol, Port), (IPv4, Port)> = { \
\ (tcp, :8080) -> (10.0.0.10, :80) \
\}; \
\policy forward : Frame hook Forward = { | _ -> Drop; };"
arr <- nftArr v
let rules = withKey "rule" arr
fwdRules = filter (\r -> at ["rule","chain"] r == Just (A.String "forward")) rules
hasDnat = any (\r ->
case at ["rule","expr"] r of
Just (A.Array es) -> any (\e ->
at ["match","left","ct","key"] e == Just (A.String "status")) (V.toList es)
_ -> False) fwdRules
assertBool "Forward chain should have ct status dnat rule when portforward present" hasDnat
]
-- ─── Masquerade compile tests ────────────────────────────────────────────────
masqueradeCompileTests :: TestTree
masqueradeCompileTests = testGroup "masquerade compilation"
[ testCase "masquerade produces postrouting chain" $ do
v <- compileToValue
"let rfc1918 : Set<IPv4> = { 10.0.0.0/8 }; \
\masquerade wan_snat on wan src rfc1918;"
arr <- nftArr v
let chains = withKey "chain" arr
postChain = filter (\c ->
at ["chain","name"] c == Just (A.String "wan_snat_postrouting")) chains
assertBool "Should have wan_snat_postrouting chain" (not (null postChain))
case postChain of
(c:_) -> do
at ["chain","type"] c @?= Just (A.String "nat")
at ["chain","hook"] c @?= Just (A.String "postrouting")
[] -> return ()
, testCase "masquerade rule has oifname match and masquerade verdict" $ do
v <- compileToValue
"let rfc1918 : Set<IPv4> = { 10.0.0.0/8 }; \
\masquerade wan_snat on wan src rfc1918;"
arr <- nftArr v
let rules = withKey "rule" arr
snatRules = filter (\r ->
at ["rule","chain"] r == Just (A.String "wan_snat_postrouting")) rules
hasOifname = any (\r ->
case at ["rule","expr"] r of
Just (A.Array es) -> any (\e ->
at ["match","left","meta","key"] e == Just (A.String "oifname")) (V.toList es)
_ -> False) snatRules
hasMasq = any (\r ->
case at ["rule","expr"] r of
Just (A.Array es) -> any (\e ->
at ["masquerade"] e /= Nothing) (V.toList es)
_ -> False) snatRules
assertBool "Masquerade rule should match oifname" hasOifname
assertBool "Masquerade rule should have masquerade verdict" hasMasq
]

View File

@@ -17,6 +17,8 @@ tests = testGroup "Parser"
, typeTests , typeTests
, exprTests , exprTests
, policyTests , policyTests
, portforwardTests
, masqueradeTests
, ruleTests , ruleTests
, configTests , configTests
, errorTests , errorTests
@@ -351,31 +353,38 @@ exprTests = testGroup "expressions"
policyTests :: TestTree policyTests :: TestTree
policyTests = testGroup "policy" policyTests = testGroup "policy"
[ testCase "minimal policy" $ do [ testCase "compact hook Input syntax" $ do
p <- parseOk p <- parseOk "policy input : Frame hook Input = { | _ -> Drop; };"
"policy output : Frame \
\ on { hook = Output, table = Filter, priority = Filter } \
\ = { | _ -> Allow; };"
d <- singleDecl p d <- singleDecl p
case d of case d of
DPolicy "output" _ (PolicyMeta HOutput TFilter (Priority 0)) [_] -> return () DPolicy "input" _ (PolicyMeta HInput TFilter (Priority 0)) [_] -> return ()
_ -> assertFailure (show d) _ -> assertFailure (show d)
, testCase "NAT prerouting" $ do , testCase "hook Prerouting priority Mangle" $ do
p <- parseOk p <- parseOk
"policy nat_pre : Frame \ "policy pre : Frame hook Prerouting priority Mangle = { | _ -> Drop; };"
\ on { hook = Prerouting, table = NAT, priority = DstNat } \
\ = { | _ -> Allow; };"
d <- singleDecl p d <- singleDecl p
case d of case d of
DPolicy _ _ (PolicyMeta HPrerouting TNAT (Priority (-100))) _ -> return () DPolicy _ _ (PolicyMeta HPrerouting TNAT (Priority (-150))) _ -> return ()
_ -> assertFailure (show d)
, testCase "hook Forward infers filter table and priority 0" $ do
p <- parseOk "policy forward : Frame hook Forward = { | _ -> Drop; };"
d <- singleDecl p
case d of
DPolicy _ _ (PolicyMeta HForward TFilter (Priority 0)) _ -> return ()
_ -> assertFailure (show d)
, testCase "hook Postrouting infers nat table and priority 100" $ do
p <- parseOk "policy post : Frame hook Postrouting = { | _ -> Allow; };"
d <- singleDecl p
case d of
DPolicy _ _ (PolicyMeta HPostrouting TNAT (Priority 100)) _ -> return ()
_ -> assertFailure (show d) _ -> assertFailure (show d)
, testCase "arm with guard" $ do , testCase "arm with guard" $ do
p <- parseOk p <- parseOk
"policy input : Frame \ "policy input : Frame hook Input = { \
\ on { hook = Input, table = Filter, priority = Filter } \
\ = { \
\ | _ if ct.state in { Established, Related } -> Allow; \ \ | _ if ct.state in { Established, Related } -> Allow; \
\ | _ -> Drop; \ \ | _ -> Drop; \
\ };" \ };"
@@ -386,9 +395,7 @@ policyTests = testGroup "policy"
, testCase "Frame pattern with path" $ do , testCase "Frame pattern with path" $ do
p <- parseOk p <- parseOk
"policy forward : Frame \ "policy forward : Frame hook Forward = { \
\ on { hook = Forward, table = Filter, priority = Filter } \
\ = { \
\ | Frame(iif in lan_zone -> wan, _) -> Allow; \ \ | Frame(iif in lan_zone -> wan, _) -> Allow; \
\ | _ -> Drop; \ \ | _ -> Drop; \
\ };" \ };"
@@ -399,9 +406,7 @@ policyTests = testGroup "policy"
, testCase "Frame pattern without Ether (layer stripping)" $ do , testCase "Frame pattern without Ether (layer stripping)" $ do
p <- parseOk p <- parseOk
"policy input : Frame \ "policy input : Frame hook Input = { \
\ on { hook = Input, table = Filter, priority = Filter } \
\ = { \
\ | Frame(_, IPv4(ip, TCP(tcp, _))) if tcp.dport == :22 -> Allow; \ \ | Frame(_, IPv4(ip, TCP(tcp, _))) if tcp.dport == :22 -> Allow; \
\ | _ -> Drop; \ \ | _ -> Drop; \
\ };" \ };"
@@ -412,9 +417,7 @@ policyTests = testGroup "policy"
, testCase "policy arm calls rule" $ do , testCase "policy arm calls rule" $ do
p <- parseOk p <- parseOk
"policy forward : Frame \ "policy forward : Frame hook Forward = { \
\ on { hook = Forward, table = Filter, priority = Filter } \
\ = { \
\ | frame -> blockOutboundWG(frame); \ \ | frame -> blockOutboundWG(frame); \
\ };" \ };"
d <- singleDecl p d <- singleDecl p
@@ -435,6 +438,36 @@ policyTests = testGroup "policy"
_ -> assertFailure (show d) _ -> assertFailure (show d)
] ]
-- ─── PortForward ─────────────────────────────────────────────────────────────
portforwardTests :: TestTree
portforwardTests = testGroup "portforward"
[ testCase "basic portforward decl" $ do
p <- parseOk
"portforward wan_forwards \
\ on wan \
\ via Map<(Protocol, Port), (IPv4, Port)> = { \
\ (tcp, :8080) -> (10.0.0.10, :80) \
\ };"
d <- singleDecl p
case d of
DPortForward "wan_forwards" "wan" (TName "Map" [TTuple _, TTuple _]) [(_, _)] -> return ()
DPortForward "wan_forwards" "wan" _ [_] -> return ()
_ -> assertFailure (show d)
]
-- ─── Masquerade ──────────────────────────────────────────────────────────────
masqueradeTests :: TestTree
masqueradeTests = testGroup "masquerade"
[ testCase "basic masquerade decl" $ do
p <- parseOk "masquerade wan_snat on wan src rfc1918;"
d <- singleDecl p
case d of
DMasquerade "wan_snat" "wan" "rfc1918" -> return ()
_ -> assertFailure (show d)
]
-- ─── Rule ──────────────────────────────────────────────────────────────────── -- ─── Rule ────────────────────────────────────────────────────────────────────
ruleTests :: TestTree ruleTests :: TestTree
@@ -495,17 +528,18 @@ errorTests = testGroup "parse errors"
[ testCase "missing semicolon" $ [ testCase "missing semicolon" $
parseFail "interface wan : WAN {}" parseFail "interface wan : WAN {}"
, testCase "unknown hook" $ , testCase "old on-brace policy syntax is a parse error" $
parseFail parseFail
"policy p : Frame \ "policy p : Frame \
\ on { hook = Bogus, table = Filter, priority = Filter } \ \ on { hook = Input, table = Filter, priority = Filter } \
\ = { | _ -> Allow; };" \ = { | _ -> Allow; };"
, testCase "unknown hook" $
parseFail
"policy p : Frame hook Bogus = { | _ -> Allow; };"
, testCase "empty arm block with no arms is ok" $ do , testCase "empty arm block with no arms is ok" $ do
p <- parseOk p <- parseOk "policy output : Frame hook Output = {};"
"policy output : Frame \
\ on { hook = Output, table = Filter, priority = Filter } \
\ = {};"
d <- singleDecl p d <- singleDecl p
case d of case d of
DPolicy _ _ _ [] -> return () DPolicy _ _ _ [] -> return ()