matchstick

documentation

how it works

matchstick compiles a lua configuration file into nftables rules. describe your network in terms of zones, hosts, services, policies, and rules. matchstick builds the nftables chains, sets, and verdict maps automatically.

firewall.lua  →  lua vm  →  validate  →  nftables ir  →  text or json

at apply time, matchstick also derives and sets kernel sysctl parameters (ip forwarding, arp hardening, etc.) based on your config.

full example

a complete config and the generated nftables output:

firewall.lua
local ssh  = fw:service("ssh", "tcp", 22)
local ping = fw:service("ping", "icmp", "echo-request")

local self = fw:zone("fw")
local wan  = fw:zone("wan", "eth0")
local lan  = fw:zone("lan", "eth1")

fw:policy(wan, self, "drop", { log = true })
fw:policy(self, wan, "accept")
fw:policy(lan, self, "accept")
fw:policy(lan, wan, "accept")

fw:rule(wan, self, "accept", ssh)
fw:rule(wan, self, "accept", ping)
fw:rule(lan, self, "accept", { proto = "tcp", port = {80, 443} })
nftables output
table inet matchstick
delete table inet matchstick

table inet matchstick {

    set _lograte_4 {
        type ipv4_addr
        flags dynamic, timeout
        size 65535
        timeout 60s
    }

    set _lograte_6 {
        type ipv6_addr
        flags dynamic, timeout
        size 65535
        timeout 60s
    }

    map input_zones {
        type ifname : verdict
        elements = {
            "eth0" : jump wan_to_fw,
            "eth1" : jump lan_to_fw
        }
    }

    map output_zones {
        type ifname : verdict
        elements = {
            "eth0" : jump fw_to_wan
        }
    }

    map forward_zones {
        type ifname . ifname : verdict
        elements = {
            eth1 . eth0 : jump lan_to_wan
        }
    }

    chain icmp_v4 {
        icmp type { destination-unreachable, time-exceeded, parameter-problem } accept
        icmp type echo-request limit rate 10/second burst 5 packets accept
    }

    chain icmp_v6 {
        icmpv6 type { nd-neighbor-solicit, nd-neighbor-advert, nd-router-solicit, nd-router-advert, mld-listener-query, mld-listener-report } accept
        icmpv6 type { destination-unreachable, packet-too-big, time-exceeded, parameter-problem } accept
        icmpv6 type echo-request limit rate 10/second burst 5 packets accept
        drop
    }

    chain rpfilter {
        type filter hook prerouting priority filter + 5; policy accept;
        fib saddr . mark . iif oif 0 drop
    }

    chain anti_smurf {
        fib saddr type broadcast drop
        fib saddr type multicast drop
    }

    chain input {
        type filter hook input priority filter + 5; policy drop;
        iif lo accept
        ct state { established, related } accept
        ct state invalid drop
        jump anti_smurf
        jump tcp_strict
        jump icmp_v4
        jump icmp_v6
        iifname vmap @input_zones
        update @_lograte_4 { ip saddr limit rate 5/minute burst 5 packets } log prefix "matchstick input DROP "
        update @_lograte_6 { ip6 saddr limit rate 5/minute burst 5 packets } log prefix "matchstick input DROP "
        drop
    }

    chain tcp_strict {
        tcp flags ! fin,syn,rst,psh,ack,urg drop comment "tcp-strict: null flags"
        tcp flags & (fin|syn) fin|syn drop comment "tcp-strict: fin+syn"
        tcp flags & (syn|rst) syn|rst drop comment "tcp-strict: syn+rst"
        tcp flags & (fin|psh|urg) fin|psh|urg drop comment "tcp-strict: xmas"
        tcp flags & (fin|syn|rst|ack) != syn ct state new drop comment "tcp-strict: new non-syn"
    }

    chain forward {
        type filter hook forward priority filter + 5; policy drop;
        ct state { established, related } accept
        ct state invalid drop
        jump anti_smurf
        jump tcp_strict
        fib daddr type broadcast drop
        fib daddr type multicast drop
        iifname . oifname vmap @forward_zones
        update @_lograte_4 { ip saddr limit rate 5/minute burst 5 packets } log prefix "matchstick forward DROP "
        drop
    }

    chain output {
        type filter hook output priority filter + 5; policy accept;
        oifname vmap @output_zones
    }

    chain fw_to_wan {
        accept
    }

    chain lan_to_fw {
        tcp dport { 80, 443 } accept
        accept
    }

    chain lan_to_wan {
        accept
    }

    chain wan_to_fw {
        meta nfproto vmap { ipv4 : jump wan_to_fw_4, ipv6 : jump wan_to_fw_6 }
    }

    chain wan_to_fw_4 {
        tcp dport 22 accept
        icmp type echo-request accept
        update @_lograte_4 { ip saddr limit rate 5/minute burst 5 packets } log prefix "matchstick wan_to_fw drop "
        drop
    }

    chain wan_to_fw_6 {
        tcp dport 22 accept
        update @_lograte_6 { ip6 saddr limit rate 5/minute burst 5 packets } log prefix "matchstick wan_to_fw drop "
        drop
    }
}

