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:
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} })
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:
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 })
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:
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} })
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:
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 })
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:
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 })
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:
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)
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) |