zones

a zone is a group of network interfaces that share the same trust level. every config needs exactly one zone with no interfaces — the firewall host itself.

local self = fw:zone("fw")              -- the firewall machine (no interface)
local wan  = fw:zone("wan", "eth0")     -- internet-facing
local lan  = fw:zone("lan", "eth1")     -- trusted lan
local dmz  = fw:zone("dmz", "eth2")     -- servers

-- multiple interfaces
local internal = fw:zone("internal", {"eth1", "eth2"})

-- bridge zone
local dock = fw:zone("dock", "docker0", { bridge = true })

hosts

a host is a specific ip address within a zone. use hosts when you need per-machine rules.

local server = fw:host("server", { zone = lan, addr = "10.0.0.10" })
local admin  = fw:host("admin",  { zone = lan, addr = "10.0.0.50" })

fw:rule(admin, self, "accept", ssh)   -- only admin gets ssh
fw:rule(server, dmz, "accept", http)  -- server can reach dmz

services

a named protocol + port combination. define once, reuse everywhere.

local ssh  = fw:service("ssh", "tcp", 22)
local dns  = fw:service("dns", {"tcp", "udp"}, 53)       -- multi-protocol
local mosh = fw:service("mosh", "udp", "60000-61000")    -- port range
local ping = fw:service("ping", "icmp", "echo-request")  -- icmp type

-- complex: multiple protocol/port pairs
local plex = fw:service("plex", {
  {"tcp", 32400},
  {"udp", 1900},
  {"udp", "32410-32414"},
})

policies

the default action for traffic between two zones. applies to all traffic that doesn't match a more specific rule.

fw:policy(wan, self, "drop", { log = true })  -- drop incoming, log it
fw:policy(self, wan, "accept")                -- allow outgoing
fw:policy(lan, wan, "accept")                 -- lan can reach internet
fw:policy("*", "*", "reject")                 -- default for everything else

actions: "accept", "drop", "reject". reject sends icmp admin-prohibited. drop silently discards.

rules

allow or deny specific traffic. evaluated before the zone-pair policy.

fw:rule(wan, self, "accept", ssh)
fw:rule(wan, self, "accept", { proto = "tcp", port = {80, 443} })
fw:rule(wan, self, "accept", { proto = "udp", port = "10000-10100" })

-- rate-limited
fw:rule(wan, self, "accept", {
  service = ssh,
  rate = util:rate("5/minute", { burst = 10 }),
})

-- connection limit
fw:rule(wan, self, "accept", { service = ssh, connlimit = 10 })

-- mac address filter
fw:rule(lan, self, "accept", { service = ssh, mac = "aa:bb:cc:dd:ee:ff" })

-- ip list matching (source and destination)
fw:rule(wan, self, "drop", { saddr_list = "blocklist" })
fw:rule(lan, wan, "accept", { daddr_list = "allowed_hosts" })

-- bare rule (match all traffic)
fw:rule(guest, self, "drop")

nat

dnat (port forwarding)

fw:dnat({ iface = wan, service = http, dest = webserver })
fw:rule(wan, webserver, "accept", http)  -- need a forward rule too

-- port remap
fw:dnat({ iface = wan, proto = "tcp", port = 2222, dest = server, dest_port = 22 })

-- hairpin nat
fw:dnat({ iface = lan, daddr = "203.0.113.1", proto = "tcp", port = {80, 443}, dest = webserver })

snat / masquerade

fw:snat({ from = "10.0.0.0/8", oif = "eth0", masquerade = true })
fw:snat({ from = "10.0.0.0/8", oif = "eth0", addr = "203.0.113.1" })

redirect

fw:redirect({ iface = lan, proto = "tcp", port = {80}, dest_port = 3128 })

nat example

a complete nat config with dnat + snat and the generated output:

firewall.lua
local http = fw:service("http", "tcp", 80)

local self = fw:zone("fw")
local wan  = fw:zone("wan", "eth0")
local lan  = fw:zone("lan", "eth1")
local dmz  = fw:zone("dmz", "eth2")

local webserver = fw:host("webserver", { zone = dmz, addr = "172.16.0.10" })

fw:policy(wan, self, "drop")
fw:policy(self, wan, "accept")
fw:policy(lan, wan, "accept")
fw:policy(wan, dmz, "drop")

fw:dnat({ iface = wan, service = http, dest = webserver })
fw:rule(wan, webserver, "accept", http)

fw:snat({ from = "10.0.0.0/8", oif = "eth0", masquerade = true })
fw:snat({ from = "172.16.0.0/12", oif = "eth0", masquerade = true })
nftables output
warning: fw:dnat to 172.16.0.10 has no 'daddr' restriction — will match traffic to ANY destination address on the interface
table inet matchstick
delete table inet matchstick

table inet matchstick {

    set _lograte_4 {
        type ipv4_addr
        flags dynamic, timeout
        size 65535
        timeout 60s
    }

    set _lograte_6 {
        type ipv6_addr
        flags dynamic, timeout
        size 65535
        timeout 60s
    }

    map input_zones {
        type ifname : verdict
        elements = {
            "eth0" : jump wan_to_fw
        }
    }

    map output_zones {
        type ifname : verdict
        elements = {
            "eth0" : jump fw_to_wan
        }
    }

    map forward_zones {
        type ifname . ifname : verdict
        elements = {
            eth1 . eth0 : jump lan_to_wan,
            eth0 . eth2 : jump wan_to_dmz
        }
    }

    chain icmp_v4 {
        icmp type { destination-unreachable, time-exceeded, parameter-problem } accept
        icmp type echo-request limit rate 10/second burst 5 packets accept
    }

    chain icmp_v6 {
        icmpv6 type { nd-neighbor-solicit, nd-neighbor-advert, nd-router-solicit, nd-router-advert, mld-listener-query, mld-listener-report } accept
        icmpv6 type { destination-unreachable, packet-too-big, time-exceeded, parameter-problem } accept
        icmpv6 type echo-request limit rate 10/second burst 5 packets accept
        drop
    }

    chain rpfilter {
        type filter hook prerouting priority filter + 5; policy accept;
        fib saddr . mark . iif oif 0 drop
    }

    chain anti_smurf {
        fib saddr type broadcast drop
        fib saddr type multicast drop
    }

    chain input {
        type filter hook input priority filter + 5; policy drop;
        iif lo accept
        ct state { established, related } accept
        ct state invalid drop
        jump anti_smurf
        jump tcp_strict
        jump icmp_v4
        jump icmp_v6
        iifname vmap @input_zones
        update @_lograte_4 { ip saddr limit rate 5/minute burst 5 packets } log prefix "matchstick input DROP "
        update @_lograte_6 { ip6 saddr limit rate 5/minute burst 5 packets } log prefix "matchstick input DROP "
        drop
    }

    chain tcp_strict {
        tcp flags ! fin,syn,rst,psh,ack,urg drop comment "tcp-strict: null flags"
        tcp flags & (fin|syn) fin|syn drop comment "tcp-strict: fin+syn"
        tcp flags & (syn|rst) syn|rst drop comment "tcp-strict: syn+rst"
        tcp flags & (fin|psh|urg) fin|psh|urg drop comment "tcp-strict: xmas"
        tcp flags & (fin|syn|rst|ack) != syn ct state new drop comment "tcp-strict: new non-syn"
    }

    chain forward {
        type filter hook forward priority filter + 5; policy drop;
        ct state { established, related } accept
        ct state invalid drop
        jump anti_smurf
        jump tcp_strict
        fib daddr type broadcast drop
        fib daddr type multicast drop
        iifname . oifname vmap @forward_zones
        update @_lograte_4 { ip saddr limit rate 5/minute burst 5 packets } log prefix "matchstick forward DROP "
        drop
    }

    chain output {
        type filter hook output priority filter + 5; policy accept;
        oifname vmap @output_zones
    }

    chain fw_to_wan {
        accept
    }

    chain lan_to_wan {
        accept
    }

    chain wan_to_dmz {
        meta nfproto vmap { ipv4 : jump wan_to_dmz_4, ipv6 : jump wan_to_dmz_6 }
    }

    chain wan_to_dmz_4 {
        ip daddr 172.16.0.10 tcp dport 80 accept
        drop
    }

    chain wan_to_dmz_6 {
        drop
    }

    chain wan_to_fw {
        drop
    }
}

table inet matchstick_nat
delete table inet matchstick_nat

table inet matchstick_nat {

    chain prerouting {
        type nat hook prerouting priority dstnat + 5; policy accept;
        iifname eth0 tcp dport 80 dnat ip to 172.16.0.10
    }

    chain postrouting {
        type nat hook postrouting priority srcnat + 5; policy accept;
        ip saddr 10.0.0.0/8 oifname eth0 masquerade
        ip saddr 172.16.0.0/12 oifname eth0 masquerade
    }
}

ip lists

ip lists create nftables sets that can be referenced in rules. they can be populated statically in the config, or dynamically at runtime by external tools like crowdsec, fail2ban, or custom scripts using nft add element.

-- dynamic set: populated externally (e.g. crowdsec, fail2ban)
-- "timeout" flag means entries auto-expire
fw:iplist("blocklist", { type = "ipv4", flags = "timeout" })

-- static set with elements baked into the ruleset
-- "interval" flag allows CIDR range matching
fw:iplist("bogons", {
  type = "ipv4", flags = "interval",
  elements = { "0.0.0.0/8", "127.0.0.0/8", "169.254.0.0/16" },
})

-- set with URL (metadata for a future "matchstick refresh" command)
fw:iplist("threats", { type = "ipv4", flags = "timeout",
  url = "https://example.com/blocklist.txt" })

-- use in rules with saddr_list or daddr_list
fw:rule(wan, self, "drop", { saddr_list = "blocklist" })

packet hygiene

matchstick automatically generates packet sanity checks. these run before your zone rules and catch malformed, spoofed, or invalid traffic. all are enabled by default.

  • rpfilter — reverse path filtering. drops packets whose source address has no return route via the incoming interface (anti-spoofing).
  • tcp_strict — drops packets with invalid tcp flag combinations (null flags, fin+syn, syn+rst, xmas, new non-syn). prevents tcp-based scanning and OS fingerprinting.
  • broadcast_drop — drops broadcast and multicast packets in the forward chain. prevents smurf amplification attacks.
fw:laundry({
  rpfilter       = true,   -- reverse path filtering
  tcp_strict     = true,   -- drop malformed tcp flags
  broadcast_drop = true,   -- drop broadcast/multicast in forward
})

sometimes legitimate traffic gets caught by these checks. for example, ipvs load-balanced traffic may be marked invalid by conntrack. use fw:exception() to add accept rules before the drop:

fw:exception("invalid", "accept", https)
fw:exception("anti_smurf", "accept", { proto = "udp", port = {67, 68} })
fw:exception("rpfilter", "accept", { proto = "udp", port = 68 })

here is what the generated packet hygiene chains look like:

firewall.lua
local self = fw:zone("fw")
local wan  = fw:zone("wan", "eth0")
local https = fw:service("https", {"tcp", "udp"}, 443)

fw:policy(wan, self, "drop")

fw:laundry({
  rpfilter       = true,
  tcp_strict     = true,
  broadcast_drop = true,
})

fw:exception("invalid", "accept", https)
fw:exception("anti_smurf", "accept", { proto = "udp", port = {67, 68} })
nftables output
table inet matchstick
delete table inet matchstick

table inet matchstick {

    set _lograte_4 {
        type ipv4_addr
        flags dynamic, timeout
        size 65535
        timeout 60s
    }

    set _lograte_6 {
        type ipv6_addr
        flags dynamic, timeout
        size 65535
        timeout 60s
    }

    map input_zones {
        type ifname : verdict
        elements = {
            "eth0" : jump wan_to_fw
        }
    }

    map output_zones {
        type ifname : verdict
    }

    map forward_zones {
        type ifname . ifname : verdict
    }

    chain icmp_v4 {
        icmp type { destination-unreachable, time-exceeded, parameter-problem } accept
        icmp type echo-request limit rate 10/second burst 5 packets accept
    }

    chain icmp_v6 {
        icmpv6 type { nd-neighbor-solicit, nd-neighbor-advert, nd-router-solicit, nd-router-advert, mld-listener-query, mld-listener-report } accept
        icmpv6 type { destination-unreachable, packet-too-big, time-exceeded, parameter-problem } accept
        icmpv6 type echo-request limit rate 10/second burst 5 packets accept
        drop
    }

    chain rpfilter {
        type filter hook prerouting priority filter + 5; policy accept;
        fib saddr . mark . iif oif 0 drop
    }

    chain anti_smurf {
        udp dport { 67, 68 } accept
        fib saddr type broadcast drop
        fib saddr type multicast drop
    }

    chain invalid {
        tcp dport 443 accept
        udp dport 443 accept
        drop
    }

    chain input {
        type filter hook input priority filter + 5; policy drop;
        iif lo accept
        ct state { established, related } accept
        ct state invalid jump invalid
        jump anti_smurf
        jump tcp_strict
        jump icmp_v4
        jump icmp_v6
        iifname vmap @input_zones
        update @_lograte_4 { ip saddr limit rate 5/minute burst 5 packets } log prefix "matchstick input DROP "
        update @_lograte_6 { ip6 saddr limit rate 5/minute burst 5 packets } log prefix "matchstick input DROP "
        drop
    }

    chain tcp_strict {
        tcp flags ! fin,syn,rst,psh,ack,urg drop comment "tcp-strict: null flags"
        tcp flags & (fin|syn) fin|syn drop comment "tcp-strict: fin+syn"
        tcp flags & (syn|rst) syn|rst drop comment "tcp-strict: syn+rst"
        tcp flags & (fin|psh|urg) fin|psh|urg drop comment "tcp-strict: xmas"
        tcp flags & (fin|syn|rst|ack) != syn ct state new drop comment "tcp-strict: new non-syn"
    }

    chain forward {
        type filter hook forward priority filter + 5; policy drop;
        ct state { established, related } accept
        ct state invalid jump invalid
        jump anti_smurf
        jump tcp_strict
        fib daddr type broadcast drop
        fib daddr type multicast drop
        iifname . oifname vmap @forward_zones
        update @_lograte_4 { ip saddr limit rate 5/minute burst 5 packets } log prefix "matchstick forward DROP "
        drop
    }

    chain output {
        type filter hook output priority filter + 5; policy accept;
        oifname vmap @output_zones
    }

    chain wan_to_fw {
        drop
    }
}

mss clamping

mss clamping is needed when your network path has a reduced mtu — common with pppoe, vpn tunnels, and wireguard. without it, tcp connections may hang or stall because packets exceed the path mtu and get silently dropped.

matchstick creates a separate chain at mangle priority that rewrites the tcp mss field in syn packets to match the route mtu (pmtud).

fw:mss_clamp("forward")    -- most common: clamp forwarded traffic
fw:mss_clamp("output")     -- also clamp locally generated traffic
fw:mss_clamp("postrouting") -- clamp at postrouting

dhcp

dhcp needs special handling because it uses broadcast udp on ports 67/68, which would normally be dropped by the firewall. fw:dhcp() adds rules to the input chain to allow dhcp traffic on the specified zone's interfaces.

  • "client" — this machine gets its ip via dhcp on this zone (allows udp 67→68 inbound)
  • "server" — this machine serves dhcp on this zone (allows udp 68→67 inbound, and the corresponding outbound)
fw:dhcp(wan, "client")   -- get ip from upstream
fw:dhcp(lan, "server")  -- serve ip to lan clients

docker

docker creates its own bridge networks (docker0, br-*) and by default manages iptables rules that bypass your firewall entirely. this is a well-known security problem with ufw and other firewalls.

matchstick handles docker by treating bridge interfaces as zones. you define the docker bridge as a zone and write explicit policies and rules for container traffic, just like any other zone. matchstick's forward chain controls all forwarded traffic, including docker.

-- declare docker bridges (br-+ matches all br-* bridges)
fw:docker({ bridges = {"docker0", "br-+"} })

-- then treat "dock" zone like any other
fw:policy(dock, self, "reject")   -- containers can't reach firewall
fw:policy(dock, wan, "accept")    -- containers can reach internet
fw:rule(dock, self, "accept", dns)  -- except dns

full example with dhcp and docker:

firewall.lua
local self = fw:zone("fw")
local wan  = fw:zone("wan", "eth0")
local lan  = fw:zone("lan", "eth1")
local dock = fw:zone("dock", "docker0")
local dns  = fw:service("dns", {"tcp", "udp"}, 53)

fw:policy(wan, self, "drop")
fw:policy(self, wan, "accept")
fw:policy(lan, self, "accept")
fw:policy(dock, self, "reject")
fw:policy(dock, wan, "accept")

fw:dhcp(wan, "client")
fw:dhcp(lan, "server")

fw:docker({ bridges = {"docker0", "br-+"} })

fw:rule(dock, self, "accept", dns)

fw:snat({ from = "172.17.0.0/12", oif = "eth0", masquerade = true })
nftables output
table inet matchstick
delete table inet matchstick

table inet matchstick {

    set _lograte_4 {
        type ipv4_addr
        flags dynamic, timeout
        size 65535
        timeout 60s
    }

    set _lograte_6 {
        type ipv6_addr
        flags dynamic, timeout
        size 65535
        timeout 60s
    }

    map input_zones {
        type ifname : verdict
        elements = {
            "eth0" : jump wan_to_fw,
            "eth1" : jump lan_to_fw,
            "docker0" : jump dock_to_fw
        }
    }

    map output_zones {
        type ifname : verdict
        elements = {
            "eth0" : jump fw_to_wan
        }
    }

    map forward_zones {
        type ifname . ifname : verdict
        elements = {
            docker0 . eth0 : jump dock_to_wan
        }
    }

    chain icmp_v4 {
        icmp type { destination-unreachable, time-exceeded, parameter-problem } accept
        icmp type echo-request limit rate 10/second burst 5 packets accept
    }

    chain icmp_v6 {
        icmpv6 type { nd-neighbor-solicit, nd-neighbor-advert, nd-router-solicit, nd-router-advert, mld-listener-query, mld-listener-report } accept
        icmpv6 type { destination-unreachable, packet-too-big, time-exceeded, parameter-problem } accept
        icmpv6 type echo-request limit rate 10/second burst 5 packets accept
        drop
    }

    chain rpfilter {
        type filter hook prerouting priority filter + 5; policy accept;
        fib saddr . mark . iif oif 0 drop
    }

    chain anti_smurf {
        fib saddr type broadcast drop
        fib saddr type multicast drop
    }

    chain input {
        type filter hook input priority filter + 5; policy drop;
        iif lo accept
        ct state { established, related } accept
        ct state invalid drop
        jump anti_smurf
        iifname eth0 udp sport 67 udp dport 68 accept
        iifname eth1 udp sport 68 udp dport 67 accept
        jump tcp_strict
        jump icmp_v4
        jump icmp_v6
        iifname vmap @input_zones
        update @_lograte_4 { ip saddr limit rate 5/minute burst 5 packets } log prefix "matchstick input DROP "
        update @_lograte_6 { ip6 saddr limit rate 5/minute burst 5 packets } log prefix "matchstick input DROP "
        drop
    }

    chain tcp_strict {
        tcp flags ! fin,syn,rst,psh,ack,urg drop comment "tcp-strict: null flags"
        tcp flags & (fin|syn) fin|syn drop comment "tcp-strict: fin+syn"
        tcp flags & (syn|rst) syn|rst drop comment "tcp-strict: syn+rst"
        tcp flags & (fin|psh|urg) fin|psh|urg drop comment "tcp-strict: xmas"
        tcp flags & (fin|syn|rst|ack) != syn ct state new drop comment "tcp-strict: new non-syn"
    }

    chain forward {
        type filter hook forward priority filter + 5; policy drop;
        ct state { established, related } accept
        ct state invalid drop
        jump anti_smurf
        jump tcp_strict
        fib daddr type broadcast drop
        fib daddr type multicast drop
        iifname . oifname vmap @forward_zones
        update @_lograte_4 { ip saddr limit rate 5/minute burst 5 packets } log prefix "matchstick forward DROP "
        drop
    }

    chain output {
        type filter hook output priority filter + 5; policy accept;
        oifname eth1 udp sport 67 udp dport 68 accept
        oifname vmap @output_zones
    }

    chain dock_to_fw {
        tcp dport 53 accept
        udp dport 53 accept
        reject with icmpx admin-prohibited
    }

    chain dock_to_wan {
        accept
    }

    chain fw_to_wan {
        accept
    }

    chain lan_to_fw {
        accept
    }

    chain wan_to_fw {
        drop
    }
}

table inet matchstick_nat
delete table inet matchstick_nat

table inet matchstick_nat {

    chain postrouting {
        type nat hook postrouting priority srcnat + 5; policy accept;
        ip saddr 172.16.0.0/12 oifname eth0 masquerade
    }
}

mss clamping and redirect example:

firewall.lua
local self = fw:zone("fw")
local wan  = fw:zone("wan", "eth0")
local lan  = fw:zone("lan", "eth1")

fw:policy(wan, self, "drop")
fw:policy(lan, self, "accept")
fw:policy(lan, wan, "accept")

fw:mss_clamp("forward")

fw:redirect({ iface = lan, proto = "tcp", port = {80}, dest_port = 3128 })
nftables output
table inet matchstick
delete table inet matchstick

table inet matchstick {

    set _lograte_4 {
        type ipv4_addr
        flags dynamic, timeout
        size 65535
        timeout 60s
    }

    set _lograte_6 {
        type ipv6_addr
        flags dynamic, timeout
        size 65535
        timeout 60s
    }

    map input_zones {
        type ifname : verdict
        elements = {
            "eth0" : jump wan_to_fw,
            "eth1" : jump lan_to_fw
        }
    }

    map output_zones {
        type ifname : verdict
    }

    map forward_zones {
        type ifname . ifname : verdict
        elements = {
            eth1 . eth0 : jump lan_to_wan
        }
    }

    chain icmp_v4 {
        icmp type { destination-unreachable, time-exceeded, parameter-problem } accept
        icmp type echo-request limit rate 10/second burst 5 packets accept
    }

    chain icmp_v6 {
        icmpv6 type { nd-neighbor-solicit, nd-neighbor-advert, nd-router-solicit, nd-router-advert, mld-listener-query, mld-listener-report } accept
        icmpv6 type { destination-unreachable, packet-too-big, time-exceeded, parameter-problem } accept
        icmpv6 type echo-request limit rate 10/second burst 5 packets accept
        drop
    }

    chain rpfilter {
        type filter hook prerouting priority filter + 5; policy accept;
        fib saddr . mark . iif oif 0 drop
    }

    chain anti_smurf {
        fib saddr type broadcast drop
        fib saddr type multicast drop
    }

    chain input {
        type filter hook input priority filter + 5; policy drop;
        iif lo accept
        ct state { established, related } accept
        ct state invalid drop
        jump anti_smurf
        jump tcp_strict
        jump icmp_v4
        jump icmp_v6
        iifname vmap @input_zones
        update @_lograte_4 { ip saddr limit rate 5/minute burst 5 packets } log prefix "matchstick input DROP "
        update @_lograte_6 { ip6 saddr limit rate 5/minute burst 5 packets } log prefix "matchstick input DROP "
        drop
    }

    chain tcp_strict {
        tcp flags ! fin,syn,rst,psh,ack,urg drop comment "tcp-strict: null flags"
        tcp flags & (fin|syn) fin|syn drop comment "tcp-strict: fin+syn"
        tcp flags & (syn|rst) syn|rst drop comment "tcp-strict: syn+rst"
        tcp flags & (fin|psh|urg) fin|psh|urg drop comment "tcp-strict: xmas"
        tcp flags & (fin|syn|rst|ack) != syn ct state new drop comment "tcp-strict: new non-syn"
    }

    chain forward {
        type filter hook forward priority filter + 5; policy drop;
        ct state { established, related } accept
        ct state invalid drop
        jump anti_smurf
        jump tcp_strict
        fib daddr type broadcast drop
        fib daddr type multicast drop
        iifname . oifname vmap @forward_zones
        update @_lograte_4 { ip saddr limit rate 5/minute burst 5 packets } log prefix "matchstick forward DROP "
        drop
    }

    chain output {
        type filter hook output priority filter + 5; policy accept;
        oifname vmap @output_zones
    }

    chain lan_to_fw {
        accept
    }

    chain lan_to_wan {
        accept
    }

    chain wan_to_fw {
        drop
    }

    chain mss_clamp_forward {
        type filter hook forward priority filter - 145; policy accept;
        tcp flags syn tcp option maxseg size set rt mtu
    }
}

table inet matchstick_nat
delete table inet matchstick_nat

table inet matchstick_nat {

    chain prerouting {
        type nat hook prerouting priority dstnat + 5; policy accept;
        iifname eth1 tcp dport 80 redirect to :3128
    }
}

sysctl

matchstick automatically derives kernel sysctl settings from your config. ip forwarding is enabled when forwarding rules exist. arp hardening, redirect protection, and source routing protection are always set.

fw:sysctl("net.ipv4.tcp_syncookies", "1")

fw:sysctl({
  ["net.netfilter.nf_conntrack_max"] = "262144",
  ["net.core.somaxconn"] = "4096",
})

-- unset a derived default (matchstick won't touch it)
fw:sysctl("net.ipv4.conf.all.forwarding", false)

example: a router config with custom sysctl and an unset override:

firewall.lua
local self = fw:zone("fw")
local wan  = fw:zone("wan", "eth0")
local lan  = fw:zone("lan", "eth1")

fw:policy(lan, wan, "accept")

fw:sysctl("net.ipv4.tcp_syncookies", "1")
fw:sysctl("net.ipv4.conf.all.log_martians", false)
derived sysctls
net.ipv4.conf.all.forwarding = 1
net.ipv6.conf.all.forwarding = 1
net.ipv4.conf.default.arp_announce = 2
net.ipv4.conf.all.arp_announce = 2
net.ipv4.conf.default.arp_ignore = 1
net.ipv4.conf.all.arp_ignore = 1
net.ipv4.conf.default.arp_filter = 1
net.ipv4.conf.all.arp_filter = 1
net.ipv4.conf.default.accept_redirects = 0
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.send_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv6.conf.default.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_source_route = 0
net.ipv4.conf.all.accept_source_route = 0
net.ipv6.conf.default.accept_source_route = 0
net.ipv6.conf.all.accept_source_route = 0
net.ipv4.conf.default.log_martians = 1
net.ipv4.tcp_syncookies = 1

hooks

lifecycle hooks run shell commands before or after applying the firewall. useful for running sysctl, restarting services, or sending notifications.

fw:hook({
  pre_start  = "echo starting firewall",
  post_start = "sysctl -p /etc/sysctl.d/custom.conf",
  pre_stop   = "/usr/local/bin/notify-slack stopping",
  post_stop  = "echo firewall stopped",
})

hooks run with the same privileges as matchstick (typically root). pre_start/post_start run during msctl enable. pre_stop/post_stop are available for init system integration.

includes

split your config across multiple files. paths are resolved relative to the current config file's directory. circular includes are detected.

-- main firewall.lua
fw:include("services.lua")    -- service definitions
fw:include("zones.lua")       -- zone + host definitions
fw:include("policies.lua")    -- zone pair policies
fw:include("rules/wan.lua")   -- subdirectory works too
fw:include("rules/lan.lua")

rate limiting

rate limiting restricts the number of new connections per time period. useful for preventing brute force attacks (ssh), dos, or api abuse. matchstick uses nftables dynamic sets to track per-source-ip rates.

-- anonymous rate limit (per-rule)
fw:rule(wan, self, "accept", {
  service = ssh,
  rate = util:rate("5/minute", { burst = 10 }),
})

-- named rate limit (shared across rules)
local ssh_limit = util:rate("3/minute", { burst = 5, name = "ssh_limit" })
fw:rule(wan, self, "accept", { service = ssh, rate = ssh_limit })

the rate format is "count/unit" where unit is second, minute, hour, or day. burst allows a short spike before the limit kicks in.

custom chains

for advanced use cases (policy routing, packet marking, traffic shaping), you can create nftables chains at arbitrary hook points and priorities. rules are nftables json objects (lua tables that map to the nftables json schema), ensuring both text and json output work correctly.

-- mark traffic from eth1 for policy routing
fw:chain("prerouting", {
  type = "filter",
  priority = "mangle",  -- or "raw", "filter", "security", or a number
  rules = {
    {  -- each rule is an array of statement objects
      { match = { op = "==",
        left = { meta = { key = "iifname" } },
        right = "eth1" } },
      { mangle = { key = { meta = { key = "mark" } }, value = 256 } },
    },
  },
})

named priorities: raw (-300), mangle (-150), filter (0), security (50), srcnat (100), dstnat (-100). all offset by your priority_offset (default 5). hooks: prerouting, postrouting, forward, input, output.

raw nftables

escape hatch for anything matchstick doesn't have a first-class api for. inject raw nftables json command objects directly into the ruleset. each argument is a lua table that maps to a single nftables json command.

-- create a custom chain
fw:raw_nft(
  { add = { chain = {
    family = "inet", table = "matchstick", name = "my_chain",
    type = "filter", hook = "input", prio = 200, policy = "accept",
  }}},
  { add = { rule = {
    family = "inet", table = "matchstick", chain = "my_chain",
    expr = {
      { match = { op = "==",
        left = { payload = { protocol = "tcp", field = "dport" } },
        right = 12345 } },
      { accept = {} },
    },
  }}}
)

raw commands are passed through verbatim to json output and rendered to text via a json-to-nftables converter. they appear inside the matchstick table.

global config

override defaults for the entire firewall:

fw:config({
  table_name      = "matchstick",      -- nftables table name
  priority_offset = 5,                 -- chain priority offset
  family          = "inet",            -- "inet" (dual-stack) or "ip" (v4 only)
  input_policy    = "drop",            -- base input chain policy
  output_policy   = "accept",          -- base output chain policy
  log_rate        = "5/minute burst 5", -- rate limit for logging
  log_prefix      = "matchstick",       -- syslog prefix
  log_level       = "info",             -- log level
  counter         = false,              -- add counters to all rules
  log_set_size    = 65535,              -- max entries in rate limit sets
  log_set_timeout = 60,                -- rate limit entry timeout (seconds)
})

the priority_offset shifts all matchstick chains relative to the standard nftables priorities. default 5 means matchstick's filter chains run at priority filter+5, which avoids conflicts with other nftables rulesets. set to 0 to use standard priorities, or higher to run after other tools.

cli

msctl manages the firewall on a running system. matchstick is the compiler (no root, no nft needed).

msctl (system management)

msctl enable compile, validate, apply config
msctl disable remove all matchstick rules
msctl status show running rules
msctl diff diff running rules vs config
msctl check validate config without applying
msctl edit edit config, validate, and apply
msctl show [sub] matrix, rules, topology, render, json, sysctl

matchstick (compiler)

matchstick check config.lua validate
matchstick render config.lua print nftables (text or --json)
matchstick diff <a> <b> diff two rulesets (- for stdin)
matchstick show matrix|rules|topology|json|sysctl visualize config
matchstick import-ufw convert ufw rules (pipe from stdin)